@oneentry/mcp-platform-server 0.1.4 → 0.1.5

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,24 +1,69 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { capSchema, normalizeSchema } from './normalize-schema.js';
3
+ import { ALWAYS_CONFIRM_PREFIXES, OPERATION_NOTES } from './operation-notes.js';
3
4
  const API_PREFIX = '/api/admin';
4
5
  const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
5
- const ALWAYS_CONFIRM = [
6
- /^\/immutable-settings/,
7
- /^\/admins/,
8
- /^\/backups/,
9
- /^\/modules/,
10
- /^\/payments\/webhook/,
11
- /^\/auth\/logout\/all-users/,
12
- /^\/settings-general/,
13
- /^\/system\/captcha-keys/,
14
- ];
15
- const riskOf = (method) => {
6
+ const ALWAYS_CONFIRM = ALWAYS_CONFIRM_PREFIXES.map((prefix) => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
7
+ const isGated = (path) => ALWAYS_CONFIRM.some((pattern) => pattern.test(path));
8
+ const riskOf = (method, opId, path) => {
16
9
  if (method === 'get') {
17
10
  return 'read';
18
11
  }
12
+ if (method === 'post' && OPERATION_NOTES[opId]?.readOnly === true && !isGated(path)) {
13
+ return 'read';
14
+ }
19
15
  return method === 'delete' ? 'destructive' : 'write';
20
16
  };
21
17
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ const jsonTypeOf = (value) => {
19
+ if (value === null) {
20
+ return 'null';
21
+ }
22
+ if (Array.isArray(value)) {
23
+ return 'array';
24
+ }
25
+ if (typeof value === 'number') {
26
+ return Number.isInteger(value) ? 'integer' : 'number';
27
+ }
28
+ return typeof value;
29
+ };
30
+ const typeFits = (declared, actual) => declared === actual ||
31
+ (declared === 'number' && actual === 'integer') ||
32
+ (declared === 'integer' && actual === 'number');
33
+ const markExampleMismatch = (schema, field) => {
34
+ if (schema['x-loose'] === true || schema.example === undefined || schema.type === undefined) {
35
+ return undefined;
36
+ }
37
+ const actual = jsonTypeOf(schema.example);
38
+ if (!typeFits(schema.type, actual)) {
39
+ const message = `declared "${schema.type}", example is ${actual}`;
40
+ schema['x-example-mismatch'] = message;
41
+ return `${field} (${message})`;
42
+ }
43
+ if (schema.type === 'array' && schema.items?.type !== undefined && schema.items['x-loose'] !== true) {
44
+ const first = schema.example[0];
45
+ if (first !== undefined && !typeFits(schema.items.type, jsonTypeOf(first))) {
46
+ const message = `items declared "${schema.items.type}", example items are ${jsonTypeOf(first)}`;
47
+ schema['x-example-mismatch'] = message;
48
+ return `${field} (${message})`;
49
+ }
50
+ }
51
+ return undefined;
52
+ };
53
+ const exampleMismatches = (body) => {
54
+ const properties = body?.schema.properties;
55
+ if (!properties) {
56
+ return [];
57
+ }
58
+ const found = [];
59
+ for (const [name, schema] of Object.entries(properties)) {
60
+ const mismatch = markExampleMismatch(schema, name);
61
+ if (mismatch) {
62
+ found.push(mismatch);
63
+ }
64
+ }
65
+ return found;
66
+ };
22
67
  const asString = (value) => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
23
68
  const responseSummary = (responses) => {
24
69
  if (!isRecord(responses)) {
@@ -60,6 +105,21 @@ const buildParams = (raw, components) => {
60
105
  }
61
106
  return params;
62
107
  };
108
+ const bodyExample = (media) => {
109
+ if (media['example'] !== undefined) {
110
+ return media['example'];
111
+ }
112
+ const examples = media['examples'];
113
+ if (!isRecord(examples)) {
114
+ return undefined;
115
+ }
116
+ for (const entry of Object.values(examples)) {
117
+ if (isRecord(entry) && entry['value'] !== undefined) {
118
+ return entry['value'];
119
+ }
120
+ }
121
+ return undefined;
122
+ };
63
123
  const buildBody = (raw, components) => {
64
124
  if (!isRecord(raw)) {
65
125
  return undefined;
@@ -76,9 +136,18 @@ const buildBody = (raw, components) => {
76
136
  if (!isRecord(media)) {
77
137
  return undefined;
78
138
  }
139
+ const schema = capSchema(normalizeSchema(media['schema'], components));
140
+ const example = bodyExample(media);
141
+ if (example !== undefined && schema.example === undefined) {
142
+ schema.example = example;
143
+ }
144
+ const description = asString(raw['description']);
145
+ if (description && schema['x-unresolved'] === true) {
146
+ schema.description = description.replace(/\s+/g, ' ').slice(0, 240);
147
+ }
79
148
  return {
80
149
  contentType,
81
- schema: capSchema(normalizeSchema(media['schema'], components)),
150
+ schema,
82
151
  required: raw['required'] === true,
83
152
  };
84
153
  };
@@ -96,6 +165,8 @@ export const buildCatalog = (params) => {
96
165
  const operations = [];
97
166
  const foreignPaths = [];
98
167
  const withoutOpId = [];
168
+ const unresolvedBodies = [];
169
+ const contradictoryExamples = [];
99
170
  for (const [fullPath, pathItem] of Object.entries(swagger['paths'])) {
100
171
  if (!isRecord(pathItem)) {
101
172
  continue;
@@ -118,9 +189,18 @@ export const buildCatalog = (params) => {
118
189
  const tags = operation['tags'];
119
190
  const tag = (Array.isArray(tags) ? asString(tags[0]) : undefined) ?? 'Other';
120
191
  const summary = asString(operation['summary']) ?? asString(operation['description']) ?? '';
121
- const risk = riskOf(method);
122
- const alwaysConfirm = method !== 'get' && ALWAYS_CONFIRM.some((pattern) => pattern.test(path));
192
+ const risk = riskOf(method, opId, path);
193
+ const alwaysConfirm = method !== 'get' && isGated(path);
123
194
  const permission = byOpId[opId];
195
+ const body = buildBody(operation['requestBody'], components);
196
+ if (body?.schema['x-unresolved'] === true) {
197
+ unresolvedBodies.push(body.schema.example === undefined ? `${opId} (no example)` : opId);
198
+ }
199
+ const mismatches = exampleMismatches(body);
200
+ if (mismatches.length > 0) {
201
+ contradictoryExamples.push(`${opId}: ${mismatches.join('; ')}`);
202
+ }
203
+ const notes = OPERATION_NOTES[opId];
124
204
  const entry = {
125
205
  opId,
126
206
  method,
@@ -132,12 +212,13 @@ export const buildCatalog = (params) => {
132
212
  risk,
133
213
  alwaysConfirm,
134
214
  params: buildParams(operation['parameters'], components),
135
- ...(buildBody(operation['requestBody'], components)
136
- ? { body: buildBody(operation['requestBody'], components) }
137
- : {}),
215
+ ...(body ? { body } : {}),
138
216
  ...(responseSummary(operation['responses'])
139
217
  ? { responseSummary: responseSummary(operation['responses']) }
140
218
  : {}),
219
+ ...(notes?.note ? { note: notes.note } : {}),
220
+ ...(notes?.verifyWith ? { verifyWith: notes.verifyWith } : {}),
221
+ ...(notes?.silentNoOp ? { silentNoOp: notes.silentNoOp } : {}),
141
222
  searchText: '',
142
223
  };
143
224
  entry.searchText = [opId, method.toUpperCase(), path, tag, summary, permission ?? '']
@@ -156,6 +237,26 @@ export const buildCatalog = (params) => {
156
237
  warnings.push(`${String(withoutOpId.length)} operation(s) have no operationId and cannot be called ` +
157
238
  `(e.g. ${withoutOpId.slice(0, 3).join(', ')}). Report it against the CMS.`);
158
239
  }
240
+ if (unresolvedBodies.length > 0) {
241
+ warnings.push(`${String(unresolvedBodies.length)} operation(s) declare a request body whose schema ` +
242
+ `does not resolve in the document (${unresolvedBodies.slice(0, 5).join(', ')}). ` +
243
+ 'Their body schema is marked "x-unresolved": true — build the body from the example ' +
244
+ 'cms_api_describe returns, and never from the empty schema. Report it against the CMS.');
245
+ }
246
+ if (contradictoryExamples.length > 0) {
247
+ warnings.push(`${String(contradictoryExamples.length)} field(s) carry an example that contradicts their ` +
248
+ `own declared type (${contradictoryExamples.slice(0, 3).join(' | ')}). ` +
249
+ 'Those fields are marked "x-example-mismatch" and listed by cms_api_describe — ' +
250
+ 'copy the example, not the type. Report it against the CMS.');
251
+ }
252
+ const noted = Object.keys(OPERATION_NOTES);
253
+ const notedButAbsent = noted.filter((opId) => !operations.some((operation) => operation.opId === opId));
254
+ if (notedButAbsent.length > 0 && notedButAbsent.length <= noted.length / 2) {
255
+ warnings.push(`${String(notedButAbsent.length)} operation(s) carry a curated behaviour note but are ` +
256
+ `absent from this catalog (${notedButAbsent.slice(0, 5).join(', ')}). ` +
257
+ 'Either this instance does not expose them, or the note table has gone stale — ' +
258
+ 'the note is not shown for an operation that is not there.');
259
+ }
159
260
  const known = new Set(operations.map((o) => o.opId));
160
261
  const missing = Object.keys(byOpId).filter((opId) => !known.has(opId) && !opId.startsWith('Developer') && !opId.startsWith('Content'));
161
262
  if (missing.length > 0) {
@@ -173,6 +274,8 @@ export const buildCatalog = (params) => {
173
274
  : 'The bundled permission map was generated from a different platform revision than ' +
174
275
  'this instance runs — update the package if the catalog looks incomplete.'));
175
276
  }
277
+ const mappedPermissions = new Set(Object.values(byOpId));
278
+ const permissionsWithoutOperation = permissions.filter((permission) => !mappedPermissions.has(permission));
176
279
  const knownPermissions = new Set(permissions);
177
280
  const orphanPermissions = [
178
281
  ...new Set(operations
@@ -191,6 +294,7 @@ export const buildCatalog = (params) => {
191
294
  swaggerHash: createHash('sha256').update(rawSwagger).digest('hex').slice(0, 16),
192
295
  permissions,
193
296
  warnings,
297
+ coverage: { unexposedOpIds: missing, permissionsWithoutOperation },
194
298
  operations,
195
299
  };
196
300
  };
@@ -26,6 +26,7 @@ export declare class OperationCatalog {
26
26
  get(opId: string): Operation | undefined;
27
27
  operations(): readonly Operation[];
28
28
  suggest(opId: string, limit?: number): string[];
29
+ unexposedMatches(query: string, limit?: number): string[];
29
30
  search(params: {
30
31
  query: string;
31
32
  tag?: string;
@@ -28,7 +28,11 @@ export class OperationCatalog {
28
28
  if (catalog.version !== 1) {
29
29
  throw new Error(`Unsupported catalog version ${String(catalog.version)}`);
30
30
  }
31
- this.catalog = { ...catalog, warnings: catalog.warnings ?? [] };
31
+ this.catalog = {
32
+ ...catalog,
33
+ warnings: catalog.warnings ?? [],
34
+ coverage: catalog.coverage ?? { unexposedOpIds: [], permissionsWithoutOperation: [] },
35
+ };
32
36
  this.byOpId = new Map(catalog.operations.map((o) => [o.opId, o]));
33
37
  }
34
38
  static permissionMap() {
@@ -70,6 +74,7 @@ export class OperationCatalog {
70
74
  'answers 200 with an OpenAPI document.',
71
75
  ...(auth.error === undefined ? [] : [loginWarning(auth.error)]),
72
76
  ],
77
+ coverage: { unexposedOpIds: [], permissionsWithoutOperation: [] },
73
78
  operations: [],
74
79
  });
75
80
  }
@@ -96,6 +101,22 @@ export class OperationCatalog {
96
101
  .slice(0, limit)
97
102
  .map((entry) => entry.opId);
98
103
  }
104
+ unexposedMatches(query, limit = 5) {
105
+ const terms = query
106
+ .toLowerCase()
107
+ .split(/[\s/.]+/)
108
+ .filter((term) => term.length > 2);
109
+ if (terms.length === 0) {
110
+ return [];
111
+ }
112
+ const { unexposedOpIds, permissionsWithoutOperation } = this.catalog.coverage;
113
+ return [...unexposedOpIds, ...permissionsWithoutOperation]
114
+ .filter((name) => {
115
+ const haystack = name.toLowerCase();
116
+ return terms.some((term) => haystack.includes(term));
117
+ })
118
+ .slice(0, limit);
119
+ }
99
120
  search(params) {
100
121
  const terms = params.query
101
122
  .toLowerCase()
@@ -21,6 +21,7 @@ export type CallResult = {
21
21
  export declare class RequestBuildError extends Error {
22
22
  }
23
23
  export declare const normalizeBody: (body: unknown) => unknown;
24
+ export declare const unsupportedBodyFormat: (operation: Operation) => string | undefined;
24
25
  export declare const buildUrl: (baseUrl: string, operation: Operation, args: CallArgs) => string;
25
26
  export declare class AdminApiClient {
26
27
  private readonly baseUrl;
@@ -16,6 +16,15 @@ export const normalizeBody = (body) => {
16
16
  return body;
17
17
  }
18
18
  };
19
+ export const unsupportedBodyFormat = (operation) => {
20
+ const contentType = operation.body?.contentType;
21
+ if (contentType === undefined || /^application\/(\w[\w.+-]*\+)?json$/.test(contentType)) {
22
+ return undefined;
23
+ }
24
+ return (`Operation ${operation.opId} expects a "${contentType}" request body, and this server ` +
25
+ 'can only send application/json. The call is not executable through MCP — no body ' +
26
+ 'shape will help. Report it to the human and use another route to that instance.');
27
+ };
19
28
  export const buildUrl = (baseUrl, operation, args) => {
20
29
  let path = operation.path;
21
30
  const provided = args.path ?? {};
@@ -36,7 +36,12 @@ export const normalizeSchema = (node, components, depth = 0, refStack = []) => {
36
36
  }
37
37
  const target = components[name];
38
38
  if (target === undefined) {
39
- return { type: 'object', description: `(unresolved ${name})` };
39
+ return {
40
+ type: 'object',
41
+ description: `(unresolved ${name})`,
42
+ 'x-loose': true,
43
+ 'x-unresolved': true,
44
+ };
40
45
  }
41
46
  return normalizeSchema(target, components, depth, [...refStack, name]);
42
47
  }
@@ -0,0 +1,10 @@
1
+ import type { VerifyWith } from './types.js';
2
+ export interface OperationNote {
3
+ note?: string;
4
+ verifyWith?: VerifyWith;
5
+ silentNoOp?: string;
6
+ readOnly?: true;
7
+ }
8
+ export declare const ALWAYS_CONFIRM_PREFIXES: readonly string[];
9
+ export declare const OPERATION_NOTES: Readonly<Record<string, OperationNote>>;
10
+ export declare const readOnlyOpIds: () => string[];
@@ -0,0 +1,146 @@
1
+ export const ALWAYS_CONFIRM_PREFIXES = [
2
+ '/immutable-settings',
3
+ '/admins',
4
+ '/backups',
5
+ '/modules',
6
+ '/payments/webhook',
7
+ '/auth/logout/all-users',
8
+ '/settings-general',
9
+ '/system/captcha-keys',
10
+ ];
11
+ export const OPERATION_NOTES = {
12
+ AdminProductsController_findAll: {
13
+ readOnly: true,
14
+ note: 'Pagination and locale are query parameters, not body fields: pass limit, offset and ' +
15
+ 'langCode in "query". The body is an ARRAY of filter objects — send [] for no filter. ' +
16
+ 'A body of { limit, offset, langCode } answers 400 complaining about langCode, because ' +
17
+ 'the validator is describing the query parameter you did not send.',
18
+ },
19
+ AdminProductsController_setStatusForProducts: {
20
+ silentNoOp: 'The status id goes in "id", not "statusId". With "statusId" the handler reads ' +
21
+ 'undefined, writes NULL over the product status and still answers 201 true. The call ' +
22
+ 'also never re-indexes, so even a correct write stays invisible to ' +
23
+ 'AdminProductsController_findAll. Setting statusId through ' +
24
+ 'AdminProductsController_update is the route that works today.',
25
+ verifyWith: {
26
+ opId: 'AdminProductsController_findOne',
27
+ check: 'statusId',
28
+ why: 'the call answers 201 true whether or not any row changed',
29
+ },
30
+ },
31
+ AdminAttributesSetsController_create: {
32
+ note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
33
+ 'locale first: validators.en_US.requiredValidator, not validators.requiredValidator. ' +
34
+ 'A flat map is stored verbatim and read by nobody.',
35
+ verifyWith: {
36
+ opId: 'AdminAttributesSetsController_findOne',
37
+ check: 'schema[].validators.<locale>',
38
+ why: 'the raw set shows exactly what you wrote, including a flat map that no consumer reads',
39
+ },
40
+ },
41
+ AdminAttributesSetsController_update: {
42
+ note: 'Inside an attribute, "validators", "localizeInfos" and "listTitles" are keyed by ' +
43
+ 'locale first. A flat map is accepted and stored where nothing reads it.',
44
+ verifyWith: {
45
+ opId: 'AdminAttributesSetsController_findOne',
46
+ check: 'schema[].validators.<locale>',
47
+ why: 'the raw set shows what you wrote; the projection a site reads shows what it gets',
48
+ },
49
+ },
50
+ AdminAttributesSetsController_updateSchema: {
51
+ note: 'The body is the schema object itself, never wrapped as { "schema": … }. The wrapped ' +
52
+ 'form answers 200 and replaces the set with a single attribute called "schema". ' +
53
+ 'Locale-keyed rules apply here too.',
54
+ verifyWith: {
55
+ opId: 'AdminAttributesSetsController_findOne',
56
+ check: 'schema[].validators.<locale>',
57
+ why: 'a wrapped or flat body answers 200 and destroys or discards what you sent',
58
+ },
59
+ },
60
+ AdminFileUploadController_uploadFiles: {
61
+ note: 'The "template" query parameter is the NUMERIC id of a /template-previews record, not ' +
62
+ 'a boolean flag. A fresh instance has no such records, and an upload without a valid ' +
63
+ 'template id stores the file with no preview and reports no error.',
64
+ },
65
+ AdminMenusController_create: {
66
+ note: 'Create the menu with an empty pagesIds, then attach pages with AdminMenusController_update. ' +
67
+ 'A non-empty pagesIds on create answers 500 (null value in column "page_id"). That 500 is ' +
68
+ 'known — do not report it as an unexplained server failure.',
69
+ },
70
+ AdminFormsController_create: {
71
+ note: 'Send the payload wrapped under "newForm". The form "type" is missing from the schema but ' +
72
+ 'is accepted and persisted: order | sign_in_up | collection | data | rating. A contact ' +
73
+ 'form is "data". Omit it and the form is created with type null.',
74
+ verifyWith: {
75
+ opId: 'AdminFormsController_findAll',
76
+ check: 'type',
77
+ why: 'the field is absent from the schema, so an omitted type is not reported as missing',
78
+ },
79
+ },
80
+ AdminFormsController_update: {
81
+ note: '"formModuleConfigs" is a full replacement list, not a patch. Omitting it deletes every ' +
82
+ 'module binding of the form AND the submissions recorded against those bindings, and the ' +
83
+ 'call still answers 200 true. Read the form first and send its current formModuleConfigs ' +
84
+ 'back unless changing them is the point. This is also the only operation that creates a ' +
85
+ 'binding: an entry with formId, moduleId and either isGlobal or entityIdentifiers.',
86
+ verifyWith: {
87
+ opId: 'AdminFormsController_findOne',
88
+ check: 'formModuleConfigs',
89
+ why: 'an omitted formModuleConfigs is applied as "unbind everything", submissions included',
90
+ },
91
+ },
92
+ AdminFormDataController_create: {
93
+ note: 'A submission needs formIdentifier, formModuleConfigId, moduleEntityIdentifier and ' +
94
+ 'locale-keyed formData. formModuleConfigId is the id of a module binding, which only ' +
95
+ 'AdminFormsController_update creates and only AdminFormsController_findOne reports, under ' +
96
+ 'formModuleConfigs[].id — a freshly created form has none and accepts nothing. The server ' +
97
+ 'joins the config back to its form and compares identifiers, so a config id belonging to ' +
98
+ 'another form, or to no form, answers 400 "Incorrect formIdentifier for provided config": ' +
99
+ 'the message names formIdentifier, the wrong field is usually the config id. Checks run ' +
100
+ 'fields first, then the form type, then the config, so a field error arriving first does ' +
101
+ 'not mean the config is optional. A form with type null fails earlier with "Form has ' +
102
+ 'incorrect type". Field validation is wired into the Content API only: through the Admin ' +
103
+ 'API a missing required field is stored rather than rejected, so a submission accepted ' +
104
+ 'here does not prove a visitor\'s submission would pass.',
105
+ verifyWith: {
106
+ opId: 'AdminFormDataController_findByFormMarker',
107
+ check: 'the submission you sent',
108
+ why: 'the stored submission is the only proof the binding it names is the intended one',
109
+ },
110
+ },
111
+ AdminPagesController_update: {
112
+ note: 'Omitting parentId does not leave the parent alone — it moves the page to the root and ' +
113
+ 'decrements the former parent\'s childrenCount. Read the page first and send parentId ' +
114
+ 'back unchanged unless you mean to re-parent it.',
115
+ verifyWith: {
116
+ opId: 'AdminPagesController_findOne',
117
+ check: 'parentId',
118
+ why: 'an omitted parentId is applied as "move to root", and the update still answers 200',
119
+ },
120
+ },
121
+ AdminBlocksController_update: {
122
+ note: 'Omitting blockPages detaches the block from every page it was on. Read the block first ' +
123
+ 'and send its current page list back unless you mean to change it.',
124
+ verifyWith: {
125
+ opId: 'AdminBlocksController_findOne',
126
+ check: 'blockPages',
127
+ why: 'an omitted blockPages is applied as "detach everything", and the update answers 200',
128
+ },
129
+ },
130
+ AdminProductsController_countAll: { readOnly: true },
131
+ AdminProductsController_findByIds: { readOnly: true },
132
+ AdminProductsController_findAllByWithOutCategory: { readOnly: true },
133
+ AdminProductsController_findAllByCategoryIdForAdmin: { readOnly: true },
134
+ AdminProductsController_countByCategoryId: { readOnly: true },
135
+ AdminProductsController_countByCategoryMarker: { readOnly: true },
136
+ AdminUsersController_findAllByConditions: { readOnly: true },
137
+ AdminUsersController_search: { readOnly: true },
138
+ AdminFormDataController_findByFormMarker: { readOnly: true },
139
+ AdminBlocksController_findCartComplementProductsByBody: { readOnly: true },
140
+ AdminBlocksController_findCartSimilarProductsByBody: { readOnly: true },
141
+ AdminBlocksController_findWishlistSimilarProductsByBody: { readOnly: true },
142
+ AdminUserGroupsController_getGroupRoutesWithPermissions: { readOnly: true },
143
+ };
144
+ export const readOnlyOpIds = () => Object.entries(OPERATION_NOTES)
145
+ .filter(([, note]) => note.readOnly === true)
146
+ .map(([opId]) => opId);
@@ -1,5 +1,10 @@
1
1
  export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
2
2
  export type RiskLevel = 'read' | 'write' | 'destructive';
3
+ export interface VerifyWith {
4
+ opId: string;
5
+ check: string;
6
+ why: string;
7
+ }
3
8
  export interface OperationParam {
4
9
  name: string;
5
10
  location: 'path' | 'query' | 'header';
@@ -13,6 +18,7 @@ export interface JsonSchema {
13
18
  format?: string;
14
19
  description?: string;
15
20
  example?: unknown;
21
+ default?: unknown;
16
22
  enum?: unknown[];
17
23
  items?: JsonSchema;
18
24
  properties?: Record<string, JsonSchema>;
@@ -20,6 +26,8 @@ export interface JsonSchema {
20
26
  'x-loose'?: boolean;
21
27
  'x-source-type'?: string;
22
28
  'x-truncated'?: boolean;
29
+ 'x-example-mismatch'?: string;
30
+ 'x-unresolved'?: boolean;
23
31
  }
24
32
  export interface Operation {
25
33
  opId: string;
@@ -38,8 +46,15 @@ export interface Operation {
38
46
  required: boolean;
39
47
  };
40
48
  responseSummary?: string;
49
+ note?: string;
50
+ verifyWith?: VerifyWith;
51
+ silentNoOp?: string;
41
52
  searchText: string;
42
53
  }
54
+ export interface CatalogCoverage {
55
+ unexposedOpIds: string[];
56
+ permissionsWithoutOperation: string[];
57
+ }
43
58
  export interface Catalog {
44
59
  version: 1;
45
60
  builtAt: string;
@@ -47,5 +62,6 @@ export interface Catalog {
47
62
  swaggerHash: string;
48
63
  permissions: string[];
49
64
  warnings: string[];
65
+ coverage: CatalogCoverage;
50
66
  operations: Operation[];
51
67
  }
@@ -0,0 +1 @@
1
+ export declare const withEnglishTerms: (query: string) => string;
@@ -0,0 +1,67 @@
1
+ const RU_EN = {
2
+ атрибут: 'attribute',
3
+ блок: 'block',
4
+ валидатор: 'validator',
5
+ вложен: 'nested',
6
+ выгрузк: 'export',
7
+ групп: 'group',
8
+ доступ: 'permission access',
9
+ заказ: 'order',
10
+ запрос: 'request',
11
+ значени: 'value',
12
+ изображени: 'image file',
13
+ импорт: 'import',
14
+ индекс: 'index',
15
+ каталог: 'catalogue catalog',
16
+ картинк: 'image file',
17
+ категори: 'page category',
18
+ локал: 'locale',
19
+ маркер: 'marker identifier',
20
+ меню: 'menu',
21
+ модул: 'module',
22
+ набор: 'set',
23
+ настройк: 'settings',
24
+ ошибк: 'error',
25
+ пагинац: 'pagination limit offset',
26
+ парамет: 'parameter',
27
+ перевод: 'localization locale',
28
+ подтвержд: 'confirm',
29
+ поиск: 'search',
30
+ позиц: 'position',
31
+ польз: 'user',
32
+ превью: 'preview',
33
+ прав: 'permission',
34
+ продукт: 'product',
35
+ сортиров: 'sort order',
36
+ ссылк: 'link',
37
+ статус: 'status',
38
+ страниц: 'page',
39
+ схем: 'schema',
40
+ товар: 'product',
41
+ тип: 'type',
42
+ файл: 'file upload',
43
+ фильтр: 'filter',
44
+ форм: 'form',
45
+ шаблон: 'template',
46
+ язык: 'language locale',
47
+ };
48
+ const STEMS = Object.keys(RU_EN).sort((a, b) => b.length - a.length);
49
+ export const withEnglishTerms = (query) => {
50
+ const lower = query.toLowerCase();
51
+ if (!/[а-яё]/.test(lower)) {
52
+ return query;
53
+ }
54
+ const added = new Set();
55
+ for (const word of lower.split(/[^\p{L}\p{N}]+/u)) {
56
+ if (word === '') {
57
+ continue;
58
+ }
59
+ const stem = STEMS.find((candidate) => word.startsWith(candidate));
60
+ if (stem) {
61
+ for (const term of RU_EN[stem]?.split(' ') ?? []) {
62
+ added.add(term);
63
+ }
64
+ }
65
+ }
66
+ return added.size > 0 ? `${query} ${[...added].join(' ')}` : query;
67
+ };
@@ -1,4 +1,5 @@
1
1
  import MiniSearch from 'minisearch';
2
+ import { withEnglishTerms } from './ru-en-terms.js';
2
3
  const REPO_WEIGHT = { mcp: 1.6, back: 1, front: 0.9 };
3
4
  const KIND_WEIGHT = {
4
5
  docs: 1.15,
@@ -69,7 +70,7 @@ export class KnowledgeIndex {
69
70
  }
70
71
  search(query, limit = 8) {
71
72
  const terms = query.split(/\s+/).filter((t) => t.length > 1);
72
- const raw = this.mini.search(meaningfulQuery(query));
73
+ const raw = this.mini.search(withEnglishTerms(meaningfulQuery(query)));
73
74
  const hits = [];
74
75
  for (const result of raw) {
75
76
  const chunk = this.byId.get(String(result.id));
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { AuditLog } from '../api/audit.js';
3
- import { buildUrl, normalizeBody, RequestBuildError } from '../api/client.js';
3
+ import { buildUrl, normalizeBody, RequestBuildError, unsupportedBodyFormat, } from '../api/client.js';
4
4
  import { checkLevel, decide } from '../api/policy.js';
5
5
  import { shapeResponse, summarizeTarget } from '../api/shape.js';
6
6
  import { errorResult, jsonResult } from './result.js';
@@ -40,6 +40,10 @@ export const registerApiCall = (server, getSession) => {
40
40
  hint: 'Operation ids come from cms_api_search — do not construct them by hand.',
41
41
  });
42
42
  }
43
+ const formatDenial = unsupportedBodyFormat(operation);
44
+ if (formatDenial) {
45
+ return errorResult(formatDenial, { opId, contentType: operation.body?.contentType });
46
+ }
43
47
  const normalizedBody = normalizeBody(body);
44
48
  const args = {
45
49
  ...(path ? { path } : {}),
@@ -141,6 +145,15 @@ export const registerApiCall = (server, getSession) => {
141
145
  status: result.status,
142
146
  truncated: shaped.truncated,
143
147
  body: shaped.body,
148
+ ...(operation.verifyWith
149
+ ? {
150
+ verifyWith: operation.verifyWith,
151
+ next: `This write is not confirmed by its status. Read it back with ` +
152
+ `${operation.verifyWith.opId} and check "${operation.verifyWith.check}" ` +
153
+ 'before reporting success.',
154
+ }
155
+ : {}),
156
+ ...(operation.silentNoOp ? { silentNoOp: operation.silentNoOp } : {}),
144
157
  });
145
158
  });
146
159
  };
@@ -1,5 +1,56 @@
1
1
  import { z } from 'zod';
2
+ import { unsupportedBodyFormat } from '../api/client.js';
2
3
  import { errorResult, jsonResult } from './result.js';
4
+ const sampleValue = (schema, example) => {
5
+ if (example !== undefined) {
6
+ return example;
7
+ }
8
+ if (schema.example !== undefined) {
9
+ return schema.example;
10
+ }
11
+ if (schema.enum && schema.enum.length > 0) {
12
+ return schema.enum[0];
13
+ }
14
+ switch (schema.type) {
15
+ case 'number':
16
+ case 'integer':
17
+ return 0;
18
+ case 'boolean':
19
+ return false;
20
+ case 'array':
21
+ return [];
22
+ case 'object':
23
+ return {};
24
+ default:
25
+ return `<${schema['x-source-type'] ?? schema.type ?? 'value'}>`;
26
+ }
27
+ };
28
+ const isWorthShowing = (param) => param.required ||
29
+ param.example !== undefined ||
30
+ param.schema.example !== undefined ||
31
+ param.schema.default !== undefined;
32
+ const argsFor = (params, location, filter) => {
33
+ const out = {};
34
+ for (const param of params) {
35
+ if (param.location !== location || !filter(param)) {
36
+ continue;
37
+ }
38
+ out[param.name] = sampleValue(param.schema, param.example);
39
+ }
40
+ return out;
41
+ };
42
+ const callExample = (operation) => {
43
+ const path = argsFor(operation.params, 'path', () => true);
44
+ const query = argsFor(operation.params, 'query', isWorthShowing);
45
+ return {
46
+ opId: operation.opId,
47
+ ...(Object.keys(path).length > 0 ? { path } : {}),
48
+ ...(Object.keys(query).length > 0 ? { query } : {}),
49
+ ...(operation.body?.schema.example !== undefined
50
+ ? { body: operation.body.schema.example }
51
+ : {}),
52
+ };
53
+ };
3
54
  export const registerApiDiscovery = (server, deps) => {
4
55
  const { catalog } = deps;
5
56
  server.registerTool('cms_api_search', {
@@ -22,10 +73,16 @@ export const registerApiDiscovery = (server, deps) => {
22
73
  ...(limit ? { limit } : {}),
23
74
  });
24
75
  if (hits.length === 0) {
76
+ const unexposed = catalog.unexposedMatches(query);
25
77
  return jsonResult({
26
78
  hits: [],
27
79
  tags: [...new Set(catalog.operations().map((o) => o.tag))].sort(),
28
- hint: 'Nothing matched. Try a bare entity name, or filter by one of the tags listed here.',
80
+ ...(unexposed.length > 0 ? { knownButNotExposed: unexposed } : {}),
81
+ hint: unexposed.length > 0
82
+ ? 'Nothing matched in this catalog, but the platform does know the names under ' +
83
+ '"knownButNotExposed" — this instance simply does not serve them, so no path ' +
84
+ 'you construct will reach them. Do not guess URLs: report them as unavailable here.'
85
+ : 'Nothing matched. Try a bare entity name, or filter by one of the tags listed here.',
29
86
  });
30
87
  }
31
88
  return jsonResult({
@@ -35,7 +92,7 @@ export const registerApiDiscovery = (server, deps) => {
35
92
  });
36
93
  server.registerTool('cms_api_describe', {
37
94
  title: 'Describe an Admin API operation',
38
- description: 'Full detail for one operation: path and query parameters, request-body schema, required permission, risk level, and whether it is permanently confirm-gated. Fields marked "x-loose": true could not be converted to a JSON Schema type — trust their example, not their type.',
95
+ description: 'Full detail for one operation: a ready-to-copy "example" call, path and query parameters, request-body schema, required permission, risk level, and whether it is permanently confirm-gated. Fields marked "x-loose": true could not be converted to a JSON Schema type — trust their example, not their type. Where the operation is known to answer success without doing the work, "silentNoOp" and "verifyWith" say so and name the read that proves it.',
39
96
  inputSchema: {
40
97
  opId: z.string().min(1).describe('Operation id from cms_api_search, e.g. "AdminPagesController_findAllRoot".'),
41
98
  },
@@ -43,18 +100,28 @@ export const registerApiDiscovery = (server, deps) => {
43
100
  }, ({ opId }) => {
44
101
  const operation = catalog.get(opId);
45
102
  if (!operation) {
103
+ const unexposed = catalog.unexposedMatches(opId);
46
104
  return errorResult(`Unknown opId "${opId}".`, {
47
105
  didYouMean: catalog.suggest(opId),
48
- hint: 'Operation ids come from cms_api_search do not construct them by hand.',
106
+ ...(unexposed.length > 0 ? { knownButNotExposed: unexposed } : {}),
107
+ hint: unexposed.length > 0
108
+ ? 'The platform knows this name, but this instance does not serve the operation, ' +
109
+ 'so it cannot be called from here by any path. Report it as unavailable.'
110
+ : 'Operation ids come from cms_api_search — do not construct them by hand.',
49
111
  });
50
112
  }
51
- const loose = [
52
- ...(operation.body?.schema.properties
53
- ? Object.entries(operation.body.schema.properties)
54
- .filter(([, schema]) => schema['x-loose'] === true)
55
- .map(([name, schema]) => ({ field: name, sourceType: schema['x-source-type'] }))
56
- : []),
57
- ];
113
+ const formatDenial = unsupportedBodyFormat(operation);
114
+ const properties = operation.body?.schema.properties;
115
+ const loose = properties
116
+ ? Object.entries(properties)
117
+ .filter(([, schema]) => schema['x-loose'] === true)
118
+ .map(([name, schema]) => ({ field: name, sourceType: schema['x-source-type'] }))
119
+ : [];
120
+ const mismatched = properties
121
+ ? Object.entries(properties)
122
+ .filter(([, schema]) => schema['x-example-mismatch'] !== undefined)
123
+ .map(([name, schema]) => ({ field: name, problem: schema['x-example-mismatch'] }))
124
+ : [];
58
125
  return jsonResult({
59
126
  opId: operation.opId,
60
127
  method: operation.method.toUpperCase(),
@@ -66,12 +133,41 @@ export const registerApiDiscovery = (server, deps) => {
66
133
  risk: operation.risk,
67
134
  alwaysConfirm: operation.alwaysConfirm,
68
135
  params: operation.params,
136
+ paramsByLocation: {
137
+ path: operation.params.filter((p) => p.location === 'path').map((p) => p.name),
138
+ query: operation.params.filter((p) => p.location === 'query').map((p) => p.name),
139
+ },
69
140
  body: operation.body ?? null,
141
+ example: callExample(operation),
70
142
  looseFields: loose,
143
+ ...(mismatched.length > 0
144
+ ? {
145
+ exampleMismatches: mismatched,
146
+ exampleMismatchHint: 'These fields declare one type and give an example of another. The example is ' +
147
+ 'the contract — the instance accepts what the example shows.',
148
+ }
149
+ : {}),
150
+ ...(operation.note ? { note: operation.note } : {}),
151
+ ...(operation.silentNoOp ? { silentNoOp: operation.silentNoOp } : {}),
152
+ ...(operation.verifyWith ? { verifyWith: operation.verifyWith } : {}),
153
+ ...(operation.body?.schema['x-unresolved'] === true
154
+ ? {
155
+ bodySchemaUnresolved: 'The document does not resolve this body schema, so the empty "properties" ' +
156
+ 'above means "unknown", not "no fields". Build the body from "example"; ' +
157
+ 'if there is none, ask the human rather than guessing.',
158
+ }
159
+ : {}),
160
+ ...(formatDenial ? { notExecutable: formatDenial } : {}),
71
161
  responseSummary: operation.responseSummary ?? null,
72
- next: operation.risk === 'read'
73
- ? 'Call it with cms_api_call { opId, path, query }.'
74
- : 'Search the knowledge base with cms_docs_search for this entity, then call cms_api_call with dryRun: true first.',
162
+ next: formatDenial
163
+ ? 'Do not call this operation report it as unavailable through MCP.'
164
+ : operation.risk === 'read'
165
+ ? 'Call it with cms_api_call, copying the "example" above.'
166
+ : operation.verifyWith
167
+ ? 'Search the knowledge base with cms_docs_search for this entity, call cms_api_call ' +
168
+ `with dryRun: true first, and afterwards read the result back with ` +
169
+ `${operation.verifyWith.opId} — a success status is not evidence here.`
170
+ : 'Search the knowledge base with cms_docs_search for this entity, then call cms_api_call with dryRun: true first.',
75
171
  });
76
172
  });
77
173
  };
@@ -16,7 +16,10 @@ export const registerDocs = (server, deps) => {
16
16
  if (hits.length === 0) {
17
17
  return jsonResult({
18
18
  hits: [],
19
- hint: 'Nothing matched. Try a module name (menus, orders, blocks, attributes) or an entity field name.',
19
+ hint: 'Nothing matched. The knowledge base is written in English ask in English, even ' +
20
+ 'when the conversation is not. Try a module name (menus, orders, blocks, ' +
21
+ 'attributes) or an entity field name. An empty result means this query found ' +
22
+ 'nothing, not that the CMS has no documentation for it.',
20
23
  });
21
24
  }
22
25
  return jsonResult({
@@ -1,3 +1,4 @@
1
+ import { ALWAYS_CONFIRM_PREFIXES } from '../api/operation-notes.js';
1
2
  import { textResult } from './result.js';
2
3
  const TAG_LIMIT = 24;
3
4
  export const renderGuide = (deps) => {
@@ -50,8 +51,19 @@ export const renderGuide = (deps) => {
50
51
  '## Hard limits',
51
52
  '',
52
53
  '- Admin API only. The Content and Developer APIs are deliberately not exposed.',
53
- '- `immutable-settings`, `admins`, `backups`, `modules`, `payments/webhook`, `settings-general`,',
54
- ' `system/captcha-keys` and `auth/logout/all-users` are permanently confirm-gated.',
54
+ `- Mutations under ${ALWAYS_CONFIRM_PREFIXES.map((p) => `\`${p}\``).join(', ')} are`,
55
+ ' permanently confirm-gated, at every allow level.',
56
+ '- **Request bodies are sent as JSON only.** An operation that declares another format —',
57
+ ' file upload is `multipart/form-data` — cannot be called through this server at all.',
58
+ ' `cms_api_describe` marks it `notExecutable`; no body shape will help. Upload files',
59
+ ' outside MCP and read `mcp/docs/api/files-and-uploads` first, because doing so gives up',
60
+ ' the confirmations, permission checks and `dryRun` this server provides.',
61
+ '- **The knowledge base is written in English.** Search it in English whatever language the',
62
+ ' conversation is in; an empty result is a failed query, not a missing document.',
63
+ '- A success status is not evidence that the write landed. Where an operation is known to',
64
+ ' answer success without doing the work, `cms_api_describe` says so under `silentNoOp` and',
65
+ ' names the read that proves it under `verifyWith`. Verify with the read the *consumer*',
66
+ ' uses, not the one you wrote to.',
55
67
  '- Responses are capped; overflow is reported as `_truncated`. Narrow the query instead.',
56
68
  ...(catalog.catalog.warnings.length > 0
57
69
  ? ['', '## Warnings', '', ...catalog.catalog.warnings.map((w) => `- ${w}`)]
@@ -26,6 +26,11 @@ export const registerWhoami = (server, getSession) => {
26
26
  swaggerHash: catalog.catalog.swaggerHash,
27
27
  builtAt: catalog.catalog.builtAt,
28
28
  knownPermissions: catalog.catalog.permissions.length,
29
+ coverage: {
30
+ unexposedOperations: catalog.catalog.coverage.unexposedOpIds.length,
31
+ permissionsWithoutOperation: catalog.catalog.coverage.permissionsWithoutOperation.length,
32
+ hint: 'This catalog is what the instance serves, not everything the platform has. When cms_api_search finds nothing, it reports whether the name exists but is unexposed — trust that over constructing a path.',
33
+ },
29
34
  warnings: catalog.catalog.warnings,
30
35
  },
31
36
  knowledge: {
@@ -1,25 +1,23 @@
1
1
  # Operating rules for the OneEntry Admin API
2
2
 
3
- Read this before your first write. Every rule here has broken a real payload, and each one links to the document that explains it in full.
4
-
5
- These rules are short on purpose. When one of them applies to what you are about to do, follow the pointer before you build the body.
3
+ Read this before your first write. Every rule here has broken a real payload; each links to the document explaining it in full. When one applies to what you are about to do, follow the pointer before you build the body.
6
4
 
7
5
  → `mcp/docs/server/doc-map` · `mcp/docs/server/payload-conventions`
8
6
 
9
7
  ## The loop you must follow
10
8
 
11
9
  1. `cms_guide` once, at the start.
12
- 2. `cms_docs_search` for the entity you are about to touch — **before** you write a payload, not after a 400.
13
- 3. `cms_api_search` to find the operation, then `cms_api_describe` for its exact shape.
10
+ 2. `cms_docs_search` for the entity you are about to touch — **before** the payload, not after a 400.
11
+ 3. `cms_api_search` for the operation, then `cms_api_describe` for its shape.
14
12
  4. `cms_api_call` with `dryRun: true` for anything that mutates, then again with the confirm token if one was issued.
15
13
 
16
- Never invent a path, an operation id or a body key. `cms_api_search` is the only authority on what exists; the documents are the authority on what the values mean.
14
+ Never invent a path, an operation id or a body key. `cms_api_search` is the only authority on what exists.
17
15
 
18
16
  ## Trust the example not the type
19
17
 
20
- The OpenAPI document this catalog is built from contains field types that are not JSON Schema types. `cms_api_describe` normalises what it can and marks the rest `"x-loose": true`, with the original under `x-source-type`.
18
+ The API document carries field types that are not JSON Schema types. `cms_api_describe` normalises what it can and marks the rest `"x-loose": true`.
21
19
 
22
- For a loose field, **the `example` is the contract**. Copy its shape. Client-side validation of your body is advisory only — the instance is the real validator, so a call is never blocked because a loose field could not be checked.
20
+ For those, **the `example` is the contract** copy its shape. Validation here is advisory; the instance is the real validator, so a call is never blocked over a field we could not check. A field flagged `x-example-mismatch` contradicts its own type, and the example wins there too.
23
21
 
24
22
  → `mcp/docs/server/cms-api-describe#loose-fields`
25
23
 
@@ -31,7 +29,7 @@ Titles and descriptive content live under `localizeInfos`, keyed by locale code:
31
29
  { "localizeInfos": { "en_US": { "title": "Summer sale" } } }
32
30
  ```
33
31
 
34
- Required when creating a product, and effectively required on pages. Do not hardcode `en_US`: read the active locale codes from the instance first, and write every locale the instance has active if the content is meant to be visible in all of them.
32
+ Required on a product, effectively required on a page. Do not hardcode `en_US`: read the active locales with `AdminLocalesController_findAllActive` and write every one the content is meant to appear in.
35
33
 
36
34
  → `mcp/docs/api/locales`
37
35
 
@@ -43,79 +41,90 @@ Required when creating a product, and effectively required on pages. Do not hard
43
41
  { "attributesSets": { "en_US": { "string_id42": "SKU-1" } } }
44
42
  ```
45
43
 
46
- The inner key is `<attribute type>_id<attribute id>`, taken from the attribute set the entity belongs to. Read the set before you build the body.
47
-
48
- A flat single-level map is accepted and stored empty. The call answers 201 and the attributes are silently missing, so always read the entity back by id after creating it.
44
+ The inner key is `<attribute type>_id<attribute id>`, read from the entity's attribute set. A flat one-level map is accepted, answers 201 and stores nothing — so read the entity back by id after creating it.
49
45
 
50
46
  → `mcp/docs/api/attribute-sets`
51
47
 
52
48
  ## Positions are lexorank or numeric depending on the endpoint
53
49
 
54
- Ordering is a lexorank **string** on parent-scoped Admin operations, and a **number** on flat lists and the Content API. Never sort a lexorank value numerically, and never reorder by patching the field directly — use the dedicated position operations of that entity.
50
+ Ordering is a lexorank **string** on parent-scoped Admin operations and a **number** on flat lists and the Content API. Never sort a lexorank numerically, and never reorder by patching the field — use that entity's position operation.
55
51
 
56
52
  → `mcp/docs/server/payload-conventions#position-is-a-lexorank-string-or-a-number`
57
53
 
58
54
  ## A read straight after a write can lag
59
55
 
60
- Reading an entity **by id** shows your write immediately. List and search responses may not, for a few seconds.
56
+ Reading an entity **by id** shows your write immediately. Lists and searches may not, for a few seconds.
61
57
 
62
- If a list does not yet show what you just created, re-read by id to confirm it exists. **Never repeat the write** — you will create a duplicate that consumes instance quota and has to be cleaned up by hand.
58
+ If a list does not show what you just created, re-read by id. **Never repeat the write** — you get a duplicate that consumes quota and has to be cleaned up by hand. And never swallow a failed read into an empty result: "empty" and "malformed" then look alike, and the next run recreates everything.
63
59
 
64
- ## Prefer marker over id
60
+ ## A 200 means accepted not applied
61
+
62
+ Several endpoints take the body as one opaque value, so a wrong **shape** is stored as happily as a right one and the answer is still `200`.
63
+
64
+ Confirm a write by its effect, and **through the read its consumer uses**. The raw record echoes your input back, wrong shape included, while the projection a site receives shows nothing — verifying through the endpoint you wrote to proves little.
65
+
66
+ → `mcp/docs/api/silent-no-ops`
67
+
68
+ ## An omitted field can mean clear it
69
+
70
+ Most updates merge. A few apply an omitted field as **"set it to nothing"**, and still answer `200`: a page without `parentId` moves to the root, a block without `blockPages` detaches from every page, a menu item without its parent reference flattens, a user without `formData` loses it.
71
+
72
+ Products, `generalTypeId` and `attributeSetId` merge, so "PUT always replaces" is the wrong lesson. Read, change what you meant to, send it back whole — then check the fields that were **not** in your body.
65
73
 
66
- Blocks, forms, menus, templates, general types and modules are addressed by a `marker` or `identifier` that is stable across instances. A numeric `id` is not: an id taken from one instance is meaningless on another, and `404 Not found` on an id you were given is usually that mistake.
74
+ `mcp/docs/server/payload-conventions#an-omitted-field-can-mean-clear-it`
67
75
 
68
- Where an operation accepts either, use the marker.
76
+ ## Prefer marker over id
77
+
78
+ Blocks, forms, menus, templates, general types and modules are addressed by a `marker` or `identifier` stable across instances. A numeric `id` is not, and a `404` on an id you were given is usually that. Where an operation accepts either, use the marker.
69
79
 
70
80
  ## Baseline data already exists do not recreate it
71
81
 
72
- Every instance arrives populated: a guest user group with its content-API permissions, the admin modules, the general types, the attribute set types and field types, the locales, the dynamic block types and their default templates, and the singleton settings records.
82
+ Every instance arrives populated: user groups, modules, general types, attribute set and field types, locales, block types with their default templates, the singleton settings.
83
+
84
+ **List first, create second.** Some duplicates fail loudly, which is harmless. The dangerous ones — user groups, modules, attribute set types, settings — **succeed silently**.
73
85
 
74
- **List first, create second.** Some duplicates fail loudly, which is harmless. The dangerous ones user groups, modules, attribute set types, settings records **succeed silently** and leave a shadow record that nothing references.
86
+ Two lists are the exception and start **empty**: product statuses and template previews. Nothing seeds them, nothing reports their absence, and without them no product is sellable and no upload gets a preview. There, create.
75
87
 
76
88
  → `mcp/docs/api/baseline-data`
77
89
 
78
90
  ## Never touch these without a human saying so
79
91
 
80
- Mutations under `immutable-settings`, `admins`, `backups`, `modules`, `payments/webhook`, `settings-general`, `system/captcha-keys` and `auth/logout/all-users` are permanently confirm-gated by this server, at every allow level.
92
+ Mutations on the instance's own configuration — admins, modules, backups, settings are permanently confirm-gated at every allow level. `cms_guide` prints the exact list.
81
93
 
82
- The gate is not a suggestion. State what you intend to change, show the human the `target` the dry run returned, and wait for them to say yes in this conversation.
94
+ The gate is not a suggestion. State what you intend to change, show the human the dry run's `target`, and wait for a yes here.
83
95
 
84
96
  → `mcp/docs/server/allow-levels#paths-that-are-always-confirm-gated`
85
97
 
86
98
  ## Permissions are checked before the request is sent
87
99
 
88
- Each operation declares the permission it requires, and this server refuses locally when the authenticated admin does not hold it. Nothing is sent, so nothing changed.
89
-
90
- A permission refusal means **ask for the grant** and stop. Retrying cannot succeed, and neither can a different operation that needs the same permission.
100
+ Each operation declares the permission it needs, and this server refuses locally when the admin does not hold it. Nothing is sent, so nothing changed. A refusal means **ask for the grant** and stop — no retry and no sibling operation gets past it.
91
101
 
92
102
  → `mcp/docs/api/admins-and-permissions`
93
103
 
94
104
  ## Truncated responses are deliberate
95
105
 
96
- Large responses come back with a `_truncated` envelope reporting what was shown and what the total was. That is this server capping what it hands you, not the API limiting itself.
97
-
98
- Do not retry hoping for more. Narrow the request with the operation's own `limit`, `offset` and filter parameters.
106
+ A large response comes back with a `_truncated` envelope reporting what was shown and what the total was. That is this server capping what it hands you, not the API. Do not retry hoping for more — narrow the request with the operation's own `limit`, `offset` and filters.
99
107
 
100
108
  → `mcp/docs/server/response-shaping`
101
109
 
102
110
  ## Operations with a single supported path
103
111
 
104
- For the calls below, one route works and the obvious alternative does not. Use the supported one directly — a different body or a retry on the other route will not help.
112
+ One route works and the obvious alternative does not. Use it directly.
105
113
 
106
- - **Create a form** — send the payload wrapped in `newForm`. The OpenAPI document shows the fields unwrapped; the wrapped form is the one the endpoint accepts.
107
- - **Update a product** — always include `blocks` (send `blocks: []` when you have nothing to set), and never include `forms`.
108
- - **Read locale codes** — use `AdminLocalesController_findAllActive`.
109
- - **List products** — the list operation is `POST /products/all`; there is no `GET /products`.
110
- - **Export operations** — `orders.export`, `payments.export` and `users.export` are not grantable on current instances. Treat them as unavailable and tell the human rather than retrying.
114
+ - **Create a form** — wrap in `newForm`, with `type` (`data` for a contact form) though the schema omits it.
115
+ - **Replace an attribute set schema** — send the schema object itself. Wrapped as `{ "schema": }` it answers 200 and destroys it.
116
+ - **Update a product** — include `blocks` (`[]` if nothing to set), never `forms`.
117
+ - **Create a menu** — with `pagesIds: []`, attaching pages later. Non-empty on create answers 500.
118
+ - **Set a product status** — `statusId` in the product update. Bulk `set-status` takes it in a field named `id`, and given `statusId` it nulls the status and answers `201 true`.
111
119
 
112
- If a call outside this list answers 5xx, stop and report it with the operation id and the request you sent. Do not retry it with a modified body.
120
+ ## List products and other calls whose input is split
113
121
 
114
- ## Where to look next
122
+ `POST /products/all` is the only way to list products, and its input is split: **paging and `langCode` go in the query, the body is an array of filters** — `[]` for none. Sent in the body they are ignored, and the 400 blames `langCode` for a value you never sent.
123
+
124
+ Copy `example` from `cms_api_describe` whole: separate `params` and `body` schemas do not assemble into an obvious call, and `example` is already one.
115
125
 
116
- With the operation hints removed from `cms_api_describe`, `cms_docs_search` and the map below are how you find anything.
126
+ A 5xx outside these two lists means stop and report it, with the operation id and the request.
127
+
128
+ ## Where to look next
117
129
 
118
- - `mcp/docs/server/doc-map` every document, with when to read it
119
- - `mcp/docs/server/payload-conventions` — the rules above, in full
120
- - `mcp/docs/api/baseline-data` — what already exists on your instance
121
- - `mcp/docs/server/errors-and-refusals` — what a specific error means and what to do next
130
+ `mcp/docs/server/doc-map` lists every document with a reason to read it. The corpus is **English** — search it in English whatever language you answer in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneentry/mcp-platform-server",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -50,6 +50,7 @@
50
50
  "start": "node dist/bin/cli.js",
51
51
  "dev": "tsx src/bin/cli.ts",
52
52
  "sync:permissions": "tsx build/sync-permissions.ts",
53
+ "docs:audit": "tsx build/docs-audit.ts",
53
54
  "publish:knowledge": "node -e \"console.error('REFUSED: the knowledge repository is hand-authored. Bulk-copying internal docs into it is prohibited.'); process.exit(1)\"",
54
55
  "lint": "eslint \"{src,build,__tests__}/**/*.ts\"",
55
56
  "test": "vitest run",