payload-mcp-toolkit 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +150 -150
  2. package/dist/__tests__/introspection.test.js.map +1 -1
  3. package/dist/draft-workflow.js +29 -29
  4. package/dist/draft-workflow.js.map +1 -1
  5. package/dist/index.js +20 -33
  6. package/dist/index.js.map +1 -1
  7. package/dist/introspection.d.ts +4 -0
  8. package/dist/introspection.js +38 -33
  9. package/dist/introspection.js.map +1 -1
  10. package/dist/prompts.js +5 -5
  11. package/dist/prompts.js.map +1 -1
  12. package/dist/resources.js +9 -16
  13. package/dist/resources.js.map +1 -1
  14. package/dist/tools/_helpers.d.ts +14 -0
  15. package/dist/tools/_helpers.js +35 -0
  16. package/dist/tools/_helpers.js.map +1 -0
  17. package/dist/tools/patch-layout.d.ts +3 -9
  18. package/dist/tools/patch-layout.js +29 -48
  19. package/dist/tools/patch-layout.js.map +1 -1
  20. package/dist/tools/publish-draft.d.ts +1 -11
  21. package/dist/tools/publish-draft.js +8 -39
  22. package/dist/tools/publish-draft.js.map +1 -1
  23. package/dist/tools/resolve-reference.d.ts +1 -12
  24. package/dist/tools/resolve-reference.js +45 -85
  25. package/dist/tools/resolve-reference.js.map +1 -1
  26. package/dist/tools/safe-delete.d.ts +8 -13
  27. package/dist/tools/safe-delete.js +68 -100
  28. package/dist/tools/safe-delete.js.map +1 -1
  29. package/dist/tools/schedule-publish.d.ts +11 -21
  30. package/dist/tools/schedule-publish.js +18 -61
  31. package/dist/tools/schedule-publish.js.map +1 -1
  32. package/dist/tools/search-content.d.ts +1 -6
  33. package/dist/tools/search-content.js +52 -64
  34. package/dist/tools/search-content.js.map +1 -1
  35. package/dist/tools/update-document.d.ts +4 -14
  36. package/dist/tools/update-document.js +23 -72
  37. package/dist/tools/update-document.js.map +1 -1
  38. package/dist/tools/upload-media.d.ts +1 -10
  39. package/dist/tools/upload-media.js +11 -54
  40. package/dist/tools/upload-media.js.map +1 -1
  41. package/dist/tools/versions.d.ts +7 -20
  42. package/dist/tools/versions.js +25 -82
  43. package/dist/tools/versions.js.map +1 -1
  44. package/dist/types.js +5 -5
  45. package/dist/types.js.map +1 -1
  46. package/package.json +1 -1
  47. package/dist/rate-limiter.d.ts +0 -25
  48. package/dist/rate-limiter.js +0 -51
  49. package/dist/rate-limiter.js.map +0 -1
@@ -2,17 +2,17 @@ import { z } from 'zod';
2
2
  import type { PayloadRequest } from 'payload';
3
3
  import type { RelationshipEdge } from '../types';
4
4
  /**
5
- * Creates the safeDelete MCP tool that wraps the official delete operation
6
- * with a relationship-graph pre-check.
5
+ * safeDelete wraps the official delete operation with a relationship-graph
6
+ * pre-check.
7
7
  *
8
8
  * Workflow:
9
9
  * 1. Use the introspected relationshipGraph to find every (collection, field)
10
10
  * pair that points TO the target collection.
11
- * 2. For each, query for documents that reference the target ID. Aggregate counts
12
- * and a small sample of inbound document IDs.
13
- * 3. If any inbound references exist and `confirm` is false, refuse and return
14
- * the impact summary so the caller (or the LLM driving it) can decide.
15
- * 4. If `confirm` is true OR there are no inbound references, perform the delete.
11
+ * 2. For each, query for documents that reference the target ID. Aggregate
12
+ * counts and a small sample of inbound document IDs.
13
+ * 3. If any inbound references exist and `confirm` is false, refuse and
14
+ * return the impact summary so the caller can decide.
15
+ * 4. Otherwise, perform the delete.
16
16
  *
17
17
  * Excludes built-in Payload bookkeeping collections from the inbound search.
18
18
  */
@@ -28,10 +28,5 @@ export declare function createSafeDeleteTool(relationships: RelationshipEdge[]):
28
28
  collection: string;
29
29
  documentId: string;
30
30
  confirm?: boolean;
31
- }, req: PayloadRequest, _extra: unknown) => Promise<{
32
- content: {
33
- type: "text";
34
- text: string;
35
- }[];
36
- }>;
31
+ }, req: PayloadRequest, _extra: unknown) => Promise<import("./_helpers").McpTextResponse>;
37
32
  };
@@ -1,21 +1,22 @@
1
1
  import { z } from 'zod';
2
+ import { errorMessage, jsonResponse, stampMcpContext, textResponse } from './_helpers';
2
3
  const SAMPLE_LIMIT = 5;
3
4
  /**
4
- * Creates the safeDelete MCP tool that wraps the official delete operation
5
- * with a relationship-graph pre-check.
5
+ * safeDelete wraps the official delete operation with a relationship-graph
6
+ * pre-check.
6
7
  *
7
8
  * Workflow:
8
9
  * 1. Use the introspected relationshipGraph to find every (collection, field)
9
10
  * pair that points TO the target collection.
10
- * 2. For each, query for documents that reference the target ID. Aggregate counts
11
- * and a small sample of inbound document IDs.
12
- * 3. If any inbound references exist and `confirm` is false, refuse and return
13
- * the impact summary so the caller (or the LLM driving it) can decide.
14
- * 4. If `confirm` is true OR there are no inbound references, perform the delete.
11
+ * 2. For each, query for documents that reference the target ID. Aggregate
12
+ * counts and a small sample of inbound document IDs.
13
+ * 3. If any inbound references exist and `confirm` is false, refuse and
14
+ * return the impact summary so the caller can decide.
15
+ * 4. Otherwise, perform the delete.
15
16
  *
16
17
  * Excludes built-in Payload bookkeeping collections from the inbound search.
17
18
  */ export function createSafeDeleteTool(relationships) {
18
- // Build a reverse index: target collection -> [(fromCollection, fieldName, hasMany)]
19
+ // reverseIndex: target collection slug edges that reference it
19
20
  const reverseIndex = new Map();
20
21
  for (const edge of relationships){
21
22
  const targets = Array.isArray(edge.toCollection) ? edge.toCollection : [
@@ -40,87 +41,69 @@ const SAMPLE_LIMIT = 5;
40
41
  },
41
42
  handler: async (args, req, _extra)=>{
42
43
  const { collection, documentId, confirm = false } = args;
43
- req.context = {
44
- ...req.context,
45
- source: 'mcp'
46
- };
47
- // Find inbound references via the reverse index
48
- const inboundEdges = reverseIndex.get(collection) ?? [];
44
+ stampMcpContext(req);
45
+ const inboundEdges = (reverseIndex.get(collection) ?? []).filter((edge)=>!edge.fromCollection.startsWith('payload-'));
46
+ const settled = await Promise.allSettled(inboundEdges.map((edge)=>req.payload.find({
47
+ collection: edge.fromCollection,
48
+ where: {
49
+ [edge.fieldName]: {
50
+ equals: documentId
51
+ }
52
+ },
53
+ limit: SAMPLE_LIMIT,
54
+ select: {
55
+ id: true
56
+ },
57
+ // CRITICAL: include drafts. Without this, a draft document
58
+ // referencing the target is invisible to the safety check and
59
+ // the delete is allowed through — exactly the failure mode this
60
+ // tool is supposed to prevent.
61
+ draft: true,
62
+ req,
63
+ overrideAccess: false,
64
+ user: req.user
65
+ })));
49
66
  const references = [];
50
67
  const failedEdges = [];
51
- for (const edge of inboundEdges){
52
- // Skip Payload's internal bookkeeping collections — they don't represent meaningful editor concerns
53
- if (edge.fromCollection.startsWith('payload-')) continue;
54
- try {
55
- const result = await req.payload.find({
56
- collection: edge.fromCollection,
57
- where: {
58
- [edge.fieldName]: {
59
- equals: documentId
60
- }
61
- },
62
- limit: SAMPLE_LIMIT,
63
- select: {
64
- id: true
65
- },
66
- // CRITICAL: include drafts. Without this, a draft document referencing
67
- // the target is invisible to the safety check and the delete is allowed
68
- // through — exactly the failure mode this tool is supposed to prevent.
69
- draft: true,
70
- req,
71
- overrideAccess: false,
72
- user: req.user
73
- });
74
- if (result.totalDocs > 0) {
75
- references.push({
76
- fromCollection: edge.fromCollection,
77
- fieldName: edge.fieldName,
78
- count: result.totalDocs,
79
- sampleIds: result.docs.map((d)=>d.id)
80
- });
81
- }
82
- } catch (error) {
83
- // Fail closed: an unverified edge means we don't actually know whether
84
- // the target is referenced. Record it and refuse the delete unless the
85
- // caller explicitly opts in via `confirm: true`.
68
+ settled.forEach((outcome, i)=>{
69
+ const edge = inboundEdges[i];
70
+ if (outcome.status === 'rejected') {
71
+ // Fail closed: an unverified edge means we don't know whether the
72
+ // target is referenced. Refuse the delete unless the caller opts in.
86
73
  failedEdges.push({
87
74
  fromCollection: edge.fromCollection,
88
75
  fieldName: edge.fieldName,
89
- error: error instanceof Error ? error.message : String(error)
76
+ error: errorMessage(outcome.reason)
90
77
  });
78
+ return;
91
79
  }
92
- }
80
+ const result = outcome.value;
81
+ if (result.totalDocs > 0) {
82
+ references.push({
83
+ fromCollection: edge.fromCollection,
84
+ fieldName: edge.fieldName,
85
+ count: result.totalDocs,
86
+ sampleIds: result.docs.map((d)=>d.id)
87
+ });
88
+ }
89
+ });
93
90
  if (failedEdges.length > 0 && !confirm) {
94
- return {
95
- content: [
96
- {
97
- type: 'text',
98
- text: JSON.stringify({
99
- success: false,
100
- refused: true,
101
- message: `Refusing to delete ${collection}#${documentId}: could not verify ` + `${failedEdges.length} inbound reference path(s). Pass confirm=true to delete anyway.`,
102
- unverifiedEdges: failedEdges
103
- })
104
- }
105
- ]
106
- };
91
+ return jsonResponse({
92
+ success: false,
93
+ refused: true,
94
+ message: `Refusing to delete ${collection}#${documentId}: could not verify ` + `${failedEdges.length} inbound reference path(s). Pass confirm=true to delete anyway.`,
95
+ unverifiedEdges: failedEdges
96
+ });
107
97
  }
108
98
  const totalReferences = references.reduce((sum, r)=>sum + r.count, 0);
109
99
  if (totalReferences > 0 && !confirm) {
110
- return {
111
- content: [
112
- {
113
- type: 'text',
114
- text: JSON.stringify({
115
- success: false,
116
- refused: true,
117
- message: `Refusing to delete ${collection}#${documentId}: ${totalReferences} inbound reference(s) ` + `found across ${references.length} field path(s). Pass confirm=true to delete anyway.`,
118
- totalReferences,
119
- references
120
- })
121
- }
122
- ]
123
- };
100
+ return jsonResponse({
101
+ success: false,
102
+ refused: true,
103
+ message: `Refusing to delete ${collection}#${documentId}: ${totalReferences} inbound reference(s) ` + `found across ${references.length} field path(s). Pass confirm=true to delete anyway.`,
104
+ totalReferences,
105
+ references
106
+ });
124
107
  }
125
108
  try {
126
109
  await req.payload.delete({
@@ -130,29 +113,14 @@ const SAMPLE_LIMIT = 5;
130
113
  overrideAccess: false,
131
114
  user: req.user
132
115
  });
133
- return {
134
- content: [
135
- {
136
- type: 'text',
137
- text: JSON.stringify({
138
- success: true,
139
- message: totalReferences > 0 ? `Deleted ${collection}#${documentId} despite ${totalReferences} inbound reference(s) (confirm=true).` : `Deleted ${collection}#${documentId}. No inbound references were found.`,
140
- totalReferences,
141
- references
142
- })
143
- }
144
- ]
145
- };
116
+ return jsonResponse({
117
+ success: true,
118
+ message: totalReferences > 0 ? `Deleted ${collection}#${documentId} despite ${totalReferences} inbound reference(s) (confirm=true).` : `Deleted ${collection}#${documentId}. No inbound references were found.`,
119
+ totalReferences,
120
+ references
121
+ });
146
122
  } catch (error) {
147
- const message = error instanceof Error ? error.message : String(error);
148
- return {
149
- content: [
150
- {
151
- type: 'text',
152
- text: `Error deleting ${collection}#${documentId}: ${message}`
153
- }
154
- ]
155
- };
123
+ return textResponse(`Error deleting ${collection}#${documentId}: ${errorMessage(error)}`);
156
124
  }
157
125
  }
158
126
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/safe-delete.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { RelationshipEdge } from '../types'\n\nconst SAMPLE_LIMIT = 5\n\ninterface InboundReference {\n fromCollection: string\n fieldName: string\n count: number\n sampleIds: (string | number)[]\n}\n\n/**\n * Creates the safeDelete MCP tool that wraps the official delete operation\n * with a relationship-graph pre-check.\n *\n * Workflow:\n * 1. Use the introspected relationshipGraph to find every (collection, field)\n * pair that points TO the target collection.\n * 2. For each, query for documents that reference the target ID. Aggregate counts\n * and a small sample of inbound document IDs.\n * 3. If any inbound references exist and `confirm` is false, refuse and return\n * the impact summary so the caller (or the LLM driving it) can decide.\n * 4. If `confirm` is true OR there are no inbound references, perform the delete.\n *\n * Excludes built-in Payload bookkeeping collections from the inbound search.\n */\nexport function createSafeDeleteTool(relationships: RelationshipEdge[]) {\n // Build a reverse index: target collection -> [(fromCollection, fieldName, hasMany)]\n const reverseIndex = new Map<\n string,\n Array<{ fromCollection: string; fieldName: string; hasMany: boolean }>\n >()\n\n for (const edge of relationships) {\n const targets = Array.isArray(edge.toCollection) ? edge.toCollection : [edge.toCollection]\n for (const target of targets) {\n if (!reverseIndex.has(target)) reverseIndex.set(target, [])\n reverseIndex.get(target)!.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n hasMany: edge.hasMany,\n })\n }\n }\n\n return {\n name: 'safeDelete',\n description:\n 'Delete a document only after checking for inbound relationships. ' +\n 'If other documents reference the target, the delete is refused unless `confirm` is true. ' +\n 'Use this in preference to the raw delete tools when removing entities that other content might depend on ' +\n '(authors, categories, media, locations, etc.). Returns the impact summary either way.',\n parameters: {\n collection: z.string().describe('Slug of the collection containing the document to delete'),\n documentId: z.string().describe('ID of the document to delete'),\n confirm: z\n .boolean()\n .optional()\n .default(false)\n .describe(\n 'If false (default), refuse to delete when inbound references exist and return the impact summary. ' +\n 'Pass true to delete anyway after reviewing the impact.',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; confirm?: boolean },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId, confirm = false } = args\n\n req.context = { ...req.context, source: 'mcp' }\n\n // Find inbound references via the reverse index\n const inboundEdges = reverseIndex.get(collection) ?? []\n const references: InboundReference[] = []\n const failedEdges: { fromCollection: string; fieldName: string; error: string }[] = []\n\n for (const edge of inboundEdges) {\n // Skip Payload's internal bookkeeping collections — they don't represent meaningful editor concerns\n if (edge.fromCollection.startsWith('payload-')) continue\n\n try {\n const result = await req.payload.find({\n collection: edge.fromCollection as any,\n where: { [edge.fieldName]: { equals: documentId } } as any,\n limit: SAMPLE_LIMIT,\n select: { id: true } as any,\n // CRITICAL: include drafts. Without this, a draft document referencing\n // the target is invisible to the safety check and the delete is allowed\n // through — exactly the failure mode this tool is supposed to prevent.\n draft: true,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n if (result.totalDocs > 0) {\n references.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n count: result.totalDocs,\n sampleIds: result.docs.map((d: any) => d.id),\n })\n }\n } catch (error) {\n // Fail closed: an unverified edge means we don't actually know whether\n // the target is referenced. Record it and refuse the delete unless the\n // caller explicitly opts in via `confirm: true`.\n failedEdges.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n if (failedEdges.length > 0 && !confirm) {\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n success: false,\n refused: true,\n message:\n `Refusing to delete ${collection}#${documentId}: could not verify ` +\n `${failedEdges.length} inbound reference path(s). Pass confirm=true to delete anyway.`,\n unverifiedEdges: failedEdges,\n }),\n },\n ],\n }\n }\n\n const totalReferences = references.reduce((sum, r) => sum + r.count, 0)\n\n if (totalReferences > 0 && !confirm) {\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n success: false,\n refused: true,\n message:\n `Refusing to delete ${collection}#${documentId}: ${totalReferences} inbound reference(s) ` +\n `found across ${references.length} field path(s). Pass confirm=true to delete anyway.`,\n totalReferences,\n references,\n }),\n },\n ],\n }\n }\n\n try {\n await req.payload.delete({\n collection: collection as any,\n id: documentId,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n success: true,\n message:\n totalReferences > 0\n ? `Deleted ${collection}#${documentId} despite ${totalReferences} inbound reference(s) (confirm=true).`\n : `Deleted ${collection}#${documentId}. No inbound references were found.`,\n totalReferences,\n references,\n }),\n },\n ],\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error deleting ${collection}#${documentId}: ${message}`,\n },\n ],\n }\n }\n },\n }\n}\n"],"names":["z","SAMPLE_LIMIT","createSafeDeleteTool","relationships","reverseIndex","Map","edge","targets","Array","isArray","toCollection","target","has","set","get","push","fromCollection","fieldName","hasMany","name","description","parameters","collection","string","describe","documentId","confirm","boolean","optional","default","handler","args","req","_extra","context","source","inboundEdges","references","failedEdges","startsWith","result","payload","find","where","equals","limit","select","id","draft","overrideAccess","user","totalDocs","count","sampleIds","docs","map","d","error","Error","message","String","length","content","type","text","JSON","stringify","success","refused","unverifiedEdges","totalReferences","reduce","sum","r","delete"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAIvB,MAAMC,eAAe;AASrB;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,qBAAqBC,aAAiC;IACpE,qFAAqF;IACrF,MAAMC,eAAe,IAAIC;IAKzB,KAAK,MAAMC,QAAQH,cAAe;QAChC,MAAMI,UAAUC,MAAMC,OAAO,CAACH,KAAKI,YAAY,IAAIJ,KAAKI,YAAY,GAAG;YAACJ,KAAKI,YAAY;SAAC;QAC1F,KAAK,MAAMC,UAAUJ,QAAS;YAC5B,IAAI,CAACH,aAAaQ,GAAG,CAACD,SAASP,aAAaS,GAAG,CAACF,QAAQ,EAAE;YAC1DP,aAAaU,GAAG,CAACH,QAASI,IAAI,CAAC;gBAC7BC,gBAAgBV,KAAKU,cAAc;gBACnCC,WAAWX,KAAKW,SAAS;gBACzBC,SAASZ,KAAKY,OAAO;YACvB;QACF;IACF;IAEA,OAAO;QACLC,MAAM;QACNC,aACE,sEACA,8FACA,8GACA;QACFC,YAAY;YACVC,YAAYtB,EAAEuB,MAAM,GAAGC,QAAQ,CAAC;YAChCC,YAAYzB,EAAEuB,MAAM,GAAGC,QAAQ,CAAC;YAChCE,SAAS1B,EACN2B,OAAO,GACPC,QAAQ,GACRC,OAAO,CAAC,OACRL,QAAQ,CACP,uGACA;QAEN;QACAM,SAAS,OACPC,MACAC,KACAC;YAEA,MAAM,EAAEX,UAAU,EAAEG,UAAU,EAAEC,UAAU,KAAK,EAAE,GAAGK;YAEpDC,IAAIE,OAAO,GAAG;gBAAE,GAAGF,IAAIE,OAAO;gBAAEC,QAAQ;YAAM;YAE9C,gDAAgD;YAChD,MAAMC,eAAehC,aAAaU,GAAG,CAACQ,eAAe,EAAE;YACvD,MAAMe,aAAiC,EAAE;YACzC,MAAMC,cAA8E,EAAE;YAEtF,KAAK,MAAMhC,QAAQ8B,aAAc;gBAC/B,oGAAoG;gBACpG,IAAI9B,KAAKU,cAAc,CAACuB,UAAU,CAAC,aAAa;gBAEhD,IAAI;oBACF,MAAMC,SAAS,MAAMR,IAAIS,OAAO,CAACC,IAAI,CAAC;wBACpCpB,YAAYhB,KAAKU,cAAc;wBAC/B2B,OAAO;4BAAE,CAACrC,KAAKW,SAAS,CAAC,EAAE;gCAAE2B,QAAQnB;4BAAW;wBAAE;wBAClDoB,OAAO5C;wBACP6C,QAAQ;4BAAEC,IAAI;wBAAK;wBACnB,uEAAuE;wBACvE,wEAAwE;wBACxE,uEAAuE;wBACvEC,OAAO;wBACPhB;wBACAiB,gBAAgB;wBAChBC,MAAMlB,IAAIkB,IAAI;oBAChB;oBAEA,IAAIV,OAAOW,SAAS,GAAG,GAAG;wBACxBd,WAAWtB,IAAI,CAAC;4BACdC,gBAAgBV,KAAKU,cAAc;4BACnCC,WAAWX,KAAKW,SAAS;4BACzBmC,OAAOZ,OAAOW,SAAS;4BACvBE,WAAWb,OAAOc,IAAI,CAACC,GAAG,CAAC,CAACC,IAAWA,EAAET,EAAE;wBAC7C;oBACF;gBACF,EAAE,OAAOU,OAAO;oBACd,uEAAuE;oBACvE,uEAAuE;oBACvE,iDAAiD;oBACjDnB,YAAYvB,IAAI,CAAC;wBACfC,gBAAgBV,KAAKU,cAAc;wBACnCC,WAAWX,KAAKW,SAAS;wBACzBwC,OAAOA,iBAAiBC,QAAQD,MAAME,OAAO,GAAGC,OAAOH;oBACzD;gBACF;YACF;YAEA,IAAInB,YAAYuB,MAAM,GAAG,KAAK,CAACnC,SAAS;gBACtC,OAAO;oBACLoC,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAMC,KAAKC,SAAS,CAAC;gCACnBC,SAAS;gCACTC,SAAS;gCACTT,SACE,CAAC,mBAAmB,EAAErC,WAAW,CAAC,EAAEG,WAAW,mBAAmB,CAAC,GACnE,GAAGa,YAAYuB,MAAM,CAAC,+DAA+D,CAAC;gCACxFQ,iBAAiB/B;4BACnB;wBACF;qBACD;gBACH;YACF;YAEA,MAAMgC,kBAAkBjC,WAAWkC,MAAM,CAAC,CAACC,KAAKC,IAAMD,MAAMC,EAAErB,KAAK,EAAE;YAErE,IAAIkB,kBAAkB,KAAK,CAAC5C,SAAS;gBACnC,OAAO;oBACLoC,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAMC,KAAKC,SAAS,CAAC;gCACnBC,SAAS;gCACTC,SAAS;gCACTT,SACE,CAAC,mBAAmB,EAAErC,WAAW,CAAC,EAAEG,WAAW,EAAE,EAAE6C,gBAAgB,sBAAsB,CAAC,GAC1F,CAAC,aAAa,EAAEjC,WAAWwB,MAAM,CAAC,mDAAmD,CAAC;gCACxFS;gCACAjC;4BACF;wBACF;qBACD;gBACH;YACF;YAEA,IAAI;gBACF,MAAML,IAAIS,OAAO,CAACiC,MAAM,CAAC;oBACvBpD,YAAYA;oBACZyB,IAAItB;oBACJO;oBACAiB,gBAAgB;oBAChBC,MAAMlB,IAAIkB,IAAI;gBAChB;gBAEA,OAAO;oBACLY,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAMC,KAAKC,SAAS,CAAC;gCACnBC,SAAS;gCACTR,SACEW,kBAAkB,IACd,CAAC,QAAQ,EAAEhD,WAAW,CAAC,EAAEG,WAAW,SAAS,EAAE6C,gBAAgB,qCAAqC,CAAC,GACrG,CAAC,QAAQ,EAAEhD,WAAW,CAAC,EAAEG,WAAW,mCAAmC,CAAC;gCAC9E6C;gCACAjC;4BACF;wBACF;qBACD;gBACH;YACF,EAAE,OAAOoB,OAAO;gBACd,MAAME,UAAUF,iBAAiBC,QAAQD,MAAME,OAAO,GAAGC,OAAOH;gBAChE,OAAO;oBACLK,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM,CAAC,eAAe,EAAE1C,WAAW,CAAC,EAAEG,WAAW,EAAE,EAAEkC,SAAS;wBAChE;qBACD;gBACH;YACF;QACF;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/tools/safe-delete.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { RelationshipEdge } from '../types'\nimport { errorMessage, jsonResponse, stampMcpContext, textResponse } from './_helpers'\n\nconst SAMPLE_LIMIT = 5\n\ninterface InboundReference {\n fromCollection: string\n fieldName: string\n count: number\n sampleIds: (string | number)[]\n}\n\ninterface ReverseEdge {\n fromCollection: string\n fieldName: string\n hasMany: boolean\n}\n\n/**\n * safeDelete wraps the official delete operation with a relationship-graph\n * pre-check.\n *\n * Workflow:\n * 1. Use the introspected relationshipGraph to find every (collection, field)\n * pair that points TO the target collection.\n * 2. For each, query for documents that reference the target ID. Aggregate\n * counts and a small sample of inbound document IDs.\n * 3. If any inbound references exist and `confirm` is false, refuse and\n * return the impact summary so the caller can decide.\n * 4. Otherwise, perform the delete.\n *\n * Excludes built-in Payload bookkeeping collections from the inbound search.\n */\nexport function createSafeDeleteTool(relationships: RelationshipEdge[]) {\n // reverseIndex: target collection slug → edges that reference it\n const reverseIndex = new Map<string, ReverseEdge[]>()\n for (const edge of relationships) {\n const targets = Array.isArray(edge.toCollection) ? edge.toCollection : [edge.toCollection]\n for (const target of targets) {\n if (!reverseIndex.has(target)) reverseIndex.set(target, [])\n reverseIndex.get(target)!.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n hasMany: edge.hasMany,\n })\n }\n }\n\n return {\n name: 'safeDelete',\n description:\n 'Delete a document only after checking for inbound relationships. ' +\n 'If other documents reference the target, the delete is refused unless `confirm` is true. ' +\n 'Use this in preference to the raw delete tools when removing entities that other content might depend on ' +\n '(authors, categories, media, locations, etc.). Returns the impact summary either way.',\n parameters: {\n collection: z.string().describe('Slug of the collection containing the document to delete'),\n documentId: z.string().describe('ID of the document to delete'),\n confirm: z\n .boolean()\n .optional()\n .default(false)\n .describe(\n 'If false (default), refuse to delete when inbound references exist and return the impact summary. ' +\n 'Pass true to delete anyway after reviewing the impact.',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; confirm?: boolean },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId, confirm = false } = args\n\n stampMcpContext(req)\n\n const inboundEdges = (reverseIndex.get(collection) ?? []).filter(\n (edge) => !edge.fromCollection.startsWith('payload-'),\n )\n\n const settled = await Promise.allSettled(\n inboundEdges.map((edge) =>\n req.payload.find({\n collection: edge.fromCollection as any,\n where: { [edge.fieldName]: { equals: documentId } } as any,\n limit: SAMPLE_LIMIT,\n select: { id: true } as any,\n // CRITICAL: include drafts. Without this, a draft document\n // referencing the target is invisible to the safety check and\n // the delete is allowed through — exactly the failure mode this\n // tool is supposed to prevent.\n draft: true,\n req,\n overrideAccess: false,\n user: req.user,\n }),\n ),\n )\n\n const references: InboundReference[] = []\n const failedEdges: { fromCollection: string; fieldName: string; error: string }[] = []\n\n settled.forEach((outcome, i) => {\n const edge = inboundEdges[i]\n if (outcome.status === 'rejected') {\n // Fail closed: an unverified edge means we don't know whether the\n // target is referenced. Refuse the delete unless the caller opts in.\n failedEdges.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n error: errorMessage(outcome.reason),\n })\n return\n }\n const result = outcome.value\n if (result.totalDocs > 0) {\n references.push({\n fromCollection: edge.fromCollection,\n fieldName: edge.fieldName,\n count: result.totalDocs,\n sampleIds: result.docs.map((d: any) => d.id),\n })\n }\n })\n\n if (failedEdges.length > 0 && !confirm) {\n return jsonResponse({\n success: false,\n refused: true,\n message:\n `Refusing to delete ${collection}#${documentId}: could not verify ` +\n `${failedEdges.length} inbound reference path(s). Pass confirm=true to delete anyway.`,\n unverifiedEdges: failedEdges,\n })\n }\n\n const totalReferences = references.reduce((sum, r) => sum + r.count, 0)\n\n if (totalReferences > 0 && !confirm) {\n return jsonResponse({\n success: false,\n refused: true,\n message:\n `Refusing to delete ${collection}#${documentId}: ${totalReferences} inbound reference(s) ` +\n `found across ${references.length} field path(s). Pass confirm=true to delete anyway.`,\n totalReferences,\n references,\n })\n }\n\n try {\n await req.payload.delete({\n collection: collection as any,\n id: documentId,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n return jsonResponse({\n success: true,\n message:\n totalReferences > 0\n ? `Deleted ${collection}#${documentId} despite ${totalReferences} inbound reference(s) (confirm=true).`\n : `Deleted ${collection}#${documentId}. No inbound references were found.`,\n totalReferences,\n references,\n })\n } catch (error) {\n return textResponse(\n `Error deleting ${collection}#${documentId}: ${errorMessage(error)}`,\n )\n }\n },\n }\n}\n"],"names":["z","errorMessage","jsonResponse","stampMcpContext","textResponse","SAMPLE_LIMIT","createSafeDeleteTool","relationships","reverseIndex","Map","edge","targets","Array","isArray","toCollection","target","has","set","get","push","fromCollection","fieldName","hasMany","name","description","parameters","collection","string","describe","documentId","confirm","boolean","optional","default","handler","args","req","_extra","inboundEdges","filter","startsWith","settled","Promise","allSettled","map","payload","find","where","equals","limit","select","id","draft","overrideAccess","user","references","failedEdges","forEach","outcome","i","status","error","reason","result","value","totalDocs","count","sampleIds","docs","d","length","success","refused","message","unverifiedEdges","totalReferences","reduce","sum","r","delete"],"mappings":"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"}
@@ -2,29 +2,24 @@ import { z } from 'zod';
2
2
  import type { PayloadRequest } from 'payload';
3
3
  import type { CollectionSchema } from '../types';
4
4
  /**
5
- * Creates the schedulePublish MCP tool that stamps a future publish time
6
- * on a draft document.
5
+ * schedulePublish stamps a future publish time on a draft document.
7
6
  *
8
- * IMPORTANT: This tool ONLY sets `publishedAt` to a future date and keeps
9
- * `_status: 'draft'`. It does NOT itself flip the draft to published when
10
- * the time arrives. The consumer's Payload config must own the actual flip,
11
- * via one of:
7
+ * IMPORTANT: this tool ONLY sets `publishedAt` and keeps `_status: 'draft'`.
8
+ * It does not flip the status when the time arrives. The consumer's Payload
9
+ * config must own the actual flip via:
12
10
  *
13
- * 1. A Payload Jobs Queue scheduled task that runs periodically and finds
14
- * drafts whose `publishedAt <= now`, then calls `payload.update` with
15
- * `_status: 'published'`. This is the canonical Payload-3 approach
16
- * (see https://payloadcms.com/docs/jobs-queue/scheduled-jobs).
17
- *
18
- * 2. An external cron / worker that does the same query + update.
19
- *
20
- * 3. A `beforeRead` hook on the collection that resolves status on the fly.
11
+ * 1. A Payload Jobs Queue scheduled task that finds drafts whose
12
+ * `publishedAt <= now` and updates `_status: 'published'`. This is the
13
+ * canonical Payload-3 approach (https://payloadcms.com/docs/jobs-queue/scheduled-jobs).
14
+ * 2. An external cron / worker doing the same query + update.
15
+ * 3. A `beforeRead` hook that resolves status on the fly.
21
16
  *
22
17
  * Without one of those, scheduled drafts remain drafts forever — the tool
23
18
  * tells the LLM exactly that in its response so the user doesn't get a
24
19
  * silent surprise.
25
20
  *
26
21
  * Skip-detects: only registered for collections that have BOTH drafts AND a
27
- * `publishedAt` field in their schema. Otherwise the tool is omitted entirely.
22
+ * `publishedAt` date field.
28
23
  */
29
24
  export declare function createSchedulePublishTool(collectionSchemas: Map<string, CollectionSchema>, draftCollections: Set<string>): ReturnType<typeof buildTool> | null;
30
25
  declare function buildTool(schedulableSlugs: string[]): {
@@ -39,11 +34,6 @@ declare function buildTool(schedulableSlugs: string[]): {
39
34
  collection: string;
40
35
  documentId: string;
41
36
  publishAt: string;
42
- }, req: PayloadRequest, _extra: unknown) => Promise<{
43
- content: {
44
- type: "text";
45
- text: string;
46
- }[];
47
- }>;
37
+ }, req: PayloadRequest, _extra: unknown) => Promise<import("./_helpers").McpTextResponse>;
48
38
  };
49
39
  export {};
@@ -1,28 +1,24 @@
1
1
  import { z } from 'zod';
2
+ import { errorMessage, getDocDisplayName, stampMcpContext, textResponse } from './_helpers';
2
3
  /**
3
- * Creates the schedulePublish MCP tool that stamps a future publish time
4
- * on a draft document.
4
+ * schedulePublish stamps a future publish time on a draft document.
5
5
  *
6
- * IMPORTANT: This tool ONLY sets `publishedAt` to a future date and keeps
7
- * `_status: 'draft'`. It does NOT itself flip the draft to published when
8
- * the time arrives. The consumer's Payload config must own the actual flip,
9
- * via one of:
6
+ * IMPORTANT: this tool ONLY sets `publishedAt` and keeps `_status: 'draft'`.
7
+ * It does not flip the status when the time arrives. The consumer's Payload
8
+ * config must own the actual flip via:
10
9
  *
11
- * 1. A Payload Jobs Queue scheduled task that runs periodically and finds
12
- * drafts whose `publishedAt <= now`, then calls `payload.update` with
13
- * `_status: 'published'`. This is the canonical Payload-3 approach
14
- * (see https://payloadcms.com/docs/jobs-queue/scheduled-jobs).
15
- *
16
- * 2. An external cron / worker that does the same query + update.
17
- *
18
- * 3. A `beforeRead` hook on the collection that resolves status on the fly.
10
+ * 1. A Payload Jobs Queue scheduled task that finds drafts whose
11
+ * `publishedAt <= now` and updates `_status: 'published'`. This is the
12
+ * canonical Payload-3 approach (https://payloadcms.com/docs/jobs-queue/scheduled-jobs).
13
+ * 2. An external cron / worker doing the same query + update.
14
+ * 3. A `beforeRead` hook that resolves status on the fly.
19
15
  *
20
16
  * Without one of those, scheduled drafts remain drafts forever — the tool
21
17
  * tells the LLM exactly that in its response so the user doesn't get a
22
18
  * silent surprise.
23
19
  *
24
20
  * Skip-detects: only registered for collections that have BOTH drafts AND a
25
- * `publishedAt` field in their schema. Otherwise the tool is omitted entirely.
21
+ * `publishedAt` date field.
26
22
  */ export function createSchedulePublishTool(collectionSchemas, draftCollections) {
27
23
  const schedulableSlugs = [];
28
24
  for (const slug of draftCollections){
@@ -46,40 +42,16 @@ function buildTool(schedulableSlugs) {
46
42
  handler: async (args, req, _extra)=>{
47
43
  const { collection, documentId, publishAt } = args;
48
44
  if (!schedulableSlugs.includes(collection)) {
49
- return {
50
- content: [
51
- {
52
- type: 'text',
53
- text: `Error: Collection "${collection}" is not schedulable. ` + `Schedulable collections: ${schedulableSlugs.join(', ')}. ` + 'A collection is schedulable when it has draft support AND a date field named "publishedAt".'
54
- }
55
- ]
56
- };
45
+ return textResponse(`Error: Collection "${collection}" is not schedulable. ` + `Schedulable collections: ${schedulableSlugs.join(', ')}. ` + 'A collection is schedulable when it has draft support AND a date field named "publishedAt".');
57
46
  }
58
47
  const parsed = new Date(publishAt);
59
48
  if (isNaN(parsed.getTime())) {
60
- return {
61
- content: [
62
- {
63
- type: 'text',
64
- text: `Error: "${publishAt}" is not a valid ISO 8601 date-time. Example: "2026-06-01T09:00:00Z"`
65
- }
66
- ]
67
- };
49
+ return textResponse(`Error: "${publishAt}" is not a valid ISO 8601 date-time. Example: "2026-06-01T09:00:00Z"`);
68
50
  }
69
51
  if (parsed.getTime() <= Date.now()) {
70
- return {
71
- content: [
72
- {
73
- type: 'text',
74
- text: `Error: publishAt (${parsed.toISOString()}) is not in the future. ` + `Use the publishDraft tool to publish immediately instead.`
75
- }
76
- ]
77
- };
52
+ return textResponse(`Error: publishAt (${parsed.toISOString()}) is not in the future. ` + `Use the publishDraft tool to publish immediately instead.`);
78
53
  }
79
- req.context = {
80
- ...req.context,
81
- source: 'mcp'
82
- };
54
+ stampMcpContext(req);
83
55
  try {
84
56
  const updated = await req.payload.update({
85
57
  collection: collection,
@@ -93,25 +65,10 @@ function buildTool(schedulableSlugs) {
93
65
  overrideAccess: false,
94
66
  user: req.user
95
67
  });
96
- const displayName = updated.name || updated.title || updated.slug || documentId;
97
- return {
98
- content: [
99
- {
100
- type: 'text',
101
- text: `Scheduled "${displayName}" (${collection}#${documentId}) for publish at ${parsed.toISOString()}. ` + `The document will remain a draft until a scheduled job (Payload jobs queue or external worker) ` + `flips its _status to "published". If your project has not configured such a job, the document ` + `will stay a draft indefinitely — see the Payload jobs queue docs for the canonical setup.`
102
- }
103
- ]
104
- };
68
+ const displayName = getDocDisplayName(updated, documentId);
69
+ return textResponse(`Scheduled "${displayName}" (${collection}#${documentId}) for publish at ${parsed.toISOString()}. ` + `The document will remain a draft until a scheduled job (Payload jobs queue or external worker) ` + `flips its _status to "published". If your project has not configured such a job, the document ` + `will stay a draft indefinitely — see the Payload jobs queue docs for the canonical setup.`);
105
70
  } catch (error) {
106
- const message = error instanceof Error ? error.message : String(error);
107
- return {
108
- content: [
109
- {
110
- type: 'text',
111
- text: `Error scheduling ${collection}#${documentId}: ${message}`
112
- }
113
- ]
114
- };
71
+ return textResponse(`Error scheduling ${collection}#${documentId}: ${errorMessage(error)}`);
115
72
  }
116
73
  }
117
74
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/schedule-publish.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\n\n/**\n * Creates the schedulePublish MCP tool that stamps a future publish time\n * on a draft document.\n *\n * IMPORTANT: This tool ONLY sets `publishedAt` to a future date and keeps\n * `_status: 'draft'`. It does NOT itself flip the draft to published when\n * the time arrives. The consumer's Payload config must own the actual flip,\n * via one of:\n *\n * 1. A Payload Jobs Queue scheduled task that runs periodically and finds\n * drafts whose `publishedAt <= now`, then calls `payload.update` with\n * `_status: 'published'`. This is the canonical Payload-3 approach\n * (see https://payloadcms.com/docs/jobs-queue/scheduled-jobs).\n *\n * 2. An external cron / worker that does the same query + update.\n *\n * 3. A `beforeRead` hook on the collection that resolves status on the fly.\n *\n * Without one of those, scheduled drafts remain drafts forever — the tool\n * tells the LLM exactly that in its response so the user doesn't get a\n * silent surprise.\n *\n * Skip-detects: only registered for collections that have BOTH drafts AND a\n * `publishedAt` field in their schema. Otherwise the tool is omitted entirely.\n */\nexport function createSchedulePublishTool(\n collectionSchemas: Map<string, CollectionSchema>,\n draftCollections: Set<string>,\n): ReturnType<typeof buildTool> | null {\n const schedulableSlugs: string[] = []\n for (const slug of draftCollections) {\n const schema = collectionSchemas.get(slug)\n if (!schema) continue\n const hasPublishedAt = schema.fields.some(\n (f) => f.name === 'publishedAt' && f.type === 'date',\n )\n if (hasPublishedAt) schedulableSlugs.push(slug)\n }\n\n if (schedulableSlugs.length === 0) return null\n return buildTool(schedulableSlugs)\n}\n\nfunction buildTool(schedulableSlugs: string[]) {\n return {\n name: 'schedulePublish',\n description:\n 'Schedule a draft to be published at a future date by stamping its publishedAt field. ' +\n 'The document stays in draft status until your Payload jobs queue (or an external worker) ' +\n 'flips it. If your project does not have a scheduled job that publishes drafts whose ' +\n 'publishedAt has passed, the document will remain a draft indefinitely. ' +\n `Schedulable collections (have both drafts and a publishedAt date field): ${schedulableSlugs.join(', ')}.`,\n parameters: {\n collection: z\n .string()\n .describe(`The collection slug. One of: ${schedulableSlugs.join(', ')}`),\n documentId: z.string().describe('The ID of the draft document to schedule'),\n publishAt: z\n .string()\n .describe(\n 'ISO 8601 date-time when the document should be published, e.g. \"2026-06-01T09:00:00Z\". ' +\n 'Must be in the future.',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; publishAt: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId, publishAt } = args\n\n if (!schedulableSlugs.includes(collection)) {\n return {\n content: [\n {\n type: 'text' as const,\n text:\n `Error: Collection \"${collection}\" is not schedulable. ` +\n `Schedulable collections: ${schedulableSlugs.join(', ')}. ` +\n 'A collection is schedulable when it has draft support AND a date field named \"publishedAt\".',\n },\n ],\n }\n }\n\n const parsed = new Date(publishAt)\n if (isNaN(parsed.getTime())) {\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error: \"${publishAt}\" is not a valid ISO 8601 date-time. Example: \"2026-06-01T09:00:00Z\"`,\n },\n ],\n }\n }\n\n if (parsed.getTime() <= Date.now()) {\n return {\n content: [\n {\n type: 'text' as const,\n text:\n `Error: publishAt (${parsed.toISOString()}) is not in the future. ` +\n `Use the publishDraft tool to publish immediately instead.`,\n },\n ],\n }\n }\n\n req.context = { ...req.context, source: 'mcp' }\n\n try {\n const updated = await req.payload.update({\n collection: collection as any,\n id: documentId,\n data: {\n publishedAt: parsed.toISOString(),\n _status: 'draft',\n } as any,\n draft: true,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n const displayName =\n (updated as any).name ||\n (updated as any).title ||\n (updated as any).slug ||\n documentId\n\n return {\n content: [\n {\n type: 'text' as const,\n text:\n `Scheduled \"${displayName}\" (${collection}#${documentId}) for publish at ${parsed.toISOString()}. ` +\n `The document will remain a draft until a scheduled job (Payload jobs queue or external worker) ` +\n `flips its _status to \"published\". If your project has not configured such a job, the document ` +\n `will stay a draft indefinitely — see the Payload jobs queue docs for the canonical setup.`,\n },\n ],\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error scheduling ${collection}#${documentId}: ${message}`,\n },\n ],\n }\n }\n },\n }\n}\n"],"names":["z","createSchedulePublishTool","collectionSchemas","draftCollections","schedulableSlugs","slug","schema","get","hasPublishedAt","fields","some","f","name","type","push","length","buildTool","description","join","parameters","collection","string","describe","documentId","publishAt","handler","args","req","_extra","includes","content","text","parsed","Date","isNaN","getTime","now","toISOString","context","source","updated","payload","update","id","data","publishedAt","_status","draft","overrideAccess","user","displayName","title","error","message","Error","String"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tools/schedule-publish.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\nimport {\n errorMessage,\n getDocDisplayName,\n stampMcpContext,\n textResponse,\n} from './_helpers'\n\n/**\n * schedulePublish stamps a future publish time on a draft document.\n *\n * IMPORTANT: this tool ONLY sets `publishedAt` and keeps `_status: 'draft'`.\n * It does not flip the status when the time arrives. The consumer's Payload\n * config must own the actual flip via:\n *\n * 1. A Payload Jobs Queue scheduled task that finds drafts whose\n * `publishedAt <= now` and updates `_status: 'published'`. This is the\n * canonical Payload-3 approach (https://payloadcms.com/docs/jobs-queue/scheduled-jobs).\n * 2. An external cron / worker doing the same query + update.\n * 3. A `beforeRead` hook that resolves status on the fly.\n *\n * Without one of those, scheduled drafts remain drafts forever — the tool\n * tells the LLM exactly that in its response so the user doesn't get a\n * silent surprise.\n *\n * Skip-detects: only registered for collections that have BOTH drafts AND a\n * `publishedAt` date field.\n */\nexport function createSchedulePublishTool(\n collectionSchemas: Map<string, CollectionSchema>,\n draftCollections: Set<string>,\n): ReturnType<typeof buildTool> | null {\n const schedulableSlugs: string[] = []\n for (const slug of draftCollections) {\n const schema = collectionSchemas.get(slug)\n if (!schema) continue\n const hasPublishedAt = schema.fields.some(\n (f) => f.name === 'publishedAt' && f.type === 'date',\n )\n if (hasPublishedAt) schedulableSlugs.push(slug)\n }\n\n if (schedulableSlugs.length === 0) return null\n return buildTool(schedulableSlugs)\n}\n\nfunction buildTool(schedulableSlugs: string[]) {\n return {\n name: 'schedulePublish',\n description:\n 'Schedule a draft to be published at a future date by stamping its publishedAt field. ' +\n 'The document stays in draft status until your Payload jobs queue (or an external worker) ' +\n 'flips it. If your project does not have a scheduled job that publishes drafts whose ' +\n 'publishedAt has passed, the document will remain a draft indefinitely. ' +\n `Schedulable collections (have both drafts and a publishedAt date field): ${schedulableSlugs.join(', ')}.`,\n parameters: {\n collection: z\n .string()\n .describe(`The collection slug. One of: ${schedulableSlugs.join(', ')}`),\n documentId: z.string().describe('The ID of the draft document to schedule'),\n publishAt: z\n .string()\n .describe(\n 'ISO 8601 date-time when the document should be published, e.g. \"2026-06-01T09:00:00Z\". ' +\n 'Must be in the future.',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; publishAt: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId, publishAt } = args\n\n if (!schedulableSlugs.includes(collection)) {\n return textResponse(\n `Error: Collection \"${collection}\" is not schedulable. ` +\n `Schedulable collections: ${schedulableSlugs.join(', ')}. ` +\n 'A collection is schedulable when it has draft support AND a date field named \"publishedAt\".',\n )\n }\n\n const parsed = new Date(publishAt)\n if (isNaN(parsed.getTime())) {\n return textResponse(\n `Error: \"${publishAt}\" is not a valid ISO 8601 date-time. Example: \"2026-06-01T09:00:00Z\"`,\n )\n }\n\n if (parsed.getTime() <= Date.now()) {\n return textResponse(\n `Error: publishAt (${parsed.toISOString()}) is not in the future. ` +\n `Use the publishDraft tool to publish immediately instead.`,\n )\n }\n\n stampMcpContext(req)\n\n try {\n const updated = await req.payload.update({\n collection: collection as any,\n id: documentId,\n data: {\n publishedAt: parsed.toISOString(),\n _status: 'draft',\n } as any,\n draft: true,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n const displayName = getDocDisplayName(updated, documentId)\n return textResponse(\n `Scheduled \"${displayName}\" (${collection}#${documentId}) for publish at ${parsed.toISOString()}. ` +\n `The document will remain a draft until a scheduled job (Payload jobs queue or external worker) ` +\n `flips its _status to \"published\". If your project has not configured such a job, the document ` +\n `will stay a draft indefinitely — see the Payload jobs queue docs for the canonical setup.`,\n )\n } catch (error) {\n return textResponse(\n `Error scheduling ${collection}#${documentId}: ${errorMessage(error)}`,\n )\n }\n },\n }\n}\n"],"names":["z","errorMessage","getDocDisplayName","stampMcpContext","textResponse","createSchedulePublishTool","collectionSchemas","draftCollections","schedulableSlugs","slug","schema","get","hasPublishedAt","fields","some","f","name","type","push","length","buildTool","description","join","parameters","collection","string","describe","documentId","publishAt","handler","args","req","_extra","includes","parsed","Date","isNaN","getTime","now","toISOString","updated","payload","update","id","data","publishedAt","_status","draft","overrideAccess","user","displayName","error"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAGvB,SACEC,YAAY,EACZC,iBAAiB,EACjBC,eAAe,EACfC,YAAY,QACP,aAAY;AAEnB;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,0BACdC,iBAAgD,EAChDC,gBAA6B;IAE7B,MAAMC,mBAA6B,EAAE;IACrC,KAAK,MAAMC,QAAQF,iBAAkB;QACnC,MAAMG,SAASJ,kBAAkBK,GAAG,CAACF;QACrC,IAAI,CAACC,QAAQ;QACb,MAAME,iBAAiBF,OAAOG,MAAM,CAACC,IAAI,CACvC,CAACC,IAAMA,EAAEC,IAAI,KAAK,iBAAiBD,EAAEE,IAAI,KAAK;QAEhD,IAAIL,gBAAgBJ,iBAAiBU,IAAI,CAACT;IAC5C;IAEA,IAAID,iBAAiBW,MAAM,KAAK,GAAG,OAAO;IAC1C,OAAOC,UAAUZ;AACnB;AAEA,SAASY,UAAUZ,gBAA0B;IAC3C,OAAO;QACLQ,MAAM;QACNK,aACE,0FACA,8FACA,yFACA,4EACA,CAAC,yEAAyE,EAAEb,iBAAiBc,IAAI,CAAC,MAAM,CAAC,CAAC;QAC5GC,YAAY;YACVC,YAAYxB,EACTyB,MAAM,GACNC,QAAQ,CAAC,CAAC,6BAA6B,EAAElB,iBAAiBc,IAAI,CAAC,OAAO;YACzEK,YAAY3B,EAAEyB,MAAM,GAAGC,QAAQ,CAAC;YAChCE,WAAW5B,EACRyB,MAAM,GACNC,QAAQ,CACP,4FACA;QAEN;QACAG,SAAS,OACPC,MACAC,KACAC;YAEA,MAAM,EAAER,UAAU,EAAEG,UAAU,EAAEC,SAAS,EAAE,GAAGE;YAE9C,IAAI,CAACtB,iBAAiByB,QAAQ,CAACT,aAAa;gBAC1C,OAAOpB,aACL,CAAC,mBAAmB,EAAEoB,WAAW,sBAAsB,CAAC,GACtD,CAAC,yBAAyB,EAAEhB,iBAAiBc,IAAI,CAAC,MAAM,EAAE,CAAC,GAC3D;YAEN;YAEA,MAAMY,SAAS,IAAIC,KAAKP;YACxB,IAAIQ,MAAMF,OAAOG,OAAO,KAAK;gBAC3B,OAAOjC,aACL,CAAC,QAAQ,EAAEwB,UAAU,oEAAoE,CAAC;YAE9F;YAEA,IAAIM,OAAOG,OAAO,MAAMF,KAAKG,GAAG,IAAI;gBAClC,OAAOlC,aACL,CAAC,kBAAkB,EAAE8B,OAAOK,WAAW,GAAG,wBAAwB,CAAC,GACjE,CAAC,yDAAyD,CAAC;YAEjE;YAEApC,gBAAgB4B;YAEhB,IAAI;gBACF,MAAMS,UAAU,MAAMT,IAAIU,OAAO,CAACC,MAAM,CAAC;oBACvClB,YAAYA;oBACZmB,IAAIhB;oBACJiB,MAAM;wBACJC,aAAaX,OAAOK,WAAW;wBAC/BO,SAAS;oBACX;oBACAC,OAAO;oBACPhB;oBACAiB,gBAAgB;oBAChBC,MAAMlB,IAAIkB,IAAI;gBAChB;gBAEA,MAAMC,cAAchD,kBAAkBsC,SAASb;gBAC/C,OAAOvB,aACL,CAAC,WAAW,EAAE8C,YAAY,GAAG,EAAE1B,WAAW,CAAC,EAAEG,WAAW,iBAAiB,EAAEO,OAAOK,WAAW,GAAG,EAAE,CAAC,GACjG,CAAC,+FAA+F,CAAC,GACjG,CAAC,8FAA8F,CAAC,GAChG,CAAC,yFAAyF,CAAC;YAEjG,EAAE,OAAOY,OAAO;gBACd,OAAO/C,aACL,CAAC,iBAAiB,EAAEoB,WAAW,CAAC,EAAEG,WAAW,EAAE,EAAE1B,aAAakD,QAAQ;YAE1E;QACF;IACF;AACF"}
@@ -34,10 +34,5 @@ export declare function createSearchContentTool(collectionSchemas: Map<string, C
34
34
  newerThanDays?: number;
35
35
  missingFields?: string[];
36
36
  limit?: number;
37
- }, req: PayloadRequest, _extra: unknown) => Promise<{
38
- content: {
39
- type: "text";
40
- text: string;
41
- }[];
42
- }>;
37
+ }, req: PayloadRequest, _extra: unknown) => Promise<import("./_helpers").McpTextResponse>;
43
38
  };