payload-mcp-toolkit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/__tests__/introspection.test.js +364 -0
- package/dist/__tests__/introspection.test.js.map +1 -0
- package/dist/__tests__/url-validator.test.js +326 -0
- package/dist/__tests__/url-validator.test.js.map +1 -0
- package/dist/draft-workflow.d.ts +60 -0
- package/dist/draft-workflow.js +93 -0
- package/dist/draft-workflow.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +142 -0
- package/dist/index.js.map +1 -0
- package/dist/introspection.d.ts +23 -0
- package/dist/introspection.js +238 -0
- package/dist/introspection.js.map +1 -0
- package/dist/prompts.d.ts +21 -0
- package/dist/prompts.js +215 -0
- package/dist/prompts.js.map +1 -0
- package/dist/rate-limiter.d.ts +25 -0
- package/dist/rate-limiter.js +51 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/resources.d.ts +18 -0
- package/dist/resources.js +77 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/compose-helpers.d.ts +117 -0
- package/dist/tools/compose-helpers.js +236 -0
- package/dist/tools/compose-helpers.js.map +1 -0
- package/dist/tools/compose-layout.d.ts +139 -0
- package/dist/tools/compose-layout.js +61 -0
- package/dist/tools/compose-layout.js.map +1 -0
- package/dist/tools/patch-layout.d.ts +107 -0
- package/dist/tools/patch-layout.js +123 -0
- package/dist/tools/patch-layout.js.map +1 -0
- package/dist/tools/publish-draft.d.ts +24 -0
- package/dist/tools/publish-draft.js +69 -0
- package/dist/tools/publish-draft.js.map +1 -0
- package/dist/tools/resolve-reference.d.ts +31 -0
- package/dist/tools/resolve-reference.js +169 -0
- package/dist/tools/resolve-reference.js.map +1 -0
- package/dist/tools/safe-delete.d.ts +37 -0
- package/dist/tools/safe-delete.js +161 -0
- package/dist/tools/safe-delete.js.map +1 -0
- package/dist/tools/schedule-publish.d.ts +49 -0
- package/dist/tools/schedule-publish.js +120 -0
- package/dist/tools/schedule-publish.js.map +1 -0
- package/dist/tools/search-content.d.ts +43 -0
- package/dist/tools/search-content.js +210 -0
- package/dist/tools/search-content.js.map +1 -0
- package/dist/tools/update-document.d.ts +32 -0
- package/dist/tools/update-document.js +114 -0
- package/dist/tools/update-document.js.map +1 -0
- package/dist/tools/upload-media.d.ts +26 -0
- package/dist/tools/upload-media.js +115 -0
- package/dist/tools/upload-media.js.map +1 -0
- package/dist/tools/versions.d.ts +50 -0
- package/dist/tools/versions.js +159 -0
- package/dist/tools/versions.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/url-validator.d.ts +36 -0
- package/dist/url-validator.js +222 -0
- package/dist/url-validator.js.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import dns from 'node:dns';
|
|
3
|
+
import { isPrivateIp, validateAndFetchUrl } from '../url-validator';
|
|
4
|
+
// ─── isPrivateIp — pure unit tests ────────────────────────────────
|
|
5
|
+
describe('isPrivateIp', ()=>{
|
|
6
|
+
describe('IPv4 — blocked ranges', ()=>{
|
|
7
|
+
it.each([
|
|
8
|
+
[
|
|
9
|
+
'10.0.0.1',
|
|
10
|
+
'10.0.0.0/8'
|
|
11
|
+
],
|
|
12
|
+
[
|
|
13
|
+
'10.255.255.255',
|
|
14
|
+
'10.0.0.0/8 upper'
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
'172.16.0.1',
|
|
18
|
+
'172.16.0.0/12 lower'
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
'172.20.5.5',
|
|
22
|
+
'172.16.0.0/12 mid'
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
'172.31.255.255',
|
|
26
|
+
'172.16.0.0/12 upper'
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'192.168.1.1',
|
|
30
|
+
'192.168.0.0/16'
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
'127.0.0.1',
|
|
34
|
+
'127.0.0.0/8 loopback'
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
'127.255.255.255',
|
|
38
|
+
'127.0.0.0/8 upper'
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'169.254.169.254',
|
|
42
|
+
'169.254.0.0/16 — AWS metadata'
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'0.0.0.0',
|
|
46
|
+
'unspecified — 0.0.0.0/8'
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'0.0.0.1',
|
|
50
|
+
'0.0.0.0/8 not just :0'
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
'0.255.255.255',
|
|
54
|
+
'0.0.0.0/8 upper'
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
'100.64.0.0',
|
|
58
|
+
'100.64.0.0/10 — CGNAT lower'
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
'100.100.50.50',
|
|
62
|
+
'100.64.0.0/10 — CGNAT mid'
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'100.127.255.255',
|
|
66
|
+
'100.64.0.0/10 — CGNAT upper'
|
|
67
|
+
]
|
|
68
|
+
])('blocks %s (%s)', (ip)=>{
|
|
69
|
+
expect(isPrivateIp(ip)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('IPv4 — public (allowed)', ()=>{
|
|
73
|
+
it.each([
|
|
74
|
+
[
|
|
75
|
+
'8.8.8.8'
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
'1.1.1.1'
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
'172.15.0.1'
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
'172.32.0.1'
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
'11.0.0.1'
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
'192.169.1.1'
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
'126.255.255.255'
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
'128.0.0.1'
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'100.63.255.255'
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
'100.128.0.0'
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
'1.0.0.1'
|
|
106
|
+
]
|
|
107
|
+
])('allows %s', (ip)=>{
|
|
108
|
+
expect(isPrivateIp(ip)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('IPv6 — blocked ranges', ()=>{
|
|
112
|
+
it.each([
|
|
113
|
+
[
|
|
114
|
+
'::1',
|
|
115
|
+
'loopback shorthand'
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
'0:0:0:0:0:0:0:1',
|
|
119
|
+
'loopback expanded'
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
'0000:0000:0000:0000:0000:0000:0000:0001',
|
|
123
|
+
'loopback fully expanded'
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'fc00::1',
|
|
127
|
+
'unique local fc'
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'fd12:3456:789a::1',
|
|
131
|
+
'unique local fd'
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
'fe80::1',
|
|
135
|
+
'link-local'
|
|
136
|
+
],
|
|
137
|
+
[
|
|
138
|
+
'FE80::1',
|
|
139
|
+
'link-local case-insensitive'
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
'febf::1',
|
|
143
|
+
'link-local upper boundary fe80::/10'
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'::ffff:127.0.0.1',
|
|
147
|
+
'IPv4-mapped loopback'
|
|
148
|
+
],
|
|
149
|
+
[
|
|
150
|
+
'::ffff:10.0.0.1',
|
|
151
|
+
'IPv4-mapped private 10/8'
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
'::ffff:169.254.169.254',
|
|
155
|
+
'IPv4-mapped AWS metadata'
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
'::ffff:100.64.0.1',
|
|
159
|
+
'IPv4-mapped CGNAT'
|
|
160
|
+
],
|
|
161
|
+
[
|
|
162
|
+
'::127.0.0.1',
|
|
163
|
+
'IPv4-compat loopback'
|
|
164
|
+
]
|
|
165
|
+
])('blocks %s (%s)', (ip)=>{
|
|
166
|
+
expect(isPrivateIp(ip)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('IPv6 — public (allowed)', ()=>{
|
|
170
|
+
it.each([
|
|
171
|
+
[
|
|
172
|
+
'2001:4860:4860::8888'
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
'2606:4700:4700::1111'
|
|
176
|
+
],
|
|
177
|
+
[
|
|
178
|
+
'ff00::1'
|
|
179
|
+
],
|
|
180
|
+
[
|
|
181
|
+
'::ffff:8.8.8.8'
|
|
182
|
+
],
|
|
183
|
+
[
|
|
184
|
+
'fec0::1'
|
|
185
|
+
]
|
|
186
|
+
])('allows %s', (ip)=>{
|
|
187
|
+
expect(isPrivateIp(ip)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
// ─── validateAndFetchUrl — integration with mocked DNS + fetch ────
|
|
192
|
+
describe('validateAndFetchUrl', ()=>{
|
|
193
|
+
const originalFetch = globalThis.fetch;
|
|
194
|
+
let dnsLookupSpy;
|
|
195
|
+
beforeEach(()=>{
|
|
196
|
+
dnsLookupSpy = vi.spyOn(dns.promises, 'lookup');
|
|
197
|
+
});
|
|
198
|
+
afterEach(()=>{
|
|
199
|
+
vi.restoreAllMocks();
|
|
200
|
+
globalThis.fetch = originalFetch;
|
|
201
|
+
});
|
|
202
|
+
function mockDns(address, family = 4) {
|
|
203
|
+
dnsLookupSpy.mockResolvedValue({
|
|
204
|
+
address,
|
|
205
|
+
family
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function mockFetch(impl) {
|
|
209
|
+
globalThis.fetch = vi.fn((url)=>Promise.resolve(impl(String(url))));
|
|
210
|
+
}
|
|
211
|
+
it('rejects non-HTTPS schemes', async ()=>{
|
|
212
|
+
await expect(validateAndFetchUrl('http://example.com/x.png')).rejects.toThrow(/HTTPS/);
|
|
213
|
+
});
|
|
214
|
+
it('rejects when DNS resolves to a private IPv4 (SSRF)', async ()=>{
|
|
215
|
+
mockDns('10.0.0.5');
|
|
216
|
+
await expect(validateAndFetchUrl('https://attacker.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
217
|
+
});
|
|
218
|
+
it('rejects when DNS resolves to AWS metadata IP (SSRF)', async ()=>{
|
|
219
|
+
mockDns('169.254.169.254');
|
|
220
|
+
await expect(validateAndFetchUrl('https://metadata.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
221
|
+
});
|
|
222
|
+
it('rejects when DNS resolves to ::1 (SSRF)', async ()=>{
|
|
223
|
+
mockDns('::1', 6);
|
|
224
|
+
await expect(validateAndFetchUrl('https://localhost.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
225
|
+
});
|
|
226
|
+
it('fetches successfully when DNS resolves to a public IP', async ()=>{
|
|
227
|
+
mockDns('8.8.8.8');
|
|
228
|
+
mockFetch(()=>new Response(new Uint8Array([
|
|
229
|
+
1,
|
|
230
|
+
2,
|
|
231
|
+
3
|
|
232
|
+
]), {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: {
|
|
235
|
+
'content-type': 'image/png'
|
|
236
|
+
}
|
|
237
|
+
}));
|
|
238
|
+
const result = await validateAndFetchUrl('https://example.com/cat.png');
|
|
239
|
+
expect(result.contentType).toBe('image/png');
|
|
240
|
+
expect(result.filename).toBe('cat.png');
|
|
241
|
+
expect(result.buffer.length).toBe(3);
|
|
242
|
+
});
|
|
243
|
+
it('follows redirects and re-validates the redirect target', async ()=>{
|
|
244
|
+
let call = 0;
|
|
245
|
+
// First lookup: public. Second lookup (after redirect): private → must block.
|
|
246
|
+
dnsLookupSpy.mockImplementation(async ()=>{
|
|
247
|
+
call++;
|
|
248
|
+
return {
|
|
249
|
+
address: call === 1 ? '8.8.8.8' : '127.0.0.1',
|
|
250
|
+
family: 4
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
mockFetch((url)=>{
|
|
254
|
+
if (url === 'https://example.com/start') {
|
|
255
|
+
return new Response(null, {
|
|
256
|
+
status: 302,
|
|
257
|
+
headers: {
|
|
258
|
+
location: 'https://internal.example/secret'
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`should not reach ${url} — redirect target must be blocked`);
|
|
263
|
+
});
|
|
264
|
+
await expect(validateAndFetchUrl('https://example.com/start')).rejects.toThrow(/SSRF blocked/);
|
|
265
|
+
expect(call).toBe(2);
|
|
266
|
+
});
|
|
267
|
+
it('caps redirect chains at maxRedirects', async ()=>{
|
|
268
|
+
mockDns('8.8.8.8');
|
|
269
|
+
mockFetch((url)=>new Response(null, {
|
|
270
|
+
status: 302,
|
|
271
|
+
headers: {
|
|
272
|
+
location: url + '/again'
|
|
273
|
+
}
|
|
274
|
+
}));
|
|
275
|
+
await expect(validateAndFetchUrl('https://example.com/loop', {
|
|
276
|
+
maxRedirects: 2
|
|
277
|
+
})).rejects.toThrow(/Too many redirects/);
|
|
278
|
+
});
|
|
279
|
+
it('errors when a redirect response has no Location header', async ()=>{
|
|
280
|
+
mockDns('8.8.8.8');
|
|
281
|
+
mockFetch(()=>new Response(null, {
|
|
282
|
+
status: 302
|
|
283
|
+
}));
|
|
284
|
+
await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/Location header/);
|
|
285
|
+
});
|
|
286
|
+
it('surfaces non-OK HTTP responses', async ()=>{
|
|
287
|
+
mockDns('8.8.8.8');
|
|
288
|
+
mockFetch(()=>new Response(null, {
|
|
289
|
+
status: 404,
|
|
290
|
+
statusText: 'Not Found'
|
|
291
|
+
}));
|
|
292
|
+
await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/404/);
|
|
293
|
+
});
|
|
294
|
+
it('derives a UUID filename when the URL path has no extension', async ()=>{
|
|
295
|
+
mockDns('8.8.8.8');
|
|
296
|
+
mockFetch(()=>new Response(new Uint8Array([
|
|
297
|
+
0
|
|
298
|
+
]), {
|
|
299
|
+
status: 200,
|
|
300
|
+
headers: {
|
|
301
|
+
'content-type': 'image/jpeg'
|
|
302
|
+
}
|
|
303
|
+
}));
|
|
304
|
+
const result = await validateAndFetchUrl('https://example.com/no-extension');
|
|
305
|
+
expect(result.filename).toMatch(/\.jpg$/);
|
|
306
|
+
expect(result.filename).not.toBe('no-extension');
|
|
307
|
+
});
|
|
308
|
+
it('strips path traversal segments from derived filenames', async ()=>{
|
|
309
|
+
mockDns('8.8.8.8');
|
|
310
|
+
mockFetch(()=>new Response(new Uint8Array([
|
|
311
|
+
0
|
|
312
|
+
]), {
|
|
313
|
+
status: 200,
|
|
314
|
+
headers: {
|
|
315
|
+
'content-type': 'image/png'
|
|
316
|
+
}
|
|
317
|
+
}));
|
|
318
|
+
const result = await validateAndFetchUrl('https://example.com/foo/..%2Fevil.png');
|
|
319
|
+
// URL parser decodes %2F into /, so lastSegment ends up "..evil.png" after .. strip
|
|
320
|
+
expect(result.filename).not.toContain('..');
|
|
321
|
+
expect(result.filename).not.toContain('/');
|
|
322
|
+
expect(result.filename).not.toContain('\\');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
//# sourceMappingURL=url-validator.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/__tests__/url-validator.test.ts"],"sourcesContent":["import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport dns from 'node:dns'\nimport { isPrivateIp, validateAndFetchUrl } from '../url-validator'\n\n// ─── isPrivateIp — pure unit tests ────────────────────────────────\n\ndescribe('isPrivateIp', () => {\n describe('IPv4 — blocked ranges', () => {\n it.each([\n ['10.0.0.1', '10.0.0.0/8'],\n ['10.255.255.255', '10.0.0.0/8 upper'],\n ['172.16.0.1', '172.16.0.0/12 lower'],\n ['172.20.5.5', '172.16.0.0/12 mid'],\n ['172.31.255.255', '172.16.0.0/12 upper'],\n ['192.168.1.1', '192.168.0.0/16'],\n ['127.0.0.1', '127.0.0.0/8 loopback'],\n ['127.255.255.255', '127.0.0.0/8 upper'],\n ['169.254.169.254', '169.254.0.0/16 — AWS metadata'],\n ['0.0.0.0', 'unspecified — 0.0.0.0/8'],\n ['0.0.0.1', '0.0.0.0/8 not just :0'],\n ['0.255.255.255', '0.0.0.0/8 upper'],\n ['100.64.0.0', '100.64.0.0/10 — CGNAT lower'],\n ['100.100.50.50', '100.64.0.0/10 — CGNAT mid'],\n ['100.127.255.255', '100.64.0.0/10 — CGNAT upper'],\n ])('blocks %s (%s)', (ip) => {\n expect(isPrivateIp(ip)).toBe(true)\n })\n })\n\n describe('IPv4 — public (allowed)', () => {\n it.each([\n ['8.8.8.8'],\n ['1.1.1.1'],\n ['172.15.0.1'], // just outside 172.16/12\n ['172.32.0.1'], // just outside 172.16/12\n ['11.0.0.1'],\n ['192.169.1.1'], // not 192.168\n ['126.255.255.255'],\n ['128.0.0.1'],\n ['100.63.255.255'], // just below CGNAT\n ['100.128.0.0'], // just above CGNAT\n ['1.0.0.1'], // outside 0.0.0.0/8\n ])('allows %s', (ip) => {\n expect(isPrivateIp(ip)).toBe(false)\n })\n })\n\n describe('IPv6 — blocked ranges', () => {\n it.each([\n ['::1', 'loopback shorthand'],\n ['0:0:0:0:0:0:0:1', 'loopback expanded'],\n ['0000:0000:0000:0000:0000:0000:0000:0001', 'loopback fully expanded'],\n ['fc00::1', 'unique local fc'],\n ['fd12:3456:789a::1', 'unique local fd'],\n ['fe80::1', 'link-local'],\n ['FE80::1', 'link-local case-insensitive'],\n ['febf::1', 'link-local upper boundary fe80::/10'],\n ['::ffff:127.0.0.1', 'IPv4-mapped loopback'],\n ['::ffff:10.0.0.1', 'IPv4-mapped private 10/8'],\n ['::ffff:169.254.169.254', 'IPv4-mapped AWS metadata'],\n ['::ffff:100.64.0.1', 'IPv4-mapped CGNAT'],\n ['::127.0.0.1', 'IPv4-compat loopback'],\n ])('blocks %s (%s)', (ip) => {\n expect(isPrivateIp(ip)).toBe(true)\n })\n })\n\n describe('IPv6 — public (allowed)', () => {\n it.each([\n ['2001:4860:4860::8888'], // Google DNS\n ['2606:4700:4700::1111'], // Cloudflare DNS\n ['ff00::1'], // multicast — not in our private blocklist\n ['::ffff:8.8.8.8'], // IPv4-mapped public\n ['fec0::1'], // outside fe80::/10 (deprecated site-local, but not our match)\n ])('allows %s', (ip) => {\n expect(isPrivateIp(ip)).toBe(false)\n })\n })\n})\n\n// ─── validateAndFetchUrl — integration with mocked DNS + fetch ────\n\ndescribe('validateAndFetchUrl', () => {\n const originalFetch = globalThis.fetch\n let dnsLookupSpy: ReturnType<typeof vi.spyOn>\n\n beforeEach(() => {\n dnsLookupSpy = vi.spyOn(dns.promises, 'lookup')\n })\n\n afterEach(() => {\n vi.restoreAllMocks()\n globalThis.fetch = originalFetch\n })\n\n function mockDns(address: string, family: 4 | 6 = 4) {\n dnsLookupSpy.mockResolvedValue({ address, family } as any)\n }\n\n function mockFetch(impl: (url: string) => Promise<Response> | Response) {\n globalThis.fetch = vi.fn(((url: any) => Promise.resolve(impl(String(url)))) as any) as any\n }\n\n it('rejects non-HTTPS schemes', async () => {\n await expect(validateAndFetchUrl('http://example.com/x.png')).rejects.toThrow(/HTTPS/)\n })\n\n it('rejects when DNS resolves to a private IPv4 (SSRF)', async () => {\n mockDns('10.0.0.5')\n await expect(validateAndFetchUrl('https://attacker.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('rejects when DNS resolves to AWS metadata IP (SSRF)', async () => {\n mockDns('169.254.169.254')\n await expect(validateAndFetchUrl('https://metadata.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('rejects when DNS resolves to ::1 (SSRF)', async () => {\n mockDns('::1', 6)\n await expect(validateAndFetchUrl('https://localhost.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('fetches successfully when DNS resolves to a public IP', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([1, 2, 3]), {\n status: 200,\n headers: { 'content-type': 'image/png' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/cat.png')\n expect(result.contentType).toBe('image/png')\n expect(result.filename).toBe('cat.png')\n expect(result.buffer.length).toBe(3)\n })\n\n it('follows redirects and re-validates the redirect target', async () => {\n let call = 0\n // First lookup: public. Second lookup (after redirect): private → must block.\n dnsLookupSpy.mockImplementation(async () => {\n call++\n return { address: call === 1 ? '8.8.8.8' : '127.0.0.1', family: 4 } as any\n })\n\n mockFetch((url) => {\n if (url === 'https://example.com/start') {\n return new Response(null, {\n status: 302,\n headers: { location: 'https://internal.example/secret' },\n })\n }\n throw new Error(`should not reach ${url} — redirect target must be blocked`)\n })\n\n await expect(validateAndFetchUrl('https://example.com/start')).rejects.toThrow(\n /SSRF blocked/,\n )\n expect(call).toBe(2)\n })\n\n it('caps redirect chains at maxRedirects', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n (url) =>\n new Response(null, {\n status: 302,\n headers: { location: url + '/again' },\n }),\n )\n\n await expect(\n validateAndFetchUrl('https://example.com/loop', { maxRedirects: 2 }),\n ).rejects.toThrow(/Too many redirects/)\n })\n\n it('errors when a redirect response has no Location header', async () => {\n mockDns('8.8.8.8')\n mockFetch(() => new Response(null, { status: 302 }))\n\n await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(\n /Location header/,\n )\n })\n\n it('surfaces non-OK HTTP responses', async () => {\n mockDns('8.8.8.8')\n mockFetch(() => new Response(null, { status: 404, statusText: 'Not Found' }))\n\n await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/404/)\n })\n\n it('derives a UUID filename when the URL path has no extension', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([0]), {\n status: 200,\n headers: { 'content-type': 'image/jpeg' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/no-extension')\n expect(result.filename).toMatch(/\\.jpg$/)\n expect(result.filename).not.toBe('no-extension')\n })\n\n it('strips path traversal segments from derived filenames', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([0]), {\n status: 200,\n headers: { 'content-type': 'image/png' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/foo/..%2Fevil.png')\n // URL parser decodes %2F into /, so lastSegment ends up \"..evil.png\" after .. strip\n expect(result.filename).not.toContain('..')\n expect(result.filename).not.toContain('/')\n expect(result.filename).not.toContain('\\\\')\n })\n})\n"],"names":["describe","it","expect","vi","beforeEach","afterEach","dns","isPrivateIp","validateAndFetchUrl","each","ip","toBe","originalFetch","globalThis","fetch","dnsLookupSpy","spyOn","promises","restoreAllMocks","mockDns","address","family","mockResolvedValue","mockFetch","impl","fn","url","Promise","resolve","String","rejects","toThrow","Response","Uint8Array","status","headers","result","contentType","filename","buffer","length","call","mockImplementation","location","Error","maxRedirects","statusText","toMatch","not","toContain"],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CollectionConfig, PayloadRequest } from 'payload';
|
|
2
|
+
import type { DraftBehavior } from './types';
|
|
3
|
+
/** MCP response shape used by overrideResponse */
|
|
4
|
+
interface McpResponse {
|
|
5
|
+
content: Array<{
|
|
6
|
+
text: string;
|
|
7
|
+
type: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/** Per-collection MCP config with enabled operations and optional overrideResponse */
|
|
11
|
+
interface McpCollectionConfig {
|
|
12
|
+
description: string;
|
|
13
|
+
enabled: {
|
|
14
|
+
create: boolean;
|
|
15
|
+
delete: boolean;
|
|
16
|
+
find: boolean;
|
|
17
|
+
update: boolean;
|
|
18
|
+
};
|
|
19
|
+
overrideResponse?: (response: McpResponse, doc: Record<string, unknown>, req: PayloadRequest) => McpResponse;
|
|
20
|
+
}
|
|
21
|
+
interface GenerateOptions {
|
|
22
|
+
/** Base site URL for preview links */
|
|
23
|
+
siteUrl: string;
|
|
24
|
+
/** Preview authentication secret */
|
|
25
|
+
previewSecret: string;
|
|
26
|
+
/**
|
|
27
|
+
* Per-collection URL path prefix used when constructing preview URLs.
|
|
28
|
+
* Defaults to `/{slug}` for collections not in the map.
|
|
29
|
+
*/
|
|
30
|
+
previewPaths?: Record<string, string>;
|
|
31
|
+
/** Per-collection draft behavior overrides */
|
|
32
|
+
draftBehavior?: Record<string, DraftBehavior>;
|
|
33
|
+
/** Collection slugs to exclude from MCP */
|
|
34
|
+
excludeCollections?: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Determines the draft behavior for a collection based on its config and user overrides.
|
|
38
|
+
*
|
|
39
|
+
* - If the collection has no `versions.drafts`: always 'publish' regardless of override
|
|
40
|
+
* - If the user specified an override: use that override
|
|
41
|
+
* - Default: 'always-draft' for draft-enabled collections, 'publish' for others
|
|
42
|
+
*/
|
|
43
|
+
export declare function getDraftBehavior(collection: CollectionConfig, options?: {
|
|
44
|
+
draftBehavior?: Record<string, DraftBehavior>;
|
|
45
|
+
}): 'always-draft' | 'always-publish' | 'publish';
|
|
46
|
+
/**
|
|
47
|
+
* Generates the mcpCollections config object for the official mcpPlugin.
|
|
48
|
+
*
|
|
49
|
+
* For each collection:
|
|
50
|
+
* - Determines enabled CRUD operations based on draft behavior
|
|
51
|
+
* - For 'always-draft' collections: disables raw `update` to force clients through publishDraft tool
|
|
52
|
+
* - Generates `overrideResponse` that appends preview URLs for draft documents
|
|
53
|
+
*
|
|
54
|
+
* @returns A record of collection slug to MCP collection config, plus the set of draft collection slugs
|
|
55
|
+
*/
|
|
56
|
+
export declare function generateMcpCollectionConfigs(collections: CollectionConfig[], options: GenerateOptions): {
|
|
57
|
+
mcpCollections: Record<string, McpCollectionConfig>;
|
|
58
|
+
draftCollections: Set<string>;
|
|
59
|
+
};
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determines the draft behavior for a collection based on its config and user overrides.
|
|
3
|
+
*
|
|
4
|
+
* - If the collection has no `versions.drafts`: always 'publish' regardless of override
|
|
5
|
+
* - If the user specified an override: use that override
|
|
6
|
+
* - Default: 'always-draft' for draft-enabled collections, 'publish' for others
|
|
7
|
+
*/ export function getDraftBehavior(collection, options) {
|
|
8
|
+
const hasDrafts = typeof collection.versions === 'object' && collection.versions !== null && 'drafts' in collection.versions && Boolean(collection.versions.drafts);
|
|
9
|
+
if (!hasDrafts) return 'publish';
|
|
10
|
+
const override = options?.draftBehavior?.[collection.slug];
|
|
11
|
+
if (override) return override;
|
|
12
|
+
return 'always-draft';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds a preview URL for a draft document.
|
|
16
|
+
*
|
|
17
|
+
* Path is `${siteUrl}/next/preview?...`. Path prefix per collection comes
|
|
18
|
+
* from `previewPaths`, defaulting to `/{collectionSlug}` when not configured.
|
|
19
|
+
*/ function buildPreviewUrl(doc, collectionSlug, siteUrl, previewSecret, previewPaths) {
|
|
20
|
+
const slug = doc.slug || '';
|
|
21
|
+
const prefix = previewPaths?.[collectionSlug] ?? `/${collectionSlug}`;
|
|
22
|
+
const path = `${prefix}/${slug}`;
|
|
23
|
+
const params = new URLSearchParams({
|
|
24
|
+
slug,
|
|
25
|
+
collection: collectionSlug,
|
|
26
|
+
path,
|
|
27
|
+
previewSecret
|
|
28
|
+
});
|
|
29
|
+
return `${siteUrl}/next/preview?${params.toString()}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates an overrideResponse function for a draft-enabled collection.
|
|
33
|
+
* When a document has `_status === 'draft'`, appends a preview URL to the response.
|
|
34
|
+
*/ function createOverrideResponse(collectionSlug, siteUrl, previewSecret, previewPaths) {
|
|
35
|
+
return (response, doc)=>{
|
|
36
|
+
if (doc._status !== 'draft') return response;
|
|
37
|
+
const previewUrl = buildPreviewUrl(doc, collectionSlug, siteUrl, previewSecret, previewPaths);
|
|
38
|
+
return {
|
|
39
|
+
content: [
|
|
40
|
+
...response.content,
|
|
41
|
+
{
|
|
42
|
+
type: 'text',
|
|
43
|
+
text: `\n📋 This document is a draft. Preview it here: ${previewUrl}`
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Generates the mcpCollections config object for the official mcpPlugin.
|
|
51
|
+
*
|
|
52
|
+
* For each collection:
|
|
53
|
+
* - Determines enabled CRUD operations based on draft behavior
|
|
54
|
+
* - For 'always-draft' collections: disables raw `update` to force clients through publishDraft tool
|
|
55
|
+
* - Generates `overrideResponse` that appends preview URLs for draft documents
|
|
56
|
+
*
|
|
57
|
+
* @returns A record of collection slug to MCP collection config, plus the set of draft collection slugs
|
|
58
|
+
*/ export function generateMcpCollectionConfigs(collections, options) {
|
|
59
|
+
const mcpCollections = {};
|
|
60
|
+
const draftCollections = new Set();
|
|
61
|
+
const excludeSlugs = new Set([
|
|
62
|
+
'users',
|
|
63
|
+
'payload-mcp-api-keys',
|
|
64
|
+
...options.excludeCollections ?? []
|
|
65
|
+
]);
|
|
66
|
+
for (const collection of collections){
|
|
67
|
+
if (excludeSlugs.has(collection.slug)) continue;
|
|
68
|
+
const behavior = getDraftBehavior(collection, options);
|
|
69
|
+
if (behavior !== 'publish') {
|
|
70
|
+
draftCollections.add(collection.slug);
|
|
71
|
+
}
|
|
72
|
+
const enabled = {
|
|
73
|
+
find: true,
|
|
74
|
+
create: true,
|
|
75
|
+
update: behavior !== 'always-draft',
|
|
76
|
+
delete: true
|
|
77
|
+
};
|
|
78
|
+
const config = {
|
|
79
|
+
description: `Manage ${collection.slug} content`,
|
|
80
|
+
enabled
|
|
81
|
+
};
|
|
82
|
+
if (draftCollections.has(collection.slug)) {
|
|
83
|
+
config.overrideResponse = createOverrideResponse(collection.slug, options.siteUrl, options.previewSecret, options.previewPaths);
|
|
84
|
+
}
|
|
85
|
+
mcpCollections[collection.slug] = config;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
mcpCollections,
|
|
89
|
+
draftCollections
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//# sourceMappingURL=draft-workflow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/draft-workflow.ts"],"sourcesContent":["import type { CollectionConfig, PayloadRequest } from 'payload'\nimport type { DraftBehavior } from './types'\n\n/** MCP response shape used by overrideResponse */\ninterface McpResponse {\n content: Array<{ text: string; type: string }>\n}\n\n/** Per-collection MCP config with enabled operations and optional overrideResponse */\ninterface McpCollectionConfig {\n description: string\n enabled: {\n create: boolean\n delete: boolean\n find: boolean\n update: boolean\n }\n overrideResponse?: (\n response: McpResponse,\n doc: Record<string, unknown>,\n req: PayloadRequest,\n ) => McpResponse\n}\n\ninterface GenerateOptions {\n /** Base site URL for preview links */\n siteUrl: string\n /** Preview authentication secret */\n previewSecret: string\n /**\n * Per-collection URL path prefix used when constructing preview URLs.\n * Defaults to `/{slug}` for collections not in the map.\n */\n previewPaths?: Record<string, string>\n /** Per-collection draft behavior overrides */\n draftBehavior?: Record<string, DraftBehavior>\n /** Collection slugs to exclude from MCP */\n excludeCollections?: string[]\n}\n\n/**\n * Determines the draft behavior for a collection based on its config and user overrides.\n *\n * - If the collection has no `versions.drafts`: always 'publish' regardless of override\n * - If the user specified an override: use that override\n * - Default: 'always-draft' for draft-enabled collections, 'publish' for others\n */\nexport function getDraftBehavior(\n collection: CollectionConfig,\n options?: { draftBehavior?: Record<string, DraftBehavior> },\n): 'always-draft' | 'always-publish' | 'publish' {\n const hasDrafts =\n typeof collection.versions === 'object' &&\n collection.versions !== null &&\n 'drafts' in collection.versions &&\n Boolean(collection.versions.drafts)\n\n if (!hasDrafts) return 'publish'\n\n const override = options?.draftBehavior?.[collection.slug]\n if (override) return override\n\n return 'always-draft'\n}\n\n/**\n * Builds a preview URL for a draft document.\n *\n * Path is `${siteUrl}/next/preview?...`. Path prefix per collection comes\n * from `previewPaths`, defaulting to `/{collectionSlug}` when not configured.\n */\nfunction buildPreviewUrl(\n doc: Record<string, unknown>,\n collectionSlug: string,\n siteUrl: string,\n previewSecret: string,\n previewPaths?: Record<string, string>,\n): string {\n const slug = (doc.slug as string) || ''\n const prefix = previewPaths?.[collectionSlug] ?? `/${collectionSlug}`\n const path = `${prefix}/${slug}`\n\n const params = new URLSearchParams({\n slug,\n collection: collectionSlug,\n path,\n previewSecret,\n })\n\n return `${siteUrl}/next/preview?${params.toString()}`\n}\n\n/**\n * Creates an overrideResponse function for a draft-enabled collection.\n * When a document has `_status === 'draft'`, appends a preview URL to the response.\n */\nfunction createOverrideResponse(\n collectionSlug: string,\n siteUrl: string,\n previewSecret: string,\n previewPaths?: Record<string, string>,\n): McpCollectionConfig['overrideResponse'] {\n return (response: McpResponse, doc: Record<string, unknown>): McpResponse => {\n if (doc._status !== 'draft') return response\n\n const previewUrl = buildPreviewUrl(doc, collectionSlug, siteUrl, previewSecret, previewPaths)\n\n return {\n content: [\n ...response.content,\n {\n type: 'text',\n text: `\\n📋 This document is a draft. Preview it here: ${previewUrl}`,\n },\n ],\n }\n }\n}\n\n/**\n * Generates the mcpCollections config object for the official mcpPlugin.\n *\n * For each collection:\n * - Determines enabled CRUD operations based on draft behavior\n * - For 'always-draft' collections: disables raw `update` to force clients through publishDraft tool\n * - Generates `overrideResponse` that appends preview URLs for draft documents\n *\n * @returns A record of collection slug to MCP collection config, plus the set of draft collection slugs\n */\nexport function generateMcpCollectionConfigs(\n collections: CollectionConfig[],\n options: GenerateOptions,\n): {\n mcpCollections: Record<string, McpCollectionConfig>\n draftCollections: Set<string>\n} {\n const mcpCollections: Record<string, McpCollectionConfig> = {}\n const draftCollections = new Set<string>()\n\n const excludeSlugs = new Set([\n 'users',\n 'payload-mcp-api-keys',\n ...(options.excludeCollections ?? []),\n ])\n\n for (const collection of collections) {\n if (excludeSlugs.has(collection.slug)) continue\n\n const behavior = getDraftBehavior(collection, options)\n\n if (behavior !== 'publish') {\n draftCollections.add(collection.slug)\n }\n\n const enabled = {\n find: true,\n create: true,\n update: behavior !== 'always-draft',\n delete: true,\n }\n\n const config: McpCollectionConfig = {\n description: `Manage ${collection.slug} content`,\n enabled,\n }\n\n if (draftCollections.has(collection.slug)) {\n config.overrideResponse = createOverrideResponse(\n collection.slug,\n options.siteUrl,\n options.previewSecret,\n options.previewPaths,\n )\n }\n\n mcpCollections[collection.slug] = config\n }\n\n return { mcpCollections, draftCollections }\n}\n"],"names":["getDraftBehavior","collection","options","hasDrafts","versions","Boolean","drafts","override","draftBehavior","slug","buildPreviewUrl","doc","collectionSlug","siteUrl","previewSecret","previewPaths","prefix","path","params","URLSearchParams","toString","createOverrideResponse","response","_status","previewUrl","content","type","text","generateMcpCollectionConfigs","collections","mcpCollections","draftCollections","Set","excludeSlugs","excludeCollections","has","behavior","add","enabled","find","create","update","delete","config","description","overrideResponse"],"mappings":"AAwCA;;;;;;CAMC,GACD,OAAO,SAASA,iBACdC,UAA4B,EAC5BC,OAA2D;IAE3D,MAAMC,YACJ,OAAOF,WAAWG,QAAQ,KAAK,YAC/BH,WAAWG,QAAQ,KAAK,QACxB,YAAYH,WAAWG,QAAQ,IAC/BC,QAAQJ,WAAWG,QAAQ,CAACE,MAAM;IAEpC,IAAI,CAACH,WAAW,OAAO;IAEvB,MAAMI,WAAWL,SAASM,eAAe,CAACP,WAAWQ,IAAI,CAAC;IAC1D,IAAIF,UAAU,OAAOA;IAErB,OAAO;AACT;AAEA;;;;;CAKC,GACD,SAASG,gBACPC,GAA4B,EAC5BC,cAAsB,EACtBC,OAAe,EACfC,aAAqB,EACrBC,YAAqC;IAErC,MAAMN,OAAO,AAACE,IAAIF,IAAI,IAAe;IACrC,MAAMO,SAASD,cAAc,CAACH,eAAe,IAAI,CAAC,CAAC,EAAEA,gBAAgB;IACrE,MAAMK,OAAO,GAAGD,OAAO,CAAC,EAAEP,MAAM;IAEhC,MAAMS,SAAS,IAAIC,gBAAgB;QACjCV;QACAR,YAAYW;QACZK;QACAH;IACF;IAEA,OAAO,GAAGD,QAAQ,cAAc,EAAEK,OAAOE,QAAQ,IAAI;AACvD;AAEA;;;CAGC,GACD,SAASC,uBACPT,cAAsB,EACtBC,OAAe,EACfC,aAAqB,EACrBC,YAAqC;IAErC,OAAO,CAACO,UAAuBX;QAC7B,IAAIA,IAAIY,OAAO,KAAK,SAAS,OAAOD;QAEpC,MAAME,aAAad,gBAAgBC,KAAKC,gBAAgBC,SAASC,eAAeC;QAEhF,OAAO;YACLU,SAAS;mBACJH,SAASG,OAAO;gBACnB;oBACEC,MAAM;oBACNC,MAAM,CAAC,gDAAgD,EAAEH,YAAY;gBACvE;aACD;QACH;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASI,6BACdC,WAA+B,EAC/B3B,OAAwB;IAKxB,MAAM4B,iBAAsD,CAAC;IAC7D,MAAMC,mBAAmB,IAAIC;IAE7B,MAAMC,eAAe,IAAID,IAAI;QAC3B;QACA;WACI9B,QAAQgC,kBAAkB,IAAI,EAAE;KACrC;IAED,KAAK,MAAMjC,cAAc4B,YAAa;QACpC,IAAII,aAAaE,GAAG,CAAClC,WAAWQ,IAAI,GAAG;QAEvC,MAAM2B,WAAWpC,iBAAiBC,YAAYC;QAE9C,IAAIkC,aAAa,WAAW;YAC1BL,iBAAiBM,GAAG,CAACpC,WAAWQ,IAAI;QACtC;QAEA,MAAM6B,UAAU;YACdC,MAAM;YACNC,QAAQ;YACRC,QAAQL,aAAa;YACrBM,QAAQ;QACV;QAEA,MAAMC,SAA8B;YAClCC,aAAa,CAAC,OAAO,EAAE3C,WAAWQ,IAAI,CAAC,QAAQ,CAAC;YAChD6B;QACF;QAEA,IAAIP,iBAAiBI,GAAG,CAAClC,WAAWQ,IAAI,GAAG;YACzCkC,OAAOE,gBAAgB,GAAGxB,uBACxBpB,WAAWQ,IAAI,EACfP,QAAQW,OAAO,EACfX,QAAQY,aAAa,EACrBZ,QAAQa,YAAY;QAExB;QAEAe,cAAc,CAAC7B,WAAWQ,IAAI,CAAC,GAAGkC;IACpC;IAEA,OAAO;QAAEb;QAAgBC;IAAiB;AAC5C"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Plugin } from 'payload';
|
|
2
|
+
import type { ContentToolkitOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Payload MCP Toolkit
|
|
5
|
+
*
|
|
6
|
+
* A wrapper plugin that introspects the Payload config and generates
|
|
7
|
+
* domain-aware MCP tools, prompts, and resources for AI content management.
|
|
8
|
+
*
|
|
9
|
+
* Usage in payload.config.ts:
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { contentToolkitPlugin } from 'payload-mcp-toolkit'
|
|
12
|
+
*
|
|
13
|
+
* plugins: [
|
|
14
|
+
* contentToolkitPlugin({
|
|
15
|
+
* siteUrl: process.env.SITE_URL!,
|
|
16
|
+
* previewSecret: process.env.PREVIEW_SECRET!,
|
|
17
|
+
* previewPaths: { posts: '/blog', pages: '' },
|
|
18
|
+
* draftBehavior: { pages: 'always-draft' },
|
|
19
|
+
* }),
|
|
20
|
+
* ]
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function contentToolkitPlugin(options: ContentToolkitOptions): Plugin;
|
|
24
|
+
export type { ContentToolkitOptions, DomainPrompt, DraftBehavior, CollectionSchema, BlockCatalog, SectionBlockSchema, LeafBlockSchema, RelationshipEdge, FieldSchema, } from './types';
|