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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Draft behavior per collection */
|
|
2
|
+
export type DraftBehavior = 'always-draft' | 'always-publish';
|
|
3
|
+
/** Configuration for the content toolkit plugin */
|
|
4
|
+
export interface ContentToolkitOptions {
|
|
5
|
+
/** Base URL of the site (used for preview URLs). e.g. "https://example.com" */
|
|
6
|
+
siteUrl: string;
|
|
7
|
+
/** Secret used for preview URL authentication */
|
|
8
|
+
previewSecret: string;
|
|
9
|
+
/**
|
|
10
|
+
* Per-collection URL path prefix used when constructing preview URLs.
|
|
11
|
+
* Keys are collection slugs; values are the path segment placed before the doc slug.
|
|
12
|
+
* Use an empty string for collections that live at the site root (e.g. pages).
|
|
13
|
+
*
|
|
14
|
+
* Example:
|
|
15
|
+
* {
|
|
16
|
+
* posts: '/blog',
|
|
17
|
+
* products: '/shop',
|
|
18
|
+
* pages: '',
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Collections without an entry default to `/{slug}`.
|
|
22
|
+
*/
|
|
23
|
+
previewPaths?: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Explicit list of block slugs that should be treated as **section** blocks
|
|
26
|
+
* (top-level layout containers). Any block not in this list is treated as
|
|
27
|
+
* a **leaf** block (composable inside a section's `blocks` field).
|
|
28
|
+
*
|
|
29
|
+
* When omitted, the toolkit falls back to a heuristic: blocks that contain
|
|
30
|
+
* a nested `blocks`-type field are sections, all others are leaves. This
|
|
31
|
+
* heuristic mis-classifies "fixed" sections (sections with no nested blocks
|
|
32
|
+
* but their own standalone fields, e.g. a CTA banner). Pass this option
|
|
33
|
+
* to disambiguate.
|
|
34
|
+
*
|
|
35
|
+
* Example: `['hero', 'fullWidth', 'twoColumn', 'ctaBanner']`
|
|
36
|
+
*/
|
|
37
|
+
sectionBlockSlugs?: string[];
|
|
38
|
+
/** Site-specific domain prompts that teach the AI business vocabulary */
|
|
39
|
+
domainPrompts?: DomainPrompt[];
|
|
40
|
+
/** Per-collection draft behavior overrides (keyed by collection slug) */
|
|
41
|
+
draftBehavior?: Record<string, DraftBehavior>;
|
|
42
|
+
/** Media upload configuration */
|
|
43
|
+
mediaUpload?: {
|
|
44
|
+
/** Maximum file size in bytes (default: 10MB) */
|
|
45
|
+
maxFileSize?: number;
|
|
46
|
+
/** Media collection slug (default: 'media') */
|
|
47
|
+
collectionSlug?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Collections to exclude from MCP exposure */
|
|
50
|
+
excludeCollections?: string[];
|
|
51
|
+
/** Globals to exclude from MCP exposure */
|
|
52
|
+
excludeGlobals?: string[];
|
|
53
|
+
}
|
|
54
|
+
/** A domain prompt that teaches the AI site-specific vocabulary */
|
|
55
|
+
export interface DomainPrompt {
|
|
56
|
+
/** Unique name for the prompt */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Display title */
|
|
59
|
+
title: string;
|
|
60
|
+
/** Description of what this prompt teaches */
|
|
61
|
+
description: string;
|
|
62
|
+
/** The prompt content */
|
|
63
|
+
content: string;
|
|
64
|
+
}
|
|
65
|
+
/** Introspected field metadata */
|
|
66
|
+
export interface FieldSchema {
|
|
67
|
+
name: string;
|
|
68
|
+
type: string;
|
|
69
|
+
required?: boolean;
|
|
70
|
+
hasMany?: boolean;
|
|
71
|
+
relationTo?: string | string[];
|
|
72
|
+
options?: Array<{
|
|
73
|
+
label: string;
|
|
74
|
+
value: string;
|
|
75
|
+
}>;
|
|
76
|
+
fields?: FieldSchema[];
|
|
77
|
+
maxRows?: number;
|
|
78
|
+
}
|
|
79
|
+
/** Introspected collection metadata */
|
|
80
|
+
export interface CollectionSchema {
|
|
81
|
+
slug: string;
|
|
82
|
+
fields: FieldSchema[];
|
|
83
|
+
hasDrafts: boolean;
|
|
84
|
+
hasLivePreview: boolean;
|
|
85
|
+
relationships: Array<{
|
|
86
|
+
fieldName: string;
|
|
87
|
+
relationTo: string | string[];
|
|
88
|
+
hasMany: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
searchableFields: string[];
|
|
91
|
+
}
|
|
92
|
+
/** Block nesting classification */
|
|
93
|
+
export type BlockNestingType = 'composable' | 'constrained' | 'fixed';
|
|
94
|
+
/** Introspected section block metadata */
|
|
95
|
+
export interface SectionBlockSchema {
|
|
96
|
+
slug: string;
|
|
97
|
+
nestingType: BlockNestingType;
|
|
98
|
+
acceptedLeafSlugs: string[];
|
|
99
|
+
maxRows?: number;
|
|
100
|
+
fields: FieldSchema[];
|
|
101
|
+
}
|
|
102
|
+
/** Introspected leaf block metadata */
|
|
103
|
+
export interface LeafBlockSchema {
|
|
104
|
+
slug: string;
|
|
105
|
+
fields: FieldSchema[];
|
|
106
|
+
}
|
|
107
|
+
/** Complete block catalog */
|
|
108
|
+
export interface BlockCatalog {
|
|
109
|
+
sections: SectionBlockSchema[];
|
|
110
|
+
leaves: LeafBlockSchema[];
|
|
111
|
+
}
|
|
112
|
+
/** Relationship edge in the collection graph */
|
|
113
|
+
export interface RelationshipEdge {
|
|
114
|
+
fromCollection: string;
|
|
115
|
+
fieldName: string;
|
|
116
|
+
toCollection: string | string[];
|
|
117
|
+
hasMany: boolean;
|
|
118
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionConfig, Block, Config, GlobalConfig } from 'payload'\n\n/** Draft behavior per collection */\nexport type DraftBehavior = 'always-draft' | 'always-publish'\n\n/** Configuration for the content toolkit plugin */\nexport interface ContentToolkitOptions {\n /** Base URL of the site (used for preview URLs). e.g. \"https://example.com\" */\n siteUrl: string\n\n /** Secret used for preview URL authentication */\n previewSecret: string\n\n /**\n * Per-collection URL path prefix used when constructing preview URLs.\n * Keys are collection slugs; values are the path segment placed before the doc slug.\n * Use an empty string for collections that live at the site root (e.g. pages).\n *\n * Example:\n * {\n * posts: '/blog',\n * products: '/shop',\n * pages: '',\n * }\n *\n * Collections without an entry default to `/{slug}`.\n */\n previewPaths?: Record<string, string>\n\n /**\n * Explicit list of block slugs that should be treated as **section** blocks\n * (top-level layout containers). Any block not in this list is treated as\n * a **leaf** block (composable inside a section's `blocks` field).\n *\n * When omitted, the toolkit falls back to a heuristic: blocks that contain\n * a nested `blocks`-type field are sections, all others are leaves. This\n * heuristic mis-classifies \"fixed\" sections (sections with no nested blocks\n * but their own standalone fields, e.g. a CTA banner). Pass this option\n * to disambiguate.\n *\n * Example: `['hero', 'fullWidth', 'twoColumn', 'ctaBanner']`\n */\n sectionBlockSlugs?: string[]\n\n /** Site-specific domain prompts that teach the AI business vocabulary */\n domainPrompts?: DomainPrompt[]\n\n /** Per-collection draft behavior overrides (keyed by collection slug) */\n draftBehavior?: Record<string, DraftBehavior>\n\n /** Media upload configuration */\n mediaUpload?: {\n /** Maximum file size in bytes (default: 10MB) */\n maxFileSize?: number\n /** Media collection slug (default: 'media') */\n collectionSlug?: string\n }\n\n /** Collections to exclude from MCP exposure */\n excludeCollections?: string[]\n\n /** Globals to exclude from MCP exposure */\n excludeGlobals?: string[]\n}\n\n/** A domain prompt that teaches the AI site-specific vocabulary */\nexport interface DomainPrompt {\n /** Unique name for the prompt */\n name: string\n /** Display title */\n title: string\n /** Description of what this prompt teaches */\n description: string\n /** The prompt content */\n content: string\n}\n\n/** Introspected field metadata */\nexport interface FieldSchema {\n name: string\n type: string\n required?: boolean\n hasMany?: boolean\n relationTo?: string | string[]\n options?: Array<{ label: string; value: string }>\n fields?: FieldSchema[]\n maxRows?: number\n}\n\n/** Introspected collection metadata */\nexport interface CollectionSchema {\n slug: string\n fields: FieldSchema[]\n hasDrafts: boolean\n hasLivePreview: boolean\n relationships: Array<{ fieldName: string; relationTo: string | string[]; hasMany: boolean }>\n searchableFields: string[]\n}\n\n/** Block nesting classification */\nexport type BlockNestingType = 'composable' | 'constrained' | 'fixed'\n\n/** Introspected section block metadata */\nexport interface SectionBlockSchema {\n slug: string\n nestingType: BlockNestingType\n acceptedLeafSlugs: string[]\n maxRows?: number\n fields: FieldSchema[]\n}\n\n/** Introspected leaf block metadata */\nexport interface LeafBlockSchema {\n slug: string\n fields: FieldSchema[]\n}\n\n/** Complete block catalog */\nexport interface BlockCatalog {\n sections: SectionBlockSchema[]\n leaves: LeafBlockSchema[]\n}\n\n/** Relationship edge in the collection graph */\nexport interface RelationshipEdge {\n fromCollection: string\n fieldName: string\n toCollection: string | string[]\n hasMany: boolean\n}\n"],"names":[],"mappings":"AA2HA,8CAA8C,GAC9C,WAKC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
interface ValidatedFetchResult {
|
|
3
|
+
buffer: Buffer;
|
|
4
|
+
contentType: string;
|
|
5
|
+
filename: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check whether an IP address falls within a private/reserved range.
|
|
9
|
+
*
|
|
10
|
+
* Blocks (IPv4): 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT),
|
|
11
|
+
* 127.0.0.0/8, 169.254.0.0/16 (link-local + AWS metadata),
|
|
12
|
+
* 172.16.0.0/12, 192.168.0.0/16.
|
|
13
|
+
*
|
|
14
|
+
* Blocks (IPv6): ::1 (loopback, in any expanded form), fc00::/7
|
|
15
|
+
* (unique local), fe80::/10 (link-local), and any IPv4-mapped or
|
|
16
|
+
* IPv4-compatible address whose embedded IPv4 falls in a blocked range.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isPrivateIp(ip: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Validate a URL for SSRF safety and fetch its contents.
|
|
21
|
+
*
|
|
22
|
+
* - HTTPS-only scheme validation
|
|
23
|
+
* - DNS resolution pre-check to block private/reserved IPs
|
|
24
|
+
* - Manual redirect following with IP re-validation at each hop
|
|
25
|
+
* - 10-second timeout
|
|
26
|
+
*
|
|
27
|
+
* NOTE: Small TOCTOU gap between DNS resolution and the actual TCP
|
|
28
|
+
* connection (DNS rebinding). A future improvement could use a custom
|
|
29
|
+
* http.Agent with a `lookup` override to validate IPs at connection time.
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateAndFetchUrl(url: string, options?: {
|
|
32
|
+
maxRedirects?: number;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
}): Promise<ValidatedFetchResult>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import dns from 'node:dns';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
const MAX_REDIRECTS = 3;
|
|
4
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
5
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024 // 50MB hard ceiling — caller may pass a tighter cap.
|
|
6
|
+
;
|
|
7
|
+
/**
|
|
8
|
+
* Check whether an IP address falls within a private/reserved range.
|
|
9
|
+
*
|
|
10
|
+
* Blocks (IPv4): 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT),
|
|
11
|
+
* 127.0.0.0/8, 169.254.0.0/16 (link-local + AWS metadata),
|
|
12
|
+
* 172.16.0.0/12, 192.168.0.0/16.
|
|
13
|
+
*
|
|
14
|
+
* Blocks (IPv6): ::1 (loopback, in any expanded form), fc00::/7
|
|
15
|
+
* (unique local), fe80::/10 (link-local), and any IPv4-mapped or
|
|
16
|
+
* IPv4-compatible address whose embedded IPv4 falls in a blocked range.
|
|
17
|
+
*/ export function isPrivateIp(ip) {
|
|
18
|
+
if (ip.includes(':')) return isPrivateIpV6(ip);
|
|
19
|
+
return isPrivateIpV4(ip);
|
|
20
|
+
}
|
|
21
|
+
function isPrivateIpV4(ip) {
|
|
22
|
+
const parts = ip.split('.');
|
|
23
|
+
if (parts.length !== 4) return false;
|
|
24
|
+
const [a, b] = parts.map(Number);
|
|
25
|
+
if (parts.some((p)=>Number.isNaN(Number(p)))) return false;
|
|
26
|
+
if (a === 0) return true // 0.0.0.0/8
|
|
27
|
+
;
|
|
28
|
+
if (a === 10) return true // 10.0.0.0/8
|
|
29
|
+
;
|
|
30
|
+
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 — CGNAT
|
|
31
|
+
;
|
|
32
|
+
if (a === 127) return true // 127.0.0.0/8
|
|
33
|
+
;
|
|
34
|
+
if (a === 169 && b === 254) return true // 169.254.0.0/16
|
|
35
|
+
;
|
|
36
|
+
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
|
37
|
+
;
|
|
38
|
+
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
|
39
|
+
;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function isPrivateIpV6(ip) {
|
|
43
|
+
const normalized = ip.toLowerCase();
|
|
44
|
+
// IPv4-mapped (::ffff:1.2.3.4) and IPv4-compatible (::1.2.3.4) — extract
|
|
45
|
+
// the embedded v4 and run the v4 checks against it.
|
|
46
|
+
const mappedMatch = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
47
|
+
if (mappedMatch) return isPrivateIpV4(mappedMatch[1]);
|
|
48
|
+
const compatMatch = normalized.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
|
|
49
|
+
if (compatMatch) return isPrivateIpV4(compatMatch[1]);
|
|
50
|
+
// Expand to full 8-group form so loopback/link-local checks aren't
|
|
51
|
+
// defeated by alternate notations (`0:0:0:0:0:0:0:1`, `0000:...:0001`).
|
|
52
|
+
const groups = expandIpV6(normalized);
|
|
53
|
+
if (!groups) return false;
|
|
54
|
+
// Loopback ::1
|
|
55
|
+
if (groups.every((g, i)=>i < 7 ? g === 0 : g === 1)) return true;
|
|
56
|
+
const first = groups[0];
|
|
57
|
+
// fc00::/7 — unique local (fc00–fdff)
|
|
58
|
+
if ((first & 0xfe00) === 0xfc00) return true;
|
|
59
|
+
// fe80::/10 — link-local
|
|
60
|
+
if ((first & 0xffc0) === 0xfe80) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Expand a textual IPv6 address into 8 numeric groups. Returns null if
|
|
65
|
+
* the address isn't a recognisable v6 string (we err toward "not private"
|
|
66
|
+
* for unparseable input — DNS already gave us a real address, this guards
|
|
67
|
+
* notation variance, not malicious DNS payloads).
|
|
68
|
+
*/ function expandIpV6(ip) {
|
|
69
|
+
if (ip.includes('.')) return null // mapped/compat handled above
|
|
70
|
+
;
|
|
71
|
+
const halves = ip.split('::');
|
|
72
|
+
if (halves.length > 2) return null;
|
|
73
|
+
const parse = (segment)=>{
|
|
74
|
+
if (segment === '') return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const piece of segment.split(':')){
|
|
77
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece)) return null;
|
|
78
|
+
out.push(parseInt(piece, 16));
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
if (halves.length === 1) {
|
|
83
|
+
const parts = parse(ip);
|
|
84
|
+
return parts && parts.length === 8 ? parts : null;
|
|
85
|
+
}
|
|
86
|
+
const head = parse(halves[0]);
|
|
87
|
+
const tail = parse(halves[1]);
|
|
88
|
+
if (!head || !tail) return null;
|
|
89
|
+
const fillCount = 8 - head.length - tail.length;
|
|
90
|
+
if (fillCount < 0) return null;
|
|
91
|
+
return [
|
|
92
|
+
...head,
|
|
93
|
+
...new Array(fillCount).fill(0),
|
|
94
|
+
...tail
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Validate a URL for SSRF safety and fetch its contents.
|
|
99
|
+
*
|
|
100
|
+
* - HTTPS-only scheme validation
|
|
101
|
+
* - DNS resolution pre-check to block private/reserved IPs
|
|
102
|
+
* - Manual redirect following with IP re-validation at each hop
|
|
103
|
+
* - 10-second timeout
|
|
104
|
+
*
|
|
105
|
+
* NOTE: Small TOCTOU gap between DNS resolution and the actual TCP
|
|
106
|
+
* connection (DNS rebinding). A future improvement could use a custom
|
|
107
|
+
* http.Agent with a `lookup` override to validate IPs at connection time.
|
|
108
|
+
*/ export async function validateAndFetchUrl(url, options) {
|
|
109
|
+
const maxRedirects = options?.maxRedirects ?? MAX_REDIRECTS;
|
|
110
|
+
const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
111
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
112
|
+
let currentUrl = url;
|
|
113
|
+
let redirectCount = 0;
|
|
114
|
+
while(true){
|
|
115
|
+
const parsed = new URL(currentUrl);
|
|
116
|
+
if (parsed.protocol !== 'https:') {
|
|
117
|
+
throw new Error(`Only HTTPS URLs are allowed. Received: ${parsed.protocol}`);
|
|
118
|
+
}
|
|
119
|
+
const hostname = parsed.hostname;
|
|
120
|
+
const { address } = await dns.promises.lookup(hostname);
|
|
121
|
+
if (isPrivateIp(address)) {
|
|
122
|
+
throw new Error(`SSRF blocked: hostname "${hostname}" resolves to private IP ${address}. ` + 'Only public internet addresses are allowed.');
|
|
123
|
+
}
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timer = setTimeout(()=>controller.abort(), timeoutMs);
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetch(currentUrl, {
|
|
129
|
+
redirect: 'manual',
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
headers: {
|
|
132
|
+
'User-Agent': 'payload-mcp-toolkit/0.1'
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
138
|
+
throw new Error(`Request timed out after ${timeoutMs}ms fetching ${currentUrl}`);
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
} finally{
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
if (response.status >= 300 && response.status < 400) {
|
|
145
|
+
const location = response.headers.get('location');
|
|
146
|
+
if (!location) {
|
|
147
|
+
throw new Error(`Redirect response (${response.status}) missing Location header.`);
|
|
148
|
+
}
|
|
149
|
+
redirectCount++;
|
|
150
|
+
if (redirectCount > maxRedirects) {
|
|
151
|
+
throw new Error(`Too many redirects (max ${maxRedirects}). Last URL: ${currentUrl}`);
|
|
152
|
+
}
|
|
153
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new Error(`Failed to fetch ${currentUrl}: HTTP ${response.status} ${response.statusText}`);
|
|
158
|
+
}
|
|
159
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
|
|
160
|
+
// Reject early if the server advertises a body larger than maxBytes.
|
|
161
|
+
const declaredLength = response.headers.get('content-length');
|
|
162
|
+
if (declaredLength !== null) {
|
|
163
|
+
const length = Number(declaredLength);
|
|
164
|
+
if (Number.isFinite(length) && length > maxBytes) {
|
|
165
|
+
throw new Error(`Response body exceeds ${maxBytes} bytes (Content-Length: ${length}).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Stream the body, enforcing the cap as we go so a server that lies
|
|
169
|
+
// (or omits) Content-Length cannot exhaust memory.
|
|
170
|
+
const buffer = await readBodyWithLimit(response, maxBytes, currentUrl);
|
|
171
|
+
const filename = deriveFilename(parsed.pathname, contentType);
|
|
172
|
+
return {
|
|
173
|
+
buffer,
|
|
174
|
+
contentType,
|
|
175
|
+
filename
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function readBodyWithLimit(response, maxBytes, url) {
|
|
180
|
+
if (!response.body) return Buffer.alloc(0);
|
|
181
|
+
const reader = response.body.getReader();
|
|
182
|
+
const chunks = [];
|
|
183
|
+
let total = 0;
|
|
184
|
+
try {
|
|
185
|
+
while(true){
|
|
186
|
+
const { done, value } = await reader.read();
|
|
187
|
+
if (done) break;
|
|
188
|
+
total += value.byteLength;
|
|
189
|
+
if (total > maxBytes) {
|
|
190
|
+
throw new Error(`Response body from ${url} exceeded ${maxBytes} bytes during streaming.`);
|
|
191
|
+
}
|
|
192
|
+
chunks.push(value);
|
|
193
|
+
}
|
|
194
|
+
} finally{
|
|
195
|
+
try {
|
|
196
|
+
reader.releaseLock();
|
|
197
|
+
} catch {
|
|
198
|
+
// Reader already released after the stream errored; nothing to do.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return Buffer.concat(chunks, total);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Derive a safe filename from a URL path segment, falling back to a
|
|
205
|
+
* UUID-based name using the Content-Type for the extension.
|
|
206
|
+
*/ function deriveFilename(pathname, contentType) {
|
|
207
|
+
const lastSegment = pathname.split('/').filter(Boolean).pop() ?? '';
|
|
208
|
+
const cleaned = lastSegment.replace(/[?#].*/g, '').replace(/\.\./g, '').replace(/[/\\]/g, '');
|
|
209
|
+
if (cleaned && cleaned.includes('.') && cleaned.length <= 200) {
|
|
210
|
+
return cleaned;
|
|
211
|
+
}
|
|
212
|
+
const extMap = {
|
|
213
|
+
'image/jpeg': '.jpg',
|
|
214
|
+
'image/png': '.png',
|
|
215
|
+
'image/webp': '.webp',
|
|
216
|
+
'image/gif': '.gif'
|
|
217
|
+
};
|
|
218
|
+
const ext = extMap[contentType] ?? '.bin';
|
|
219
|
+
return `${crypto.randomUUID()}${ext}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
//# sourceMappingURL=url-validator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/url-validator.ts"],"sourcesContent":["import dns from 'node:dns'\nimport { Buffer } from 'node:buffer'\n\nconst MAX_REDIRECTS = 3\nconst FETCH_TIMEOUT_MS = 10_000\n\ninterface ValidatedFetchResult {\n buffer: Buffer\n contentType: string\n filename: string\n}\n\nconst DEFAULT_MAX_BYTES = 50 * 1024 * 1024 // 50MB hard ceiling — caller may pass a tighter cap.\n\n/**\n * Check whether an IP address falls within a private/reserved range.\n *\n * Blocks (IPv4): 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT),\n * 127.0.0.0/8, 169.254.0.0/16 (link-local + AWS metadata),\n * 172.16.0.0/12, 192.168.0.0/16.\n *\n * Blocks (IPv6): ::1 (loopback, in any expanded form), fc00::/7\n * (unique local), fe80::/10 (link-local), and any IPv4-mapped or\n * IPv4-compatible address whose embedded IPv4 falls in a blocked range.\n */\nexport function isPrivateIp(ip: string): boolean {\n if (ip.includes(':')) return isPrivateIpV6(ip)\n return isPrivateIpV4(ip)\n}\n\nfunction isPrivateIpV4(ip: string): boolean {\n const parts = ip.split('.')\n if (parts.length !== 4) return false\n const [a, b] = parts.map(Number)\n if (parts.some((p) => Number.isNaN(Number(p)))) return false\n\n if (a === 0) return true // 0.0.0.0/8\n if (a === 10) return true // 10.0.0.0/8\n if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 — CGNAT\n if (a === 127) return true // 127.0.0.0/8\n if (a === 169 && b === 254) return true // 169.254.0.0/16\n if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12\n if (a === 192 && b === 168) return true // 192.168.0.0/16\n\n return false\n}\n\nfunction isPrivateIpV6(ip: string): boolean {\n const normalized = ip.toLowerCase()\n\n // IPv4-mapped (::ffff:1.2.3.4) and IPv4-compatible (::1.2.3.4) — extract\n // the embedded v4 and run the v4 checks against it.\n const mappedMatch = normalized.match(/^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/)\n if (mappedMatch) return isPrivateIpV4(mappedMatch[1]!)\n const compatMatch = normalized.match(/^::(\\d+\\.\\d+\\.\\d+\\.\\d+)$/)\n if (compatMatch) return isPrivateIpV4(compatMatch[1]!)\n\n // Expand to full 8-group form so loopback/link-local checks aren't\n // defeated by alternate notations (`0:0:0:0:0:0:0:1`, `0000:...:0001`).\n const groups = expandIpV6(normalized)\n if (!groups) return false\n\n // Loopback ::1\n if (groups.every((g, i) => (i < 7 ? g === 0 : g === 1))) return true\n\n const first = groups[0]!\n // fc00::/7 — unique local (fc00–fdff)\n if ((first & 0xfe00) === 0xfc00) return true\n // fe80::/10 — link-local\n if ((first & 0xffc0) === 0xfe80) return true\n\n return false\n}\n\n/**\n * Expand a textual IPv6 address into 8 numeric groups. Returns null if\n * the address isn't a recognisable v6 string (we err toward \"not private\"\n * for unparseable input — DNS already gave us a real address, this guards\n * notation variance, not malicious DNS payloads).\n */\nfunction expandIpV6(ip: string): number[] | null {\n if (ip.includes('.')) return null // mapped/compat handled above\n const halves = ip.split('::')\n if (halves.length > 2) return null\n\n const parse = (segment: string): number[] | null => {\n if (segment === '') return []\n const out: number[] = []\n for (const piece of segment.split(':')) {\n if (!/^[0-9a-f]{1,4}$/.test(piece)) return null\n out.push(parseInt(piece, 16))\n }\n return out\n }\n\n if (halves.length === 1) {\n const parts = parse(ip)\n return parts && parts.length === 8 ? parts : null\n }\n\n const head = parse(halves[0]!)\n const tail = parse(halves[1]!)\n if (!head || !tail) return null\n const fillCount = 8 - head.length - tail.length\n if (fillCount < 0) return null\n return [...head, ...new Array(fillCount).fill(0), ...tail]\n}\n\n/**\n * Validate a URL for SSRF safety and fetch its contents.\n *\n * - HTTPS-only scheme validation\n * - DNS resolution pre-check to block private/reserved IPs\n * - Manual redirect following with IP re-validation at each hop\n * - 10-second timeout\n *\n * NOTE: Small TOCTOU gap between DNS resolution and the actual TCP\n * connection (DNS rebinding). A future improvement could use a custom\n * http.Agent with a `lookup` override to validate IPs at connection time.\n */\nexport async function validateAndFetchUrl(\n url: string,\n options?: { maxRedirects?: number; timeoutMs?: number; maxBytes?: number },\n): Promise<ValidatedFetchResult> {\n const maxRedirects = options?.maxRedirects ?? MAX_REDIRECTS\n const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES\n\n let currentUrl = url\n let redirectCount = 0\n\n while (true) {\n const parsed = new URL(currentUrl)\n\n if (parsed.protocol !== 'https:') {\n throw new Error(`Only HTTPS URLs are allowed. Received: ${parsed.protocol}`)\n }\n\n const hostname = parsed.hostname\n const { address } = await dns.promises.lookup(hostname)\n\n if (isPrivateIp(address)) {\n throw new Error(\n `SSRF blocked: hostname \"${hostname}\" resolves to private IP ${address}. ` +\n 'Only public internet addresses are allowed.',\n )\n }\n\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n\n let response: Response\n try {\n response = await fetch(currentUrl, {\n redirect: 'manual',\n signal: controller.signal,\n headers: { 'User-Agent': 'payload-mcp-toolkit/0.1' },\n })\n } catch (error) {\n clearTimeout(timer)\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${timeoutMs}ms fetching ${currentUrl}`)\n }\n throw error\n } finally {\n clearTimeout(timer)\n }\n\n if (response.status >= 300 && response.status < 400) {\n const location = response.headers.get('location')\n if (!location) {\n throw new Error(`Redirect response (${response.status}) missing Location header.`)\n }\n\n redirectCount++\n if (redirectCount > maxRedirects) {\n throw new Error(`Too many redirects (max ${maxRedirects}). Last URL: ${currentUrl}`)\n }\n\n currentUrl = new URL(location, currentUrl).href\n continue\n }\n\n if (!response.ok) {\n throw new Error(`Failed to fetch ${currentUrl}: HTTP ${response.status} ${response.statusText}`)\n }\n\n const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''\n\n // Reject early if the server advertises a body larger than maxBytes.\n const declaredLength = response.headers.get('content-length')\n if (declaredLength !== null) {\n const length = Number(declaredLength)\n if (Number.isFinite(length) && length > maxBytes) {\n throw new Error(\n `Response body exceeds ${maxBytes} bytes (Content-Length: ${length}).`,\n )\n }\n }\n\n // Stream the body, enforcing the cap as we go so a server that lies\n // (or omits) Content-Length cannot exhaust memory.\n const buffer = await readBodyWithLimit(response, maxBytes, currentUrl)\n const filename = deriveFilename(parsed.pathname, contentType)\n\n return { buffer, contentType, filename }\n }\n}\n\nasync function readBodyWithLimit(\n response: Response,\n maxBytes: number,\n url: string,\n): Promise<Buffer> {\n if (!response.body) return Buffer.alloc(0)\n const reader = response.body.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n total += value.byteLength\n if (total > maxBytes) {\n throw new Error(`Response body from ${url} exceeded ${maxBytes} bytes during streaming.`)\n }\n chunks.push(value)\n }\n } finally {\n try {\n reader.releaseLock()\n } catch {\n // Reader already released after the stream errored; nothing to do.\n }\n }\n return Buffer.concat(chunks, total)\n}\n\n/**\n * Derive a safe filename from a URL path segment, falling back to a\n * UUID-based name using the Content-Type for the extension.\n */\nfunction deriveFilename(pathname: string, contentType: string): string {\n const lastSegment = pathname.split('/').filter(Boolean).pop() ?? ''\n\n const cleaned = lastSegment\n .replace(/[?#].*/g, '')\n .replace(/\\.\\./g, '')\n .replace(/[/\\\\]/g, '')\n\n if (cleaned && cleaned.includes('.') && cleaned.length <= 200) {\n return cleaned\n }\n\n const extMap: Record<string, string> = {\n 'image/jpeg': '.jpg',\n 'image/png': '.png',\n 'image/webp': '.webp',\n 'image/gif': '.gif',\n }\n const ext = extMap[contentType] ?? '.bin'\n return `${crypto.randomUUID()}${ext}`\n}\n"],"names":["dns","Buffer","MAX_REDIRECTS","FETCH_TIMEOUT_MS","DEFAULT_MAX_BYTES","isPrivateIp","ip","includes","isPrivateIpV6","isPrivateIpV4","parts","split","length","a","b","map","Number","some","p","isNaN","normalized","toLowerCase","mappedMatch","match","compatMatch","groups","expandIpV6","every","g","i","first","halves","parse","segment","out","piece","test","push","parseInt","head","tail","fillCount","Array","fill","validateAndFetchUrl","url","options","maxRedirects","timeoutMs","maxBytes","currentUrl","redirectCount","parsed","URL","protocol","Error","hostname","address","promises","lookup","controller","AbortController","timer","setTimeout","abort","response","fetch","redirect","signal","headers","error","clearTimeout","DOMException","name","status","location","get","href","ok","statusText","contentType","trim","declaredLength","isFinite","buffer","readBodyWithLimit","filename","deriveFilename","pathname","body","alloc","reader","getReader","chunks","total","done","value","read","byteLength","releaseLock","concat","lastSegment","filter","Boolean","pop","cleaned","replace","extMap","ext","crypto","randomUUID"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payload-mcp-toolkit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Schema-aware MCP toolkit for Payload CMS — wraps the official @payloadcms/plugin-mcp with introspected prompts, resources, draft workflow, and AI-friendly tools so non-technical editors can manage content via AI chat.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "jon8800",
|
|
7
|
+
"homepage": "https://github.com/jon8800/payload-mcp-toolkit#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jon8800/payload-mcp-toolkit.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/jon8800/payload-mcp-toolkit/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"payload",
|
|
18
|
+
"payload-plugin",
|
|
19
|
+
"payloadcms",
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"ai",
|
|
23
|
+
"cms",
|
|
24
|
+
"content-management"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@payloadcms/plugin-mcp": "^3.0.0",
|
|
41
|
+
"payload": "^3.0.0",
|
|
42
|
+
"zod": "^3.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@payloadcms/db-sqlite": "3.82.1",
|
|
46
|
+
"@payloadcms/next": "3.82.1",
|
|
47
|
+
"@payloadcms/plugin-mcp": "3.82.1",
|
|
48
|
+
"@payloadcms/richtext-lexical": "3.82.1",
|
|
49
|
+
"@payloadcms/ui": "3.82.1",
|
|
50
|
+
"@swc/cli": "0.6.0",
|
|
51
|
+
"@types/node": "22.19.9",
|
|
52
|
+
"@types/react": "19.2.14",
|
|
53
|
+
"@types/react-dom": "19.2.3",
|
|
54
|
+
"copyfiles": "2.4.1",
|
|
55
|
+
"cross-env": "^7.0.3",
|
|
56
|
+
"graphql": "^16.8.1",
|
|
57
|
+
"next": "16.2.3",
|
|
58
|
+
"payload": "3.82.1",
|
|
59
|
+
"react": "19.2.4",
|
|
60
|
+
"react-dom": "19.2.4",
|
|
61
|
+
"rimraf": "^5.0.5",
|
|
62
|
+
"sharp": "0.34.2",
|
|
63
|
+
"typescript": "5.7.3",
|
|
64
|
+
"vite-tsconfig-paths": "6.0.5",
|
|
65
|
+
"vitest": "4.0.18",
|
|
66
|
+
"zod": "^3.23.8"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": "^18.20.2 || >=20.9.0",
|
|
70
|
+
"pnpm": "^9 || ^10"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
|
74
|
+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
|
75
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
76
|
+
"clean": "rimraf dist",
|
|
77
|
+
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,svg,json}\" dist/",
|
|
78
|
+
"dev": "next dev dev --turbo",
|
|
79
|
+
"dev:generate-importmap": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:importmap",
|
|
80
|
+
"dev:generate-types": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload generate:types",
|
|
81
|
+
"dev:payload": "cd dev && cross-env PAYLOAD_CONFIG_PATH=./payload.config.ts payload",
|
|
82
|
+
"test": "vitest run",
|
|
83
|
+
"test:watch": "vitest"
|
|
84
|
+
}
|
|
85
|
+
}
|