payload-mcp-toolkit 0.7.0 → 0.7.4
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/README.md +29 -8
- package/dist/api-keys.js +57 -21
- package/dist/api-keys.js.map +1 -1
- package/dist/auth-strategy.d.ts +18 -7
- package/dist/auth-strategy.js +54 -12
- package/dist/auth-strategy.js.map +1 -1
- package/dist/tools/_helpers.d.ts +34 -0
- package/dist/tools/_helpers.js +98 -0
- package/dist/tools/_helpers.js.map +1 -1
- package/dist/tools/publish-draft.js +33 -1
- package/dist/tools/publish-draft.js.map +1 -1
- package/dist/tools/publish-global-draft.js +30 -1
- package/dist/tools/publish-global-draft.js.map +1 -1
- package/package.json +29 -15
- package/dist/__tests__/api-keys.test.js +0 -292
- package/dist/__tests__/api-keys.test.js.map +0 -1
- package/dist/__tests__/auth-strategy.test.js +0 -681
- package/dist/__tests__/auth-strategy.test.js.map +0 -1
- package/dist/__tests__/conflict-detection.test.js +0 -69
- package/dist/__tests__/conflict-detection.test.js.map +0 -1
- package/dist/__tests__/delete-document.test.js +0 -70
- package/dist/__tests__/delete-document.test.js.map +0 -1
- package/dist/__tests__/endpoint.test.js +0 -143
- package/dist/__tests__/endpoint.test.js.map +0 -1
- package/dist/__tests__/find-document.test.js +0 -178
- package/dist/__tests__/find-document.test.js.map +0 -1
- package/dist/__tests__/find-global.test.js +0 -173
- package/dist/__tests__/find-global.test.js.map +0 -1
- package/dist/__tests__/global-versions.test.js +0 -183
- package/dist/__tests__/global-versions.test.js.map +0 -1
- package/dist/__tests__/hash.test.js +0 -58
- package/dist/__tests__/hash.test.js.map +0 -1
- package/dist/__tests__/index-integration.test.js +0 -191
- package/dist/__tests__/index-integration.test.js.map +0 -1
- package/dist/__tests__/introspection.test.js +0 -659
- package/dist/__tests__/introspection.test.js.map +0 -1
- package/dist/__tests__/patch-global-layout.test.js +0 -474
- package/dist/__tests__/patch-global-layout.test.js.map +0 -1
- package/dist/__tests__/patch-layout.test.js +0 -171
- package/dist/__tests__/patch-layout.test.js.map +0 -1
- package/dist/__tests__/registry.test.js +0 -795
- package/dist/__tests__/registry.test.js.map +0 -1
- package/dist/__tests__/resources.test.js +0 -139
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/update-global.test.js +0 -157
- package/dist/__tests__/update-global.test.js.map +0 -1
- package/dist/__tests__/url-validator.test.js +0 -326
- package/dist/__tests__/url-validator.test.js.map +0 -1
package/dist/tools/_helpers.js
CHANGED
|
@@ -31,6 +31,104 @@ export function jsonResponse(payload) {
|
|
|
31
31
|
export function errorMessage(error) {
|
|
32
32
|
return error instanceof Error ? error.message : String(error);
|
|
33
33
|
}
|
|
34
|
+
function asIsoString(v) {
|
|
35
|
+
if (typeof v === 'string') return v;
|
|
36
|
+
if (v instanceof Date) return v.toISOString();
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Capture the document's current `updatedAt` BEFORE a publish attempt so
|
|
41
|
+
* the recovery branch can tell "this attempt landed despite a post-write
|
|
42
|
+
* validator throw" from "an older publish was successful and this attempt
|
|
43
|
+
* did nothing". A missing snapshot is non-fatal — the recovery branch
|
|
44
|
+
* conservatively falls through to the original error in that case.
|
|
45
|
+
*/ export async function snapshotPublishMarker(req, target) {
|
|
46
|
+
try {
|
|
47
|
+
const pre = target.kind === 'collection' ? await req.payload.findByID({
|
|
48
|
+
collection: target.slug,
|
|
49
|
+
id: target.id,
|
|
50
|
+
draft: true,
|
|
51
|
+
depth: 0,
|
|
52
|
+
req,
|
|
53
|
+
overrideAccess: false,
|
|
54
|
+
user: req.user
|
|
55
|
+
}) : await req.payload.findGlobal({
|
|
56
|
+
slug: target.slug,
|
|
57
|
+
draft: true,
|
|
58
|
+
depth: 0,
|
|
59
|
+
// `fallbackLocale: false` disables Payload's locale-fallback so
|
|
60
|
+
// the read returns the literal state of the requested locale.
|
|
61
|
+
// Without this, a localized global with fallbackLocale='en'
|
|
62
|
+
// could report the 'en' updatedAt while the caller is
|
|
63
|
+
// publishing 'de', producing a false-positive in
|
|
64
|
+
// verifyPublishSucceededDespiteError.
|
|
65
|
+
...target.locale ? {
|
|
66
|
+
locale: target.locale,
|
|
67
|
+
fallbackLocale: false
|
|
68
|
+
} : {},
|
|
69
|
+
req,
|
|
70
|
+
overrideAccess: false,
|
|
71
|
+
user: req.user
|
|
72
|
+
});
|
|
73
|
+
return asIsoString(pre?.updatedAt);
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* After a Payload update throws on a publish call, determine whether the
|
|
80
|
+
* publish actually landed despite the error. Returns the live document
|
|
81
|
+
* only when (a) the live `_status` is 'published' AND (b) `updatedAt`
|
|
82
|
+
* strictly advanced past the pre-update snapshot — i.e. the current
|
|
83
|
+
* attempt produced the published row. Without the strictly-newer check,
|
|
84
|
+
* a pre-existing published version from an earlier successful publish
|
|
85
|
+
* would mask a real failure of the current attempt.
|
|
86
|
+
*
|
|
87
|
+
* Returns null on:
|
|
88
|
+
* - missing pre-snapshot (cannot disambiguate; conservative)
|
|
89
|
+
* - verify read failure (do not mask the original error with a
|
|
90
|
+
* secondary read error)
|
|
91
|
+
* - live `_status` not 'published'
|
|
92
|
+
* - live `updatedAt` not strictly newer than the pre-snapshot
|
|
93
|
+
*/ export async function verifyPublishSucceededDespiteError(req, target, preUpdatedAt) {
|
|
94
|
+
if (!preUpdatedAt) return null;
|
|
95
|
+
try {
|
|
96
|
+
const live = target.kind === 'collection' ? await req.payload.findByID({
|
|
97
|
+
collection: target.slug,
|
|
98
|
+
id: target.id,
|
|
99
|
+
draft: false,
|
|
100
|
+
depth: 0,
|
|
101
|
+
req,
|
|
102
|
+
overrideAccess: false,
|
|
103
|
+
user: req.user
|
|
104
|
+
}) : await req.payload.findGlobal({
|
|
105
|
+
slug: target.slug,
|
|
106
|
+
draft: false,
|
|
107
|
+
depth: 0,
|
|
108
|
+
// Disable Payload locale-fallback (see snapshotPublishMarker
|
|
109
|
+
// note) so verify reads the literal state of the locale that
|
|
110
|
+
// updateGlobal was called against.
|
|
111
|
+
...target.locale ? {
|
|
112
|
+
locale: target.locale,
|
|
113
|
+
fallbackLocale: false
|
|
114
|
+
} : {},
|
|
115
|
+
req,
|
|
116
|
+
overrideAccess: false,
|
|
117
|
+
user: req.user
|
|
118
|
+
});
|
|
119
|
+
const d = live;
|
|
120
|
+
if (!d || d._status !== 'published') return null;
|
|
121
|
+
const liveUpdatedAt = asIsoString(d.updatedAt);
|
|
122
|
+
if (!liveUpdatedAt || liveUpdatedAt <= preUpdatedAt) return null;
|
|
123
|
+
return d;
|
|
124
|
+
} catch (verifyError) {
|
|
125
|
+
req.payload.logger?.debug?.({
|
|
126
|
+
event: 'mcp.publish.verify_read_failed',
|
|
127
|
+
err: verifyError
|
|
128
|
+
}, '[payload-mcp-toolkit] publish-recovery verify-read failed; surfacing original error');
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
34
132
|
export function stampMcpContext(req) {
|
|
35
133
|
req.context = {
|
|
36
134
|
...req.context,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tools/_helpers.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { CollectionConfig, PayloadRequest } from 'payload'\r\n\r\n/**\r\n * Build a `z.enum` over a list of valid resource slugs with a friendly\r\n * error message that names the valid set and clarifies why an unknown\r\n * slug (e.g. one removed via `options.exclude.globals`) is rejected.\r\n *\r\n * Default Zod enum errors are \"Invalid enum value. …\" — accurate but\r\n * unhelpful when the slug looks plausible to a caller who isn't aware\r\n * the host config excluded it.\r\n */\r\nexport function slugEnum(\r\n slugs: string[],\r\n kind: 'global' | 'collection',\r\n): z.ZodEnum<[string, ...string[]]> {\r\n return z.enum(slugs as [string, ...string[]], {\r\n errorMap: () => ({\r\n message: `${kind === 'global' ? 'Global' : 'Collection'} slug must be one of: ${slugs.join(', ')}. Unknown or excluded slugs are rejected.`,\r\n }),\r\n })\r\n}\r\n\r\nexport interface McpTextResponse {\r\n content: Array<{ type: 'text'; text: string }>\r\n}\r\n\r\nexport const DRAFT_NOTE = ' Document is in draft status — use publishDraft to make it live.'\r\n\r\nexport function textResponse(text: string): McpTextResponse {\r\n return { content: [{ type: 'text', text }] }\r\n}\r\n\r\nexport function jsonResponse(payload: unknown): McpTextResponse {\r\n return textResponse(JSON.stringify(payload))\r\n}\r\n\r\nexport function errorMessage(error: unknown): string {\r\n return error instanceof Error ? error.message : String(error)\r\n}\r\n\r\nexport function stampMcpContext(req: PayloadRequest): void {\r\n req.context = { ...req.context, source: 'mcp' }\r\n}\r\n\r\nexport function getDocDisplayName(doc: unknown, fallback: string): string {\r\n const d = doc as Record<string, unknown> | null | undefined\r\n return (\r\n (typeof d?.name === 'string' && d.name) ||\r\n (typeof d?.title === 'string' && d.title) ||\r\n (typeof d?.slug === 'string' && d.slug) ||\r\n fallback\r\n )\r\n}\r\n\r\nexport function requireDraftCollection(\r\n collection: string,\r\n draftCollections: Set<string>,\r\n noun = 'drafts',\r\n): McpTextResponse | null {\r\n if (draftCollections.has(collection)) return null\r\n return textResponse(\r\n `Error: Collection \"${collection}\" does not support ${noun}. ` +\r\n `Draft-enabled collections: ${[...draftCollections].join(', ') || 'none'}`,\r\n )\r\n}\r\n\r\n/**\r\n * Resolves the preview URL for a draft document by delegating to the\r\n * collection's own configured preview function (`admin.livePreview.url`\r\n * preferred, then `admin.preview`). Returns null when no function is\r\n * configured, when it fails, or when it returns a relative path with no\r\n * absolute `siteUrl` to anchor it.\r\n */\r\nexport async function resolvePreviewUrl(\r\n collection: CollectionConfig,\r\n doc: Record<string, unknown>,\r\n req: PayloadRequest,\r\n siteUrl: string | undefined,\r\n): Promise<string | null> {\r\n const admin = (collection.admin ?? {}) as Record<string, any>\r\n const locale = (req as unknown as { locale?: string }).locale ?? 'en'\r\n\r\n let raw: string | null | undefined\r\n\r\n const livePreviewUrl = admin.livePreview?.url\r\n if (typeof livePreviewUrl === 'function') {\r\n try {\r\n raw = await livePreviewUrl({\r\n data: doc,\r\n locale: { code: locale, label: locale },\r\n req,\r\n payload: req.payload,\r\n collectionConfig: collection,\r\n })\r\n } catch {\r\n raw = null\r\n }\r\n } else if (typeof livePreviewUrl === 'string') {\r\n raw = livePreviewUrl\r\n }\r\n\r\n if (!raw && typeof admin.preview === 'function') {\r\n try {\r\n raw = await admin.preview(doc, { locale, req, token: null })\r\n } catch {\r\n raw = null\r\n }\r\n }\r\n\r\n if (!raw || typeof raw !== 'string') return null\r\n\r\n if (raw.startsWith('http://') || raw.startsWith('https://')) return raw\r\n if (!siteUrl) return null\r\n\r\n const base = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl\r\n const path = raw.startsWith('/') ? raw : `/${raw}`\r\n return `${base}${path}`\r\n}\r\n\r\n/**\r\n * If `doc` is a draft, appends a preview-URL hint to the MCP response so the\r\n * AI can present it to the user. Falls back to a generic admin-panel hint\r\n * when the collection has no preview function configured.\r\n *\r\n * Pure with respect to the response: a fresh content array is returned.\r\n */\r\nexport async function decorateDraftResponse(\r\n response: McpTextResponse,\r\n doc: Record<string, unknown> | null | undefined,\r\n collection: CollectionConfig | undefined,\r\n req: PayloadRequest,\r\n siteUrl: string | undefined,\r\n): Promise<McpTextResponse> {\r\n if (!doc || doc._status !== 'draft' || !collection) return response\r\n\r\n const previewUrl = await resolvePreviewUrl(collection, doc, req, siteUrl)\r\n const hint = previewUrl\r\n ? `\\n📋 This document is a draft. Preview it here: ${previewUrl}`\r\n : '\\n📋 This document is a draft. Use the admin panel to preview it.'\r\n\r\n return { content: [...response.content, { type: 'text', text: hint }] }\r\n}\r\n"],"names":["z","slugEnum","slugs","kind","enum","errorMap","message","join","DRAFT_NOTE","textResponse","text","content","type","jsonResponse","payload","JSON","stringify","errorMessage","error","Error","String","stampMcpContext","req","context","source","getDocDisplayName","doc","fallback","d","name","title","slug","requireDraftCollection","collection","draftCollections","noun","has","resolvePreviewUrl","siteUrl","admin","locale","raw","livePreviewUrl","livePreview","url","data","code","label","collectionConfig","preview","token","startsWith","base","endsWith","slice","path","decorateDraftResponse","response","_status","previewUrl","hint"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/tools/_helpers.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { CollectionConfig, PayloadRequest } from 'payload'\r\n\r\n/**\r\n * Build a `z.enum` over a list of valid resource slugs with a friendly\r\n * error message that names the valid set and clarifies why an unknown\r\n * slug (e.g. one removed via `options.exclude.globals`) is rejected.\r\n *\r\n * Default Zod enum errors are \"Invalid enum value. …\" — accurate but\r\n * unhelpful when the slug looks plausible to a caller who isn't aware\r\n * the host config excluded it.\r\n */\r\nexport function slugEnum(\r\n slugs: string[],\r\n kind: 'global' | 'collection',\r\n): z.ZodEnum<[string, ...string[]]> {\r\n return z.enum(slugs as [string, ...string[]], {\r\n errorMap: () => ({\r\n message: `${kind === 'global' ? 'Global' : 'Collection'} slug must be one of: ${slugs.join(', ')}. Unknown or excluded slugs are rejected.`,\r\n }),\r\n })\r\n}\r\n\r\nexport interface McpTextResponse {\r\n content: Array<{ type: 'text'; text: string }>\r\n}\r\n\r\nexport const DRAFT_NOTE = ' Document is in draft status — use publishDraft to make it live.'\r\n\r\nexport function textResponse(text: string): McpTextResponse {\r\n return { content: [{ type: 'text', text }] }\r\n}\r\n\r\nexport function jsonResponse(payload: unknown): McpTextResponse {\r\n return textResponse(JSON.stringify(payload))\r\n}\r\n\r\nexport function errorMessage(error: unknown): string {\r\n return error instanceof Error ? error.message : String(error)\r\n}\r\n\r\nfunction asIsoString(v: unknown): string | undefined {\r\n if (typeof v === 'string') return v\r\n if (v instanceof Date) return v.toISOString()\r\n return undefined\r\n}\r\n\r\nexport type PublishVerifyTarget =\r\n | { kind: 'collection'; slug: string; id: string }\r\n | { kind: 'global'; slug: string; locale?: string }\r\n\r\n/**\r\n * Capture the document's current `updatedAt` BEFORE a publish attempt so\r\n * the recovery branch can tell \"this attempt landed despite a post-write\r\n * validator throw\" from \"an older publish was successful and this attempt\r\n * did nothing\". A missing snapshot is non-fatal — the recovery branch\r\n * conservatively falls through to the original error in that case.\r\n */\r\nexport async function snapshotPublishMarker(\r\n req: PayloadRequest,\r\n target: PublishVerifyTarget,\r\n): Promise<string | undefined> {\r\n try {\r\n const pre =\r\n target.kind === 'collection'\r\n ? await req.payload.findByID({\r\n collection: target.slug as any,\r\n id: target.id,\r\n draft: true,\r\n depth: 0,\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n : await req.payload.findGlobal({\r\n slug: target.slug as never,\r\n draft: true,\r\n depth: 0,\r\n // `fallbackLocale: false` disables Payload's locale-fallback so\r\n // the read returns the literal state of the requested locale.\r\n // Without this, a localized global with fallbackLocale='en'\r\n // could report the 'en' updatedAt while the caller is\r\n // publishing 'de', producing a false-positive in\r\n // verifyPublishSucceededDespiteError.\r\n ...(target.locale\r\n ? { locale: target.locale as never, fallbackLocale: false as never }\r\n : {}),\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n return asIsoString((pre as { updatedAt?: unknown } | null | undefined)?.updatedAt)\r\n } catch {\r\n return undefined\r\n }\r\n}\r\n\r\n/**\r\n * After a Payload update throws on a publish call, determine whether the\r\n * publish actually landed despite the error. Returns the live document\r\n * only when (a) the live `_status` is 'published' AND (b) `updatedAt`\r\n * strictly advanced past the pre-update snapshot — i.e. the current\r\n * attempt produced the published row. Without the strictly-newer check,\r\n * a pre-existing published version from an earlier successful publish\r\n * would mask a real failure of the current attempt.\r\n *\r\n * Returns null on:\r\n * - missing pre-snapshot (cannot disambiguate; conservative)\r\n * - verify read failure (do not mask the original error with a\r\n * secondary read error)\r\n * - live `_status` not 'published'\r\n * - live `updatedAt` not strictly newer than the pre-snapshot\r\n */\r\nexport async function verifyPublishSucceededDespiteError(\r\n req: PayloadRequest,\r\n target: PublishVerifyTarget,\r\n preUpdatedAt: string | undefined,\r\n): Promise<Record<string, unknown> | null> {\r\n if (!preUpdatedAt) return null\r\n try {\r\n const live =\r\n target.kind === 'collection'\r\n ? await req.payload.findByID({\r\n collection: target.slug as any,\r\n id: target.id,\r\n draft: false,\r\n depth: 0,\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n : await req.payload.findGlobal({\r\n slug: target.slug as never,\r\n draft: false,\r\n depth: 0,\r\n // Disable Payload locale-fallback (see snapshotPublishMarker\r\n // note) so verify reads the literal state of the locale that\r\n // updateGlobal was called against.\r\n ...(target.locale\r\n ? { locale: target.locale as never, fallbackLocale: false as never }\r\n : {}),\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n const d = live as Record<string, unknown> | null | undefined\r\n if (!d || d._status !== 'published') return null\r\n const liveUpdatedAt = asIsoString(d.updatedAt)\r\n if (!liveUpdatedAt || liveUpdatedAt <= preUpdatedAt) return null\r\n return d\r\n } catch (verifyError) {\r\n req.payload.logger?.debug?.(\r\n { event: 'mcp.publish.verify_read_failed', err: verifyError },\r\n '[payload-mcp-toolkit] publish-recovery verify-read failed; surfacing original error',\r\n )\r\n return null\r\n }\r\n}\r\n\r\nexport function stampMcpContext(req: PayloadRequest): void {\r\n req.context = { ...req.context, source: 'mcp' }\r\n}\r\n\r\nexport function getDocDisplayName(doc: unknown, fallback: string): string {\r\n const d = doc as Record<string, unknown> | null | undefined\r\n return (\r\n (typeof d?.name === 'string' && d.name) ||\r\n (typeof d?.title === 'string' && d.title) ||\r\n (typeof d?.slug === 'string' && d.slug) ||\r\n fallback\r\n )\r\n}\r\n\r\nexport function requireDraftCollection(\r\n collection: string,\r\n draftCollections: Set<string>,\r\n noun = 'drafts',\r\n): McpTextResponse | null {\r\n if (draftCollections.has(collection)) return null\r\n return textResponse(\r\n `Error: Collection \"${collection}\" does not support ${noun}. ` +\r\n `Draft-enabled collections: ${[...draftCollections].join(', ') || 'none'}`,\r\n )\r\n}\r\n\r\n/**\r\n * Resolves the preview URL for a draft document by delegating to the\r\n * collection's own configured preview function (`admin.livePreview.url`\r\n * preferred, then `admin.preview`). Returns null when no function is\r\n * configured, when it fails, or when it returns a relative path with no\r\n * absolute `siteUrl` to anchor it.\r\n */\r\nexport async function resolvePreviewUrl(\r\n collection: CollectionConfig,\r\n doc: Record<string, unknown>,\r\n req: PayloadRequest,\r\n siteUrl: string | undefined,\r\n): Promise<string | null> {\r\n const admin = (collection.admin ?? {}) as Record<string, any>\r\n const locale = (req as unknown as { locale?: string }).locale ?? 'en'\r\n\r\n let raw: string | null | undefined\r\n\r\n const livePreviewUrl = admin.livePreview?.url\r\n if (typeof livePreviewUrl === 'function') {\r\n try {\r\n raw = await livePreviewUrl({\r\n data: doc,\r\n locale: { code: locale, label: locale },\r\n req,\r\n payload: req.payload,\r\n collectionConfig: collection,\r\n })\r\n } catch {\r\n raw = null\r\n }\r\n } else if (typeof livePreviewUrl === 'string') {\r\n raw = livePreviewUrl\r\n }\r\n\r\n if (!raw && typeof admin.preview === 'function') {\r\n try {\r\n raw = await admin.preview(doc, { locale, req, token: null })\r\n } catch {\r\n raw = null\r\n }\r\n }\r\n\r\n if (!raw || typeof raw !== 'string') return null\r\n\r\n if (raw.startsWith('http://') || raw.startsWith('https://')) return raw\r\n if (!siteUrl) return null\r\n\r\n const base = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl\r\n const path = raw.startsWith('/') ? raw : `/${raw}`\r\n return `${base}${path}`\r\n}\r\n\r\n/**\r\n * If `doc` is a draft, appends a preview-URL hint to the MCP response so the\r\n * AI can present it to the user. Falls back to a generic admin-panel hint\r\n * when the collection has no preview function configured.\r\n *\r\n * Pure with respect to the response: a fresh content array is returned.\r\n */\r\nexport async function decorateDraftResponse(\r\n response: McpTextResponse,\r\n doc: Record<string, unknown> | null | undefined,\r\n collection: CollectionConfig | undefined,\r\n req: PayloadRequest,\r\n siteUrl: string | undefined,\r\n): Promise<McpTextResponse> {\r\n if (!doc || doc._status !== 'draft' || !collection) return response\r\n\r\n const previewUrl = await resolvePreviewUrl(collection, doc, req, siteUrl)\r\n const hint = previewUrl\r\n ? `\\n📋 This document is a draft. Preview it here: ${previewUrl}`\r\n : '\\n📋 This document is a draft. Use the admin panel to preview it.'\r\n\r\n return { content: [...response.content, { type: 'text', text: hint }] }\r\n}\r\n"],"names":["z","slugEnum","slugs","kind","enum","errorMap","message","join","DRAFT_NOTE","textResponse","text","content","type","jsonResponse","payload","JSON","stringify","errorMessage","error","Error","String","asIsoString","v","Date","toISOString","undefined","snapshotPublishMarker","req","target","pre","findByID","collection","slug","id","draft","depth","overrideAccess","user","findGlobal","locale","fallbackLocale","updatedAt","verifyPublishSucceededDespiteError","preUpdatedAt","live","d","_status","liveUpdatedAt","verifyError","logger","debug","event","err","stampMcpContext","context","source","getDocDisplayName","doc","fallback","name","title","requireDraftCollection","draftCollections","noun","has","resolvePreviewUrl","siteUrl","admin","raw","livePreviewUrl","livePreview","url","data","code","label","collectionConfig","preview","token","startsWith","base","endsWith","slice","path","decorateDraftResponse","response","previewUrl","hint"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { errorMessage, getDocDisplayName, requireDraftCollection, stampMcpContext, textResponse } from './_helpers';
|
|
2
|
+
import { errorMessage, getDocDisplayName, requireDraftCollection, snapshotPublishMarker, stampMcpContext, textResponse, verifyPublishSucceededDespiteError } from './_helpers';
|
|
3
3
|
export function createPublishDraftTool(draftCollections) {
|
|
4
4
|
return {
|
|
5
5
|
name: 'publishDraft',
|
|
@@ -20,6 +20,15 @@ export function createPublishDraftTool(draftCollections) {
|
|
|
20
20
|
const guard = requireDraftCollection(collection, draftCollections);
|
|
21
21
|
if (guard) return guard;
|
|
22
22
|
stampMcpContext(req);
|
|
23
|
+
// Snapshot the doc's pre-update `updatedAt` so the recovery branch
|
|
24
|
+
// below can distinguish "this attempt landed despite a post-write
|
|
25
|
+
// validator throw" from "an older publish was successful and this
|
|
26
|
+
// attempt did nothing" (see verifyPublishSucceededDespiteError).
|
|
27
|
+
const preMarker = await snapshotPublishMarker(req, {
|
|
28
|
+
kind: 'collection',
|
|
29
|
+
slug: collection,
|
|
30
|
+
id: documentId
|
|
31
|
+
});
|
|
23
32
|
try {
|
|
24
33
|
const doc = await req.payload.update({
|
|
25
34
|
collection: collection,
|
|
@@ -34,6 +43,29 @@ export function createPublishDraftTool(draftCollections) {
|
|
|
34
43
|
const displayName = getDocDisplayName(doc, documentId);
|
|
35
44
|
return textResponse(`Successfully published "${displayName}" in ${collection} (ID: ${documentId}).`);
|
|
36
45
|
} catch (error) {
|
|
46
|
+
// Payload's update can throw a field-validation error AFTER a new
|
|
47
|
+
// published version has already been written to the `_<slug>_v`
|
|
48
|
+
// versions table (the validator runs in beforeChange-Fields, which
|
|
49
|
+
// fires after Collection-level beforeChange hooks have already
|
|
50
|
+
// mutated `data` and after the version row has been committed in
|
|
51
|
+
// some draft+versions setups — see the breadcrumb self-reference
|
|
52
|
+
// bug surfaced by `@payloadcms/plugin-nested-docs` on Payload v3).
|
|
53
|
+
// The visible-to-the-user effect is "publish appears to fail but
|
|
54
|
+
// the document is in fact live". Verify against the pre-update
|
|
55
|
+
// marker before downgrading to a warning, so a stale published
|
|
56
|
+
// version from a prior successful publish cannot mask a real
|
|
57
|
+
// failure of the current attempt.
|
|
58
|
+
const liveDoc = await verifyPublishSucceededDespiteError(req, {
|
|
59
|
+
kind: 'collection',
|
|
60
|
+
slug: collection,
|
|
61
|
+
id: documentId
|
|
62
|
+
}, preMarker);
|
|
63
|
+
if (liveDoc) {
|
|
64
|
+
const displayName = getDocDisplayName(liveDoc, documentId);
|
|
65
|
+
// Stable token prefix lets MCP clients branch on the published-
|
|
66
|
+
// with-warning state without regex-matching the prose body.
|
|
67
|
+
return textResponse(`[publishDraft:published_with_warning] ` + `Published "${displayName}" in ${collection} (ID: ${documentId}) — ` + `but Payload reported a post-write validation error: ${errorMessage(error)}. ` + `The document is live; the error did not roll back the published version.`);
|
|
68
|
+
}
|
|
37
69
|
return textResponse(`Error publishing document ${documentId} in ${collection}: ${errorMessage(error)}`);
|
|
38
70
|
}
|
|
39
71
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tools/publish-draft.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { PayloadRequest } from 'payload'\r\nimport {\r\n errorMessage,\r\n getDocDisplayName,\r\n requireDraftCollection,\r\n stampMcpContext,\r\n textResponse,\r\n} from './_helpers'\r\n\r\nexport function createPublishDraftTool(draftCollections: Set<string>) {\r\n return {\r\n name: 'publishDraft',\r\n routing: { kind: 'collection', action: 'update' } as const,\r\n description:\r\n 'Publish a draft document by transitioning its _status from \"draft\" to \"published\". ' +\r\n 'Only works on collections that support drafts. Use after creating or editing content ' +\r\n 'to make it live on the site.',\r\n parameters: {\r\n collection: z\r\n .string()\r\n .describe(\r\n `The collection slug. Draft-enabled collections: ${[...draftCollections].join(', ') || 'none'}`,\r\n ),\r\n documentId: z.string().describe('The ID of the document to publish'),\r\n },\r\n handler: async (\r\n rawArgs: Record<string, unknown>,\r\n req: PayloadRequest,\r\n _extra: unknown,\r\n ) => {\r\n const args = rawArgs as { collection: string; documentId: string }\r\n const { collection, documentId } = args\r\n\r\n const guard = requireDraftCollection(collection, draftCollections)\r\n if (guard) return guard\r\n\r\n stampMcpContext(req)\r\n\r\n try {\r\n const doc = await req.payload.update({\r\n collection: collection as any,\r\n id: documentId,\r\n data: { _status: 'published' } as any,\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n\r\n const displayName = getDocDisplayName(doc, documentId)\r\n return textResponse(\r\n `Successfully published \"${displayName}\" in ${collection} (ID: ${documentId}).`,\r\n )\r\n } catch (error) {\r\n return textResponse(\r\n `Error publishing document ${documentId} in ${collection}: ${errorMessage(error)}`,\r\n )\r\n }\r\n },\r\n }\r\n}\r\n"],"names":["z","errorMessage","getDocDisplayName","requireDraftCollection","stampMcpContext","textResponse","createPublishDraftTool","draftCollections","name","routing","kind","action","description","parameters","collection","string","describe","join","documentId","handler","rawArgs","req","_extra","args","guard","doc","payload","update","
|
|
1
|
+
{"version":3,"sources":["../../src/tools/publish-draft.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { PayloadRequest } from 'payload'\r\nimport {\r\n errorMessage,\r\n getDocDisplayName,\r\n requireDraftCollection,\r\n snapshotPublishMarker,\r\n stampMcpContext,\r\n textResponse,\r\n verifyPublishSucceededDespiteError,\r\n} from './_helpers'\r\n\r\nexport function createPublishDraftTool(draftCollections: Set<string>) {\r\n return {\r\n name: 'publishDraft',\r\n routing: { kind: 'collection', action: 'update' } as const,\r\n description:\r\n 'Publish a draft document by transitioning its _status from \"draft\" to \"published\". ' +\r\n 'Only works on collections that support drafts. Use after creating or editing content ' +\r\n 'to make it live on the site.',\r\n parameters: {\r\n collection: z\r\n .string()\r\n .describe(\r\n `The collection slug. Draft-enabled collections: ${[...draftCollections].join(', ') || 'none'}`,\r\n ),\r\n documentId: z.string().describe('The ID of the document to publish'),\r\n },\r\n handler: async (\r\n rawArgs: Record<string, unknown>,\r\n req: PayloadRequest,\r\n _extra: unknown,\r\n ) => {\r\n const args = rawArgs as { collection: string; documentId: string }\r\n const { collection, documentId } = args\r\n\r\n const guard = requireDraftCollection(collection, draftCollections)\r\n if (guard) return guard\r\n\r\n stampMcpContext(req)\r\n\r\n // Snapshot the doc's pre-update `updatedAt` so the recovery branch\r\n // below can distinguish \"this attempt landed despite a post-write\r\n // validator throw\" from \"an older publish was successful and this\r\n // attempt did nothing\" (see verifyPublishSucceededDespiteError).\r\n const preMarker = await snapshotPublishMarker(req, {\r\n kind: 'collection',\r\n slug: collection,\r\n id: documentId,\r\n })\r\n\r\n try {\r\n const doc = await req.payload.update({\r\n collection: collection as any,\r\n id: documentId,\r\n data: { _status: 'published' } as any,\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n\r\n const displayName = getDocDisplayName(doc, documentId)\r\n return textResponse(\r\n `Successfully published \"${displayName}\" in ${collection} (ID: ${documentId}).`,\r\n )\r\n } catch (error) {\r\n // Payload's update can throw a field-validation error AFTER a new\r\n // published version has already been written to the `_<slug>_v`\r\n // versions table (the validator runs in beforeChange-Fields, which\r\n // fires after Collection-level beforeChange hooks have already\r\n // mutated `data` and after the version row has been committed in\r\n // some draft+versions setups — see the breadcrumb self-reference\r\n // bug surfaced by `@payloadcms/plugin-nested-docs` on Payload v3).\r\n // The visible-to-the-user effect is \"publish appears to fail but\r\n // the document is in fact live\". Verify against the pre-update\r\n // marker before downgrading to a warning, so a stale published\r\n // version from a prior successful publish cannot mask a real\r\n // failure of the current attempt.\r\n const liveDoc = await verifyPublishSucceededDespiteError(\r\n req,\r\n { kind: 'collection', slug: collection, id: documentId },\r\n preMarker,\r\n )\r\n if (liveDoc) {\r\n const displayName = getDocDisplayName(liveDoc, documentId)\r\n // Stable token prefix lets MCP clients branch on the published-\r\n // with-warning state without regex-matching the prose body.\r\n return textResponse(\r\n `[publishDraft:published_with_warning] ` +\r\n `Published \"${displayName}\" in ${collection} (ID: ${documentId}) — ` +\r\n `but Payload reported a post-write validation error: ${errorMessage(error)}. ` +\r\n `The document is live; the error did not roll back the published version.`,\r\n )\r\n }\r\n return textResponse(\r\n `Error publishing document ${documentId} in ${collection}: ${errorMessage(error)}`,\r\n )\r\n }\r\n },\r\n }\r\n}\r\n"],"names":["z","errorMessage","getDocDisplayName","requireDraftCollection","snapshotPublishMarker","stampMcpContext","textResponse","verifyPublishSucceededDespiteError","createPublishDraftTool","draftCollections","name","routing","kind","action","description","parameters","collection","string","describe","join","documentId","handler","rawArgs","req","_extra","args","guard","preMarker","slug","id","doc","payload","update","data","_status","overrideAccess","user","displayName","error","liveDoc"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAEvB,SACEC,YAAY,EACZC,iBAAiB,EACjBC,sBAAsB,EACtBC,qBAAqB,EACrBC,eAAe,EACfC,YAAY,EACZC,kCAAkC,QAC7B,aAAY;AAEnB,OAAO,SAASC,uBAAuBC,gBAA6B;IAClE,OAAO;QACLC,MAAM;QACNC,SAAS;YAAEC,MAAM;YAAcC,QAAQ;QAAS;QAChDC,aACE,wFACA,0FACA;QACFC,YAAY;YACVC,YAAYhB,EACTiB,MAAM,GACNC,QAAQ,CACP,CAAC,gDAAgD,EAAE;mBAAIT;aAAiB,CAACU,IAAI,CAAC,SAAS,QAAQ;YAEnGC,YAAYpB,EAAEiB,MAAM,GAAGC,QAAQ,CAAC;QAClC;QACAG,SAAS,OACPC,SACAC,KACAC;YAEA,MAAMC,OAAOH;YACb,MAAM,EAAEN,UAAU,EAAEI,UAAU,EAAE,GAAGK;YAEnC,MAAMC,QAAQvB,uBAAuBa,YAAYP;YACjD,IAAIiB,OAAO,OAAOA;YAElBrB,gBAAgBkB;YAEhB,mEAAmE;YACnE,kEAAkE;YAClE,kEAAkE;YAClE,iEAAiE;YACjE,MAAMI,YAAY,MAAMvB,sBAAsBmB,KAAK;gBACjDX,MAAM;gBACNgB,MAAMZ;gBACNa,IAAIT;YACN;YAEA,IAAI;gBACF,MAAMU,MAAM,MAAMP,IAAIQ,OAAO,CAACC,MAAM,CAAC;oBACnChB,YAAYA;oBACZa,IAAIT;oBACJa,MAAM;wBAAEC,SAAS;oBAAY;oBAC7BX;oBACAY,gBAAgB;oBAChBC,MAAMb,IAAIa,IAAI;gBAChB;gBAEA,MAAMC,cAAcnC,kBAAkB4B,KAAKV;gBAC3C,OAAOd,aACL,CAAC,wBAAwB,EAAE+B,YAAY,KAAK,EAAErB,WAAW,MAAM,EAAEI,WAAW,EAAE,CAAC;YAEnF,EAAE,OAAOkB,OAAO;gBACd,kEAAkE;gBAClE,gEAAgE;gBAChE,mEAAmE;gBACnE,+DAA+D;gBAC/D,iEAAiE;gBACjE,iEAAiE;gBACjE,mEAAmE;gBACnE,iEAAiE;gBACjE,+DAA+D;gBAC/D,+DAA+D;gBAC/D,6DAA6D;gBAC7D,kCAAkC;gBAClC,MAAMC,UAAU,MAAMhC,mCACpBgB,KACA;oBAAEX,MAAM;oBAAcgB,MAAMZ;oBAAYa,IAAIT;gBAAW,GACvDO;gBAEF,IAAIY,SAAS;oBACX,MAAMF,cAAcnC,kBAAkBqC,SAASnB;oBAC/C,gEAAgE;oBAChE,4DAA4D;oBAC5D,OAAOd,aACL,CAAC,sCAAsC,CAAC,GACtC,CAAC,WAAW,EAAE+B,YAAY,KAAK,EAAErB,WAAW,MAAM,EAAEI,WAAW,IAAI,CAAC,GACpE,CAAC,oDAAoD,EAAEnB,aAAaqC,OAAO,EAAE,CAAC,GAC9E,CAAC,wEAAwE,CAAC;gBAEhF;gBACA,OAAOhC,aACL,CAAC,0BAA0B,EAAEc,WAAW,IAAI,EAAEJ,WAAW,EAAE,EAAEf,aAAaqC,QAAQ;YAEtF;QACF;IACF;AACF"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { errorMessage, slugEnum, stampMcpContext, textResponse } from './_helpers';
|
|
2
|
+
import { errorMessage, slugEnum, snapshotPublishMarker, stampMcpContext, textResponse, verifyPublishSucceededDespiteError } from './_helpers';
|
|
3
3
|
/**
|
|
4
4
|
* Promote a draft-enabled global's pending draft to published. Mirrors
|
|
5
5
|
* publishDraft for collections. Returns null when no global has drafts
|
|
@@ -26,7 +26,21 @@ import { errorMessage, slugEnum, stampMcpContext, textResponse } from './_helper
|
|
|
26
26
|
return textResponse(`Error: Global "${slug}" does not support drafts. Draft-enabled globals: ${slugs.join(', ') || 'none'}`);
|
|
27
27
|
}
|
|
28
28
|
stampMcpContext(req);
|
|
29
|
+
// Capture pre-update marker so the recovery branch below can
|
|
30
|
+
// distinguish a publish that landed despite a post-write throw from
|
|
31
|
+
// a pre-existing published version inherited from an earlier
|
|
32
|
+
// successful publish.
|
|
33
|
+
const preMarker = await snapshotPublishMarker(req, {
|
|
34
|
+
kind: 'global',
|
|
35
|
+
slug,
|
|
36
|
+
...locale ? {
|
|
37
|
+
locale
|
|
38
|
+
} : {}
|
|
39
|
+
});
|
|
29
40
|
try {
|
|
41
|
+
// `slug as never` / `locale as never`: Payload's updateGlobal /
|
|
42
|
+
// findGlobal generic narrows the slug to a TConfig-derived literal
|
|
43
|
+
// union we cannot satisfy with a runtime-supplied string.
|
|
30
44
|
await req.payload.updateGlobal({
|
|
31
45
|
slug: slug,
|
|
32
46
|
data: {
|
|
@@ -41,6 +55,21 @@ import { errorMessage, slugEnum, stampMcpContext, textResponse } from './_helper
|
|
|
41
55
|
});
|
|
42
56
|
return textResponse(`Successfully published global "${slug}".`);
|
|
43
57
|
} catch (err) {
|
|
58
|
+
// Mirror publishDraft's recovery (see publish-draft.ts for the
|
|
59
|
+
// longer note on the post-write validator throw). The shared
|
|
60
|
+
// helper enforces the strictly-newer `updatedAt` check, so a
|
|
61
|
+
// pre-existing published row from an earlier publish cannot mask
|
|
62
|
+
// a real failure of the current attempt.
|
|
63
|
+
const liveGlobal = await verifyPublishSucceededDespiteError(req, {
|
|
64
|
+
kind: 'global',
|
|
65
|
+
slug,
|
|
66
|
+
...locale ? {
|
|
67
|
+
locale
|
|
68
|
+
} : {}
|
|
69
|
+
}, preMarker);
|
|
70
|
+
if (liveGlobal) {
|
|
71
|
+
return textResponse(`[publishGlobalDraft:published_with_warning] ` + `Published global "${slug}" — but Payload reported a post-write validation error: ` + `${errorMessage(err)}. The global is live; the error did not roll back the ` + `published version.`);
|
|
72
|
+
}
|
|
44
73
|
return textResponse(`Error publishing global "${slug}": ${errorMessage(err)}`);
|
|
45
74
|
}
|
|
46
75
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tools/publish-global-draft.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { PayloadRequest } from 'payload'\r\nimport {
|
|
1
|
+
{"version":3,"sources":["../../src/tools/publish-global-draft.ts"],"sourcesContent":["import { z } from 'zod'\r\nimport type { PayloadRequest } from 'payload'\r\nimport {\r\n errorMessage,\r\n slugEnum,\r\n snapshotPublishMarker,\r\n stampMcpContext,\r\n textResponse,\r\n verifyPublishSucceededDespiteError,\r\n} from './_helpers'\r\n\r\n/**\r\n * Promote a draft-enabled global's pending draft to published. Mirrors\r\n * publishDraft for collections. Returns null when no global has drafts\r\n * enabled so the plugin entry can skip registration.\r\n */\r\nexport function createPublishGlobalDraftTool(draftGlobals: Set<string>) {\r\n if (draftGlobals.size === 0) return null\r\n\r\n const slugs = [...draftGlobals]\r\n return {\r\n name: 'publishGlobalDraft',\r\n routing: { kind: 'global', action: 'update' } as const,\r\n description:\r\n 'Publish a draft-enabled global by transitioning its _status from \"draft\" to \"published\". ' +\r\n `Draft-enabled globals: ${slugs.join(', ')}`,\r\n parameters: {\r\n slug: slugEnum(slugs, 'global').describe(`The global slug. One of: ${slugs.join(', ')}`),\r\n locale: z\r\n .string()\r\n .optional()\r\n .describe('Optional locale code (e.g. \"en\", \"fr\") for the publish operation.'),\r\n },\r\n handler: async (args: Record<string, unknown>, req: PayloadRequest, _extra: unknown) => {\r\n const { slug, locale } = args as { slug: string; locale?: string }\r\n\r\n if (!draftGlobals.has(slug)) {\r\n return textResponse(\r\n `Error: Global \"${slug}\" does not support drafts. Draft-enabled globals: ${slugs.join(', ') || 'none'}`,\r\n )\r\n }\r\n\r\n stampMcpContext(req)\r\n\r\n // Capture pre-update marker so the recovery branch below can\r\n // distinguish a publish that landed despite a post-write throw from\r\n // a pre-existing published version inherited from an earlier\r\n // successful publish.\r\n const preMarker = await snapshotPublishMarker(req, {\r\n kind: 'global',\r\n slug,\r\n ...(locale ? { locale } : {}),\r\n })\r\n\r\n try {\r\n // `slug as never` / `locale as never`: Payload's updateGlobal /\r\n // findGlobal generic narrows the slug to a TConfig-derived literal\r\n // union we cannot satisfy with a runtime-supplied string.\r\n await req.payload.updateGlobal({\r\n slug: slug as never,\r\n data: { _status: 'published' } as never,\r\n ...(locale ? { locale: locale as never } : {}),\r\n req,\r\n overrideAccess: false,\r\n user: req.user,\r\n })\r\n return textResponse(`Successfully published global \"${slug}\".`)\r\n } catch (err) {\r\n // Mirror publishDraft's recovery (see publish-draft.ts for the\r\n // longer note on the post-write validator throw). The shared\r\n // helper enforces the strictly-newer `updatedAt` check, so a\r\n // pre-existing published row from an earlier publish cannot mask\r\n // a real failure of the current attempt.\r\n const liveGlobal = await verifyPublishSucceededDespiteError(\r\n req,\r\n { kind: 'global', slug, ...(locale ? { locale } : {}) },\r\n preMarker,\r\n )\r\n if (liveGlobal) {\r\n return textResponse(\r\n `[publishGlobalDraft:published_with_warning] ` +\r\n `Published global \"${slug}\" — but Payload reported a post-write validation error: ` +\r\n `${errorMessage(err)}. The global is live; the error did not roll back the ` +\r\n `published version.`,\r\n )\r\n }\r\n return textResponse(`Error publishing global \"${slug}\": ${errorMessage(err)}`)\r\n }\r\n },\r\n }\r\n}\r\n"],"names":["z","errorMessage","slugEnum","snapshotPublishMarker","stampMcpContext","textResponse","verifyPublishSucceededDespiteError","createPublishGlobalDraftTool","draftGlobals","size","slugs","name","routing","kind","action","description","join","parameters","slug","describe","locale","string","optional","handler","args","req","_extra","has","preMarker","payload","updateGlobal","data","_status","overrideAccess","user","err","liveGlobal"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAEvB,SACEC,YAAY,EACZC,QAAQ,EACRC,qBAAqB,EACrBC,eAAe,EACfC,YAAY,EACZC,kCAAkC,QAC7B,aAAY;AAEnB;;;;CAIC,GACD,OAAO,SAASC,6BAA6BC,YAAyB;IACpE,IAAIA,aAAaC,IAAI,KAAK,GAAG,OAAO;IAEpC,MAAMC,QAAQ;WAAIF;KAAa;IAC/B,OAAO;QACLG,MAAM;QACNC,SAAS;YAAEC,MAAM;YAAUC,QAAQ;QAAS;QAC5CC,aACE,8FACA,CAAC,uBAAuB,EAAEL,MAAMM,IAAI,CAAC,OAAO;QAC9CC,YAAY;YACVC,MAAMhB,SAASQ,OAAO,UAAUS,QAAQ,CAAC,CAAC,yBAAyB,EAAET,MAAMM,IAAI,CAAC,OAAO;YACvFI,QAAQpB,EACLqB,MAAM,GACNC,QAAQ,GACRH,QAAQ,CAAC;QACd;QACAI,SAAS,OAAOC,MAA+BC,KAAqBC;YAClE,MAAM,EAAER,IAAI,EAAEE,MAAM,EAAE,GAAGI;YAEzB,IAAI,CAAChB,aAAamB,GAAG,CAACT,OAAO;gBAC3B,OAAOb,aACL,CAAC,eAAe,EAAEa,KAAK,kDAAkD,EAAER,MAAMM,IAAI,CAAC,SAAS,QAAQ;YAE3G;YAEAZ,gBAAgBqB;YAEhB,6DAA6D;YAC7D,oEAAoE;YACpE,6DAA6D;YAC7D,sBAAsB;YACtB,MAAMG,YAAY,MAAMzB,sBAAsBsB,KAAK;gBACjDZ,MAAM;gBACNK;gBACA,GAAIE,SAAS;oBAAEA;gBAAO,IAAI,CAAC,CAAC;YAC9B;YAEA,IAAI;gBACF,gEAAgE;gBAChE,mEAAmE;gBACnE,0DAA0D;gBAC1D,MAAMK,IAAII,OAAO,CAACC,YAAY,CAAC;oBAC7BZ,MAAMA;oBACNa,MAAM;wBAAEC,SAAS;oBAAY;oBAC7B,GAAIZ,SAAS;wBAAEA,QAAQA;oBAAgB,IAAI,CAAC,CAAC;oBAC7CK;oBACAQ,gBAAgB;oBAChBC,MAAMT,IAAIS,IAAI;gBAChB;gBACA,OAAO7B,aAAa,CAAC,+BAA+B,EAAEa,KAAK,EAAE,CAAC;YAChE,EAAE,OAAOiB,KAAK;gBACZ,+DAA+D;gBAC/D,6DAA6D;gBAC7D,6DAA6D;gBAC7D,iEAAiE;gBACjE,yCAAyC;gBACzC,MAAMC,aAAa,MAAM9B,mCACvBmB,KACA;oBAAEZ,MAAM;oBAAUK;oBAAM,GAAIE,SAAS;wBAAEA;oBAAO,IAAI,CAAC,CAAC;gBAAE,GACtDQ;gBAEF,IAAIQ,YAAY;oBACd,OAAO/B,aACL,CAAC,4CAA4C,CAAC,GAC5C,CAAC,kBAAkB,EAAEa,KAAK,wDAAwD,CAAC,GACnF,GAAGjB,aAAakC,KAAK,sDAAsD,CAAC,GAC5E,CAAC,kBAAkB,CAAC;gBAE1B;gBACA,OAAO9B,aAAa,CAAC,yBAAyB,EAAEa,KAAK,GAAG,EAAEjB,aAAakC,MAAM;YAC/E;QACF;IACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payload-mcp-toolkit",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Standalone schema-aware MCP plugin for Payload CMS v3 — owns the /api/mcp endpoint, scoped API keys, draft workflow, and AI-friendly tools so non-technical editors can manage content via AI chat.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "jon8800",
|
|
@@ -38,9 +38,29 @@
|
|
|
38
38
|
"main": "./dist/index.js",
|
|
39
39
|
"types": "./dist/index.d.ts",
|
|
40
40
|
"files": [
|
|
41
|
-
"dist"
|
|
41
|
+
"dist",
|
|
42
|
+
"!dist/__tests__",
|
|
43
|
+
"!dist/**/*.test.js",
|
|
44
|
+
"!dist/**/*.test.js.map",
|
|
45
|
+
"!dist/**/*.test.d.ts"
|
|
42
46
|
],
|
|
43
47
|
"sideEffects": false,
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
|
50
|
+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
|
51
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
52
|
+
"clean": "rimraf dist",
|
|
53
|
+
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,svg,json}\" dist/",
|
|
54
|
+
"dev": "next dev dev --turbo",
|
|
55
|
+
"dev:generate-importmap": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:importmap",
|
|
56
|
+
"dev:generate-types": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:types",
|
|
57
|
+
"dev:payload": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload",
|
|
58
|
+
"prepack": "node scripts/prepack.mjs",
|
|
59
|
+
"postpack": "node scripts/postpack.mjs",
|
|
60
|
+
"prepublishOnly": "pnpm clean && pnpm build && pnpm test",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest"
|
|
63
|
+
},
|
|
44
64
|
"dependencies": {
|
|
45
65
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
46
66
|
"mcp-handler": "^1.1.0"
|
|
@@ -76,17 +96,11 @@
|
|
|
76
96
|
"node": "^18.20.2 || >=20.9.0",
|
|
77
97
|
"pnpm": "^9 || ^10"
|
|
78
98
|
},
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"dev": "next dev dev --turbo",
|
|
86
|
-
"dev:generate-importmap": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:importmap",
|
|
87
|
-
"dev:generate-types": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:types",
|
|
88
|
-
"dev:payload": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload",
|
|
89
|
-
"test": "vitest run",
|
|
90
|
-
"test:watch": "vitest"
|
|
99
|
+
"pnpm": {
|
|
100
|
+
"onlyBuiltDependencies": [
|
|
101
|
+
"sharp",
|
|
102
|
+
"esbuild",
|
|
103
|
+
"better-sqlite3"
|
|
104
|
+
]
|
|
91
105
|
}
|
|
92
|
-
}
|
|
106
|
+
}
|