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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/__tests__/introspection.test.js +364 -0
- package/dist/__tests__/introspection.test.js.map +1 -0
- package/dist/__tests__/url-validator.test.js +326 -0
- package/dist/__tests__/url-validator.test.js.map +1 -0
- package/dist/draft-workflow.d.ts +60 -0
- package/dist/draft-workflow.js +93 -0
- package/dist/draft-workflow.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +142 -0
- package/dist/index.js.map +1 -0
- package/dist/introspection.d.ts +23 -0
- package/dist/introspection.js +238 -0
- package/dist/introspection.js.map +1 -0
- package/dist/prompts.d.ts +21 -0
- package/dist/prompts.js +215 -0
- package/dist/prompts.js.map +1 -0
- package/dist/rate-limiter.d.ts +25 -0
- package/dist/rate-limiter.js +51 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/resources.d.ts +18 -0
- package/dist/resources.js +77 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/compose-helpers.d.ts +117 -0
- package/dist/tools/compose-helpers.js +236 -0
- package/dist/tools/compose-helpers.js.map +1 -0
- package/dist/tools/compose-layout.d.ts +139 -0
- package/dist/tools/compose-layout.js +61 -0
- package/dist/tools/compose-layout.js.map +1 -0
- package/dist/tools/patch-layout.d.ts +107 -0
- package/dist/tools/patch-layout.js +123 -0
- package/dist/tools/patch-layout.js.map +1 -0
- package/dist/tools/publish-draft.d.ts +24 -0
- package/dist/tools/publish-draft.js +69 -0
- package/dist/tools/publish-draft.js.map +1 -0
- package/dist/tools/resolve-reference.d.ts +31 -0
- package/dist/tools/resolve-reference.js +169 -0
- package/dist/tools/resolve-reference.js.map +1 -0
- package/dist/tools/safe-delete.d.ts +37 -0
- package/dist/tools/safe-delete.js +161 -0
- package/dist/tools/safe-delete.js.map +1 -0
- package/dist/tools/schedule-publish.d.ts +49 -0
- package/dist/tools/schedule-publish.js +120 -0
- package/dist/tools/schedule-publish.js.map +1 -0
- package/dist/tools/search-content.d.ts +43 -0
- package/dist/tools/search-content.js +210 -0
- package/dist/tools/search-content.js.map +1 -0
- package/dist/tools/update-document.d.ts +32 -0
- package/dist/tools/update-document.js +114 -0
- package/dist/tools/update-document.js.map +1 -0
- package/dist/tools/upload-media.d.ts +26 -0
- package/dist/tools/upload-media.js +115 -0
- package/dist/tools/upload-media.js.map +1 -0
- package/dist/tools/versions.d.ts +50 -0
- package/dist/tools/versions.js +159 -0
- package/dist/tools/versions.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/url-validator.d.ts +36 -0
- package/dist/url-validator.js +222 -0
- package/dist/url-validator.js.map +1 -0
- 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
|