@oneentry/mcp-platform-server 0.1.8 → 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.
@@ -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,6 +57,16 @@ 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
71
  note: 'The status id goes in "statusId". The older field name "id" is accepted for the same ' +
62
72
  'value, and a body carrying neither answers 400 rather than writing an empty status. ' +
@@ -223,7 +233,14 @@ export const OPERATION_NOTES = {
223
233
  note: '"formModuleConfigs" is a full replacement list, not a patch. Omitting it deletes every ' +
224
234
  'module binding of the form AND the submissions recorded against those bindings, and the ' +
225
235
  'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
226
- '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 ' +
227
244
  'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers. ' +
228
245
  'The rating behaviour of a review form lives in that same entry and has no operation of ' +
229
246
  'its own: isRating, ratingCalculation, maxRatingScale, allowHalfRatings, allowRerating ' +
@@ -247,8 +264,9 @@ export const OPERATION_NOTES = {
247
264
  },
248
265
  verifyWith: {
249
266
  opId: 'AdminFormsController_findOne',
250
- check: 'formModuleConfigs',
251
- 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',
252
270
  },
253
271
  },
254
272
  AdminFormDataController_create: {
@@ -263,9 +281,13 @@ export const OPERATION_NOTES = {
263
281
  'not mean the config is optional. A form with type null fails earlier with "Form has ' +
264
282
  'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
265
283
  'API a missing required field is stored rather than rejected, so a submission accepted ' +
266
- 'here does not prove a visitor\'s submission would pass. A field of type text takes ' +
267
- '[{ "htmlValue": "…" }] a bare string, a list of strings and [{ "value": … }] are all ' +
268
- '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 ' +
269
291
  'form is invalid with "Form must have a rating marker". This route also treats one ' +
270
292
  'authenticated author as one submission per entity, so it rejects the second review of a ' +
271
293
  'product with "You have already rated": a bulk import of visitor reviews cannot go ' +
@@ -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.8",
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",