payload-mcp-toolkit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/__tests__/introspection.test.js +364 -0
  4. package/dist/__tests__/introspection.test.js.map +1 -0
  5. package/dist/__tests__/url-validator.test.js +326 -0
  6. package/dist/__tests__/url-validator.test.js.map +1 -0
  7. package/dist/draft-workflow.d.ts +60 -0
  8. package/dist/draft-workflow.js +93 -0
  9. package/dist/draft-workflow.js.map +1 -0
  10. package/dist/index.d.ts +24 -0
  11. package/dist/index.js +142 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/introspection.d.ts +23 -0
  14. package/dist/introspection.js +238 -0
  15. package/dist/introspection.js.map +1 -0
  16. package/dist/prompts.d.ts +21 -0
  17. package/dist/prompts.js +215 -0
  18. package/dist/prompts.js.map +1 -0
  19. package/dist/rate-limiter.d.ts +25 -0
  20. package/dist/rate-limiter.js +51 -0
  21. package/dist/rate-limiter.js.map +1 -0
  22. package/dist/resources.d.ts +18 -0
  23. package/dist/resources.js +77 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/compose-helpers.d.ts +117 -0
  26. package/dist/tools/compose-helpers.js +236 -0
  27. package/dist/tools/compose-helpers.js.map +1 -0
  28. package/dist/tools/compose-layout.d.ts +139 -0
  29. package/dist/tools/compose-layout.js +61 -0
  30. package/dist/tools/compose-layout.js.map +1 -0
  31. package/dist/tools/patch-layout.d.ts +107 -0
  32. package/dist/tools/patch-layout.js +123 -0
  33. package/dist/tools/patch-layout.js.map +1 -0
  34. package/dist/tools/publish-draft.d.ts +24 -0
  35. package/dist/tools/publish-draft.js +69 -0
  36. package/dist/tools/publish-draft.js.map +1 -0
  37. package/dist/tools/resolve-reference.d.ts +31 -0
  38. package/dist/tools/resolve-reference.js +169 -0
  39. package/dist/tools/resolve-reference.js.map +1 -0
  40. package/dist/tools/safe-delete.d.ts +37 -0
  41. package/dist/tools/safe-delete.js +161 -0
  42. package/dist/tools/safe-delete.js.map +1 -0
  43. package/dist/tools/schedule-publish.d.ts +49 -0
  44. package/dist/tools/schedule-publish.js +120 -0
  45. package/dist/tools/schedule-publish.js.map +1 -0
  46. package/dist/tools/search-content.d.ts +43 -0
  47. package/dist/tools/search-content.js +210 -0
  48. package/dist/tools/search-content.js.map +1 -0
  49. package/dist/tools/update-document.d.ts +32 -0
  50. package/dist/tools/update-document.js +114 -0
  51. package/dist/tools/update-document.js.map +1 -0
  52. package/dist/tools/upload-media.d.ts +26 -0
  53. package/dist/tools/upload-media.js +115 -0
  54. package/dist/tools/upload-media.js.map +1 -0
  55. package/dist/tools/versions.d.ts +50 -0
  56. package/dist/tools/versions.js +159 -0
  57. package/dist/tools/versions.js.map +1 -0
  58. package/dist/types.d.ts +118 -0
  59. package/dist/types.js +3 -0
  60. package/dist/types.js.map +1 -0
  61. package/dist/url-validator.d.ts +36 -0
  62. package/dist/url-validator.js +222 -0
  63. package/dist/url-validator.js.map +1 -0
  64. package/package.json +85 -0
@@ -0,0 +1,161 @@
1
+ import { z } from 'zod';
2
+ const SAMPLE_LIMIT = 5;
3
+ /**
4
+ * Creates the safeDelete MCP tool that wraps the official delete operation
5
+ * with a relationship-graph pre-check.
6
+ *
7
+ * Workflow:
8
+ * 1. Use the introspected relationshipGraph to find every (collection, field)
9
+ * 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.
15
+ *
16
+ * Excludes built-in Payload bookkeeping collections from the inbound search.
17
+ */ export function createSafeDeleteTool(relationships) {
18
+ // Build a reverse index: target collection -> [(fromCollection, fieldName, hasMany)]
19
+ const reverseIndex = new Map();
20
+ for (const edge of relationships){
21
+ const targets = Array.isArray(edge.toCollection) ? edge.toCollection : [
22
+ edge.toCollection
23
+ ];
24
+ for (const target of targets){
25
+ if (!reverseIndex.has(target)) reverseIndex.set(target, []);
26
+ reverseIndex.get(target).push({
27
+ fromCollection: edge.fromCollection,
28
+ fieldName: edge.fieldName,
29
+ hasMany: edge.hasMany
30
+ });
31
+ }
32
+ }
33
+ return {
34
+ name: 'safeDelete',
35
+ description: 'Delete a document only after checking for inbound relationships. ' + 'If other documents reference the target, the delete is refused unless `confirm` is true. ' + 'Use this in preference to the raw delete tools when removing entities that other content might depend on ' + '(authors, categories, media, locations, etc.). Returns the impact summary either way.',
36
+ parameters: {
37
+ collection: z.string().describe('Slug of the collection containing the document to delete'),
38
+ documentId: z.string().describe('ID of the document to delete'),
39
+ confirm: z.boolean().optional().default(false).describe('If false (default), refuse to delete when inbound references exist and return the impact summary. ' + 'Pass true to delete anyway after reviewing the impact.')
40
+ },
41
+ handler: async (args, req, _extra)=>{
42
+ 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) ?? [];
49
+ const references = [];
50
+ 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`.
86
+ failedEdges.push({
87
+ fromCollection: edge.fromCollection,
88
+ fieldName: edge.fieldName,
89
+ error: error instanceof Error ? error.message : String(error)
90
+ });
91
+ }
92
+ }
93
+ 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
+ };
107
+ }
108
+ const totalReferences = references.reduce((sum, r)=>sum + r.count, 0);
109
+ 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
+ };
124
+ }
125
+ try {
126
+ await req.payload.delete({
127
+ collection: collection,
128
+ id: documentId,
129
+ req,
130
+ overrideAccess: false,
131
+ user: req.user
132
+ });
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
+ };
146
+ } 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
+ };
156
+ }
157
+ }
158
+ };
159
+ }
160
+
161
+ //# sourceMappingURL=safe-delete.js.map
@@ -0,0 +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":"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"}
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+ import type { PayloadRequest } from 'payload';
3
+ import type { CollectionSchema } from '../types';
4
+ /**
5
+ * Creates the schedulePublish MCP tool that stamps a future publish time
6
+ * on a draft document.
7
+ *
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:
12
+ *
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.
21
+ *
22
+ * Without one of those, scheduled drafts remain drafts forever — the tool
23
+ * tells the LLM exactly that in its response so the user doesn't get a
24
+ * silent surprise.
25
+ *
26
+ * Skip-detects: only registered for collections that have BOTH drafts AND a
27
+ * `publishedAt` field in their schema. Otherwise the tool is omitted entirely.
28
+ */
29
+ export declare function createSchedulePublishTool(collectionSchemas: Map<string, CollectionSchema>, draftCollections: Set<string>): ReturnType<typeof buildTool> | null;
30
+ declare function buildTool(schedulableSlugs: string[]): {
31
+ name: string;
32
+ description: string;
33
+ parameters: {
34
+ collection: z.ZodString;
35
+ documentId: z.ZodString;
36
+ publishAt: z.ZodString;
37
+ };
38
+ handler: (args: {
39
+ collection: string;
40
+ documentId: string;
41
+ publishAt: string;
42
+ }, req: PayloadRequest, _extra: unknown) => Promise<{
43
+ content: {
44
+ type: "text";
45
+ text: string;
46
+ }[];
47
+ }>;
48
+ };
49
+ export {};
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Creates the schedulePublish MCP tool that stamps a future publish time
4
+ * on a draft document.
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:
10
+ *
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.
19
+ *
20
+ * Without one of those, scheduled drafts remain drafts forever — the tool
21
+ * tells the LLM exactly that in its response so the user doesn't get a
22
+ * silent surprise.
23
+ *
24
+ * Skip-detects: only registered for collections that have BOTH drafts AND a
25
+ * `publishedAt` field in their schema. Otherwise the tool is omitted entirely.
26
+ */ export function createSchedulePublishTool(collectionSchemas, draftCollections) {
27
+ const schedulableSlugs = [];
28
+ for (const slug of draftCollections){
29
+ const schema = collectionSchemas.get(slug);
30
+ if (!schema) continue;
31
+ const hasPublishedAt = schema.fields.some((f)=>f.name === 'publishedAt' && f.type === 'date');
32
+ if (hasPublishedAt) schedulableSlugs.push(slug);
33
+ }
34
+ if (schedulableSlugs.length === 0) return null;
35
+ return buildTool(schedulableSlugs);
36
+ }
37
+ function buildTool(schedulableSlugs) {
38
+ return {
39
+ name: 'schedulePublish',
40
+ description: 'Schedule a draft to be published at a future date by stamping its publishedAt field. ' + 'The document stays in draft status until your Payload jobs queue (or an external worker) ' + 'flips it. If your project does not have a scheduled job that publishes drafts whose ' + 'publishedAt has passed, the document will remain a draft indefinitely. ' + `Schedulable collections (have both drafts and a publishedAt date field): ${schedulableSlugs.join(', ')}.`,
41
+ parameters: {
42
+ collection: z.string().describe(`The collection slug. One of: ${schedulableSlugs.join(', ')}`),
43
+ documentId: z.string().describe('The ID of the draft document to schedule'),
44
+ publishAt: z.string().describe('ISO 8601 date-time when the document should be published, e.g. "2026-06-01T09:00:00Z". ' + 'Must be in the future.')
45
+ },
46
+ handler: async (args, req, _extra)=>{
47
+ const { collection, documentId, publishAt } = args;
48
+ 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
+ };
57
+ }
58
+ const parsed = new Date(publishAt);
59
+ 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
+ };
68
+ }
69
+ 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
+ };
78
+ }
79
+ req.context = {
80
+ ...req.context,
81
+ source: 'mcp'
82
+ };
83
+ try {
84
+ const updated = await req.payload.update({
85
+ collection: collection,
86
+ id: documentId,
87
+ data: {
88
+ publishedAt: parsed.toISOString(),
89
+ _status: 'draft'
90
+ },
91
+ draft: true,
92
+ req,
93
+ overrideAccess: false,
94
+ user: req.user
95
+ });
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
+ };
105
+ } 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
+ };
115
+ }
116
+ }
117
+ };
118
+ }
119
+
120
+ //# sourceMappingURL=schedule-publish.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ import type { PayloadRequest } from 'payload';
3
+ import type { CollectionSchema } from '../types';
4
+ /**
5
+ * Creates the searchContent MCP tool — natural-language filters across collections,
6
+ * built for editor triage tasks that the official find tools don't express well.
7
+ *
8
+ * Examples the LLM can drive:
9
+ * - "all posts that are still drafts"
10
+ * - "pages missing a meta description"
11
+ * - "anything updated more than 30 days ago"
12
+ * - "posts by jane in the last quarter"
13
+ *
14
+ * Returns compact hits per collection — id, displayName, _status, updatedAt, and
15
+ * (when missingFields was requested) which of those fields are blank on each doc.
16
+ */
17
+ export declare function createSearchContentTool(collectionSchemas: Map<string, CollectionSchema>): {
18
+ name: string;
19
+ description: string;
20
+ parameters: {
21
+ collection: z.ZodOptional<z.ZodString>;
22
+ query: z.ZodOptional<z.ZodString>;
23
+ status: z.ZodOptional<z.ZodEnum<["draft", "published", "any"]>>;
24
+ olderThanDays: z.ZodOptional<z.ZodNumber>;
25
+ newerThanDays: z.ZodOptional<z.ZodNumber>;
26
+ missingFields: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
27
+ limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
28
+ };
29
+ handler: (args: {
30
+ collection?: string;
31
+ query?: string;
32
+ status?: "draft" | "published" | "any";
33
+ olderThanDays?: number;
34
+ newerThanDays?: number;
35
+ missingFields?: string[];
36
+ limit?: number;
37
+ }, req: PayloadRequest, _extra: unknown) => Promise<{
38
+ content: {
39
+ type: "text";
40
+ text: string;
41
+ }[];
42
+ }>;
43
+ };
@@ -0,0 +1,210 @@
1
+ import { z } from 'zod';
2
+ const DEFAULT_LIMIT = 20;
3
+ const HARD_LIMIT = 100;
4
+ /**
5
+ * Creates the searchContent MCP tool — natural-language filters across collections,
6
+ * built for editor triage tasks that the official find tools don't express well.
7
+ *
8
+ * Examples the LLM can drive:
9
+ * - "all posts that are still drafts"
10
+ * - "pages missing a meta description"
11
+ * - "anything updated more than 30 days ago"
12
+ * - "posts by jane in the last quarter"
13
+ *
14
+ * Returns compact hits per collection — id, displayName, _status, updatedAt, and
15
+ * (when missingFields was requested) which of those fields are blank on each doc.
16
+ */ export function createSearchContentTool(collectionSchemas) {
17
+ const allSlugs = [
18
+ ...collectionSchemas.keys()
19
+ ];
20
+ return {
21
+ name: 'searchContent',
22
+ description: 'Search and filter content across collections by status, age, missing fields, or free-text query. ' + 'Designed for editor triage — finding drafts, stale content, content with missing SEO fields, etc. ' + `Searchable collections: ${allSlugs.join(', ')}.`,
23
+ parameters: {
24
+ collection: z.string().optional().describe('Restrict search to a single collection slug. Omit to search all.'),
25
+ query: z.string().optional().describe('Free-text query matched against name/title/slug fields (case-insensitive).'),
26
+ status: z.enum([
27
+ 'draft',
28
+ 'published',
29
+ 'any'
30
+ ]).optional().describe('Filter by draft status. "draft" or "published" only return matching docs; "any" or omitted returns all.'),
31
+ olderThanDays: z.number().optional().describe('Only docs whose updatedAt is older than this many days.'),
32
+ newerThanDays: z.number().optional().describe('Only docs whose updatedAt is newer than this many days.'),
33
+ missingFields: z.array(z.string()).optional().describe('Field names that should be empty/null. Useful for finding e.g. posts without a coverImage. ' + 'Each hit will include a missingFields array confirming which were actually blank.'),
34
+ limit: z.number().optional().default(DEFAULT_LIMIT).describe(`Maximum hits per collection (default ${DEFAULT_LIMIT}, max ${HARD_LIMIT}).`)
35
+ },
36
+ handler: async (args, req, _extra)=>{
37
+ const { collection, query, status, olderThanDays, newerThanDays, missingFields, limit = DEFAULT_LIMIT } = args;
38
+ req.context = {
39
+ ...req.context,
40
+ source: 'mcp'
41
+ };
42
+ const cappedLimit = Math.min(Math.max(1, limit), HARD_LIMIT);
43
+ // Determine target collections.
44
+ // When the user filters by a specific draft status, only consider collections
45
+ // that actually support drafts — other collections are not "draft" or
46
+ // "published" in any meaningful sense and shouldn't pollute the results.
47
+ const isDraftStatusFilter = status === 'draft' || status === 'published';
48
+ const initialTargets = collection ? collectionSchemas.has(collection) ? [
49
+ collection
50
+ ] : [] : allSlugs;
51
+ const targets = isDraftStatusFilter ? initialTargets.filter((slug)=>collectionSchemas.get(slug)?.hasDrafts) : initialTargets;
52
+ if (targets.length === 0) {
53
+ return {
54
+ content: [
55
+ {
56
+ type: 'text',
57
+ text: JSON.stringify({
58
+ hits: {},
59
+ message: collection ? `Unknown collection "${collection}". Available: ${allSlugs.join(', ')}` : 'No searchable collections found.'
60
+ })
61
+ }
62
+ ]
63
+ };
64
+ }
65
+ const grouped = {};
66
+ const stats = {};
67
+ for (const slug of targets){
68
+ const schema = collectionSchemas.get(slug);
69
+ const where = buildWhereClause(schema, {
70
+ query,
71
+ status,
72
+ olderThanDays,
73
+ newerThanDays,
74
+ missingFields
75
+ });
76
+ try {
77
+ const result = await req.payload.find({
78
+ collection: slug,
79
+ where: where,
80
+ limit: cappedLimit,
81
+ sort: '-updatedAt',
82
+ depth: 0,
83
+ // Include drafts so status='draft' actually works and so missingFields
84
+ // queries against draft-only collections aren't misleadingly empty.
85
+ draft: schema.hasDrafts,
86
+ req,
87
+ overrideAccess: false,
88
+ user: req.user
89
+ });
90
+ if (result.totalDocs > 0) {
91
+ stats[slug] = {
92
+ totalDocs: result.totalDocs,
93
+ returned: result.docs.length
94
+ };
95
+ grouped[slug] = result.docs.map((doc)=>buildHit(doc, missingFields));
96
+ }
97
+ } catch {
98
+ continue;
99
+ }
100
+ }
101
+ const totalHits = Object.values(grouped).reduce((sum, hits)=>sum + hits.length, 0);
102
+ return {
103
+ content: [
104
+ {
105
+ type: 'text',
106
+ text: JSON.stringify({
107
+ totalHits,
108
+ stats,
109
+ hits: grouped
110
+ })
111
+ }
112
+ ]
113
+ };
114
+ }
115
+ };
116
+ }
117
+ function buildHit(doc, missingFields) {
118
+ const hit = {
119
+ id: doc.id,
120
+ displayName: doc.name || doc.title || doc.slug || String(doc.id),
121
+ status: doc._status,
122
+ updatedAt: doc.updatedAt
123
+ };
124
+ if (missingFields?.length) {
125
+ hit.missingFields = missingFields.filter((f)=>isFieldEmpty(getByPath(doc, f)));
126
+ }
127
+ return hit;
128
+ }
129
+ function getByPath(doc, path) {
130
+ // Walk dotted paths so `meta.description` reads doc.meta?.description rather
131
+ // than the literal key `"meta.description"`. Matches the `where` keys we emit.
132
+ let current = doc;
133
+ for (const segment of path.split('.')){
134
+ if (current === null || current === undefined) return undefined;
135
+ current = current[segment];
136
+ }
137
+ return current;
138
+ }
139
+ function isFieldEmpty(value) {
140
+ if (value === null || value === undefined) return true;
141
+ if (typeof value === 'string') return value.trim() === '';
142
+ if (Array.isArray(value)) return value.length === 0;
143
+ if (typeof value === 'object') return Object.keys(value).length === 0;
144
+ return false;
145
+ }
146
+ function buildWhereClause(schema, filters) {
147
+ const and = [];
148
+ // Free-text query against searchable fields
149
+ if (filters.query && schema.searchableFields.length > 0) {
150
+ const or = schema.searchableFields.map((field)=>({
151
+ [field]: {
152
+ like: filters.query
153
+ }
154
+ }));
155
+ and.push({
156
+ or
157
+ });
158
+ }
159
+ // Status filter (only meaningful on draft-enabled collections)
160
+ if (filters.status && filters.status !== 'any' && schema.hasDrafts) {
161
+ and.push({
162
+ _status: {
163
+ equals: filters.status
164
+ }
165
+ });
166
+ }
167
+ // Age filters
168
+ if (filters.olderThanDays !== undefined) {
169
+ const cutoff = new Date(Date.now() - filters.olderThanDays * 24 * 60 * 60 * 1000);
170
+ and.push({
171
+ updatedAt: {
172
+ less_than: cutoff.toISOString()
173
+ }
174
+ });
175
+ }
176
+ if (filters.newerThanDays !== undefined) {
177
+ const cutoff = new Date(Date.now() - filters.newerThanDays * 24 * 60 * 60 * 1000);
178
+ and.push({
179
+ updatedAt: {
180
+ greater_than: cutoff.toISOString()
181
+ }
182
+ });
183
+ }
184
+ // Missing-field filter — express as "field is null OR field doesn't exist"
185
+ if (filters.missingFields?.length) {
186
+ for (const field of filters.missingFields){
187
+ and.push({
188
+ or: [
189
+ {
190
+ [field]: {
191
+ exists: false
192
+ }
193
+ },
194
+ {
195
+ [field]: {
196
+ equals: null
197
+ }
198
+ }
199
+ ]
200
+ });
201
+ }
202
+ }
203
+ if (and.length === 0) return {};
204
+ if (and.length === 1) return and[0];
205
+ return {
206
+ and
207
+ };
208
+ }
209
+
210
+ //# sourceMappingURL=search-content.js.map