payload-mcp-toolkit 0.7.0 → 0.7.4
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/README.md +29 -8
- package/dist/api-keys.js +57 -21
- package/dist/api-keys.js.map +1 -1
- package/dist/auth-strategy.d.ts +18 -7
- package/dist/auth-strategy.js +54 -12
- package/dist/auth-strategy.js.map +1 -1
- package/dist/tools/_helpers.d.ts +34 -0
- package/dist/tools/_helpers.js +98 -0
- package/dist/tools/_helpers.js.map +1 -1
- package/dist/tools/publish-draft.js +33 -1
- package/dist/tools/publish-draft.js.map +1 -1
- package/dist/tools/publish-global-draft.js +30 -1
- package/dist/tools/publish-global-draft.js.map +1 -1
- package/package.json +29 -15
- package/dist/__tests__/api-keys.test.js +0 -292
- package/dist/__tests__/api-keys.test.js.map +0 -1
- package/dist/__tests__/auth-strategy.test.js +0 -681
- package/dist/__tests__/auth-strategy.test.js.map +0 -1
- package/dist/__tests__/conflict-detection.test.js +0 -69
- package/dist/__tests__/conflict-detection.test.js.map +0 -1
- package/dist/__tests__/delete-document.test.js +0 -70
- package/dist/__tests__/delete-document.test.js.map +0 -1
- package/dist/__tests__/endpoint.test.js +0 -143
- package/dist/__tests__/endpoint.test.js.map +0 -1
- package/dist/__tests__/find-document.test.js +0 -178
- package/dist/__tests__/find-document.test.js.map +0 -1
- package/dist/__tests__/find-global.test.js +0 -173
- package/dist/__tests__/find-global.test.js.map +0 -1
- package/dist/__tests__/global-versions.test.js +0 -183
- package/dist/__tests__/global-versions.test.js.map +0 -1
- package/dist/__tests__/hash.test.js +0 -58
- package/dist/__tests__/hash.test.js.map +0 -1
- package/dist/__tests__/index-integration.test.js +0 -191
- package/dist/__tests__/index-integration.test.js.map +0 -1
- package/dist/__tests__/introspection.test.js +0 -659
- package/dist/__tests__/introspection.test.js.map +0 -1
- package/dist/__tests__/patch-global-layout.test.js +0 -474
- package/dist/__tests__/patch-global-layout.test.js.map +0 -1
- package/dist/__tests__/patch-layout.test.js +0 -171
- package/dist/__tests__/patch-layout.test.js.map +0 -1
- package/dist/__tests__/registry.test.js +0 -795
- package/dist/__tests__/registry.test.js.map +0 -1
- package/dist/__tests__/resources.test.js +0 -139
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/update-global.test.js +0 -157
- package/dist/__tests__/update-global.test.js.map +0 -1
- package/dist/__tests__/url-validator.test.js +0 -326
- package/dist/__tests__/url-validator.test.js.map +0 -1
|
@@ -1,795 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { buildScopeChecker, createInitializeServer } from '../registry';
|
|
4
|
-
// Test fixture: mirrors the production tool catalogue's routing so the
|
|
5
|
-
// scope-checker tests stay stable across factory-internal changes. Each
|
|
6
|
-
// entry is a minimal `ToolFactoryOutput` — only `name` and `routing` are
|
|
7
|
-
// read by `buildScopeChecker`.
|
|
8
|
-
function fixtureTool(name, routing) {
|
|
9
|
-
return {
|
|
10
|
-
name,
|
|
11
|
-
routing,
|
|
12
|
-
description: '',
|
|
13
|
-
parameters: {},
|
|
14
|
-
handler: async ()=>({
|
|
15
|
-
content: [
|
|
16
|
-
{
|
|
17
|
-
type: 'text',
|
|
18
|
-
text: ''
|
|
19
|
-
}
|
|
20
|
-
]
|
|
21
|
-
})
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
const PRODUCTION_TOOLS = [
|
|
25
|
-
// collection
|
|
26
|
-
fixtureTool('findDocument', {
|
|
27
|
-
kind: 'collection',
|
|
28
|
-
action: 'read'
|
|
29
|
-
}),
|
|
30
|
-
fixtureTool('listVersions', {
|
|
31
|
-
kind: 'collection',
|
|
32
|
-
action: 'read'
|
|
33
|
-
}),
|
|
34
|
-
fixtureTool('createDocument', {
|
|
35
|
-
kind: 'collection',
|
|
36
|
-
action: 'create'
|
|
37
|
-
}),
|
|
38
|
-
fixtureTool('updateDocument', {
|
|
39
|
-
kind: 'collection',
|
|
40
|
-
action: 'update'
|
|
41
|
-
}),
|
|
42
|
-
fixtureTool('patchLayout', {
|
|
43
|
-
kind: 'collection',
|
|
44
|
-
action: 'update'
|
|
45
|
-
}),
|
|
46
|
-
fixtureTool('publishDraft', {
|
|
47
|
-
kind: 'collection',
|
|
48
|
-
action: 'update'
|
|
49
|
-
}),
|
|
50
|
-
fixtureTool('schedulePublish', {
|
|
51
|
-
kind: 'collection',
|
|
52
|
-
action: 'update'
|
|
53
|
-
}),
|
|
54
|
-
fixtureTool('restoreVersion', {
|
|
55
|
-
kind: 'collection',
|
|
56
|
-
action: 'update'
|
|
57
|
-
}),
|
|
58
|
-
fixtureTool('deleteDocument', {
|
|
59
|
-
kind: 'collection',
|
|
60
|
-
action: 'delete'
|
|
61
|
-
}),
|
|
62
|
-
fixtureTool('safeDelete', {
|
|
63
|
-
kind: 'collection',
|
|
64
|
-
action: 'delete'
|
|
65
|
-
}),
|
|
66
|
-
// global
|
|
67
|
-
fixtureTool('findGlobal', {
|
|
68
|
-
kind: 'global',
|
|
69
|
-
action: 'read'
|
|
70
|
-
}),
|
|
71
|
-
fixtureTool('updateGlobal', {
|
|
72
|
-
kind: 'global',
|
|
73
|
-
action: 'update'
|
|
74
|
-
}),
|
|
75
|
-
fixtureTool('patchGlobalLayout', {
|
|
76
|
-
kind: 'global',
|
|
77
|
-
action: 'update'
|
|
78
|
-
}),
|
|
79
|
-
fixtureTool('publishGlobalDraft', {
|
|
80
|
-
kind: 'global',
|
|
81
|
-
action: 'update'
|
|
82
|
-
}),
|
|
83
|
-
fixtureTool('listGlobalVersions', {
|
|
84
|
-
kind: 'global',
|
|
85
|
-
action: 'read'
|
|
86
|
-
}),
|
|
87
|
-
fixtureTool('restoreGlobalVersion', {
|
|
88
|
-
kind: 'global',
|
|
89
|
-
action: 'update'
|
|
90
|
-
}),
|
|
91
|
-
// account
|
|
92
|
-
fixtureTool('searchContent', {
|
|
93
|
-
kind: 'account',
|
|
94
|
-
action: 'read'
|
|
95
|
-
}),
|
|
96
|
-
fixtureTool('resolveReference', {
|
|
97
|
-
kind: 'account',
|
|
98
|
-
action: 'read'
|
|
99
|
-
}),
|
|
100
|
-
fixtureTool('uploadMedia', {
|
|
101
|
-
kind: 'account',
|
|
102
|
-
action: 'create'
|
|
103
|
-
})
|
|
104
|
-
];
|
|
105
|
-
const assertScopeAllows = buildScopeChecker(PRODUCTION_TOOLS);
|
|
106
|
-
describe('assertScopeAllows', ()=>{
|
|
107
|
-
it('grants full access when scopes is null/undefined or empty', ()=>{
|
|
108
|
-
expect(assertScopeAllows(null, 'createDocument', 'posts').allowed).toBe(true);
|
|
109
|
-
expect(assertScopeAllows(undefined, 'deleteDocument', 'posts').allowed).toBe(true);
|
|
110
|
-
expect(assertScopeAllows({}, 'createDocument', 'posts').allowed).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
it('denies every dispatch path under the deny-all sentinel from composeScopes', ()=>{
|
|
113
|
-
// composeScopes returns this shape for "preset = custom, no overrides".
|
|
114
|
-
// The registry must reject all three dispatch shapes:
|
|
115
|
-
// - collection-keyed tools (createDocument, etc.)
|
|
116
|
-
// - account-wide tools (searchContent, uploadMedia)
|
|
117
|
-
// - delete tools
|
|
118
|
-
const denyAll = {
|
|
119
|
-
collections: {},
|
|
120
|
-
tools: {
|
|
121
|
-
allow: []
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
expect(assertScopeAllows(denyAll, 'createDocument', 'posts').allowed).toBe(false);
|
|
125
|
-
expect(assertScopeAllows(denyAll, 'findDocument', 'posts').allowed).toBe(false);
|
|
126
|
-
expect(assertScopeAllows(denyAll, 'deleteDocument', 'posts').allowed).toBe(false);
|
|
127
|
-
expect(assertScopeAllows(denyAll, 'searchContent', undefined).allowed).toBe(false);
|
|
128
|
-
expect(assertScopeAllows(denyAll, 'uploadMedia', undefined).allowed).toBe(false);
|
|
129
|
-
});
|
|
130
|
-
it('respects the read-only preset for write tools', ()=>{
|
|
131
|
-
const decision = assertScopeAllows({
|
|
132
|
-
preset: 'read-only'
|
|
133
|
-
}, 'createDocument', 'posts');
|
|
134
|
-
expect(decision.allowed).toBe(false);
|
|
135
|
-
expect(decision.reason).toMatch(/create/);
|
|
136
|
-
});
|
|
137
|
-
it('respects the editor preset (no deletes)', ()=>{
|
|
138
|
-
expect(assertScopeAllows({
|
|
139
|
-
preset: 'editor'
|
|
140
|
-
}, 'createDocument', 'posts').allowed).toBe(true);
|
|
141
|
-
expect(assertScopeAllows({
|
|
142
|
-
preset: 'editor'
|
|
143
|
-
}, 'deleteDocument', 'posts').allowed).toBe(false);
|
|
144
|
-
expect(assertScopeAllows({
|
|
145
|
-
preset: 'editor'
|
|
146
|
-
}, 'safeDelete', 'posts').allowed).toBe(false);
|
|
147
|
-
});
|
|
148
|
-
it('admin preset allows everything', ()=>{
|
|
149
|
-
expect(assertScopeAllows({
|
|
150
|
-
preset: 'admin'
|
|
151
|
-
}, 'deleteDocument', 'posts').allowed).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
it('per-collection override replaces preset for that slug', ()=>{
|
|
154
|
-
const decision = assertScopeAllows({
|
|
155
|
-
preset: 'admin',
|
|
156
|
-
collections: {
|
|
157
|
-
posts: [
|
|
158
|
-
'read'
|
|
159
|
-
]
|
|
160
|
-
}
|
|
161
|
-
}, 'updateDocument', 'posts');
|
|
162
|
-
expect(decision.allowed).toBe(false);
|
|
163
|
-
});
|
|
164
|
-
it('treats scopes.collections as a whitelist — unlisted collections are denied', ()=>{
|
|
165
|
-
const scopes = {
|
|
166
|
-
collections: {
|
|
167
|
-
posts: [
|
|
168
|
-
'read',
|
|
169
|
-
'update'
|
|
170
|
-
]
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
expect(assertScopeAllows(scopes, 'updateDocument', 'pages').allowed).toBe(false);
|
|
174
|
-
expect(assertScopeAllows(scopes, 'deleteDocument', 'categories').allowed).toBe(false);
|
|
175
|
-
// Listed collection still works for allowed actions
|
|
176
|
-
expect(assertScopeAllows(scopes, 'updateDocument', 'posts').allowed).toBe(true);
|
|
177
|
-
});
|
|
178
|
-
it('blocks no-collection tools at the preset action level (read-only cannot uploadMedia)', ()=>{
|
|
179
|
-
const decision = assertScopeAllows({
|
|
180
|
-
preset: 'read-only'
|
|
181
|
-
}, 'uploadMedia', undefined);
|
|
182
|
-
expect(decision.allowed).toBe(false);
|
|
183
|
-
expect(decision.reason).toMatch(/create/);
|
|
184
|
-
});
|
|
185
|
-
it('allows no-collection read tools under read-only preset', ()=>{
|
|
186
|
-
expect(assertScopeAllows({
|
|
187
|
-
preset: 'read-only'
|
|
188
|
-
}, 'searchContent', undefined).allowed).toBe(true);
|
|
189
|
-
expect(assertScopeAllows({
|
|
190
|
-
preset: 'read-only'
|
|
191
|
-
}, 'resolveReference', undefined).allowed).toBe(true);
|
|
192
|
-
});
|
|
193
|
-
it('denies no-collection tools when key is collection-scoped only (no preset)', ()=>{
|
|
194
|
-
const scopes = {
|
|
195
|
-
collections: {
|
|
196
|
-
posts: [
|
|
197
|
-
'read'
|
|
198
|
-
]
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
expect(assertScopeAllows(scopes, 'searchContent', undefined).allowed).toBe(false);
|
|
202
|
-
expect(assertScopeAllows(scopes, 'uploadMedia', undefined).allowed).toBe(false);
|
|
203
|
-
});
|
|
204
|
-
it('tools.deny blocks an explicitly listed tool', ()=>{
|
|
205
|
-
const decision = assertScopeAllows({
|
|
206
|
-
preset: 'admin',
|
|
207
|
-
tools: {
|
|
208
|
-
deny: [
|
|
209
|
-
'safeDelete'
|
|
210
|
-
]
|
|
211
|
-
}
|
|
212
|
-
}, 'safeDelete', 'posts');
|
|
213
|
-
expect(decision.allowed).toBe(false);
|
|
214
|
-
expect(decision.reason).toMatch(/denied/);
|
|
215
|
-
});
|
|
216
|
-
it('tools.allow restricts to listed tools only', ()=>{
|
|
217
|
-
const decision = assertScopeAllows({
|
|
218
|
-
tools: {
|
|
219
|
-
allow: [
|
|
220
|
-
'findDocument'
|
|
221
|
-
]
|
|
222
|
-
}
|
|
223
|
-
}, 'searchContent', undefined);
|
|
224
|
-
expect(decision.allowed).toBe(false);
|
|
225
|
-
});
|
|
226
|
-
it('skips collection check when the tool has no collection arg', ()=>{
|
|
227
|
-
expect(assertScopeAllows({
|
|
228
|
-
preset: 'read-only'
|
|
229
|
-
}, 'searchContent', undefined).allowed).toBe(true);
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
// ─── Globals scope routing (U6) ──────────────────────────────────────
|
|
233
|
-
describe('assertScopeAllows — globals', ()=>{
|
|
234
|
-
it('read-only preset allows findGlobal', ()=>{
|
|
235
|
-
expect(assertScopeAllows({
|
|
236
|
-
preset: 'read-only'
|
|
237
|
-
}, 'findGlobal', 'siteSettings').allowed).toBe(true);
|
|
238
|
-
});
|
|
239
|
-
it('editor preset allows findGlobal (read still permitted on globals)', ()=>{
|
|
240
|
-
expect(assertScopeAllows({
|
|
241
|
-
preset: 'editor'
|
|
242
|
-
}, 'findGlobal', 'siteSettings').allowed).toBe(true);
|
|
243
|
-
});
|
|
244
|
-
it('editor preset DENIES updateGlobal (asymmetric — global writes need admin)', ()=>{
|
|
245
|
-
const decision = assertScopeAllows({
|
|
246
|
-
preset: 'editor'
|
|
247
|
-
}, 'updateGlobal', 'siteSettings');
|
|
248
|
-
expect(decision.allowed).toBe(false);
|
|
249
|
-
expect(decision.reason).toMatch(/global "siteSettings"/);
|
|
250
|
-
expect(decision.reason).toMatch(/preset/);
|
|
251
|
-
});
|
|
252
|
-
it('admin preset allows updateGlobal', ()=>{
|
|
253
|
-
expect(assertScopeAllows({
|
|
254
|
-
preset: 'admin'
|
|
255
|
-
}, 'updateGlobal', 'siteSettings').allowed).toBe(true);
|
|
256
|
-
});
|
|
257
|
-
it('Custom override grants update via globals scope', ()=>{
|
|
258
|
-
expect(assertScopeAllows({
|
|
259
|
-
globals: {
|
|
260
|
-
siteSettings: [
|
|
261
|
-
'read',
|
|
262
|
-
'update'
|
|
263
|
-
]
|
|
264
|
-
}
|
|
265
|
-
}, 'updateGlobal', 'siteSettings').allowed).toBe(true);
|
|
266
|
-
});
|
|
267
|
-
it('Custom narrow denies update when only read is granted', ()=>{
|
|
268
|
-
const decision = assertScopeAllows({
|
|
269
|
-
globals: {
|
|
270
|
-
siteSettings: [
|
|
271
|
-
'read'
|
|
272
|
-
]
|
|
273
|
-
}
|
|
274
|
-
}, 'updateGlobal', 'siteSettings');
|
|
275
|
-
expect(decision.allowed).toBe(false);
|
|
276
|
-
expect(decision.reason).toMatch(/Action "update" on global "siteSettings"/);
|
|
277
|
-
});
|
|
278
|
-
it('globals-only key denies a collection tool', ()=>{
|
|
279
|
-
const decision = assertScopeAllows({
|
|
280
|
-
globals: {
|
|
281
|
-
siteSettings: [
|
|
282
|
-
'read',
|
|
283
|
-
'update'
|
|
284
|
-
]
|
|
285
|
-
}
|
|
286
|
-
}, 'findDocument', 'pages');
|
|
287
|
-
expect(decision.allowed).toBe(false);
|
|
288
|
-
});
|
|
289
|
-
it('read-only preset denies updateGlobal', ()=>{
|
|
290
|
-
const decision = assertScopeAllows({
|
|
291
|
-
preset: 'read-only'
|
|
292
|
-
}, 'updateGlobal', 'siteSettings');
|
|
293
|
-
expect(decision.allowed).toBe(false);
|
|
294
|
-
});
|
|
295
|
-
it('globals whitelist: unlisted slug is denied', ()=>{
|
|
296
|
-
const decision = assertScopeAllows({
|
|
297
|
-
globals: {
|
|
298
|
-
siteSettings: [
|
|
299
|
-
'read',
|
|
300
|
-
'update'
|
|
301
|
-
]
|
|
302
|
-
}
|
|
303
|
-
}, 'updateGlobal', 'footer');
|
|
304
|
-
expect(decision.allowed).toBe(false);
|
|
305
|
-
expect(decision.reason).toMatch(/Global "footer" is not in this API key's allowed globals/);
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
// ─── Fail-closed extensions for tools.allow without resource scope ───
|
|
309
|
-
describe('assertScopeAllows — tools.allow-only fail-closed', ()=>{
|
|
310
|
-
it('denies updateGlobal when only tools.allow is set (no globals map, no preset)', ()=>{
|
|
311
|
-
const decision = assertScopeAllows({
|
|
312
|
-
tools: {
|
|
313
|
-
allow: [
|
|
314
|
-
'updateGlobal'
|
|
315
|
-
]
|
|
316
|
-
}
|
|
317
|
-
}, 'updateGlobal', 'siteSettings');
|
|
318
|
-
expect(decision.allowed).toBe(false);
|
|
319
|
-
expect(decision.reason).toMatch(/explicit global scope or preset/);
|
|
320
|
-
});
|
|
321
|
-
it('denies updateDocument when only tools.allow is set (no collections map, no preset)', ()=>{
|
|
322
|
-
const decision = assertScopeAllows({
|
|
323
|
-
tools: {
|
|
324
|
-
allow: [
|
|
325
|
-
'updateDocument'
|
|
326
|
-
]
|
|
327
|
-
}
|
|
328
|
-
}, 'updateDocument', 'pages');
|
|
329
|
-
expect(decision.allowed).toBe(false);
|
|
330
|
-
expect(decision.reason).toMatch(/explicit collection scope or preset/);
|
|
331
|
-
});
|
|
332
|
-
it('post-empty-Custom sentinel denies every dispatch path including globals', ()=>{
|
|
333
|
-
const denyAll = {
|
|
334
|
-
collections: {},
|
|
335
|
-
globals: {},
|
|
336
|
-
tools: {
|
|
337
|
-
allow: []
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
expect(assertScopeAllows(denyAll, 'findGlobal', 'siteSettings').allowed).toBe(false);
|
|
341
|
-
expect(assertScopeAllows(denyAll, 'updateGlobal', 'siteSettings').allowed).toBe(false);
|
|
342
|
-
expect(assertScopeAllows(denyAll, 'findDocument', 'pages').allowed).toBe(false);
|
|
343
|
-
expect(assertScopeAllows(denyAll, 'searchContent', undefined).allowed).toBe(false);
|
|
344
|
-
});
|
|
345
|
-
});
|
|
346
|
-
// ─── Account-level routing ──────────────────────────────────────────
|
|
347
|
-
describe('assertScopeAllows — account-level tools', ()=>{
|
|
348
|
-
it('searchContent allowed under read-only preset', ()=>{
|
|
349
|
-
expect(assertScopeAllows({
|
|
350
|
-
preset: 'read-only'
|
|
351
|
-
}, 'searchContent', undefined).allowed).toBe(true);
|
|
352
|
-
});
|
|
353
|
-
it('uploadMedia denied under read-only preset (requires create)', ()=>{
|
|
354
|
-
expect(assertScopeAllows({
|
|
355
|
-
preset: 'read-only'
|
|
356
|
-
}, 'uploadMedia', undefined).allowed).toBe(false);
|
|
357
|
-
});
|
|
358
|
-
it('explicit collections override denies account tools even under admin preset', ()=>{
|
|
359
|
-
// Regression: account-routed tools (searchContent, resolveReference,
|
|
360
|
-
// uploadMedia) cross every collection, so an explicit resource
|
|
361
|
-
// whitelist must trump the preset's wider grant.
|
|
362
|
-
const scopes = {
|
|
363
|
-
preset: 'admin',
|
|
364
|
-
collections: {
|
|
365
|
-
posts: [
|
|
366
|
-
'read'
|
|
367
|
-
]
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
expect(assertScopeAllows(scopes, 'searchContent', undefined).allowed).toBe(false);
|
|
371
|
-
expect(assertScopeAllows(scopes, 'resolveReference', undefined).allowed).toBe(false);
|
|
372
|
-
expect(assertScopeAllows(scopes, 'uploadMedia', undefined).allowed).toBe(false);
|
|
373
|
-
});
|
|
374
|
-
it('explicit globals override denies account tools even under admin preset', ()=>{
|
|
375
|
-
const scopes = {
|
|
376
|
-
preset: 'admin',
|
|
377
|
-
globals: {
|
|
378
|
-
siteSettings: [
|
|
379
|
-
'read'
|
|
380
|
-
]
|
|
381
|
-
}
|
|
382
|
-
};
|
|
383
|
-
expect(assertScopeAllows(scopes, 'searchContent', undefined).allowed).toBe(false);
|
|
384
|
-
});
|
|
385
|
-
it('unregistered tool name is denied with the registry-mapping reason', ()=>{
|
|
386
|
-
const decision = assertScopeAllows({
|
|
387
|
-
preset: 'admin'
|
|
388
|
-
}, 'whatIsThisTool', undefined);
|
|
389
|
-
expect(decision.allowed).toBe(false);
|
|
390
|
-
expect(decision.reason).toMatch(/no registered scope mapping/);
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
// ─── Routing collocation (TS-enforced; spot-check via the checker) ───
|
|
394
|
-
//
|
|
395
|
-
// The boot-time `assertScopeRegistryInvariant` is gone: every factory
|
|
396
|
-
// must declare its `routing` field, so "missing routing" is a TS error
|
|
397
|
-
// at the factory return site. Duplicate routing is impossible because
|
|
398
|
-
// each tool name appears exactly once in the production tool list (the
|
|
399
|
-
// plugin entry assembles the array). What remains is a behavioural
|
|
400
|
-
// spot-check that the production fixture routes each canonical tool to
|
|
401
|
-
// the correct policy branch.
|
|
402
|
-
describe('routing is collocated on every tool factory', ()=>{
|
|
403
|
-
it('collection-keyed tool dispatches through the collection policy', ()=>{
|
|
404
|
-
expect(assertScopeAllows({
|
|
405
|
-
preset: 'editor'
|
|
406
|
-
}, 'createDocument', 'posts').allowed).toBe(true);
|
|
407
|
-
});
|
|
408
|
-
it('global-keyed tool dispatches through the global policy', ()=>{
|
|
409
|
-
expect(assertScopeAllows({
|
|
410
|
-
preset: 'admin'
|
|
411
|
-
}, 'updateGlobal', 'siteSettings').allowed).toBe(true);
|
|
412
|
-
});
|
|
413
|
-
it('account-keyed tool dispatches through the account policy', ()=>{
|
|
414
|
-
expect(assertScopeAllows({
|
|
415
|
-
preset: 'read-only'
|
|
416
|
-
}, 'searchContent', undefined).allowed).toBe(true);
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
function buildMockServer() {
|
|
420
|
-
return {
|
|
421
|
-
registerTool: vi.fn(),
|
|
422
|
-
registerPrompt: vi.fn(),
|
|
423
|
-
registerResource: vi.fn()
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
function buildReq(opts = {}) {
|
|
427
|
-
const headers = new Headers();
|
|
428
|
-
if (opts.requestId) headers.set('x-request-id', opts.requestId);
|
|
429
|
-
return {
|
|
430
|
-
headers,
|
|
431
|
-
context: {},
|
|
432
|
-
payload: {
|
|
433
|
-
logger: {
|
|
434
|
-
info: vi.fn(),
|
|
435
|
-
warn: vi.fn(),
|
|
436
|
-
error: vi.fn()
|
|
437
|
-
}
|
|
438
|
-
},
|
|
439
|
-
user: {
|
|
440
|
-
_mcpKey: {
|
|
441
|
-
keyId: opts.keyId ?? 'k1',
|
|
442
|
-
keyPrefix: opts.keyPrefix ?? 'abc12345',
|
|
443
|
-
scopes: opts.scopes ?? null
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
function makeTool(handler) {
|
|
449
|
-
return {
|
|
450
|
-
name: 'createDocument',
|
|
451
|
-
routing: {
|
|
452
|
-
kind: 'collection',
|
|
453
|
-
action: 'create'
|
|
454
|
-
},
|
|
455
|
-
description: 'create a document',
|
|
456
|
-
parameters: {
|
|
457
|
-
collection: z.string(),
|
|
458
|
-
data: z.string()
|
|
459
|
-
},
|
|
460
|
-
handler
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
describe('createInitializeServer', ()=>{
|
|
464
|
-
let server;
|
|
465
|
-
beforeEach(()=>{
|
|
466
|
-
server = buildMockServer();
|
|
467
|
-
});
|
|
468
|
-
it('registers each tool exactly once on the McpServer', ()=>{
|
|
469
|
-
const toolHandler = vi.fn(async ()=>({
|
|
470
|
-
content: [
|
|
471
|
-
{
|
|
472
|
-
type: 'text',
|
|
473
|
-
text: 'ok'
|
|
474
|
-
}
|
|
475
|
-
]
|
|
476
|
-
}));
|
|
477
|
-
const init = createInitializeServer({
|
|
478
|
-
tools: [
|
|
479
|
-
makeTool(toolHandler)
|
|
480
|
-
]
|
|
481
|
-
});
|
|
482
|
-
init(buildReq())(server);
|
|
483
|
-
expect(server.registerTool).toHaveBeenCalledTimes(1);
|
|
484
|
-
expect(server.registerTool.mock.calls[0][0]).toBe('createDocument');
|
|
485
|
-
});
|
|
486
|
-
it('wraps the handler so a happy call invokes the tool and logs success', async ()=>{
|
|
487
|
-
const toolHandler = vi.fn(async ()=>({
|
|
488
|
-
content: [
|
|
489
|
-
{
|
|
490
|
-
type: 'text',
|
|
491
|
-
text: 'ok'
|
|
492
|
-
}
|
|
493
|
-
]
|
|
494
|
-
}));
|
|
495
|
-
const req = buildReq();
|
|
496
|
-
const init = createInitializeServer({
|
|
497
|
-
tools: [
|
|
498
|
-
makeTool(toolHandler)
|
|
499
|
-
]
|
|
500
|
-
});
|
|
501
|
-
init(req)(server);
|
|
502
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
503
|
-
const result = await wrapped({
|
|
504
|
-
collection: 'posts',
|
|
505
|
-
data: '{"title":"hi"}'
|
|
506
|
-
}, {
|
|
507
|
-
ok: 1
|
|
508
|
-
});
|
|
509
|
-
expect(toolHandler).toHaveBeenCalledTimes(1);
|
|
510
|
-
expect(result).toEqual({
|
|
511
|
-
content: [
|
|
512
|
-
{
|
|
513
|
-
type: 'text',
|
|
514
|
-
text: 'ok'
|
|
515
|
-
}
|
|
516
|
-
]
|
|
517
|
-
});
|
|
518
|
-
expect(req.payload.logger.info).toHaveBeenCalled();
|
|
519
|
-
});
|
|
520
|
-
it('rejects with isError result when scopes deny the call (no JSON-RPC error)', async ()=>{
|
|
521
|
-
const toolHandler = vi.fn(async ()=>({
|
|
522
|
-
content: [
|
|
523
|
-
{
|
|
524
|
-
type: 'text',
|
|
525
|
-
text: 'ok'
|
|
526
|
-
}
|
|
527
|
-
]
|
|
528
|
-
}));
|
|
529
|
-
const req = buildReq({
|
|
530
|
-
scopes: {
|
|
531
|
-
preset: 'read-only'
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
const init = createInitializeServer({
|
|
535
|
-
tools: [
|
|
536
|
-
makeTool(toolHandler)
|
|
537
|
-
]
|
|
538
|
-
});
|
|
539
|
-
init(req)(server);
|
|
540
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
541
|
-
const result = await wrapped({
|
|
542
|
-
collection: 'posts',
|
|
543
|
-
data: '{}'
|
|
544
|
-
}, {});
|
|
545
|
-
expect(result.isError).toBe(true);
|
|
546
|
-
expect(result.content[0].text).toMatch(/scope/i);
|
|
547
|
-
expect(toolHandler).not.toHaveBeenCalled();
|
|
548
|
-
expect(req.payload.logger.warn).toHaveBeenCalledWith(expect.objectContaining({
|
|
549
|
-
errorClass: 'ScopeRejection',
|
|
550
|
-
success: false
|
|
551
|
-
}), expect.any(String));
|
|
552
|
-
});
|
|
553
|
-
it('catches handler errors and returns isError result without throwing', async ()=>{
|
|
554
|
-
const toolHandler = vi.fn(async ()=>{
|
|
555
|
-
throw new Error('payload exploded');
|
|
556
|
-
});
|
|
557
|
-
const req = buildReq();
|
|
558
|
-
const init = createInitializeServer({
|
|
559
|
-
tools: [
|
|
560
|
-
makeTool(toolHandler)
|
|
561
|
-
]
|
|
562
|
-
});
|
|
563
|
-
init(req)(server);
|
|
564
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
565
|
-
const result = await wrapped({
|
|
566
|
-
collection: 'posts',
|
|
567
|
-
data: '{}'
|
|
568
|
-
}, {});
|
|
569
|
-
expect(result.isError).toBe(true);
|
|
570
|
-
expect(result.content[0].text).toMatch(/payload exploded/);
|
|
571
|
-
expect(req.payload.logger.error).toHaveBeenCalledWith(expect.objectContaining({
|
|
572
|
-
errorClass: 'Error'
|
|
573
|
-
}), expect.any(String));
|
|
574
|
-
});
|
|
575
|
-
it('logs the parsed top-level data keys (not values)', async ()=>{
|
|
576
|
-
const toolHandler = vi.fn(async ()=>({
|
|
577
|
-
content: [
|
|
578
|
-
{
|
|
579
|
-
type: 'text',
|
|
580
|
-
text: 'ok'
|
|
581
|
-
}
|
|
582
|
-
]
|
|
583
|
-
}));
|
|
584
|
-
const req = buildReq();
|
|
585
|
-
const init = createInitializeServer({
|
|
586
|
-
tools: [
|
|
587
|
-
makeTool(toolHandler)
|
|
588
|
-
]
|
|
589
|
-
});
|
|
590
|
-
init(req)(server);
|
|
591
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
592
|
-
await wrapped({
|
|
593
|
-
collection: 'posts',
|
|
594
|
-
data: '{"title":"hi","slug":"hi","secretField":"sensitive"}'
|
|
595
|
-
}, {});
|
|
596
|
-
const [logFields] = req.payload.logger.info.mock.calls[0];
|
|
597
|
-
expect(logFields.dataKeys).toEqual([
|
|
598
|
-
'title',
|
|
599
|
-
'slug',
|
|
600
|
-
'secretField'
|
|
601
|
-
]);
|
|
602
|
-
// Values must NOT be in the log, only key names
|
|
603
|
-
expect(JSON.stringify(logFields)).not.toContain('sensitive');
|
|
604
|
-
});
|
|
605
|
-
it('truncates long string args in the error log summary', async ()=>{
|
|
606
|
-
const toolHandler = vi.fn(async ()=>{
|
|
607
|
-
throw new Error('boom');
|
|
608
|
-
});
|
|
609
|
-
const req = buildReq();
|
|
610
|
-
const init = createInitializeServer({
|
|
611
|
-
tools: [
|
|
612
|
-
makeTool(toolHandler)
|
|
613
|
-
]
|
|
614
|
-
});
|
|
615
|
-
init(req)(server);
|
|
616
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
617
|
-
const big = 'x'.repeat(5000);
|
|
618
|
-
await wrapped({
|
|
619
|
-
collection: 'posts',
|
|
620
|
-
data: big
|
|
621
|
-
}, {});
|
|
622
|
-
const [logFields] = req.payload.logger.error.mock.calls[0];
|
|
623
|
-
expect(logFields.argsSummary.data).toBe('<truncated:5000>');
|
|
624
|
-
});
|
|
625
|
-
it('normalizes z.object() params to a raw shape before registering with the SDK', ()=>{
|
|
626
|
-
const tool = {
|
|
627
|
-
name: 'resolveReference',
|
|
628
|
-
routing: {
|
|
629
|
-
kind: 'account',
|
|
630
|
-
action: 'read'
|
|
631
|
-
},
|
|
632
|
-
description: 'x',
|
|
633
|
-
parameters: z.object({
|
|
634
|
-
query: z.string(),
|
|
635
|
-
collection: z.string().optional()
|
|
636
|
-
}),
|
|
637
|
-
handler: async ()=>({
|
|
638
|
-
content: [
|
|
639
|
-
{
|
|
640
|
-
type: 'text',
|
|
641
|
-
text: 'ok'
|
|
642
|
-
}
|
|
643
|
-
]
|
|
644
|
-
})
|
|
645
|
-
};
|
|
646
|
-
const init = createInitializeServer({
|
|
647
|
-
tools: [
|
|
648
|
-
tool
|
|
649
|
-
]
|
|
650
|
-
});
|
|
651
|
-
init(buildReq())(server);
|
|
652
|
-
const inputSchema = server.registerTool.mock.calls[0][1].inputSchema;
|
|
653
|
-
expect(Object.keys(inputSchema).sort()).toEqual([
|
|
654
|
-
'collection',
|
|
655
|
-
'query'
|
|
656
|
-
]);
|
|
657
|
-
});
|
|
658
|
-
it('audit log carries targetSlug + targetKind for a collection tool', async ()=>{
|
|
659
|
-
const toolHandler = vi.fn(async ()=>({
|
|
660
|
-
content: [
|
|
661
|
-
{
|
|
662
|
-
type: 'text',
|
|
663
|
-
text: 'ok'
|
|
664
|
-
}
|
|
665
|
-
]
|
|
666
|
-
}));
|
|
667
|
-
const req = buildReq();
|
|
668
|
-
const tool = {
|
|
669
|
-
name: 'findDocument',
|
|
670
|
-
routing: {
|
|
671
|
-
kind: 'collection',
|
|
672
|
-
action: 'read'
|
|
673
|
-
},
|
|
674
|
-
description: 'x',
|
|
675
|
-
parameters: {
|
|
676
|
-
collection: z.string()
|
|
677
|
-
},
|
|
678
|
-
handler: toolHandler
|
|
679
|
-
};
|
|
680
|
-
const init = createInitializeServer({
|
|
681
|
-
tools: [
|
|
682
|
-
tool
|
|
683
|
-
]
|
|
684
|
-
});
|
|
685
|
-
init(req)(server);
|
|
686
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
687
|
-
await wrapped({
|
|
688
|
-
collection: 'pages'
|
|
689
|
-
}, {});
|
|
690
|
-
const [logFields] = req.payload.logger.info.mock.calls[0];
|
|
691
|
-
expect(logFields.targetSlug).toBe('pages');
|
|
692
|
-
expect(logFields.targetKind).toBe('collection');
|
|
693
|
-
expect(logFields.collectionArg).toBeUndefined();
|
|
694
|
-
});
|
|
695
|
-
it('audit log carries targetSlug + targetKind for a global tool', async ()=>{
|
|
696
|
-
const toolHandler = vi.fn(async ()=>({
|
|
697
|
-
content: [
|
|
698
|
-
{
|
|
699
|
-
type: 'text',
|
|
700
|
-
text: 'ok'
|
|
701
|
-
}
|
|
702
|
-
]
|
|
703
|
-
}));
|
|
704
|
-
const req = buildReq();
|
|
705
|
-
const tool = {
|
|
706
|
-
name: 'findGlobal',
|
|
707
|
-
routing: {
|
|
708
|
-
kind: 'global',
|
|
709
|
-
action: 'read'
|
|
710
|
-
},
|
|
711
|
-
description: 'x',
|
|
712
|
-
parameters: {
|
|
713
|
-
slug: z.string()
|
|
714
|
-
},
|
|
715
|
-
handler: toolHandler
|
|
716
|
-
};
|
|
717
|
-
const init = createInitializeServer({
|
|
718
|
-
tools: [
|
|
719
|
-
tool
|
|
720
|
-
]
|
|
721
|
-
});
|
|
722
|
-
init(req)(server);
|
|
723
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
724
|
-
await wrapped({
|
|
725
|
-
slug: 'siteSettings'
|
|
726
|
-
}, {});
|
|
727
|
-
const [logFields] = req.payload.logger.info.mock.calls[0];
|
|
728
|
-
expect(logFields.targetSlug).toBe('siteSettings');
|
|
729
|
-
expect(logFields.targetKind).toBe('global');
|
|
730
|
-
});
|
|
731
|
-
it('audit log marks account-level tools with targetKind="account" and no targetSlug', async ()=>{
|
|
732
|
-
const toolHandler = vi.fn(async ()=>({
|
|
733
|
-
content: [
|
|
734
|
-
{
|
|
735
|
-
type: 'text',
|
|
736
|
-
text: 'ok'
|
|
737
|
-
}
|
|
738
|
-
]
|
|
739
|
-
}));
|
|
740
|
-
const req = buildReq();
|
|
741
|
-
const tool = {
|
|
742
|
-
name: 'searchContent',
|
|
743
|
-
routing: {
|
|
744
|
-
kind: 'account',
|
|
745
|
-
action: 'read'
|
|
746
|
-
},
|
|
747
|
-
description: 'x',
|
|
748
|
-
parameters: {
|
|
749
|
-
q: z.string()
|
|
750
|
-
},
|
|
751
|
-
handler: toolHandler
|
|
752
|
-
};
|
|
753
|
-
const init = createInitializeServer({
|
|
754
|
-
tools: [
|
|
755
|
-
tool
|
|
756
|
-
]
|
|
757
|
-
});
|
|
758
|
-
init(req)(server);
|
|
759
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
760
|
-
await wrapped({
|
|
761
|
-
q: 'hello'
|
|
762
|
-
}, {});
|
|
763
|
-
const [logFields] = req.payload.logger.info.mock.calls[0];
|
|
764
|
-
expect(logFields.targetSlug).toBeUndefined();
|
|
765
|
-
expect(logFields.targetKind).toBe('account');
|
|
766
|
-
});
|
|
767
|
-
it('stamps mcp context on req before invoking the handler', async ()=>{
|
|
768
|
-
const toolHandler = vi.fn(async (_args, req)=>{
|
|
769
|
-
expect(req.context.source).toBe('mcp');
|
|
770
|
-
return {
|
|
771
|
-
content: [
|
|
772
|
-
{
|
|
773
|
-
type: 'text',
|
|
774
|
-
text: 'ok'
|
|
775
|
-
}
|
|
776
|
-
]
|
|
777
|
-
};
|
|
778
|
-
});
|
|
779
|
-
const req = buildReq();
|
|
780
|
-
const init = createInitializeServer({
|
|
781
|
-
tools: [
|
|
782
|
-
makeTool(toolHandler)
|
|
783
|
-
]
|
|
784
|
-
});
|
|
785
|
-
init(req)(server);
|
|
786
|
-
const wrapped = server.registerTool.mock.calls[0][2];
|
|
787
|
-
await wrapped({
|
|
788
|
-
collection: 'posts',
|
|
789
|
-
data: '{}'
|
|
790
|
-
}, {});
|
|
791
|
-
expect(toolHandler).toHaveBeenCalled();
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
//# sourceMappingURL=registry.test.js.map
|