@oneentry/mcp-platform-server 0.1.7 → 0.1.9

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.
@@ -214,6 +214,7 @@
214
214
  "AdminTemplatePreviewsController_update": "settings.templatePreview.update",
215
215
  "AdminTemplatePreviewsController_remove": "settings.templatePreview.delete",
216
216
  "AdminTemplatePreviewsController_updateAttributeSetPosition": "settings.templatePreview.changePositions",
217
+ "AdminTemplatePreviewsController_regenerate": "settings.templatePreview.update",
217
218
  "AdminTemplatesController_create": "settings.templates.create",
218
219
  "AdminTemplatesController_update": "settings.templates.update",
219
220
  "AdminTemplatesController_remove": "settings.templates.delete",
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { phantomPathParams } from './client.js';
2
3
  import { capSchema, normalizeSchema } from './normalize-schema.js';
3
4
  import { ALWAYS_CONFIRM_PREFIXES, OPERATION_NOTES } from './operation-notes.js';
4
5
  const API_PREFIX = '/api/admin';
@@ -167,6 +168,7 @@ export const buildCatalog = (params) => {
167
168
  const withoutOpId = [];
168
169
  const unresolvedBodies = [];
169
170
  const contradictoryExamples = [];
171
+ const undeliverableParams = [];
170
172
  for (const [fullPath, pathItem] of Object.entries(swagger['paths'])) {
171
173
  if (!isRecord(pathItem)) {
172
174
  continue;
@@ -225,6 +227,10 @@ export const buildCatalog = (params) => {
225
227
  entry.searchText = [opId, method.toUpperCase(), path, tag, summary, permission ?? '']
226
228
  .join(' ')
227
229
  .toLowerCase();
230
+ const phantom = phantomPathParams(entry);
231
+ if (phantom.length > 0) {
232
+ undeliverableParams.push(`${opId} (${phantom.join(', ')} on ${path})`);
233
+ }
228
234
  operations.push(entry);
229
235
  }
230
236
  }
@@ -250,6 +256,13 @@ export const buildCatalog = (params) => {
250
256
  'Those fields are marked "x-example-mismatch" and listed by cms_api_describe — ' +
251
257
  'copy the example, not the type. Report it against the CMS.');
252
258
  }
259
+ if (undeliverableParams.length > 0) {
260
+ warnings.push(`${String(undeliverableParams.length)} operation(s) declare a path parameter that their ` +
261
+ `own path does not contain (${undeliverableParams.slice(0, 5).join(', ')}). ` +
262
+ 'The value has nowhere to go, so this server refuses those calls instead of sending ' +
263
+ 'the request without it. cms_api_describe marks them "notExecutable" and names a ' +
264
+ 'working route where one is known. Report it against the CMS.');
265
+ }
253
266
  const noted = Object.keys(OPERATION_NOTES);
254
267
  const notedButAbsent = noted.filter((opId) => !operations.some((operation) => operation.opId === opId));
255
268
  if (notedButAbsent.length > 0 && notedButAbsent.length <= noted.length / 2) {
@@ -22,6 +22,8 @@ export declare class RequestBuildError extends Error {
22
22
  }
23
23
  export declare const normalizeBody: (body: unknown) => unknown;
24
24
  export declare const unsupportedBodyFormat: (operation: Operation) => string | undefined;
25
+ export declare const phantomPathParams: (operation: Operation) => string[];
26
+ export declare const undeliverablePathParams: (operation: Operation) => string | undefined;
25
27
  export declare const buildUrl: (baseUrl: string, operation: Operation, args: CallArgs) => string;
26
28
  export declare class AdminApiClient {
27
29
  private readonly baseUrl;
@@ -31,7 +31,26 @@ export const unsupportedBodyFormat = (operation) => {
31
31
  }
32
32
  return `${base} Report it to the human and use another route to that instance.`;
33
33
  };
34
+ export const phantomPathParams = (operation) => operation.params
35
+ .filter((param) => param.location === 'path' && !operation.path.includes(`{${param.name}}`))
36
+ .map((param) => param.name);
37
+ export const undeliverablePathParams = (operation) => {
38
+ const phantom = phantomPathParams(operation);
39
+ if (phantom.length === 0) {
40
+ return undefined;
41
+ }
42
+ return (`Operation ${operation.opId} declares ${phantom.map((name) => `"${name}"`).join(', ')} as a ` +
43
+ `path parameter, but its path ${operation.path} has no place to put it. The value cannot ` +
44
+ 'reach the API, so the request would go out without it and do something other than what ' +
45
+ 'you asked. The call is not executable through this server — find the operation that ' +
46
+ 'takes the same values where the API really expects them with cms_api_search, and read ' +
47
+ '"note" in cms_api_describe for a route that has been verified.');
48
+ };
34
49
  export const buildUrl = (baseUrl, operation, args) => {
50
+ const undeliverable = undeliverablePathParams(operation);
51
+ if (undeliverable !== undefined) {
52
+ throw new RequestBuildError(undeliverable);
53
+ }
35
54
  let path = operation.path;
36
55
  const provided = args.path ?? {};
37
56
  for (const param of operation.params) {
@@ -94,11 +113,11 @@ const readBody = async (response) => {
94
113
  return text.slice(0, 2000);
95
114
  }
96
115
  };
97
- const isLoginPage = (response, body) => {
98
- if ((response.headers.get('content-type') ?? '').includes('text/html')) {
99
- return true;
100
- }
101
- return typeof body === 'string' && body.trimStart().startsWith('<');
116
+ const isLoginPage = (body) => typeof body === 'string' && body.trimStart().startsWith('<');
117
+ const describeResponse = (response, body) => {
118
+ const contentType = response.headers.get('content-type') ?? 'no content type';
119
+ const text = typeof body === 'string' ? body : JSON.stringify(body ?? null);
120
+ return `${contentType}, body starts with "${text.trimStart().slice(0, 80).replace(/\s+/g, ' ')}"`;
102
121
  };
103
122
  const extractMessage = (body, fallback) => {
104
123
  if (typeof body === 'string') {
@@ -163,17 +182,18 @@ export class AdminApiClient {
163
182
  response = await attempt(await this.tokens.refresh());
164
183
  }
165
184
  body = await readBody(response);
166
- if (response.ok && isLoginPage(response, body)) {
185
+ if (response.ok && isLoginPage(body)) {
167
186
  response = await attempt(await this.tokens.refresh());
168
187
  body = await readBody(response);
169
- if (response.ok && isLoginPage(response, body)) {
188
+ if (response.ok && isLoginPage(body)) {
170
189
  return {
171
190
  ok: false,
172
191
  error: {
173
192
  status: response.status,
174
193
  message: `${operation.method.toUpperCase()} ${url} answered ${String(response.status)} with ` +
175
194
  'the instance login page instead of a result: the admin session is not valid and ' +
176
- 're-authenticating did not change that. Nothing was written by this call.',
195
+ 're-authenticating did not change that. Nothing was written by this call. ' +
196
+ `The response was ${describeResponse(response, body)}.`,
177
197
  hint: 'Check the credentials this server runs with, then retry. Treat every write since ' +
178
198
  'the last verified read as unconfirmed and read those entities back.',
179
199
  },
@@ -57,37 +57,50 @@ export const OPERATION_NOTES = {
57
57
  'A body of { limit, offset, langCode } answers 400 complaining about langCode, because ' +
58
58
  'the validator is describing the query parameter you did not send.',
59
59
  },
60
+ AdminProductsController_removeMany: {
61
+ note: 'Not executable through this server: the document declares "ids" as a path parameter ' +
62
+ 'while the path is /products, so the value has nowhere to go and the request would go ' +
63
+ 'out as a bare DELETE /products. The verified way to delete a chosen set is the ' +
64
+ 'operation on DELETE /api/admin/products/category/{pageId} with a body of ' +
65
+ '{ "ids": [ … ] } — find it with cms_api_search { "query": "products category delete" }. ' +
66
+ 'The same route with the ids in the query string answers 500 instead, so send them in ' +
67
+ 'the body. Deleting products one by one with AdminProductsController_remove is the other ' +
68
+ 'route and needs no category.',
69
+ },
60
70
  AdminProductsController_setStatusForProducts: {
61
- silentNoOp: 'The status id goes in "id", not "statusId". With "statusId" the handler reads ' +
62
- 'undefined, writes NULL over the product status and still answers 201 true. The call ' +
63
- 'also never re-indexes, so even a correct write stays invisible to ' +
64
- 'AdminProductsController_findAll. Setting statusId through ' +
65
- 'AdminProductsController_update is the route that works today.',
71
+ note: 'The status id goes in "statusId". The older field name "id" is accepted for the same ' +
72
+ 'value, and a body carrying neither answers 400 rather than writing an empty status. ' +
73
+ 'The call re-indexes what it changed, so the new status is visible to ' +
74
+ 'AdminProductsController_findAll and not only to a read by id. For a single product, ' +
75
+ 'sending statusId through AdminProductsController_update does the same job.',
66
76
  verifyWith: {
67
77
  opId: 'AdminProductsController_findOne',
68
78
  check: 'statusId',
69
- why: 'the call answers 201 true whether or not any row changed',
79
+ why: 'the response says the call was accepted, not which products ended up with the status',
70
80
  },
71
81
  },
72
82
  AdminAttributesSetsController_create: {
73
- note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
74
- 'locale first: validators.en_US.requiredValidator, not validators.requiredValidator. ' +
75
- 'A flat map is stored verbatim and read by nobody. ' +
83
+ note: 'Inside an attribute, "validators", "localizeInfos", "listTitles" and "additionalFields" ' +
84
+ 'are keyed by locale first: validators.en_US.requiredValidator, not ' +
85
+ 'validators.requiredValidator. A flat map answers 400 here, and the message names the ' +
86
+ 'attribute and the field — only AdminAttributesSetsController_updateSchema still accepts ' +
87
+ 'one silently. ' +
76
88
  LIST_EXTRAS_NOTE,
77
89
  example: LIST_ATTRIBUTE_WITH_EXTRAS,
78
90
  verifyWith: {
79
91
  opId: 'AdminAttributesSetsController_findOne',
80
92
  check: 'schema[].validators.<locale>',
81
- why: 'the raw set shows exactly what you wrote, including a flat map that no consumer reads',
93
+ why: 'the raw set is where a locale key is either present or not, whatever the panel shows',
82
94
  },
83
95
  },
84
96
  AdminAttributesSetsController_update: {
85
- note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
86
- 'locale first. A flat map is accepted and stored where nothing reads it. ' +
87
- 'Dropping an attribute from the set does not remove the values entities already hold ' +
88
- 'under its key: they stay on every entity, are invisible in the panel, and a ' +
89
- 'read-modify-write cycle sends them back, so they outlive the attribute indefinitely. ' +
90
- 'Strip the removed keys from each entity yourself, in the same run. ' +
97
+ note: 'Inside an attribute, "validators", "localizeInfos", "listTitles" and "additionalFields" ' +
98
+ 'are keyed by locale first, and a flat map answers 400 here as it does on create. ' +
99
+ 'Dropping an attribute from the set clears the values entities held under its key, but ' +
100
+ 'not by the time the call returns: the cleanup runs behind the write, so for a short ' +
101
+ 'window a read still shows the removed key. Do not build an update from a read taken ' +
102
+ 'immediately after removing an attribute compare the keys you are about to send against ' +
103
+ 'the current set and drop the ones it no longer defines. ' +
91
104
  LIST_EXTRAS_NOTE,
92
105
  example: LIST_ATTRIBUTE_WITH_EXTRAS,
93
106
  verifyWith: {
@@ -97,12 +110,18 @@ export const OPERATION_NOTES = {
97
110
  },
98
111
  },
99
112
  AdminAttributesSetsController_updateSchema: {
100
- note: 'The body is the schema object itself, never wrapped as { "schema": … }. The wrapped ' +
101
- 'form answers 200 and replaces the set with a single attribute called "schema". ' +
102
- 'Locale-keyed rules apply here too, and so does the residue rule: an attribute removed ' +
103
- 'here leaves its values on every entity that had one. ' +
113
+ note: 'The body is the schema object itself, never wrapped as { "schema": … }. Locale-keyed ' +
114
+ 'rules apply here too, and so does the cleanup rule: an attribute removed here has its ' +
115
+ 'values cleared behind the write, so a read taken straight afterwards can still show the ' +
116
+ 'old key. ' +
104
117
  LIST_EXTRAS_NOTE,
105
118
  example: LIST_ATTRIBUTE_WITH_EXTRAS,
119
+ silentNoOp: 'This is the one route that does NOT check locale keying: a flat "validators", ' +
120
+ '"localizeInfos", "listTitles" or "additionalFields" answers 200 and is stored where no ' +
121
+ 'consumer reads it, so a field you meant to make required is simply not enforced. Create ' +
122
+ 'and update of the set answer 400 for the same body. Wrapping the body as ' +
123
+ '{ "schema": … } is worse and equally quiet: it answers 200 and replaces the set with a ' +
124
+ 'single attribute called "schema". Read the set back after every schema replace.',
106
125
  verifyWith: {
107
126
  opId: 'AdminAttributesSetsController_findOne',
108
127
  check: 'schema[].validators.<locale>',
@@ -120,13 +139,20 @@ export const OPERATION_NOTES = {
120
139
  'and reports no error; the only repair is uploading the file again. That matters because ' +
121
140
  'previewLink.default[0] is the inline placeholder a site renders while the full image ' +
122
141
  'loads, so an image without it cannot be shown progressively. The stored record carries ' +
123
- 'no "alt" and no "title": alternative text has to live in a sibling attribute, and the ' +
124
- 'project has to agree on one naming convention for it before the first upload.',
142
+ 'no "alt" and no "title", but the attribute slot you write it into keeps whatever keys ' +
143
+ 'you add: send "alt" and "title" beside the upload\'s own fields and they come back from ' +
144
+ 'the public read. Use exactly those two names — the admin panel offers the same pair on ' +
145
+ 'an image attribute value and writes them into the same slot, so text set either way ' +
146
+ 'survives an edit made the other way.',
125
147
  },
126
148
  AdminMenusController_create: {
127
- note: 'Create the menu with an empty pagesIds, then attach pages with AdminMenusController_update. ' +
128
- 'A non-empty pagesIds on create answers 500 (null value in column "page_id"). That 500 is ' +
129
- 'known do not report it as an unexplained server failure.',
149
+ note: 'A create may carry pagesIds. The ids are checked before anything is written: one that ' +
150
+ 'does not exist answers 404 naming it and no menu is created, so a typo costs nothing to ' +
151
+ 'clean up. What the create does NOT do is nest anything pagesIds is a flat set wherever ' +
152
+ 'it appears, and the pages arrive as siblings at the top level whatever their relationship ' +
153
+ 'in the page tree. Creating the menu empty and attaching with ' +
154
+ 'AdminMenusController_update is equally valid and the better shape when you are building ' +
155
+ 'the tree level by level.',
130
156
  },
131
157
  AdminMenusController_update: {
132
158
  note: '"pagesIds" is a flat set of page ids and nothing else: nesting is NOT taken from the ' +
@@ -145,46 +171,52 @@ export const OPERATION_NOTES = {
145
171
  AdminMenusController_createCustomItem: {
146
172
  note: 'A custom item is for anything that is not a page: a product, an external address, a ' +
147
173
  'column heading. An empty "value" is rejected, so a heading with no link needs a ' +
148
- 'placeholder target such as "#". The parent reference is a bare number with no kind ' +
149
- 'attached, while custom items and page items are numbered separately so a custom item ' +
150
- 'and a page item can share an id, and children hung on that number are then returned ' +
151
- 'under BOTH parents. Before nesting anything, read the menu and check that the included ' +
152
- 'pages and the custom items share no id; if they do, the only route today is to recreate ' +
153
- 'the custom item until its id differs.',
174
+ 'placeholder target such as "#". The create body takes localizeInfos and value only: the ' +
175
+ 'item arrives at the top level and is nested afterwards with ' +
176
+ 'AdminMenusController_updateCustomItemPosition. Custom items and page items are numbered ' +
177
+ 'separately, so the same number can name one of each which is why a parent is addressed ' +
178
+ 'by kind as well as number. Reads return "parentType" beside "parentId", and a public read ' +
179
+ 'also returns "itemType" for the item itself, which is the value its children have to send.',
154
180
  verifyWith: {
155
181
  opId: 'AdminMenusController_findOne',
156
- check: 'the ids of included pages against the ids of customItems',
157
- why: 'a shared id duplicates a whole branch, and the write that caused it answers 200',
182
+ check: 'the parent of the item, its parentType, and where it appears in the tree',
183
+ why: 'a parent resolved to the other kind puts the item under a plausible wrong branch',
158
184
  },
159
185
  },
160
186
  AdminMenusController_updatePosition: {
161
- example: { position: { leftObjectId: null, rightObjectId: null }, newParentId: 12 },
162
- note: '"position" is required even when the order does not matter, and it must be an object: ' +
163
- 'omitting it answers 500 about destructuring, and an empty object is not enough — send ' +
164
- '{ leftObjectId: null, rightObjectId: null }. Re-parenting through "newParentId" does ' +
165
- 'apply; the ordering does not.',
166
- silentNoOp: 'Re-parenting lands, but sibling order does not change: the call answers success and ' +
167
- 'every root item keeps the position it had. Public reads do not take item order from ' +
168
- 'positions either page items arrive ordered by page id and custom items after them. ' +
169
- 'There is no body that produces a chosen order today, so build the menu correct in ' +
170
- 'composition and nesting, tell the human the order is not reproducible, and report it ' +
171
- 'rather than trying further bodies.',
187
+ example: {
188
+ position: { leftObjectId: null, rightObjectId: null },
189
+ newParentId: 12,
190
+ newParentType: 'custom',
191
+ },
192
+ note: '"position" is required even when the order does not matter, and it must be an object: a ' +
193
+ 'body without it answers 400 naming the field, and an empty object is not enough send ' +
194
+ '{ leftObjectId: null, rightObjectId: null }. Both re-parenting and sibling order apply, ' +
195
+ 'and a public read returns the items in that order. Neighbours are addressed by the id of ' +
196
+ 'the item, not of its position, and page items and custom items are numbered separately: ' +
197
+ 'in a mixed row name each neighbour with "leftObjectType" and "rightObjectType", or a ' +
198
+ 'neighbour of the other kind is not found and the item lands at the edge of the list. ' +
199
+ '"newParentType" says which kind the new parent is; omit it and the number is looked up ' +
200
+ 'among pages first, which is not necessarily the parent you meant.',
172
201
  verifyWith: {
173
202
  opId: 'AdminMenusController_findOne',
174
- check: 'the parent of the item you moved, and the order of its siblings',
175
- why: 'the parent change applies while the order does not, and one response covers both',
203
+ check: 'the parent of the item you moved, its parentType, and the order of its siblings',
204
+ why: 'a neighbour or parent resolved to the other kind answers success and moves it elsewhere',
176
205
  },
177
206
  },
178
207
  AdminMenusController_updateCustomItemPosition: {
179
- example: { position: { leftObjectId: null, rightObjectId: null }, newParentId: 12 },
208
+ example: {
209
+ position: { leftObjectId: null, rightObjectId: null },
210
+ newParentId: 12,
211
+ newParentType: 'custom',
212
+ },
180
213
  note: 'Same body as AdminMenusController_updatePosition: "position" is required and must be an ' +
181
- 'object — { leftObjectId: null, rightObjectId: null } when the order does not matter.',
182
- silentNoOp: 'Re-parenting lands; sibling order does not. See AdminMenusController_updatePosition ' +
183
- 'public reads order items by page id rather than by the positions stored here.',
214
+ 'object — { leftObjectId: null, rightObjectId: null } when the order does not matter — and ' +
215
+ 'the kind of every neighbour and of the new parent is named alongside its number.',
184
216
  verifyWith: {
185
217
  opId: 'AdminMenusController_findOne',
186
- check: 'the parent of the item you moved, and the order of its siblings',
187
- why: 'the parent change applies while the order does not, and one response covers both',
218
+ check: 'the parent of the item you moved, its parentType, and the order of its siblings',
219
+ why: 'a neighbour or parent resolved to the other kind answers success and moves it elsewhere',
188
220
  },
189
221
  },
190
222
  AdminFormsController_create: {
@@ -201,7 +233,14 @@ export const OPERATION_NOTES = {
201
233
  note: '"formModuleConfigs" is a full replacement list, not a patch. Omitting it deletes every ' +
202
234
  'module binding of the form AND the submissions recorded against those bindings, and the ' +
203
235
  'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
204
- 'back unless changing them is the point. This is also the only operation that creates a ' +
236
+ 'back unless changing them is the point. An existing binding is matched by its own "id" ' +
237
+ 'and by nothing else: an entry carrying the same formId, moduleId and entityIdentifiers ' +
238
+ 'but no id is created as a NEW binding, the previous one is deleted, and the submissions ' +
239
+ 'counted against it stop being counted — the count route for the form goes to zero. ' +
240
+ 'Worse, a public read of the entity keeps naming the previous binding for as long as its ' +
241
+ 'answer is cached, so a site submitting against it answers 400 "Incorrect formIdentifier ' +
242
+ 'for provided config" after the edit rather than during it. Echo formModuleConfigs[].id ' +
243
+ 'back on every entry you are keeping. This is also the only operation that creates a ' +
205
244
  'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers. ' +
206
245
  'The rating behaviour of a review form lives in that same entry and has no operation of ' +
207
246
  'its own: isRating, ratingCalculation, maxRatingScale, allowHalfRatings, allowRerating ' +
@@ -225,8 +264,9 @@ export const OPERATION_NOTES = {
225
264
  },
226
265
  verifyWith: {
227
266
  opId: 'AdminFormsController_findOne',
228
- check: 'formModuleConfigs',
229
- why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included',
267
+ check: 'formModuleConfigs[].id',
268
+ why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included, ' +
269
+ 'and a binding sent without its id comes back with a new one, which is the same loss',
230
270
  },
231
271
  },
232
272
  AdminFormDataController_create: {
@@ -241,9 +281,13 @@ export const OPERATION_NOTES = {
241
281
  'not mean the config is optional. A form with type null fails earlier with "Form has ' +
242
282
  'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
243
283
  'API a missing required field is stored rather than rejected, so a submission accepted ' +
244
- 'here does not prove a visitor\'s submission would pass. A field of type text takes ' +
245
- '[{ "htmlValue": "…" }] a bare string, a list of strings and [{ "value": … }] are all ' +
246
- 'rejected. On a rating form the score attribute must be marked isRatingValue, or the ' +
284
+ 'here does not prove a visitor\'s submission would pass. A field of type text takes a ' +
285
+ 'list of ONE object carrying EXACTLY ONE of plainValue, htmlValue or mdValue and no ' +
286
+ 'other key: [{ "plainValue": "…" }]. A bare string, a list of strings, ' +
287
+ '[{ "value": … }], two of the three keys together and the four-key shape an entity ' +
288
+ 'attribute of type text uses (htmlValue, mdValue, plainValue, params) are each ' +
289
+ 'rejected, the last two with a message that names no field. On a rating form the score ' +
290
+ 'attribute must be marked isRatingValue, or the ' +
247
291
  'form is invalid with "Form must have a rating marker". This route also treats one ' +
248
292
  'authenticated author as one submission per entity, so it rejects the second review of a ' +
249
293
  'product with "You have already rated": a bulk import of visitor reviews cannot go ' +
@@ -257,10 +301,10 @@ export const OPERATION_NOTES = {
257
301
  AdminPagesController_update: {
258
302
  note: 'Omitting parentId does not leave the parent alone — it moves the page to the root and ' +
259
303
  'decrements the former parent\'s childrenCount. Read the page first and send parentId ' +
260
- 'back unchanged unless you mean to re-parent it. One field cannot be echoed back at all: ' +
261
- 'a listing returns "position" as a lexorank string while this operation expects an object ' +
262
- 'and answers 400 "position must be either object or array" drop the field from a ' +
263
- 'read-modify-write body and reorder with the page position operation instead.',
304
+ 'back unchanged unless you mean to re-parent it. "position" is safe to echo back: a ' +
305
+ 'listing returns it as a lexorank string, and this operation accepts that string and ' +
306
+ 'ignores it, so a read-modify-write body no longer has to be stripped of the field. It ' +
307
+ 'also does not reorder anything the page position operation is what changes order.',
264
308
  verifyWith: {
265
309
  opId: 'AdminPagesController_findOne',
266
310
  check: 'parentId',
@@ -286,25 +330,24 @@ export const OPERATION_NOTES = {
286
330
  'under "mailing", which belongs to the mailing module. Placeholders depend on the module: ' +
287
331
  '{{ product.title }} and {{ product.<marker> }} for the catalogue, {{ user.<marker> }} ' +
288
332
  'for the recipient. Events support six modules — catalog, forms, orders, users, payments ' +
289
- 'and discounts. There is no content module among them, so an event on a page or a block ' +
290
- 'attribute cannot be built this way; see silentNoOp.',
291
- silentNoOp: 'A moduleId outside the six supported modules is accepted: the event is created, reads ' +
292
- 'back complete, and never fires the panel draws no settings for it and nothing is ever ' +
293
- 'sent. Nothing in the response distinguishes it from a working event. If the task needs ' +
294
- '"notify when this page changes", say that events do not cover it rather than creating ' +
295
- 'the object and reporting success.',
333
+ 'and discounts. A moduleId outside those six answers 400 and the message names every ' +
334
+ 'module that is accepted; the same check runs on update, so an event cannot be moved onto ' +
335
+ 'an unsupported one either. There is no content module among them, so "notify when this ' +
336
+ 'page changes" is not an events task: say so rather than looking for a body that gets ' +
337
+ 'through. Whether an event that fired actually reached anyone is a separate read ' +
338
+ 'AdminEventsController_findEmailLogs.',
296
339
  verifyWith: {
297
340
  opId: 'AdminEventsController_findOne',
298
341
  check: 'moduleId and localizeInfos.<locale>.title',
299
- why: 'an event on an unsupported module reads back fully populated and still does nothing',
342
+ why: 'a name sent only as "name" is stored and never displayed, and the create still answers 201',
300
343
  },
301
344
  },
302
345
  AdminEventsController_update: {
303
346
  note: 'The event name, the mail subject and the mail body all live in ' +
304
347
  'localizeInfos.<locale> as "title", "subject" and "template", with "push" for the push ' +
305
348
  'channel. Nothing about the message belongs under "mailing". Supported modules are ' +
306
- 'catalog, forms, orders, users, payments and discounts; any other moduleId is accepted ' +
307
- 'and the event never fires.',
349
+ 'catalog, forms, orders, users, payments and discounts; any other moduleId answers 400, ' +
350
+ 'checked against the merged result so a partial update cannot slip past it.',
308
351
  example: {
309
352
  localizeInfos: {
310
353
  en_US: {
@@ -337,28 +380,29 @@ export const OPERATION_NOTES = {
337
380
  },
338
381
  },
339
382
  AdminDiscountsController_createCoupon: {
340
- note: 'A coupon created from a code you supply comes back isReusable: true one code, valid ' +
341
- 'for everyone, any number of times. "isReusable" is not accepted in the body, only ' +
342
- 'reported in the response, so the reuse behaviour follows from WHICH operation created ' +
343
- 'the coupon. For codes that expire on first use, generate them with ' +
344
- 'AdminDiscountsController_generateCouponsByMask instead. On a "first order" discount the ' +
345
- 'difference decides whether one customer gets a discount or everyone does, permanently.',
383
+ note: '"isReusable" is accepted in the body, and the default when you omit it is TRUE for this ' +
384
+ 'operation — one code, valid for everyone, any number of times. That default is the ' +
385
+ 'opposite of the one AdminDiscountsController_generateCouponsByMask applies, so on a ' +
386
+ '"first order" discount the field decides whether one customer gets the discount or ' +
387
+ 'everyone does, permanently. State the reuse you mean rather than relying on either ' +
388
+ 'default.',
346
389
  verifyWith: {
347
390
  opId: 'AdminDiscountsController_findOne',
348
391
  check: 'the coupon\'s isReusable',
349
- why: 'reuse is not something the create body can set, so the response is the only place it appears',
392
+ why: 'an omitted field takes a default that differs between the two ways of making a coupon',
350
393
  },
351
394
  },
352
395
  AdminDiscountsController_generateCouponsByMask: {
353
- note: 'Coupons generated from a mask come back isReusable: false each code stops working ' +
354
- 'after the order that used it. That is the only route to single-use codes: a coupon ' +
355
- 'created from a supplied string is reusable and cannot be changed through the body.',
396
+ note: '"isReusable" is accepted in the body here too, and the default when you omit it is ' +
397
+ 'FALSE — each generated code stops working after the order that used it. Send the field ' +
398
+ 'explicitly when the reuse matters; the two coupon operations disagree only in what an ' +
399
+ 'omitted value means.',
356
400
  },
357
401
  AdminProductsController_update: {
358
402
  note: 'Always include "blocks" — send [] when there is nothing to set, because omitting it ' +
359
403
  'fails the update. Never include "forms": the schema accepts the field and saving rejects ' +
360
- 'it. This is also the working route for a product status: send statusId here rather than ' +
361
- 'through the bulk set-status operation.',
404
+ 'it. A product status can be set here with "statusId", or for many products in one call ' +
405
+ 'with AdminProductsController_setStatusForProducts.',
362
406
  },
363
407
  AdminBlocksController_update: {
364
408
  note: 'Omitting blockPages detaches the block from every page it was on. Read the block first ' +
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { unsupportedBodyFormat } from '../api/client.js';
2
+ import { undeliverablePathParams, unsupportedBodyFormat } from '../api/client.js';
3
3
  import { errorResult, jsonResult } from './result.js';
4
4
  const sampleValue = (schema, example) => {
5
5
  if (example !== undefined) {
@@ -110,7 +110,7 @@ export const registerApiDiscovery = (server, deps) => {
110
110
  : 'Operation ids come from cms_api_search — do not construct them by hand.',
111
111
  });
112
112
  }
113
- const formatDenial = unsupportedBodyFormat(operation);
113
+ const formatDenial = unsupportedBodyFormat(operation) ?? undeliverablePathParams(operation);
114
114
  const properties = operation.body?.schema.properties;
115
115
  const loose = properties
116
116
  ? Object.entries(properties)
@@ -170,7 +170,8 @@ export const registerApiDiscovery = (server, deps) => {
170
170
  next: formatDenial
171
171
  ? operation.body?.contentType === 'multipart/form-data'
172
172
  ? 'Do not call this with cms_api_call — upload with cms_upload_file or cms_import_file_from_url.'
173
- : 'Do not call this operation report it as unavailable through MCP.'
173
+ : 'Do not call this operation. Take the route named in "notExecutable" or in ' +
174
+ '"note" above, and report it as unavailable through MCP when there is none.'
174
175
  : operation.risk === 'read'
175
176
  ? 'Call it with cms_api_call, copying the "example" above.'
176
177
  : operation.verifyWith
@@ -51,7 +51,7 @@ Ordering is a lexorank **string** on parent-scoped Admin operations and a **numb
51
51
 
52
52
  ## A read straight after a write can lag
53
53
 
54
- Reading an entity **by id** shows your write immediately; lists and searches may lag by seconds. So re-read by id and **never repeat the write** — that makes a duplicate somebody cleans up by hand. Never swallow a failed read into an empty result either: the next run then recreates everything.
54
+ An **admin** read by id shows your write immediately; admin lists and **every public read**, one entity included, lag by seconds. So re-read after a pause and **never repeat the write** — that makes a duplicate somebody cleans up by hand. Never swallow a failed read into an empty result either: the next run then recreates everything.
55
55
 
56
56
  ## A 200 means accepted not applied
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneentry/mcp-platform-server",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "MCP server that lets an AI agent operate the OneEntry Admin API, grounded in the project's own rules",
5
5
  "license": "MIT",
6
6
  "type": "module",