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
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { jsonResponse, stampMcpContext } from './_helpers';
2
3
  const DEFAULT_LIMIT = 20;
3
4
  const HARD_LIMIT = 100;
4
5
  /**
@@ -35,36 +36,29 @@ const HARD_LIMIT = 100;
35
36
  },
36
37
  handler: async (args, req, _extra)=>{
37
38
  const { collection, query, status, olderThanDays, newerThanDays, missingFields, limit = DEFAULT_LIMIT } = args;
38
- req.context = {
39
- ...req.context,
40
- source: 'mcp'
41
- };
39
+ stampMcpContext(req);
42
40
  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.
41
+ // When filtering by draft status, only collections that support drafts
42
+ // are meaningful others can't be "draft" or "published".
47
43
  const isDraftStatusFilter = status === 'draft' || status === 'published';
48
- const initialTargets = collection ? collectionSchemas.has(collection) ? [
49
- collection
50
- ] : [] : allSlugs;
44
+ let initialTargets;
45
+ if (!collection) {
46
+ initialTargets = allSlugs;
47
+ } else if (collectionSchemas.has(collection)) {
48
+ initialTargets = [
49
+ collection
50
+ ];
51
+ } else {
52
+ initialTargets = [];
53
+ }
51
54
  const targets = isDraftStatusFilter ? initialTargets.filter((slug)=>collectionSchemas.get(slug)?.hasDrafts) : initialTargets;
52
55
  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
- };
56
+ return jsonResponse({
57
+ hits: {},
58
+ message: collection ? `Unknown collection "${collection}". Available: ${allSlugs.join(', ')}` : 'No searchable collections found.'
59
+ });
64
60
  }
65
- const grouped = {};
66
- const stats = {};
67
- for (const slug of targets){
61
+ const settled = await Promise.allSettled(targets.map((slug)=>{
68
62
  const schema = collectionSchemas.get(slug);
69
63
  const where = buildWhereClause(schema, {
70
64
  query,
@@ -73,44 +67,40 @@ const HARD_LIMIT = 100;
73
67
  newerThanDays,
74
68
  missingFields
75
69
  });
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;
70
+ return req.payload.find({
71
+ collection: slug,
72
+ where: where,
73
+ limit: cappedLimit,
74
+ sort: '-updatedAt',
75
+ depth: 0,
76
+ // Include drafts so status='draft' works and missingFields queries
77
+ // against draft-only collections aren't misleadingly empty.
78
+ draft: schema.hasDrafts,
79
+ req,
80
+ overrideAccess: false,
81
+ user: req.user
82
+ });
83
+ }));
84
+ const grouped = {};
85
+ const stats = {};
86
+ settled.forEach((outcome, i)=>{
87
+ if (outcome.status !== 'fulfilled') return;
88
+ const slug = targets[i];
89
+ const result = outcome.value;
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));
99
96
  }
100
- }
97
+ });
101
98
  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
- };
99
+ return jsonResponse({
100
+ totalHits,
101
+ stats,
102
+ hits: grouped
103
+ });
114
104
  }
115
105
  };
116
106
  }
@@ -145,7 +135,6 @@ function isFieldEmpty(value) {
145
135
  }
146
136
  function buildWhereClause(schema, filters) {
147
137
  const and = [];
148
- // Free-text query against searchable fields
149
138
  if (filters.query && schema.searchableFields.length > 0) {
150
139
  const or = schema.searchableFields.map((field)=>({
151
140
  [field]: {
@@ -156,7 +145,7 @@ function buildWhereClause(schema, filters) {
156
145
  or
157
146
  });
158
147
  }
159
- // Status filter (only meaningful on draft-enabled collections)
148
+ // Status filter is only meaningful on draft-enabled collections
160
149
  if (filters.status && filters.status !== 'any' && schema.hasDrafts) {
161
150
  and.push({
162
151
  _status: {
@@ -164,7 +153,6 @@ function buildWhereClause(schema, filters) {
164
153
  }
165
154
  });
166
155
  }
167
- // Age filters
168
156
  if (filters.olderThanDays !== undefined) {
169
157
  const cutoff = new Date(Date.now() - filters.olderThanDays * 24 * 60 * 60 * 1000);
170
158
  and.push({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/search-content.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\n\nconst DEFAULT_LIMIT = 20\nconst HARD_LIMIT = 100\n\ninterface SearchHit {\n id: string | number\n displayName: string\n status?: string\n updatedAt?: string\n missingFields?: string[]\n}\n\n/**\n * Creates the searchContent MCP tool — natural-language filters across collections,\n * built for editor triage tasks that the official find tools don't express well.\n *\n * Examples the LLM can drive:\n * - \"all posts that are still drafts\"\n * - \"pages missing a meta description\"\n * - \"anything updated more than 30 days ago\"\n * - \"posts by jane in the last quarter\"\n *\n * Returns compact hits per collection — id, displayName, _status, updatedAt, and\n * (when missingFields was requested) which of those fields are blank on each doc.\n */\nexport function createSearchContentTool(\n collectionSchemas: Map<string, CollectionSchema>,\n) {\n const allSlugs = [...collectionSchemas.keys()]\n\n return {\n name: 'searchContent',\n description:\n 'Search and filter content across collections by status, age, missing fields, or free-text query. ' +\n 'Designed for editor triage — finding drafts, stale content, content with missing SEO fields, etc. ' +\n `Searchable collections: ${allSlugs.join(', ')}.`,\n parameters: {\n collection: z\n .string()\n .optional()\n .describe('Restrict search to a single collection slug. Omit to search all.'),\n query: z\n .string()\n .optional()\n .describe('Free-text query matched against name/title/slug fields (case-insensitive).'),\n status: z\n .enum(['draft', 'published', 'any'])\n .optional()\n .describe(\n 'Filter by draft status. \"draft\" or \"published\" only return matching docs; \"any\" or omitted returns all.',\n ),\n olderThanDays: z\n .number()\n .optional()\n .describe('Only docs whose updatedAt is older than this many days.'),\n newerThanDays: z\n .number()\n .optional()\n .describe('Only docs whose updatedAt is newer than this many days.'),\n missingFields: z\n .array(z.string())\n .optional()\n .describe(\n 'Field names that should be empty/null. Useful for finding e.g. posts without a coverImage. ' +\n 'Each hit will include a missingFields array confirming which were actually blank.',\n ),\n limit: z\n .number()\n .optional()\n .default(DEFAULT_LIMIT)\n .describe(`Maximum hits per collection (default ${DEFAULT_LIMIT}, max ${HARD_LIMIT}).`),\n },\n handler: async (\n args: {\n collection?: string\n query?: string\n status?: 'draft' | 'published' | 'any'\n olderThanDays?: number\n newerThanDays?: number\n missingFields?: string[]\n limit?: number\n },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const {\n collection,\n query,\n status,\n olderThanDays,\n newerThanDays,\n missingFields,\n limit = DEFAULT_LIMIT,\n } = args\n\n req.context = { ...req.context, source: 'mcp' }\n\n const cappedLimit = Math.min(Math.max(1, limit), HARD_LIMIT)\n\n // Determine target collections.\n // When the user filters by a specific draft status, only consider collections\n // that actually support drafts — other collections are not \"draft\" or\n // \"published\" in any meaningful sense and shouldn't pollute the results.\n const isDraftStatusFilter = status === 'draft' || status === 'published'\n const initialTargets = collection\n ? collectionSchemas.has(collection)\n ? [collection]\n : []\n : allSlugs\n\n const targets = isDraftStatusFilter\n ? initialTargets.filter((slug) => collectionSchemas.get(slug)?.hasDrafts)\n : initialTargets\n\n if (targets.length === 0) {\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n hits: {},\n message: collection\n ? `Unknown collection \"${collection}\". Available: ${allSlugs.join(', ')}`\n : 'No searchable collections found.',\n }),\n },\n ],\n }\n }\n\n const grouped: Record<string, SearchHit[]> = {}\n const stats: Record<string, { totalDocs: number; returned: number }> = {}\n\n for (const slug of targets) {\n const schema = collectionSchemas.get(slug)!\n const where = buildWhereClause(schema, {\n query,\n status,\n olderThanDays,\n newerThanDays,\n missingFields,\n })\n\n try {\n const result = await req.payload.find({\n collection: slug as any,\n where: where as any,\n limit: cappedLimit,\n sort: '-updatedAt',\n depth: 0,\n // Include drafts so status='draft' actually works and so missingFields\n // queries against draft-only collections aren't misleadingly empty.\n draft: schema.hasDrafts,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n if (result.totalDocs > 0) {\n stats[slug] = { totalDocs: result.totalDocs, returned: result.docs.length }\n grouped[slug] = result.docs.map((doc: any) => buildHit(doc, missingFields))\n }\n } catch {\n // Skip collections that fail (permissions, missing fields, etc.) — return what we can\n continue\n }\n }\n\n const totalHits = Object.values(grouped).reduce((sum, hits) => sum + hits.length, 0)\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n totalHits,\n stats,\n hits: grouped,\n }),\n },\n ],\n }\n },\n }\n}\n\nfunction buildHit(doc: Record<string, any>, missingFields?: string[]): SearchHit {\n const hit: SearchHit = {\n id: doc.id,\n displayName: doc.name || doc.title || doc.slug || String(doc.id),\n status: doc._status,\n updatedAt: doc.updatedAt,\n }\n\n if (missingFields?.length) {\n hit.missingFields = missingFields.filter((f) => isFieldEmpty(getByPath(doc, f)))\n }\n\n return hit\n}\n\nfunction getByPath(doc: Record<string, any>, path: string): unknown {\n // Walk dotted paths so `meta.description` reads doc.meta?.description rather\n // than the literal key `\"meta.description\"`. Matches the `where` keys we emit.\n let current: any = doc\n for (const segment of path.split('.')) {\n if (current === null || current === undefined) return undefined\n current = current[segment]\n }\n return current\n}\n\nfunction isFieldEmpty(value: unknown): boolean {\n if (value === null || value === undefined) return true\n if (typeof value === 'string') return value.trim() === ''\n if (Array.isArray(value)) return value.length === 0\n if (typeof value === 'object') return Object.keys(value as object).length === 0\n return false\n}\n\ninterface FilterArgs {\n query?: string\n status?: 'draft' | 'published' | 'any'\n olderThanDays?: number\n newerThanDays?: number\n missingFields?: string[]\n}\n\nfunction buildWhereClause(schema: CollectionSchema, filters: FilterArgs): Record<string, unknown> {\n const and: Array<Record<string, unknown>> = []\n\n // Free-text query against searchable fields\n if (filters.query && schema.searchableFields.length > 0) {\n const or = schema.searchableFields.map((field) => ({\n [field]: { like: filters.query },\n }))\n and.push({ or })\n }\n\n // Status filter (only meaningful on draft-enabled collections)\n if (filters.status && filters.status !== 'any' && schema.hasDrafts) {\n and.push({ _status: { equals: filters.status } })\n }\n\n // Age filters\n if (filters.olderThanDays !== undefined) {\n const cutoff = new Date(Date.now() - filters.olderThanDays * 24 * 60 * 60 * 1000)\n and.push({ updatedAt: { less_than: cutoff.toISOString() } })\n }\n if (filters.newerThanDays !== undefined) {\n const cutoff = new Date(Date.now() - filters.newerThanDays * 24 * 60 * 60 * 1000)\n and.push({ updatedAt: { greater_than: cutoff.toISOString() } })\n }\n\n // Missing-field filter — express as \"field is null OR field doesn't exist\"\n if (filters.missingFields?.length) {\n for (const field of filters.missingFields) {\n and.push({\n or: [{ [field]: { exists: false } }, { [field]: { equals: null } }],\n })\n }\n }\n\n if (and.length === 0) return {}\n if (and.length === 1) return and[0]\n return { and }\n}\n"],"names":["z","DEFAULT_LIMIT","HARD_LIMIT","createSearchContentTool","collectionSchemas","allSlugs","keys","name","description","join","parameters","collection","string","optional","describe","query","status","enum","olderThanDays","number","newerThanDays","missingFields","array","limit","default","handler","args","req","_extra","context","source","cappedLimit","Math","min","max","isDraftStatusFilter","initialTargets","has","targets","filter","slug","get","hasDrafts","length","content","type","text","JSON","stringify","hits","message","grouped","stats","schema","where","buildWhereClause","result","payload","find","sort","depth","draft","overrideAccess","user","totalDocs","returned","docs","map","doc","buildHit","totalHits","Object","values","reduce","sum","hit","id","displayName","title","String","_status","updatedAt","f","isFieldEmpty","getByPath","path","current","segment","split","undefined","value","trim","Array","isArray","filters","and","searchableFields","or","field","like","push","equals","cutoff","Date","now","less_than","toISOString","greater_than","exists"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tools/search-content.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\nimport { jsonResponse, stampMcpContext } from './_helpers'\n\nconst DEFAULT_LIMIT = 20\nconst HARD_LIMIT = 100\n\ninterface SearchHit {\n id: string | number\n displayName: string\n status?: string\n updatedAt?: string\n missingFields?: string[]\n}\n\n/**\n * Creates the searchContent MCP tool — natural-language filters across collections,\n * built for editor triage tasks that the official find tools don't express well.\n *\n * Examples the LLM can drive:\n * - \"all posts that are still drafts\"\n * - \"pages missing a meta description\"\n * - \"anything updated more than 30 days ago\"\n * - \"posts by jane in the last quarter\"\n *\n * Returns compact hits per collection — id, displayName, _status, updatedAt, and\n * (when missingFields was requested) which of those fields are blank on each doc.\n */\nexport function createSearchContentTool(\n collectionSchemas: Map<string, CollectionSchema>,\n) {\n const allSlugs = [...collectionSchemas.keys()]\n\n return {\n name: 'searchContent',\n description:\n 'Search and filter content across collections by status, age, missing fields, or free-text query. ' +\n 'Designed for editor triage — finding drafts, stale content, content with missing SEO fields, etc. ' +\n `Searchable collections: ${allSlugs.join(', ')}.`,\n parameters: {\n collection: z\n .string()\n .optional()\n .describe('Restrict search to a single collection slug. Omit to search all.'),\n query: z\n .string()\n .optional()\n .describe('Free-text query matched against name/title/slug fields (case-insensitive).'),\n status: z\n .enum(['draft', 'published', 'any'])\n .optional()\n .describe(\n 'Filter by draft status. \"draft\" or \"published\" only return matching docs; \"any\" or omitted returns all.',\n ),\n olderThanDays: z\n .number()\n .optional()\n .describe('Only docs whose updatedAt is older than this many days.'),\n newerThanDays: z\n .number()\n .optional()\n .describe('Only docs whose updatedAt is newer than this many days.'),\n missingFields: z\n .array(z.string())\n .optional()\n .describe(\n 'Field names that should be empty/null. Useful for finding e.g. posts without a coverImage. ' +\n 'Each hit will include a missingFields array confirming which were actually blank.',\n ),\n limit: z\n .number()\n .optional()\n .default(DEFAULT_LIMIT)\n .describe(`Maximum hits per collection (default ${DEFAULT_LIMIT}, max ${HARD_LIMIT}).`),\n },\n handler: async (\n args: {\n collection?: string\n query?: string\n status?: 'draft' | 'published' | 'any'\n olderThanDays?: number\n newerThanDays?: number\n missingFields?: string[]\n limit?: number\n },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const {\n collection,\n query,\n status,\n olderThanDays,\n newerThanDays,\n missingFields,\n limit = DEFAULT_LIMIT,\n } = args\n\n stampMcpContext(req)\n\n const cappedLimit = Math.min(Math.max(1, limit), HARD_LIMIT)\n\n // When filtering by draft status, only collections that support drafts\n // are meaningful — others can't be \"draft\" or \"published\".\n const isDraftStatusFilter = status === 'draft' || status === 'published'\n\n let initialTargets: string[]\n if (!collection) {\n initialTargets = allSlugs\n } else if (collectionSchemas.has(collection)) {\n initialTargets = [collection]\n } else {\n initialTargets = []\n }\n\n const targets = isDraftStatusFilter\n ? initialTargets.filter((slug) => collectionSchemas.get(slug)?.hasDrafts)\n : initialTargets\n\n if (targets.length === 0) {\n return jsonResponse({\n hits: {},\n message: collection\n ? `Unknown collection \"${collection}\". Available: ${allSlugs.join(', ')}`\n : 'No searchable collections found.',\n })\n }\n\n const settled = await Promise.allSettled(\n targets.map((slug) => {\n const schema = collectionSchemas.get(slug)!\n const where = buildWhereClause(schema, {\n query,\n status,\n olderThanDays,\n newerThanDays,\n missingFields,\n })\n return req.payload.find({\n collection: slug as any,\n where: where as any,\n limit: cappedLimit,\n sort: '-updatedAt',\n depth: 0,\n // Include drafts so status='draft' works and missingFields queries\n // against draft-only collections aren't misleadingly empty.\n draft: schema.hasDrafts,\n req,\n overrideAccess: false,\n user: req.user,\n })\n }),\n )\n\n const grouped: Record<string, SearchHit[]> = {}\n const stats: Record<string, { totalDocs: number; returned: number }> = {}\n\n settled.forEach((outcome, i) => {\n if (outcome.status !== 'fulfilled') return\n const slug = targets[i]\n const result = outcome.value\n if (result.totalDocs > 0) {\n stats[slug] = { totalDocs: result.totalDocs, returned: result.docs.length }\n grouped[slug] = result.docs.map((doc: any) => buildHit(doc, missingFields))\n }\n })\n\n const totalHits = Object.values(grouped).reduce((sum, hits) => sum + hits.length, 0)\n\n return jsonResponse({ totalHits, stats, hits: grouped })\n },\n }\n}\n\nfunction buildHit(doc: Record<string, any>, missingFields?: string[]): SearchHit {\n const hit: SearchHit = {\n id: doc.id,\n displayName: doc.name || doc.title || doc.slug || String(doc.id),\n status: doc._status,\n updatedAt: doc.updatedAt,\n }\n\n if (missingFields?.length) {\n hit.missingFields = missingFields.filter((f) => isFieldEmpty(getByPath(doc, f)))\n }\n\n return hit\n}\n\nfunction getByPath(doc: Record<string, any>, path: string): unknown {\n // Walk dotted paths so `meta.description` reads doc.meta?.description rather\n // than the literal key `\"meta.description\"`. Matches the `where` keys we emit.\n let current: any = doc\n for (const segment of path.split('.')) {\n if (current === null || current === undefined) return undefined\n current = current[segment]\n }\n return current\n}\n\nfunction isFieldEmpty(value: unknown): boolean {\n if (value === null || value === undefined) return true\n if (typeof value === 'string') return value.trim() === ''\n if (Array.isArray(value)) return value.length === 0\n if (typeof value === 'object') return Object.keys(value as object).length === 0\n return false\n}\n\ninterface FilterArgs {\n query?: string\n status?: 'draft' | 'published' | 'any'\n olderThanDays?: number\n newerThanDays?: number\n missingFields?: string[]\n}\n\nfunction buildWhereClause(schema: CollectionSchema, filters: FilterArgs): Record<string, unknown> {\n const and: Array<Record<string, unknown>> = []\n\n if (filters.query && schema.searchableFields.length > 0) {\n const or = schema.searchableFields.map((field) => ({\n [field]: { like: filters.query },\n }))\n and.push({ or })\n }\n\n // Status filter is only meaningful on draft-enabled collections\n if (filters.status && filters.status !== 'any' && schema.hasDrafts) {\n and.push({ _status: { equals: filters.status } })\n }\n\n if (filters.olderThanDays !== undefined) {\n const cutoff = new Date(Date.now() - filters.olderThanDays * 24 * 60 * 60 * 1000)\n and.push({ updatedAt: { less_than: cutoff.toISOString() } })\n }\n if (filters.newerThanDays !== undefined) {\n const cutoff = new Date(Date.now() - filters.newerThanDays * 24 * 60 * 60 * 1000)\n and.push({ updatedAt: { greater_than: cutoff.toISOString() } })\n }\n\n // Missing-field filter — express as \"field is null OR field doesn't exist\"\n if (filters.missingFields?.length) {\n for (const field of filters.missingFields) {\n and.push({\n or: [{ [field]: { exists: false } }, { [field]: { equals: null } }],\n })\n }\n }\n\n if (and.length === 0) return {}\n if (and.length === 1) return and[0]\n return { and }\n}\n"],"names":["z","jsonResponse","stampMcpContext","DEFAULT_LIMIT","HARD_LIMIT","createSearchContentTool","collectionSchemas","allSlugs","keys","name","description","join","parameters","collection","string","optional","describe","query","status","enum","olderThanDays","number","newerThanDays","missingFields","array","limit","default","handler","args","req","_extra","cappedLimit","Math","min","max","isDraftStatusFilter","initialTargets","has","targets","filter","slug","get","hasDrafts","length","hits","message","settled","Promise","allSettled","map","schema","where","buildWhereClause","payload","find","sort","depth","draft","overrideAccess","user","grouped","stats","forEach","outcome","i","result","value","totalDocs","returned","docs","doc","buildHit","totalHits","Object","values","reduce","sum","hit","id","displayName","title","String","_status","updatedAt","f","isFieldEmpty","getByPath","path","current","segment","split","undefined","trim","Array","isArray","filters","and","searchableFields","or","field","like","push","equals","cutoff","Date","now","less_than","toISOString","greater_than","exists"],"mappings":"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"}
@@ -2,14 +2,9 @@ import { z } from 'zod';
2
2
  import type { PayloadRequest } from 'payload';
3
3
  import type { CollectionSchema } from '../types';
4
4
  /**
5
- * Creates the updateDocument MCP tool that updates fields on an existing document.
6
- *
7
- * This is a custom replacement for the official plugin's update tools, which
8
- * fail on collections with upload fields due to a Zod schema generation bug.
9
- * Uses Payload's Local API directly, bypassing the problematic schema pipeline.
10
- *
11
- * @param collectionSchemas - Introspected collection schemas for validation and description
12
- * @param draftCollections - Set of collection slugs that support drafts
5
+ * Custom replacement for the official plugin's update tools, which fail on
6
+ * collections with upload fields due to a Zod schema generation bug. Uses
7
+ * Payload's Local API directly, bypassing the problematic schema pipeline.
13
8
  */
14
9
  export declare function createUpdateDocumentTool(collectionSchemas: Map<string, CollectionSchema>, draftCollections: Set<string>): {
15
10
  name: string;
@@ -23,10 +18,5 @@ export declare function createUpdateDocumentTool(collectionSchemas: Map<string,
23
18
  collection: string;
24
19
  documentId: string;
25
20
  data: string;
26
- }, req: PayloadRequest, _extra: unknown) => Promise<{
27
- content: {
28
- type: "text";
29
- text: string;
30
- }[];
31
- }>;
21
+ }, req: PayloadRequest, _extra: unknown) => Promise<import("./_helpers").McpTextResponse>;
32
22
  };
@@ -1,22 +1,19 @@
1
1
  import { z } from 'zod';
2
+ import { DRAFT_NOTE, errorMessage, getDocDisplayName, stampMcpContext, textResponse } from './_helpers';
3
+ const MEDIA_SLUG = 'media';
2
4
  /**
3
- * Creates the updateDocument MCP tool that updates fields on an existing document.
4
- *
5
- * This is a custom replacement for the official plugin's update tools, which
6
- * fail on collections with upload fields due to a Zod schema generation bug.
7
- * Uses Payload's Local API directly, bypassing the problematic schema pipeline.
8
- *
9
- * @param collectionSchemas - Introspected collection schemas for validation and description
10
- * @param draftCollections - Set of collection slugs that support drafts
5
+ * Custom replacement for the official plugin's update tools, which fail on
6
+ * collections with upload fields due to a Zod schema generation bug. Uses
7
+ * Payload's Local API directly, bypassing the problematic schema pipeline.
11
8
  */ export function createUpdateDocumentTool(collectionSchemas, draftCollections) {
12
- const updatableSlugs = [
13
- ...collectionSchemas.keys()
14
- ].filter((slug)=>slug !== 'media');
15
- const collectionDescriptions = updatableSlugs.map((slug)=>{
16
- const schema = collectionSchemas.get(slug);
17
- const fieldNames = schema.fields.map((f)=>f.name).join(', ');
18
- return ` - "${slug}": ${fieldNames}`;
19
- }).join('\n');
9
+ const updatableSlugs = [];
10
+ const descriptionLines = [];
11
+ for (const [slug, schema] of collectionSchemas){
12
+ if (slug === MEDIA_SLUG) continue;
13
+ updatableSlugs.push(slug);
14
+ descriptionLines.push(` - "${slug}": ${schema.fields.map((f)=>f.name).join(', ')}`);
15
+ }
16
+ const collectionDescriptions = descriptionLines.join('\n');
20
17
  return {
21
18
  name: 'updateDocument',
22
19
  description: 'Update fields on an existing document in any collection. ' + 'Pass only the fields you want to change — unspecified fields are left untouched. ' + 'For draft-enabled collections, updates create a new draft version (use publishDraft to make it live). ' + 'For relationship fields, pass the related document ID (use resolveReference to find IDs). ' + 'For upload fields, pass the media document ID (use uploadMedia to create one first).\n\n' + 'Available collections and their fields:\n' + collectionDescriptions,
@@ -31,49 +28,18 @@ import { z } from 'zod';
31
28
  try {
32
29
  data = JSON.parse(args.data);
33
30
  } catch {
34
- return {
35
- content: [
36
- {
37
- type: 'text',
38
- text: 'Error: "data" must be a valid JSON string. Example: \'{"title": "New Title"}\''
39
- }
40
- ]
41
- };
31
+ return textResponse('Error: "data" must be a valid JSON string. Example: \'{"title": "New Title"}\'');
42
32
  }
43
33
  if (!collectionSchemas.has(collection)) {
44
- return {
45
- content: [
46
- {
47
- type: 'text',
48
- text: `Error: Unknown collection "${collection}". ` + `Available: ${updatableSlugs.join(', ')}`
49
- }
50
- ]
51
- };
34
+ return textResponse(`Error: Unknown collection "${collection}". Available: ${updatableSlugs.join(', ')}`);
52
35
  }
53
- if (collection === 'media') {
54
- return {
55
- content: [
56
- {
57
- type: 'text',
58
- text: 'Error: Use the uploadMedia tool to manage media files.'
59
- }
60
- ]
61
- };
36
+ if (collection === MEDIA_SLUG) {
37
+ return textResponse('Error: Use the uploadMedia tool to manage media files.');
62
38
  }
63
39
  if (!data || Object.keys(data).length === 0) {
64
- return {
65
- content: [
66
- {
67
- type: 'text',
68
- text: 'Error: No fields provided in "data". Pass an object with field names and values to update.'
69
- }
70
- ]
71
- };
40
+ return textResponse('Error: No fields provided in "data". Pass an object with field names and values to update.');
72
41
  }
73
- req.context = {
74
- ...req.context,
75
- source: 'mcp'
76
- };
42
+ stampMcpContext(req);
77
43
  const isDraftCollection = draftCollections.has(collection);
78
44
  try {
79
45
  const doc = await req.payload.update({
@@ -85,27 +51,12 @@ import { z } from 'zod';
85
51
  overrideAccess: false,
86
52
  user: req.user
87
53
  });
88
- const displayName = doc.name || doc.title || doc.slug || documentId;
54
+ const displayName = getDocDisplayName(doc, documentId);
89
55
  const updatedFields = Object.keys(data).join(', ');
90
- const draftNote = isDraftCollection ? ` Document is in draft status — use publishDraft to make it live.` : '';
91
- return {
92
- content: [
93
- {
94
- type: 'text',
95
- text: `Updated "${displayName}" in ${collection} (ID: ${documentId}). ` + `Changed fields: ${updatedFields}.${draftNote}`
96
- }
97
- ]
98
- };
56
+ const draftNote = isDraftCollection ? DRAFT_NOTE : '';
57
+ return textResponse(`Updated "${displayName}" in ${collection} (ID: ${documentId}). ` + `Changed fields: ${updatedFields}.${draftNote}`);
99
58
  } catch (error) {
100
- const message = error instanceof Error ? error.message : String(error);
101
- return {
102
- content: [
103
- {
104
- type: 'text',
105
- text: `Error updating document ${documentId} in ${collection}: ${message}`
106
- }
107
- ]
108
- };
59
+ return textResponse(`Error updating document ${documentId} in ${collection}: ${errorMessage(error)}`);
109
60
  }
110
61
  }
111
62
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/update-document.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\n\n/**\n * Creates the updateDocument MCP tool that updates fields on an existing document.\n *\n * This is a custom replacement for the official plugin's update tools, which\n * fail on collections with upload fields due to a Zod schema generation bug.\n * Uses Payload's Local API directly, bypassing the problematic schema pipeline.\n *\n * @param collectionSchemas - Introspected collection schemas for validation and description\n * @param draftCollections - Set of collection slugs that support drafts\n */\nexport function createUpdateDocumentTool(\n collectionSchemas: Map<string, CollectionSchema>,\n draftCollections: Set<string>,\n) {\n const updatableSlugs = [...collectionSchemas.keys()].filter(\n (slug) => slug !== 'media',\n )\n\n const collectionDescriptions = updatableSlugs\n .map((slug) => {\n const schema = collectionSchemas.get(slug)!\n const fieldNames = schema.fields.map((f) => f.name).join(', ')\n return ` - \"${slug}\": ${fieldNames}`\n })\n .join('\\n')\n\n return {\n name: 'updateDocument',\n description:\n 'Update fields on an existing document in any collection. ' +\n 'Pass only the fields you want to change — unspecified fields are left untouched. ' +\n 'For draft-enabled collections, updates create a new draft version (use publishDraft to make it live). ' +\n 'For relationship fields, pass the related document ID (use resolveReference to find IDs). ' +\n 'For upload fields, pass the media document ID (use uploadMedia to create one first).\\n\\n' +\n 'Available collections and their fields:\\n' +\n collectionDescriptions,\n parameters: {\n collection: z\n .string()\n .describe(`The collection slug. One of: ${updatableSlugs.join(', ')}`),\n documentId: z\n .string()\n .describe('The ID of the document to update'),\n data: z\n .string()\n .describe(\n 'JSON string of field names to new values. Only include fields you want to change. ' +\n 'Examples: \\'{\"title\": \"New Title\"}\\', \\'{\"featured\": true, \"category\": \"category-id\"}\\', ' +\n '\\'{\"tags\": [\"news\", \"update\"]}\\'',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; data: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId } = args\n\n let data: Record<string, unknown>\n try {\n data = JSON.parse(args.data)\n } catch {\n return {\n content: [\n {\n type: 'text' as const,\n text: 'Error: \"data\" must be a valid JSON string. Example: \\'{\"title\": \"New Title\"}\\'',\n },\n ],\n }\n }\n\n if (!collectionSchemas.has(collection)) {\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error: Unknown collection \"${collection}\". ` +\n `Available: ${updatableSlugs.join(', ')}`,\n },\n ],\n }\n }\n\n if (collection === 'media') {\n return {\n content: [\n {\n type: 'text' as const,\n text: 'Error: Use the uploadMedia tool to manage media files.',\n },\n ],\n }\n }\n\n if (!data || Object.keys(data).length === 0) {\n return {\n content: [\n {\n type: 'text' as const,\n text: 'Error: No fields provided in \"data\". Pass an object with field names and values to update.',\n },\n ],\n }\n }\n\n req.context = { ...req.context, source: 'mcp' }\n\n const isDraftCollection = draftCollections.has(collection)\n\n try {\n const doc = await req.payload.update({\n collection: collection as any,\n id: documentId,\n data: data as any,\n draft: isDraftCollection,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n const displayName =\n (doc as any).name ||\n (doc as any).title ||\n (doc as any).slug ||\n documentId\n\n const updatedFields = Object.keys(data).join(', ')\n const draftNote = isDraftCollection\n ? ` Document is in draft status — use publishDraft to make it live.`\n : ''\n\n return {\n content: [\n {\n type: 'text' as const,\n text: `Updated \"${displayName}\" in ${collection} (ID: ${documentId}). ` +\n `Changed fields: ${updatedFields}.${draftNote}`,\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 updating document ${documentId} in ${collection}: ${message}`,\n },\n ],\n }\n }\n },\n }\n}\n"],"names":["z","createUpdateDocumentTool","collectionSchemas","draftCollections","updatableSlugs","keys","filter","slug","collectionDescriptions","map","schema","get","fieldNames","fields","f","name","join","description","parameters","collection","string","describe","documentId","data","handler","args","req","_extra","JSON","parse","content","type","text","has","Object","length","context","source","isDraftCollection","doc","payload","update","id","draft","overrideAccess","user","displayName","title","updatedFields","draftNote","error","message","Error","String"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAIvB;;;;;;;;;CASC,GACD,OAAO,SAASC,yBACdC,iBAAgD,EAChDC,gBAA6B;IAE7B,MAAMC,iBAAiB;WAAIF,kBAAkBG,IAAI;KAAG,CAACC,MAAM,CACzD,CAACC,OAASA,SAAS;IAGrB,MAAMC,yBAAyBJ,eAC5BK,GAAG,CAAC,CAACF;QACJ,MAAMG,SAASR,kBAAkBS,GAAG,CAACJ;QACrC,MAAMK,aAAaF,OAAOG,MAAM,CAACJ,GAAG,CAAC,CAACK,IAAMA,EAAEC,IAAI,EAAEC,IAAI,CAAC;QACzD,OAAO,CAAC,KAAK,EAAET,KAAK,GAAG,EAAEK,YAAY;IACvC,GACCI,IAAI,CAAC;IAER,OAAO;QACLD,MAAM;QACNE,aACE,8DACA,sFACA,2GACA,+FACA,6FACA,8CACAT;QACFU,YAAY;YACVC,YAAYnB,EACToB,MAAM,GACNC,QAAQ,CAAC,CAAC,6BAA6B,EAAEjB,eAAeY,IAAI,CAAC,OAAO;YACvEM,YAAYtB,EACToB,MAAM,GACNC,QAAQ,CAAC;YACZE,MAAMvB,EACHoB,MAAM,GACNC,QAAQ,CACP,uFACA,8FACA;QAEN;QACAG,SAAS,OACPC,MACAC,KACAC;YAEA,MAAM,EAAER,UAAU,EAAEG,UAAU,EAAE,GAAGG;YAEnC,IAAIF;YACJ,IAAI;gBACFA,OAAOK,KAAKC,KAAK,CAACJ,KAAKF,IAAI;YAC7B,EAAE,OAAM;gBACN,OAAO;oBACLO,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM;wBACR;qBACD;gBACH;YACF;YAEA,IAAI,CAAC9B,kBAAkB+B,GAAG,CAACd,aAAa;gBACtC,OAAO;oBACLW,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM,CAAC,2BAA2B,EAAEb,WAAW,GAAG,CAAC,GACjD,CAAC,WAAW,EAAEf,eAAeY,IAAI,CAAC,OAAO;wBAC7C;qBACD;gBACH;YACF;YAEA,IAAIG,eAAe,SAAS;gBAC1B,OAAO;oBACLW,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM;wBACR;qBACD;gBACH;YACF;YAEA,IAAI,CAACT,QAAQW,OAAO7B,IAAI,CAACkB,MAAMY,MAAM,KAAK,GAAG;gBAC3C,OAAO;oBACLL,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM;wBACR;qBACD;gBACH;YACF;YAEAN,IAAIU,OAAO,GAAG;gBAAE,GAAGV,IAAIU,OAAO;gBAAEC,QAAQ;YAAM;YAE9C,MAAMC,oBAAoBnC,iBAAiB8B,GAAG,CAACd;YAE/C,IAAI;gBACF,MAAMoB,MAAM,MAAMb,IAAIc,OAAO,CAACC,MAAM,CAAC;oBACnCtB,YAAYA;oBACZuB,IAAIpB;oBACJC,MAAMA;oBACNoB,OAAOL;oBACPZ;oBACAkB,gBAAgB;oBAChBC,MAAMnB,IAAImB,IAAI;gBAChB;gBAEA,MAAMC,cACJ,AAACP,IAAYxB,IAAI,IACjB,AAACwB,IAAYQ,KAAK,IAClB,AAACR,IAAYhC,IAAI,IACjBe;gBAEF,MAAM0B,gBAAgBd,OAAO7B,IAAI,CAACkB,MAAMP,IAAI,CAAC;gBAC7C,MAAMiC,YAAYX,oBACd,CAAC,gEAAgE,CAAC,GAClE;gBAEJ,OAAO;oBACLR,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM,CAAC,SAAS,EAAEc,YAAY,KAAK,EAAE3B,WAAW,MAAM,EAAEG,WAAW,GAAG,CAAC,GACrE,CAAC,gBAAgB,EAAE0B,cAAc,CAAC,EAAEC,WAAW;wBACnD;qBACD;gBACH;YACF,EAAE,OAAOC,OAAO;gBACd,MAAMC,UAAUD,iBAAiBE,QAAQF,MAAMC,OAAO,GAAGE,OAAOH;gBAChE,OAAO;oBACLpB,SAAS;wBACP;4BACEC,MAAM;4BACNC,MAAM,CAAC,wBAAwB,EAAEV,WAAW,IAAI,EAAEH,WAAW,EAAE,EAAEgC,SAAS;wBAC5E;qBACD;gBACH;YACF;QACF;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/tools/update-document.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport type { CollectionSchema } from '../types'\nimport {\n DRAFT_NOTE,\n errorMessage,\n getDocDisplayName,\n stampMcpContext,\n textResponse,\n} from './_helpers'\n\nconst MEDIA_SLUG = 'media'\n\n/**\n * Custom replacement for the official plugin's update tools, which fail on\n * collections with upload fields due to a Zod schema generation bug. Uses\n * Payload's Local API directly, bypassing the problematic schema pipeline.\n */\nexport function createUpdateDocumentTool(\n collectionSchemas: Map<string, CollectionSchema>,\n draftCollections: Set<string>,\n) {\n const updatableSlugs: string[] = []\n const descriptionLines: string[] = []\n for (const [slug, schema] of collectionSchemas) {\n if (slug === MEDIA_SLUG) continue\n updatableSlugs.push(slug)\n descriptionLines.push(` - \"${slug}\": ${schema.fields.map((f) => f.name).join(', ')}`)\n }\n const collectionDescriptions = descriptionLines.join('\\n')\n\n return {\n name: 'updateDocument',\n description:\n 'Update fields on an existing document in any collection. ' +\n 'Pass only the fields you want to change — unspecified fields are left untouched. ' +\n 'For draft-enabled collections, updates create a new draft version (use publishDraft to make it live). ' +\n 'For relationship fields, pass the related document ID (use resolveReference to find IDs). ' +\n 'For upload fields, pass the media document ID (use uploadMedia to create one first).\\n\\n' +\n 'Available collections and their fields:\\n' +\n collectionDescriptions,\n parameters: {\n collection: z\n .string()\n .describe(`The collection slug. One of: ${updatableSlugs.join(', ')}`),\n documentId: z\n .string()\n .describe('The ID of the document to update'),\n data: z\n .string()\n .describe(\n 'JSON string of field names to new values. Only include fields you want to change. ' +\n 'Examples: \\'{\"title\": \"New Title\"}\\', \\'{\"featured\": true, \"category\": \"category-id\"}\\', ' +\n '\\'{\"tags\": [\"news\", \"update\"]}\\'',\n ),\n },\n handler: async (\n args: { collection: string; documentId: string; data: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { collection, documentId } = args\n\n let data: Record<string, unknown>\n try {\n data = JSON.parse(args.data)\n } catch {\n return textResponse(\n 'Error: \"data\" must be a valid JSON string. Example: \\'{\"title\": \"New Title\"}\\'',\n )\n }\n\n if (!collectionSchemas.has(collection)) {\n return textResponse(\n `Error: Unknown collection \"${collection}\". Available: ${updatableSlugs.join(', ')}`,\n )\n }\n\n if (collection === MEDIA_SLUG) {\n return textResponse('Error: Use the uploadMedia tool to manage media files.')\n }\n\n if (!data || Object.keys(data).length === 0) {\n return textResponse(\n 'Error: No fields provided in \"data\". Pass an object with field names and values to update.',\n )\n }\n\n stampMcpContext(req)\n\n const isDraftCollection = draftCollections.has(collection)\n\n try {\n const doc = await req.payload.update({\n collection: collection as any,\n id: documentId,\n data: data as any,\n draft: isDraftCollection,\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n const displayName = getDocDisplayName(doc, documentId)\n const updatedFields = Object.keys(data).join(', ')\n const draftNote = isDraftCollection ? DRAFT_NOTE : ''\n\n return textResponse(\n `Updated \"${displayName}\" in ${collection} (ID: ${documentId}). ` +\n `Changed fields: ${updatedFields}.${draftNote}`,\n )\n } catch (error) {\n return textResponse(\n `Error updating document ${documentId} in ${collection}: ${errorMessage(error)}`,\n )\n }\n },\n }\n}\n"],"names":["z","DRAFT_NOTE","errorMessage","getDocDisplayName","stampMcpContext","textResponse","MEDIA_SLUG","createUpdateDocumentTool","collectionSchemas","draftCollections","updatableSlugs","descriptionLines","slug","schema","push","fields","map","f","name","join","collectionDescriptions","description","parameters","collection","string","describe","documentId","data","handler","args","req","_extra","JSON","parse","has","Object","keys","length","isDraftCollection","doc","payload","update","id","draft","overrideAccess","user","displayName","updatedFields","draftNote","error"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAGvB,SACEC,UAAU,EACVC,YAAY,EACZC,iBAAiB,EACjBC,eAAe,EACfC,YAAY,QACP,aAAY;AAEnB,MAAMC,aAAa;AAEnB;;;;CAIC,GACD,OAAO,SAASC,yBACdC,iBAAgD,EAChDC,gBAA6B;IAE7B,MAAMC,iBAA2B,EAAE;IACnC,MAAMC,mBAA6B,EAAE;IACrC,KAAK,MAAM,CAACC,MAAMC,OAAO,IAAIL,kBAAmB;QAC9C,IAAII,SAASN,YAAY;QACzBI,eAAeI,IAAI,CAACF;QACpBD,iBAAiBG,IAAI,CAAC,CAAC,KAAK,EAAEF,KAAK,GAAG,EAAEC,OAAOE,MAAM,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,EAAEC,IAAI,CAAC,OAAO;IACvF;IACA,MAAMC,yBAAyBT,iBAAiBQ,IAAI,CAAC;IAErD,OAAO;QACLD,MAAM;QACNG,aACE,8DACA,sFACA,2GACA,+FACA,6FACA,8CACAD;QACFE,YAAY;YACVC,YAAYvB,EACTwB,MAAM,GACNC,QAAQ,CAAC,CAAC,6BAA6B,EAAEf,eAAeS,IAAI,CAAC,OAAO;YACvEO,YAAY1B,EACTwB,MAAM,GACNC,QAAQ,CAAC;YACZE,MAAM3B,EACHwB,MAAM,GACNC,QAAQ,CACP,uFACA,8FACA;QAEN;QACAG,SAAS,OACPC,MACAC,KACAC;YAEA,MAAM,EAAER,UAAU,EAAEG,UAAU,EAAE,GAAGG;YAEnC,IAAIF;YACJ,IAAI;gBACFA,OAAOK,KAAKC,KAAK,CAACJ,KAAKF,IAAI;YAC7B,EAAE,OAAM;gBACN,OAAOtB,aACL;YAEJ;YAEA,IAAI,CAACG,kBAAkB0B,GAAG,CAACX,aAAa;gBACtC,OAAOlB,aACL,CAAC,2BAA2B,EAAEkB,WAAW,cAAc,EAAEb,eAAeS,IAAI,CAAC,OAAO;YAExF;YAEA,IAAII,eAAejB,YAAY;gBAC7B,OAAOD,aAAa;YACtB;YAEA,IAAI,CAACsB,QAAQQ,OAAOC,IAAI,CAACT,MAAMU,MAAM,KAAK,GAAG;gBAC3C,OAAOhC,aACL;YAEJ;YAEAD,gBAAgB0B;YAEhB,MAAMQ,oBAAoB7B,iBAAiByB,GAAG,CAACX;YAE/C,IAAI;gBACF,MAAMgB,MAAM,MAAMT,IAAIU,OAAO,CAACC,MAAM,CAAC;oBACnClB,YAAYA;oBACZmB,IAAIhB;oBACJC,MAAMA;oBACNgB,OAAOL;oBACPR;oBACAc,gBAAgB;oBAChBC,MAAMf,IAAIe,IAAI;gBAChB;gBAEA,MAAMC,cAAc3C,kBAAkBoC,KAAKb;gBAC3C,MAAMqB,gBAAgBZ,OAAOC,IAAI,CAACT,MAAMR,IAAI,CAAC;gBAC7C,MAAM6B,YAAYV,oBAAoBrC,aAAa;gBAEnD,OAAOI,aACL,CAAC,SAAS,EAAEyC,YAAY,KAAK,EAAEvB,WAAW,MAAM,EAAEG,WAAW,GAAG,CAAC,GAC/D,CAAC,gBAAgB,EAAEqB,cAAc,CAAC,EAAEC,WAAW;YAErD,EAAE,OAAOC,OAAO;gBACd,OAAO5C,aACL,CAAC,wBAAwB,EAAEqB,WAAW,IAAI,EAAEH,WAAW,EAAE,EAAErB,aAAa+C,QAAQ;YAEpF;QACF;IACF;AACF"}
@@ -1,9 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import type { PayloadRequest } from 'payload';
3
- /**
4
- * Creates the uploadMedia MCP tool that fetches an image from a public URL,
5
- * validates it for SSRF safety and content type, then creates a Media document.
6
- */
7
3
  export declare function createUploadMediaTool(options?: {
8
4
  maxFileSize?: number;
9
5
  collectionSlug?: string;
@@ -17,10 +13,5 @@ export declare function createUploadMediaTool(options?: {
17
13
  handler: (args: {
18
14
  url: string;
19
15
  alt?: string;
20
- }, req: PayloadRequest, _extra: unknown) => Promise<{
21
- content: {
22
- type: "text";
23
- text: string;
24
- }[];
25
- }>;
16
+ }, req: PayloadRequest, _extra: unknown) => Promise<import("./_helpers").McpTextResponse>;
26
17
  };
@@ -1,17 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import { validateAndFetchUrl } from '../url-validator';
3
- const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
4
- ;
3
+ import { errorMessage, stampMcpContext, textResponse } from './_helpers';
4
+ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
5
5
  const ALLOWED_IMAGE_TYPES = new Set([
6
6
  'image/jpeg',
7
7
  'image/png',
8
8
  'image/webp',
9
9
  'image/gif'
10
10
  ]);
11
- /**
12
- * Creates the uploadMedia MCP tool that fetches an image from a public URL,
13
- * validates it for SSRF safety and content type, then creates a Media document.
14
- */ export function createUploadMediaTool(options) {
11
+ export function createUploadMediaTool(options) {
15
12
  const maxFileSize = options?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
16
13
  const mediaSlug = options?.collectionSlug ?? 'media';
17
14
  return {
@@ -34,45 +31,20 @@ const ALLOWED_IMAGE_TYPES = new Set([
34
31
  contentType = result.contentType;
35
32
  filename = result.filename;
36
33
  } catch (error) {
37
- const message = error instanceof Error ? error.message : String(error);
38
- return {
39
- content: [
40
- {
41
- type: 'text',
42
- text: `Error fetching URL: ${message}`
43
- }
44
- ]
45
- };
34
+ return textResponse(`Error fetching URL: ${errorMessage(error)}`);
46
35
  }
47
36
  if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
48
- return {
49
- content: [
50
- {
51
- type: 'text',
52
- text: `Error: Unsupported Content-Type "${contentType}". ` + `Allowed types: ${[
53
- ...ALLOWED_IMAGE_TYPES
54
- ].join(', ')}`
55
- }
56
- ]
57
- };
37
+ return textResponse(`Error: Unsupported Content-Type "${contentType}". ` + `Allowed types: ${[
38
+ ...ALLOWED_IMAGE_TYPES
39
+ ].join(', ')}`);
58
40
  }
59
41
  if (buffer.byteLength > maxFileSize) {
60
42
  const sizeMB = (buffer.byteLength / (1024 * 1024)).toFixed(2);
61
43
  const limitMB = (maxFileSize / (1024 * 1024)).toFixed(2);
62
- return {
63
- content: [
64
- {
65
- type: 'text',
66
- text: `Error: File size ${sizeMB}MB exceeds maximum ${limitMB}MB.`
67
- }
68
- ]
69
- };
44
+ return textResponse(`Error: File size ${sizeMB}MB exceeds maximum ${limitMB}MB.`);
70
45
  }
71
46
  const alt = args.alt || filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim() || 'Uploaded image';
72
- req.context = {
73
- ...req.context,
74
- source: 'mcp'
75
- };
47
+ stampMcpContext(req);
76
48
  try {
77
49
  const doc = await req.payload.create({
78
50
  collection: mediaSlug,
@@ -89,24 +61,9 @@ const ALLOWED_IMAGE_TYPES = new Set([
89
61
  overrideAccess: false,
90
62
  user: req.user
91
63
  });
92
- return {
93
- content: [
94
- {
95
- type: 'text',
96
- text: `Successfully uploaded "${filename}" to ${mediaSlug}.\n` + `ID: ${doc.id}\n` + `Filename: ${filename}\n` + `Alt: ${alt}`
97
- }
98
- ]
99
- };
64
+ return textResponse(`Successfully uploaded "${filename}" to ${mediaSlug}.\n` + `ID: ${doc.id}\n` + `Filename: ${filename}\n` + `Alt: ${alt}`);
100
65
  } catch (error) {
101
- const message = error instanceof Error ? error.message : String(error);
102
- return {
103
- content: [
104
- {
105
- type: 'text',
106
- text: `Error creating media document: ${message}`
107
- }
108
- ]
109
- };
66
+ return textResponse(`Error creating media document: ${errorMessage(error)}`);
110
67
  }
111
68
  }
112
69
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/upload-media.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport { validateAndFetchUrl } from '../url-validator'\n\nconst DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB\nconst ALLOWED_IMAGE_TYPES = new Set([\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n])\n\n/**\n * Creates the uploadMedia MCP tool that fetches an image from a public URL,\n * validates it for SSRF safety and content type, then creates a Media document.\n */\nexport function createUploadMediaTool(options?: {\n maxFileSize?: number\n collectionSlug?: string\n}) {\n const maxFileSize = options?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE\n const mediaSlug = options?.collectionSlug ?? 'media'\n\n return {\n name: 'uploadMedia',\n description:\n 'Upload an image to the media library from a public HTTPS URL. ' +\n 'Fetches the image with SSRF protection, validates it is an allowed image type ' +\n '(JPEG, PNG, WebP, GIF), and creates a Media document with alt text. ' +\n 'Returns the created document ID, filename, and alt text.',\n parameters: {\n url: z.string().url().describe('Public HTTPS URL of the image to upload'),\n alt: z\n .string()\n .optional()\n .describe('Alt text for the image. Generates from filename if omitted.'),\n },\n handler: async (\n args: { url: string; alt?: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { url } = args\n\n let buffer: Buffer\n let contentType: string\n let filename: string\n try {\n const result = await validateAndFetchUrl(url, { maxBytes: maxFileSize })\n buffer = result.buffer\n contentType = result.contentType\n filename = result.filename\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 fetching URL: ${message}`,\n },\n ],\n }\n }\n\n if (!ALLOWED_IMAGE_TYPES.has(contentType)) {\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error: Unsupported Content-Type \"${contentType}\". ` +\n `Allowed types: ${[...ALLOWED_IMAGE_TYPES].join(', ')}`,\n },\n ],\n }\n }\n\n if (buffer.byteLength > maxFileSize) {\n const sizeMB = (buffer.byteLength / (1024 * 1024)).toFixed(2)\n const limitMB = (maxFileSize / (1024 * 1024)).toFixed(2)\n return {\n content: [\n {\n type: 'text' as const,\n text: `Error: File size ${sizeMB}MB exceeds maximum ${limitMB}MB.`,\n },\n ],\n }\n }\n\n const alt =\n args.alt ||\n filename\n .replace(/\\.[^.]+$/, '')\n .replace(/[-_]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim() ||\n 'Uploaded image'\n\n req.context = { ...req.context, source: 'mcp' }\n\n try {\n const doc = await req.payload.create({\n collection: mediaSlug as any,\n data: { alt } as any,\n file: {\n data: buffer,\n mimetype: contentType,\n name: filename,\n size: buffer.byteLength,\n },\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n return {\n content: [\n {\n type: 'text' as const,\n text: `Successfully uploaded \"${filename}\" to ${mediaSlug}.\\n` +\n `ID: ${doc.id}\\n` +\n `Filename: ${filename}\\n` +\n `Alt: ${alt}`,\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 creating media document: ${message}`,\n },\n ],\n }\n }\n },\n }\n}\n"],"names":["z","validateAndFetchUrl","DEFAULT_MAX_FILE_SIZE","ALLOWED_IMAGE_TYPES","Set","createUploadMediaTool","options","maxFileSize","mediaSlug","collectionSlug","name","description","parameters","url","string","describe","alt","optional","handler","args","req","_extra","buffer","contentType","filename","result","maxBytes","error","message","Error","String","content","type","text","has","join","byteLength","sizeMB","toFixed","limitMB","replace","trim","context","source","doc","payload","create","collection","data","file","mimetype","size","overrideAccess","user","id"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tools/upload-media.ts"],"sourcesContent":["import { z } from 'zod'\nimport type { PayloadRequest } from 'payload'\nimport { validateAndFetchUrl } from '../url-validator'\nimport { errorMessage, stampMcpContext, textResponse } from './_helpers'\n\nconst DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024\nconst ALLOWED_IMAGE_TYPES = new Set([\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n])\n\nexport function createUploadMediaTool(options?: {\n maxFileSize?: number\n collectionSlug?: string\n}) {\n const maxFileSize = options?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE\n const mediaSlug = options?.collectionSlug ?? 'media'\n\n return {\n name: 'uploadMedia',\n description:\n 'Upload an image to the media library from a public HTTPS URL. ' +\n 'Fetches the image with SSRF protection, validates it is an allowed image type ' +\n '(JPEG, PNG, WebP, GIF), and creates a Media document with alt text. ' +\n 'Returns the created document ID, filename, and alt text.',\n parameters: {\n url: z.string().url().describe('Public HTTPS URL of the image to upload'),\n alt: z\n .string()\n .optional()\n .describe('Alt text for the image. Generates from filename if omitted.'),\n },\n handler: async (\n args: { url: string; alt?: string },\n req: PayloadRequest,\n _extra: unknown,\n ) => {\n const { url } = args\n\n let buffer: Buffer\n let contentType: string\n let filename: string\n try {\n const result = await validateAndFetchUrl(url, { maxBytes: maxFileSize })\n buffer = result.buffer\n contentType = result.contentType\n filename = result.filename\n } catch (error) {\n return textResponse(`Error fetching URL: ${errorMessage(error)}`)\n }\n\n if (!ALLOWED_IMAGE_TYPES.has(contentType)) {\n return textResponse(\n `Error: Unsupported Content-Type \"${contentType}\". ` +\n `Allowed types: ${[...ALLOWED_IMAGE_TYPES].join(', ')}`,\n )\n }\n\n if (buffer.byteLength > maxFileSize) {\n const sizeMB = (buffer.byteLength / (1024 * 1024)).toFixed(2)\n const limitMB = (maxFileSize / (1024 * 1024)).toFixed(2)\n return textResponse(`Error: File size ${sizeMB}MB exceeds maximum ${limitMB}MB.`)\n }\n\n const alt =\n args.alt ||\n filename\n .replace(/\\.[^.]+$/, '')\n .replace(/[-_]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim() ||\n 'Uploaded image'\n\n stampMcpContext(req)\n\n try {\n const doc = await req.payload.create({\n collection: mediaSlug as any,\n data: { alt } as any,\n file: {\n data: buffer,\n mimetype: contentType,\n name: filename,\n size: buffer.byteLength,\n },\n req,\n overrideAccess: false,\n user: req.user,\n })\n\n return textResponse(\n `Successfully uploaded \"${filename}\" to ${mediaSlug}.\\n` +\n `ID: ${doc.id}\\n` +\n `Filename: ${filename}\\n` +\n `Alt: ${alt}`,\n )\n } catch (error) {\n return textResponse(`Error creating media document: ${errorMessage(error)}`)\n }\n },\n }\n}\n"],"names":["z","validateAndFetchUrl","errorMessage","stampMcpContext","textResponse","DEFAULT_MAX_FILE_SIZE","ALLOWED_IMAGE_TYPES","Set","createUploadMediaTool","options","maxFileSize","mediaSlug","collectionSlug","name","description","parameters","url","string","describe","alt","optional","handler","args","req","_extra","buffer","contentType","filename","result","maxBytes","error","has","join","byteLength","sizeMB","toFixed","limitMB","replace","trim","doc","payload","create","collection","data","file","mimetype","size","overrideAccess","user","id"],"mappings":"AAAA,SAASA,CAAC,QAAQ,MAAK;AAEvB,SAASC,mBAAmB,QAAQ,mBAAkB;AACtD,SAASC,YAAY,EAAEC,eAAe,EAAEC,YAAY,QAAQ,aAAY;AAExE,MAAMC,wBAAwB,KAAK,OAAO;AAC1C,MAAMC,sBAAsB,IAAIC,IAAI;IAClC;IACA;IACA;IACA;CACD;AAED,OAAO,SAASC,sBAAsBC,OAGrC;IACC,MAAMC,cAAcD,SAASC,eAAeL;IAC5C,MAAMM,YAAYF,SAASG,kBAAkB;IAE7C,OAAO;QACLC,MAAM;QACNC,aACE,mEACA,mFACA,yEACA;QACFC,YAAY;YACVC,KAAKhB,EAAEiB,MAAM,GAAGD,GAAG,GAAGE,QAAQ,CAAC;YAC/BC,KAAKnB,EACFiB,MAAM,GACNG,QAAQ,GACRF,QAAQ,CAAC;QACd;QACAG,SAAS,OACPC,MACAC,KACAC;YAEA,MAAM,EAAER,GAAG,EAAE,GAAGM;YAEhB,IAAIG;YACJ,IAAIC;YACJ,IAAIC;YACJ,IAAI;gBACF,MAAMC,SAAS,MAAM3B,oBAAoBe,KAAK;oBAAEa,UAAUnB;gBAAY;gBACtEe,SAASG,OAAOH,MAAM;gBACtBC,cAAcE,OAAOF,WAAW;gBAChCC,WAAWC,OAAOD,QAAQ;YAC5B,EAAE,OAAOG,OAAO;gBACd,OAAO1B,aAAa,CAAC,oBAAoB,EAAEF,aAAa4B,QAAQ;YAClE;YAEA,IAAI,CAACxB,oBAAoByB,GAAG,CAACL,cAAc;gBACzC,OAAOtB,aACL,CAAC,iCAAiC,EAAEsB,YAAY,GAAG,CAAC,GAClD,CAAC,eAAe,EAAE;uBAAIpB;iBAAoB,CAAC0B,IAAI,CAAC,OAAO;YAE7D;YAEA,IAAIP,OAAOQ,UAAU,GAAGvB,aAAa;gBACnC,MAAMwB,SAAS,AAACT,CAAAA,OAAOQ,UAAU,GAAI,CAAA,OAAO,IAAG,CAAC,EAAGE,OAAO,CAAC;gBAC3D,MAAMC,UAAU,AAAC1B,CAAAA,cAAe,CAAA,OAAO,IAAG,CAAC,EAAGyB,OAAO,CAAC;gBACtD,OAAO/B,aAAa,CAAC,iBAAiB,EAAE8B,OAAO,mBAAmB,EAAEE,QAAQ,GAAG,CAAC;YAClF;YAEA,MAAMjB,MACJG,KAAKH,GAAG,IACRQ,SACGU,OAAO,CAAC,YAAY,IACpBA,OAAO,CAAC,SAAS,KACjBA,OAAO,CAAC,QAAQ,KAChBC,IAAI,MACP;YAEFnC,gBAAgBoB;YAEhB,IAAI;gBACF,MAAMgB,MAAM,MAAMhB,IAAIiB,OAAO,CAACC,MAAM,CAAC;oBACnCC,YAAY/B;oBACZgC,MAAM;wBAAExB;oBAAI;oBACZyB,MAAM;wBACJD,MAAMlB;wBACNoB,UAAUnB;wBACVb,MAAMc;wBACNmB,MAAMrB,OAAOQ,UAAU;oBACzB;oBACAV;oBACAwB,gBAAgB;oBAChBC,MAAMzB,IAAIyB,IAAI;gBAChB;gBAEA,OAAO5C,aACL,CAAC,uBAAuB,EAAEuB,SAAS,KAAK,EAAEhB,UAAU,GAAG,CAAC,GACtD,CAAC,IAAI,EAAE4B,IAAIU,EAAE,CAAC,EAAE,CAAC,GACjB,CAAC,UAAU,EAAEtB,SAAS,EAAE,CAAC,GACzB,CAAC,KAAK,EAAER,KAAK;YAEnB,EAAE,OAAOW,OAAO;gBACd,OAAO1B,aAAa,CAAC,+BAA+B,EAAEF,aAAa4B,QAAQ;YAC7E;QACF;IACF;AACF"}