@oneentry/mcp-platform-server 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -19,6 +19,11 @@ Options
19
19
  --request-timeout <ms> per-request timeout, default 30000
20
20
  --max-response-bytes <n> response cap handed to the model, default 24576
21
21
 
22
+ File uploads (cms_upload_file, cms_import_file_from_url)
23
+ --upload-root <dir> directory cms_upload_file may read from, default the working directory
24
+ --upload-max-bytes <n> size limit of one upload, default 26214400
25
+ --upload-allowed-hosts a,b hosts cms_import_file_from_url may fetch from; required in remote mode
26
+
22
27
  Knowledge (documentation is fetched from GitHub, not bundled)
23
28
  --knowledge-repo <o/n> knowledge repository, default ONEENTRY-PLATFORM/oneentry-platform-rules
24
29
  --knowledge-ref <ref> branch, tag or commit, default main
@@ -31,7 +36,8 @@ Environment
31
36
  ONEENTRY_CMS_LOGIN, ONEENTRY_CMS_PASSWORD, ONEENTRY_CMS_TOKEN, ONEENTRY_CMS_BASE_URL, ONEENTRY_MCP_ALLOW,
32
37
  ONEENTRY_MCP_AUDIT_PATH, ONEENTRY_MCP_PORT, ONEENTRY_MCP_HOST, ONEENTRY_MCP_CACHE_DIR,
33
38
  ONEENTRY_MCP_KNOWLEDGE_REPO, ONEENTRY_MCP_KNOWLEDGE_REF, ONEENTRY_MCP_KNOWLEDGE_PATH,
34
- ONEENTRY_MCP_KNOWLEDGE_TTL_MS, ONEENTRY_MCP_OFFLINE, ONEENTRY_GITHUB_TOKEN
39
+ ONEENTRY_MCP_KNOWLEDGE_TTL_MS, ONEENTRY_MCP_OFFLINE, ONEENTRY_GITHUB_TOKEN,
40
+ ONEENTRY_MCP_UPLOAD_ROOT, ONEENTRY_MCP_UPLOAD_MAX_BYTES, ONEENTRY_MCP_UPLOAD_ALLOWED_HOSTS
35
41
  `;
36
42
  const main = async () => {
37
43
  const argv = process.argv.slice(2);
@@ -26,6 +26,20 @@ export declare const KnowledgeConfigSchema: z.ZodObject<{
26
26
  token?: string | undefined;
27
27
  }>;
28
28
  export type KnowledgeSettings = z.infer<typeof KnowledgeConfigSchema>;
29
+ export declare const UploadConfigSchema: z.ZodObject<{
30
+ root: z.ZodString;
31
+ maxBytes: z.ZodNumber;
32
+ allowedHosts: z.ZodArray<z.ZodString, "many">;
33
+ }, "strip", z.ZodTypeAny, {
34
+ root: string;
35
+ maxBytes: number;
36
+ allowedHosts: string[];
37
+ }, {
38
+ root: string;
39
+ maxBytes: number;
40
+ allowedHosts: string[];
41
+ }>;
42
+ export type UploadSettings = z.infer<typeof UploadConfigSchema>;
29
43
  export declare const ConfigSchema: z.ZodObject<{
30
44
  mode: z.ZodEnum<["local", "remote"]>;
31
45
  baseUrl: z.ZodString;
@@ -59,6 +73,19 @@ export declare const ConfigSchema: z.ZodObject<{
59
73
  auditPath: z.ZodOptional<z.ZodString>;
60
74
  requestTimeoutMs: z.ZodNumber;
61
75
  maxResponseBytes: z.ZodNumber;
76
+ upload: z.ZodObject<{
77
+ root: z.ZodString;
78
+ maxBytes: z.ZodNumber;
79
+ allowedHosts: z.ZodArray<z.ZodString, "many">;
80
+ }, "strip", z.ZodTypeAny, {
81
+ root: string;
82
+ maxBytes: number;
83
+ allowedHosts: string[];
84
+ }, {
85
+ root: string;
86
+ maxBytes: number;
87
+ allowedHosts: string[];
88
+ }>;
62
89
  http: z.ZodObject<{
63
90
  port: z.ZodNumber;
64
91
  host: z.ZodString;
@@ -87,6 +114,11 @@ export declare const ConfigSchema: z.ZodObject<{
87
114
  cacheDir: string;
88
115
  requestTimeoutMs: number;
89
116
  maxResponseBytes: number;
117
+ upload: {
118
+ root: string;
119
+ maxBytes: number;
120
+ allowedHosts: string[];
121
+ };
90
122
  http: {
91
123
  port: number;
92
124
  host: string;
@@ -111,6 +143,11 @@ export declare const ConfigSchema: z.ZodObject<{
111
143
  cacheDir: string;
112
144
  requestTimeoutMs: number;
113
145
  maxResponseBytes: number;
146
+ upload: {
147
+ root: string;
148
+ maxBytes: number;
149
+ allowedHosts: string[];
150
+ };
114
151
  http: {
115
152
  port: number;
116
153
  host: string;
@@ -12,6 +12,11 @@ export const KnowledgeConfigSchema = z.object({
12
12
  offline: z.boolean(),
13
13
  token: z.string().min(1).optional(),
14
14
  });
15
+ export const UploadConfigSchema = z.object({
16
+ root: z.string().min(1),
17
+ maxBytes: z.number().int().positive(),
18
+ allowedHosts: z.array(z.string()),
19
+ });
15
20
  export const ConfigSchema = z.object({
16
21
  mode: ServerModeSchema,
17
22
  baseUrl: z.string().url(),
@@ -24,6 +29,7 @@ export const ConfigSchema = z.object({
24
29
  auditPath: z.string().optional(),
25
30
  requestTimeoutMs: z.number().int().positive(),
26
31
  maxResponseBytes: z.number().int().positive(),
32
+ upload: UploadConfigSchema,
27
33
  http: z.object({
28
34
  port: z.number().int().min(1).max(65535),
29
35
  host: z.string().min(1),
@@ -40,6 +46,7 @@ const DEFAULTS = {
40
46
  knowledgeRepo: 'ONEENTRY-PLATFORM/oneentry-platform-rules',
41
47
  knowledgeRef: 'main',
42
48
  knowledgeTtlMs: 3_600_000,
49
+ uploadMaxBytes: 26_214_400,
43
50
  };
44
51
  export const parseFlags = (argv) => {
45
52
  const flags = {};
@@ -132,6 +139,7 @@ export const loadConfig = (argv, env = process.env, cwd = process.cwd()) => {
132
139
  const pickKnowledge = (flagKey, envKey, fileKey) => asString(flags[flagKey]) ?? asString(env[envKey]) ?? knowledgeFile(fileKey);
133
140
  const mode = flags['http'] === true || asString(flags['http']) ? 'remote' : 'local';
134
141
  const originsRaw = pick('allowed-origins', 'ONEENTRY_MCP_ALLOWED_ORIGINS', 'allowedOrigins');
142
+ const uploadHostsRaw = pick('upload-allowed-hosts', 'ONEENTRY_MCP_UPLOAD_ALLOWED_HOSTS', 'uploadAllowedHosts');
135
143
  const candidate = {
136
144
  mode,
137
145
  baseUrl: normalizeBaseUrl(pick('base-url', 'ONEENTRY_CMS_BASE_URL', 'baseUrl') ?? DEFAULTS.baseUrl),
@@ -153,6 +161,13 @@ export const loadConfig = (argv, env = process.env, cwd = process.cwd()) => {
153
161
  auditPath: pick('audit', 'ONEENTRY_MCP_AUDIT_PATH', 'auditPath'),
154
162
  requestTimeoutMs: asNumber(pick('request-timeout', 'ONEENTRY_MCP_REQUEST_TIMEOUT_MS', 'requestTimeoutMs'), DEFAULTS.requestTimeoutMs),
155
163
  maxResponseBytes: asNumber(pick('max-response-bytes', 'ONEENTRY_MCP_MAX_RESPONSE_BYTES', 'maxResponseBytes'), DEFAULTS.maxResponseBytes),
164
+ upload: {
165
+ root: pick('upload-root', 'ONEENTRY_MCP_UPLOAD_ROOT', 'uploadRoot') ?? cwd,
166
+ maxBytes: asNumber(pick('upload-max-bytes', 'ONEENTRY_MCP_UPLOAD_MAX_BYTES', 'uploadMaxBytes'), DEFAULTS.uploadMaxBytes),
167
+ allowedHosts: uploadHostsRaw
168
+ ? uploadHostsRaw.split(',').map((host) => host.trim().toLowerCase()).filter(Boolean)
169
+ : [],
170
+ },
156
171
  http: {
157
172
  port: asNumber(pick('port', 'ONEENTRY_MCP_PORT', 'port'), DEFAULTS.httpPort),
158
173
  host: pick('host', 'ONEENTRY_MCP_HOST', 'host') ?? DEFAULTS.httpHost,
@@ -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));
package/dist/server.js CHANGED
@@ -9,6 +9,7 @@ import { registerApiCall } from './tools/api-call.js';
9
9
  import { registerApiDiscovery } from './tools/api-discovery.js';
10
10
  import { registerDocs } from './tools/docs.js';
11
11
  import { registerGuide } from './tools/guide.js';
12
+ import { registerUpload } from './tools/upload.js';
12
13
  import { registerWhoami } from './tools/whoami.js';
13
14
  const SERVER_VERSION = (() => {
14
15
  try {
@@ -58,6 +59,7 @@ export const createServer = (deps, getSession) => {
58
59
  const server = new McpServer({ name: 'oneentry-mcp-platform', version: SERVER_VERSION }, {
59
60
  instructions: 'OneEntry CMS Admin API. Call cms_guide first, then cms_docs_read on "mcp/operating-rules" before any write. ' +
60
61
  'Discover endpoints with cms_api_search, get payload shapes with cms_api_describe, execute with cms_api_call. ' +
62
+ 'Files are the exception: cms_api_call cannot send multipart, so upload with cms_upload_file or cms_import_file_from_url. ' +
61
63
  'Never construct paths or operation ids by hand.',
62
64
  });
63
65
  registerGuide(server, deps);
@@ -65,5 +67,6 @@ export const createServer = (deps, getSession) => {
65
67
  registerDocs(server, deps);
66
68
  registerApiDiscovery(server, deps);
67
69
  registerApiCall(server, getSession);
70
+ registerUpload(server, getSession);
68
71
  return server;
69
72
  };
@@ -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 a body shape has been verified on a live instance it is returned as "curatedBody", which wins over the document\'s own example wherever the two disagree. 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,51 @@ 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.curatedExample !== undefined
151
+ ? {
152
+ curatedBody: operation.curatedExample,
153
+ curatedBodySource: 'Verified on a live instance. Where this disagrees with "example" or with the ' +
154
+ 'body schema above, this is the shape the instance and the admin panel read — ' +
155
+ 'copy it and read "note" for what each part of it prevents.',
156
+ }
157
+ : {}),
158
+ ...(operation.note ? { note: operation.note } : {}),
159
+ ...(operation.silentNoOp ? { silentNoOp: operation.silentNoOp } : {}),
160
+ ...(operation.verifyWith ? { verifyWith: operation.verifyWith } : {}),
161
+ ...(operation.body?.schema['x-unresolved'] === true
162
+ ? {
163
+ bodySchemaUnresolved: 'The document does not resolve this body schema, so the empty "properties" ' +
164
+ 'above means "unknown", not "no fields". Build the body from "example"; ' +
165
+ 'if there is none, ask the human rather than guessing.',
166
+ }
167
+ : {}),
168
+ ...(formatDenial ? { notExecutable: formatDenial } : {}),
71
169
  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.',
170
+ next: formatDenial
171
+ ? operation.body?.contentType === 'multipart/form-data'
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.'
174
+ : operation.risk === 'read'
175
+ ? 'Call it with cms_api_call, copying the "example" above.'
176
+ : operation.verifyWith
177
+ ? 'Search the knowledge base with cms_docs_search for this entity, call cms_api_call ' +
178
+ `with dryRun: true first, and afterwards read the result back with ` +
179
+ `${operation.verifyWith.opId} — a success status is not evidence here.`
180
+ : 'Search the knowledge base with cms_docs_search for this entity, then call cms_api_call with dryRun: true first.',
75
181
  });
76
182
  });
77
183
  };
@@ -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) => {
@@ -39,7 +40,10 @@ export const renderGuide = (deps) => {
39
40
  '2. `cms_docs_search` — find the reference doc for the entity you are touching;',
40
41
  ' `mcp/docs/server/doc-map` lists every document with a one-line "read this when".',
41
42
  '3. `cms_api_search` → `cms_api_describe` — get the real operation and its payload shape.',
42
- '4. `cms_api_call` `dryRun: true` first for anything that mutates.',
43
+ ' Where `cms_api_describe` returns `curatedBody`, that shape was verified on a live instance',
44
+ ' and wins over the document\'s own example.',
45
+ '4. `cms_api_call` — `dryRun: true` first for anything that mutates. Files go through',
46
+ ' `cms_upload_file` or `cms_import_file_from_url` instead.',
43
47
  '',
44
48
  'Never invent a path: `cms_api_search` is the only authority on what exists.',
45
49
  '',
@@ -50,8 +54,20 @@ export const renderGuide = (deps) => {
50
54
  '## Hard limits',
51
55
  '',
52
56
  '- 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.',
57
+ `- Mutations under ${ALWAYS_CONFIRM_PREFIXES.map((p) => `\`${p}\``).join(', ')} are`,
58
+ ' permanently confirm-gated, at every allow level.',
59
+ '- **`cms_api_call` sends JSON only.** The file upload endpoint wants `multipart/form-data`,',
60
+ ' so it has two tools of its own: `cms_upload_file` for a file on the machine running this',
61
+ ' server, `cms_import_file_from_url` for one this server downloads. Both need',
62
+ ' `--allow=write`, both are audited, both support `dryRun`, and both refuse a source outside',
63
+ ' the operator\'s bounds. Pass `template` — the numeric id of a `/template-previews` record —',
64
+ ' or the file is stored with no preview and nothing reports it.',
65
+ '- **The knowledge base is written in English.** Search it in English whatever language the',
66
+ ' conversation is in; an empty result is a failed query, not a missing document.',
67
+ '- A success status is not evidence that the write landed. Where an operation is known to',
68
+ ' answer success without doing the work, `cms_api_describe` says so under `silentNoOp` and',
69
+ ' names the read that proves it under `verifyWith`. Verify with the read the *consumer*',
70
+ ' uses, not the one you wrote to.',
55
71
  '- Responses are capped; overflow is reported as `_truncated`. Narrow the query instead.',
56
72
  ...(catalog.catalog.warnings.length > 0
57
73
  ? ['', '## Warnings', '', ...catalog.catalog.warnings.map((w) => `- ${w}`)]
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Session } from '../session.js';
3
+ export declare const registerUpload: (server: McpServer, getSession: () => Session) => void;