@oneentry/mcp-platform-server 0.1.5 → 0.1.7

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.
Files changed (68) hide show
  1. package/README.md +29 -2
  2. package/data/permissions.json +2 -1
  3. package/dist/api/build-catalog.js +1 -0
  4. package/dist/api/client.d.ts +6 -0
  5. package/dist/api/client.js +57 -8
  6. package/dist/api/operation-notes.d.ts +1 -0
  7. package/dist/api/operation-notes.js +258 -10
  8. package/dist/api/types.d.ts +1 -0
  9. package/dist/api/upload.d.ts +23 -0
  10. package/dist/api/upload.js +189 -0
  11. package/dist/bin/cli.js +7 -1
  12. package/dist/config/config.d.ts +37 -0
  13. package/dist/config/config.js +15 -0
  14. package/dist/server.js +3 -0
  15. package/dist/tools/api-discovery.js +12 -2
  16. package/dist/tools/guide.js +10 -6
  17. package/dist/tools/upload.d.ts +3 -0
  18. package/dist/tools/upload.js +229 -0
  19. package/knowledge/operating-rules.md +34 -36
  20. package/package.json +3 -6
  21. package/dist/api/audit.d.ts.map +0 -1
  22. package/dist/api/audit.js.map +0 -1
  23. package/dist/api/auth.d.ts.map +0 -1
  24. package/dist/api/auth.js.map +0 -1
  25. package/dist/api/catalog.d.ts.map +0 -1
  26. package/dist/api/catalog.js.map +0 -1
  27. package/dist/api/client.d.ts.map +0 -1
  28. package/dist/api/client.js.map +0 -1
  29. package/dist/api/policy.d.ts.map +0 -1
  30. package/dist/api/policy.js.map +0 -1
  31. package/dist/api/shape.d.ts.map +0 -1
  32. package/dist/api/shape.js.map +0 -1
  33. package/dist/api/types.d.ts.map +0 -1
  34. package/dist/api/types.js.map +0 -1
  35. package/dist/bin/cli.d.ts.map +0 -1
  36. package/dist/bin/cli.js.map +0 -1
  37. package/dist/config/config.d.ts.map +0 -1
  38. package/dist/config/config.js.map +0 -1
  39. package/dist/index.d.ts.map +0 -1
  40. package/dist/index.js.map +0 -1
  41. package/dist/knowledge/chunk.d.ts.map +0 -1
  42. package/dist/knowledge/chunk.js.map +0 -1
  43. package/dist/knowledge/loader.d.ts.map +0 -1
  44. package/dist/knowledge/loader.js.map +0 -1
  45. package/dist/knowledge/search.d.ts.map +0 -1
  46. package/dist/knowledge/search.js.map +0 -1
  47. package/dist/knowledge/types.d.ts.map +0 -1
  48. package/dist/knowledge/types.js.map +0 -1
  49. package/dist/server.d.ts.map +0 -1
  50. package/dist/server.js.map +0 -1
  51. package/dist/session.d.ts.map +0 -1
  52. package/dist/session.js.map +0 -1
  53. package/dist/tools/api-call.d.ts.map +0 -1
  54. package/dist/tools/api-call.js.map +0 -1
  55. package/dist/tools/api-discovery.d.ts.map +0 -1
  56. package/dist/tools/api-discovery.js.map +0 -1
  57. package/dist/tools/docs.d.ts.map +0 -1
  58. package/dist/tools/docs.js.map +0 -1
  59. package/dist/tools/guide.d.ts.map +0 -1
  60. package/dist/tools/guide.js.map +0 -1
  61. package/dist/tools/result.d.ts.map +0 -1
  62. package/dist/tools/result.js.map +0 -1
  63. package/dist/tools/whoami.d.ts.map +0 -1
  64. package/dist/tools/whoami.js.map +0 -1
  65. package/dist/transports/http.d.ts.map +0 -1
  66. package/dist/transports/http.js.map +0 -1
  67. package/dist/transports/stdio.d.ts.map +0 -1
  68. package/dist/transports/stdio.js.map +0 -1
@@ -0,0 +1,189 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { basename, extname, isAbsolute, relative, resolve } from 'node:path';
4
+ export class UploadSourceError extends Error {
5
+ }
6
+ const CONTENT_TYPES = {
7
+ '.png': 'image/png',
8
+ '.jpg': 'image/jpeg',
9
+ '.jpeg': 'image/jpeg',
10
+ '.gif': 'image/gif',
11
+ '.webp': 'image/webp',
12
+ '.svg': 'image/svg+xml',
13
+ '.avif': 'image/avif',
14
+ '.ico': 'image/x-icon',
15
+ '.pdf': 'application/pdf',
16
+ '.mp4': 'video/mp4',
17
+ '.webm': 'video/webm',
18
+ '.mov': 'video/quicktime',
19
+ '.mp3': 'audio/mpeg',
20
+ '.zip': 'application/zip',
21
+ '.csv': 'text/csv',
22
+ '.json': 'application/json',
23
+ '.txt': 'text/plain',
24
+ };
25
+ export const contentTypeOf = (filename) => CONTENT_TYPES[extname(filename).toLowerCase()] ?? 'application/octet-stream';
26
+ export const resolveUploadOperation = (catalog) => catalog
27
+ .operations()
28
+ .find((operation) => operation.method === 'post' && operation.body?.contentType === 'multipart/form-data');
29
+ const asMegabytes = (bytes) => `${(bytes / 1_048_576).toFixed(1)} MB`;
30
+ export const readLocalUpload = async (params) => {
31
+ const { path, root, maxBytes } = params;
32
+ const requested = isAbsolute(path) ? path : resolve(root, path);
33
+ let realRoot;
34
+ try {
35
+ realRoot = await realpath(root);
36
+ }
37
+ catch {
38
+ throw new UploadSourceError(`The upload root "${root}" does not exist. Point --upload-root at a directory that holds ` +
39
+ 'the files to upload.');
40
+ }
41
+ let real;
42
+ try {
43
+ real = await realpath(requested);
44
+ }
45
+ catch {
46
+ throw new UploadSourceError(`No file at "${requested}". Paths are resolved against the upload root "${realRoot}".`);
47
+ }
48
+ const inside = relative(realRoot, real);
49
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
50
+ throw new UploadSourceError(`"${requested}" resolves to "${real}", which is outside the upload root "${realRoot}". ` +
51
+ 'Only files under that root can be uploaded — move the file there or start the server ' +
52
+ 'with a different --upload-root.');
53
+ }
54
+ const info = await stat(real);
55
+ if (!info.isFile()) {
56
+ throw new UploadSourceError(`"${real}" is not a regular file. Upload one file per call.`);
57
+ }
58
+ if (info.size === 0) {
59
+ throw new UploadSourceError(`"${real}" is empty. An empty upload consumes quota and stores nothing useful.`);
60
+ }
61
+ if (info.size > maxBytes) {
62
+ throw new UploadSourceError(`"${real}" is ${asMegabytes(info.size)}, over the ${asMegabytes(maxBytes)} upload limit. ` +
63
+ 'Raise --upload-max-bytes deliberately or upload a smaller file.');
64
+ }
65
+ const filename = basename(real);
66
+ return { bytes: await readFile(real), filename, contentType: contentTypeOf(filename) };
67
+ };
68
+ const isPrivateAddress = (address, family) => {
69
+ if (family === 6) {
70
+ const normalized = address.toLowerCase().split('%')[0] ?? '';
71
+ if (normalized === '::1' || normalized === '::') {
72
+ return true;
73
+ }
74
+ if (/^f[cd]/.test(normalized) || normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
75
+ normalized.startsWith('fea') || normalized.startsWith('feb')) {
76
+ return true;
77
+ }
78
+ const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
79
+ if (dotted?.[1] !== undefined) {
80
+ return isPrivateAddress(dotted[1], 4);
81
+ }
82
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
83
+ if (hex?.[1] !== undefined && hex[2] !== undefined) {
84
+ const high = Number.parseInt(hex[1], 16);
85
+ const low = Number.parseInt(hex[2], 16);
86
+ const octets = [high >> 8, high & 0xff, low >> 8, low & 0xff];
87
+ return isPrivateAddress(octets.join('.'), 4);
88
+ }
89
+ return false;
90
+ }
91
+ const parts = address.split('.').map(Number);
92
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
93
+ return true;
94
+ }
95
+ const [a = 0, b = 0] = parts;
96
+ return (a === 0 ||
97
+ a === 10 ||
98
+ a === 127 ||
99
+ (a === 100 && b >= 64 && b <= 127) ||
100
+ (a === 169 && b === 254) ||
101
+ (a === 172 && b >= 16 && b <= 31) ||
102
+ (a === 192 && b === 168) ||
103
+ (a === 198 && (b === 18 || b === 19)) ||
104
+ a >= 224);
105
+ };
106
+ const assertPublicUrl = async (raw, allowedHosts) => {
107
+ let url;
108
+ try {
109
+ url = new URL(raw);
110
+ }
111
+ catch {
112
+ throw new UploadSourceError(`"${raw}" is not a URL. Pass an absolute http or https address.`);
113
+ }
114
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
115
+ throw new UploadSourceError(`The scheme "${url.protocol}" is not supported. Only http and https addresses are fetched.`);
116
+ }
117
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
118
+ if (allowedHosts.length > 0 && !allowedHosts.includes(host)) {
119
+ throw new UploadSourceError(`The host "${host}" is not in the upload allowlist (${allowedHosts.join(', ')}). ` +
120
+ 'Ask the operator to add it with --upload-allowed-hosts.');
121
+ }
122
+ const resolved = await lookup(host, { all: true, verbatim: true }).catch(() => {
123
+ throw new UploadSourceError(`The host "${host}" does not resolve. Check the address.`);
124
+ });
125
+ const blocked = resolved.filter((entry) => isPrivateAddress(entry.address, entry.family));
126
+ if (blocked.length > 0) {
127
+ throw new UploadSourceError(`The host "${host}" resolves to a private or loopback address (${blocked[0]?.address ?? ''}). ` +
128
+ 'This server does not fetch from the network it runs in — download the file and use ' +
129
+ 'cms_upload_file instead.');
130
+ }
131
+ return url;
132
+ };
133
+ const filenameFromUrl = (url, override) => {
134
+ const fromOverride = override?.trim();
135
+ if (fromOverride) {
136
+ return basename(fromOverride);
137
+ }
138
+ const last = basename(decodeURIComponent(url.pathname));
139
+ return last === '' || last === '/' ? 'upload' : last;
140
+ };
141
+ const MAX_REDIRECTS = 3;
142
+ export const fetchRemoteUpload = async (params) => {
143
+ const { allowedHosts, maxBytes, timeoutMs } = params;
144
+ let target = await assertPublicUrl(params.url, allowedHosts);
145
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
146
+ let response;
147
+ try {
148
+ response = await fetch(target, {
149
+ redirect: 'manual',
150
+ signal: AbortSignal.timeout(timeoutMs),
151
+ });
152
+ }
153
+ catch (error) {
154
+ throw new UploadSourceError(`Fetching ${target.toString()} failed: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ if (response.status >= 300 && response.status < 400) {
157
+ const location = response.headers.get('location');
158
+ if (!location) {
159
+ throw new UploadSourceError(`${target.toString()} answered ${String(response.status)} with no location header.`);
160
+ }
161
+ if (hop === MAX_REDIRECTS) {
162
+ throw new UploadSourceError(`${params.url} redirects more than ${String(MAX_REDIRECTS)} times. Pass the final address.`);
163
+ }
164
+ target = await assertPublicUrl(new URL(location, target).toString(), allowedHosts);
165
+ continue;
166
+ }
167
+ if (!response.ok) {
168
+ throw new UploadSourceError(`${target.toString()} answered ${String(response.status)}. Nothing was uploaded.`);
169
+ }
170
+ const declared = Number(response.headers.get('content-length') ?? '');
171
+ if (Number.isFinite(declared) && declared > maxBytes) {
172
+ throw new UploadSourceError(`${target.toString()} declares ${asMegabytes(declared)}, over the ${asMegabytes(maxBytes)} ` +
173
+ 'upload limit. Nothing was downloaded.');
174
+ }
175
+ const bytes = new Uint8Array(await response.arrayBuffer());
176
+ if (bytes.byteLength === 0) {
177
+ throw new UploadSourceError(`${target.toString()} returned an empty body.`);
178
+ }
179
+ if (bytes.byteLength > maxBytes) {
180
+ throw new UploadSourceError(`${target.toString()} returned ${asMegabytes(bytes.byteLength)}, over the ` +
181
+ `${asMegabytes(maxBytes)} upload limit.`);
182
+ }
183
+ const filename = filenameFromUrl(target, params.filename);
184
+ const headerType = (response.headers.get('content-type') ?? '').split(';')[0]?.trim();
185
+ const contentType = headerType && headerType !== 'application/octet-stream' ? headerType : contentTypeOf(filename);
186
+ return { bytes, filename, contentType };
187
+ }
188
+ throw new UploadSourceError(`${params.url} could not be fetched within ${String(MAX_REDIRECTS)} redirects.`);
189
+ };
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,
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
  };
@@ -92,7 +92,7 @@ export const registerApiDiscovery = (server, deps) => {
92
92
  });
93
93
  server.registerTool('cms_api_describe', {
94
94
  title: 'Describe an Admin API operation',
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.',
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.',
96
96
  inputSchema: {
97
97
  opId: z.string().min(1).describe('Operation id from cms_api_search, e.g. "AdminPagesController_findAllRoot".'),
98
98
  },
@@ -147,6 +147,14 @@ export const registerApiDiscovery = (server, deps) => {
147
147
  'the contract — the instance accepts what the example shows.',
148
148
  }
149
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
+ : {}),
150
158
  ...(operation.note ? { note: operation.note } : {}),
151
159
  ...(operation.silentNoOp ? { silentNoOp: operation.silentNoOp } : {}),
152
160
  ...(operation.verifyWith ? { verifyWith: operation.verifyWith } : {}),
@@ -160,7 +168,9 @@ export const registerApiDiscovery = (server, deps) => {
160
168
  ...(formatDenial ? { notExecutable: formatDenial } : {}),
161
169
  responseSummary: operation.responseSummary ?? null,
162
170
  next: formatDenial
163
- ? 'Do not call this operation report it as unavailable through MCP.'
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.'
164
174
  : operation.risk === 'read'
165
175
  ? 'Call it with cms_api_call, copying the "example" above.'
166
176
  : operation.verifyWith
@@ -40,7 +40,10 @@ export const renderGuide = (deps) => {
40
40
  '2. `cms_docs_search` — find the reference doc for the entity you are touching;',
41
41
  ' `mcp/docs/server/doc-map` lists every document with a one-line "read this when".',
42
42
  '3. `cms_api_search` → `cms_api_describe` — get the real operation and its payload shape.',
43
- '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.',
44
47
  '',
45
48
  'Never invent a path: `cms_api_search` is the only authority on what exists.',
46
49
  '',
@@ -53,11 +56,12 @@ export const renderGuide = (deps) => {
53
56
  '- Admin API only. The Content and Developer APIs are deliberately not exposed.',
54
57
  `- Mutations under ${ALWAYS_CONFIRM_PREFIXES.map((p) => `\`${p}\``).join(', ')} are`,
55
58
  ' 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.',
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.',
61
65
  '- **The knowledge base is written in English.** Search it in English whatever language the',
62
66
  ' conversation is in; an empty result is a failed query, not a missing document.',
63
67
  '- A success status is not evidence that the write landed. Where an operation is known to',
@@ -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;
@@ -0,0 +1,229 @@
1
+ import { z } from 'zod';
2
+ import { AuditLog } from '../api/audit.js';
3
+ import { buildUrl, RequestBuildError } from '../api/client.js';
4
+ import { checkLevel, decide } from '../api/policy.js';
5
+ import { shapeResponse } from '../api/shape.js';
6
+ import { fetchRemoteUpload, readLocalUpload, resolveUploadOperation, UploadSourceError, } from '../api/upload.js';
7
+ import { errorResult, jsonResult } from './result.js';
8
+ const targetingSchema = {
9
+ type: z
10
+ .string()
11
+ .optional()
12
+ .describe('What kind of file this is, e.g. "image" or "file". Copy the values cms_api_describe lists.'),
13
+ entity: z
14
+ .string()
15
+ .optional()
16
+ .describe('The entity kind the file belongs to, e.g. "product", "page", "block".'),
17
+ id: z.number().int().optional().describe('Id of the entity the file belongs to.'),
18
+ template: z
19
+ .number()
20
+ .int()
21
+ .optional()
22
+ .describe('NUMERIC id of a /template-previews record. Without a valid one no previewLink is ever generated and nothing reports it.'),
23
+ compress: z.boolean().optional().describe('Ask the instance to compress the image.'),
24
+ edit: z.boolean().optional().describe('Replace an existing file rather than adding one.'),
25
+ dryRun: z
26
+ .boolean()
27
+ .optional()
28
+ .describe('Do not send: return the resolved request, the resolved source and the policy decision.'),
29
+ confirm: z.string().optional().describe('Confirm token from a dryRun of this exact call.'),
30
+ };
31
+ const queryOf = (args) => {
32
+ const query = {};
33
+ for (const key of ['type', 'entity', 'id', 'template', 'compress', 'edit']) {
34
+ const value = args[key];
35
+ if (value !== undefined) {
36
+ query[key] = value;
37
+ }
38
+ }
39
+ return query;
40
+ };
41
+ const runUpload = async (params) => {
42
+ const { session, toolName, args, load, source } = params;
43
+ const { config, catalog, audit } = session.shared;
44
+ const operation = resolveUploadOperation(catalog);
45
+ if (!operation) {
46
+ return errorResult('This instance does not expose a file upload operation, so there is nothing to send to. ' +
47
+ 'Report it rather than trying another path.', { tool: toolName });
48
+ }
49
+ const query = queryOf(args);
50
+ const callArgs = { ...(Object.keys(query).length > 0 ? { query } : {}) };
51
+ const argsHash = AuditLog.hashArgs({ ...callArgs, body: source });
52
+ const auditBase = {
53
+ opId: operation.opId,
54
+ method: operation.method.toUpperCase(),
55
+ path: operation.path,
56
+ argsHash,
57
+ };
58
+ const levelDenial = checkLevel(operation, config.allow);
59
+ if (levelDenial) {
60
+ audit.record({ ...auditBase, outcome: 'denied' });
61
+ return errorResult(levelDenial.reason, {
62
+ tool: toolName,
63
+ policy: config.allow,
64
+ risk: operation.risk,
65
+ });
66
+ }
67
+ const identity = operation.permission ? await session.identity() : undefined;
68
+ if (identity) {
69
+ Object.assign(auditBase, { adminId: identity.id });
70
+ }
71
+ const confirmValid = args.confirm !== undefined && session.confirms.verify(args.confirm, operation.opId, callArgs);
72
+ if (args.confirm !== undefined && !confirmValid) {
73
+ return errorResult('Confirm token is expired, already used, or does not match these arguments. Re-run with dryRun: true to get a fresh one.', { tool: toolName });
74
+ }
75
+ const decision = decide({
76
+ operation,
77
+ allow: config.allow,
78
+ ...(identity ? { identity } : {}),
79
+ confirmValid,
80
+ });
81
+ if (decision.kind === 'deny') {
82
+ audit.record({ ...auditBase, outcome: 'denied' });
83
+ return errorResult(decision.reason, {
84
+ tool: toolName,
85
+ policy: config.allow,
86
+ risk: operation.risk,
87
+ });
88
+ }
89
+ let url;
90
+ try {
91
+ url = buildUrl(config.baseUrl, operation, callArgs);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof RequestBuildError) {
95
+ return errorResult(error.message, { tool: toolName, params: operation.params });
96
+ }
97
+ throw error;
98
+ }
99
+ if (decision.kind === 'needsConfirm') {
100
+ const token = session.confirms.issue(operation.opId, callArgs);
101
+ audit.record({ ...auditBase, outcome: 'needs-confirm' });
102
+ return jsonResult({
103
+ needsConfirm: true,
104
+ reason: decision.reason,
105
+ request: { method: operation.method.toUpperCase(), url },
106
+ source,
107
+ confirm: token,
108
+ expiresInSeconds: 300,
109
+ next: 'Show the source and the target to the human, then repeat this exact call with the confirm token added.',
110
+ });
111
+ }
112
+ if (args.dryRun === true) {
113
+ return jsonResult({
114
+ dryRun: true,
115
+ request: { method: operation.method.toUpperCase(), url },
116
+ source,
117
+ policy: { allow: config.allow, risk: operation.risk, decision: 'would be sent' },
118
+ ...(args.template === undefined
119
+ ? {
120
+ warning: 'No "template" given. The file will be stored without a previewLink, no error will ' +
121
+ 'be reported, and the only repair is uploading it again. Read /template-previews ' +
122
+ 'first and pass the numeric id.',
123
+ }
124
+ : {}),
125
+ });
126
+ }
127
+ if (args.confirm !== undefined && !session.confirms.consume(args.confirm, operation.opId, callArgs)) {
128
+ return errorResult('Confirm token was consumed concurrently. Re-run with dryRun: true to get a fresh one.', { tool: toolName });
129
+ }
130
+ let payload;
131
+ try {
132
+ payload = await load();
133
+ }
134
+ catch (error) {
135
+ if (error instanceof UploadSourceError) {
136
+ return errorResult(error.message, { tool: toolName, sent: false });
137
+ }
138
+ throw error;
139
+ }
140
+ const result = await session.client.upload(operation, callArgs, payload);
141
+ audit.record({
142
+ ...auditBase,
143
+ outcome: 'sent',
144
+ status: result.ok ? result.status : result.error.status,
145
+ });
146
+ if (!result.ok) {
147
+ return errorResult(result.error.message, {
148
+ tool: toolName,
149
+ status: result.error.status,
150
+ ...(result.error.hint ? { hint: result.error.hint } : {}),
151
+ });
152
+ }
153
+ const shaped = shapeResponse(result.body, config.maxResponseBytes);
154
+ return jsonResult({
155
+ tool: toolName,
156
+ opId: operation.opId,
157
+ status: result.status,
158
+ uploaded: { filename: payload.filename, contentType: payload.contentType, bytes: payload.bytes.byteLength },
159
+ truncated: shaped.truncated,
160
+ body: shaped.body,
161
+ next: 'Keep the WHOLE record as the attribute value, and check "previewLink": an upload with no ' +
162
+ 'valid template id stores the file without one and reports no error. The record carries no ' +
163
+ '"alt" — alternative text needs a sibling attribute.',
164
+ ...(operation.note ? { note: operation.note } : {}),
165
+ });
166
+ };
167
+ export const registerUpload = (server, getSession) => {
168
+ server.registerTool('cms_upload_file', {
169
+ title: 'Upload a local file',
170
+ description: 'Upload one file from the machine running this server to the instance, as multipart — the one thing cms_api_call cannot send. Requires --allow=write, is audited, and supports dryRun. Local mode only: in remote mode there is no shared filesystem, so use cms_import_file_from_url. Pass "template" (the numeric id of a /template-previews record) or the file is stored with no preview and nothing reports it.',
171
+ inputSchema: {
172
+ path: z
173
+ .string()
174
+ .min(1)
175
+ .describe('Path to the file, absolute or relative to the server\'s upload root.'),
176
+ ...targetingSchema,
177
+ },
178
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
179
+ }, async ({ path, ...args }) => {
180
+ const session = getSession();
181
+ const { config } = session.shared;
182
+ if (config.mode === 'remote') {
183
+ return errorResult('cms_upload_file reads a file from the machine running this server, and in remote mode ' +
184
+ 'that machine is not yours: nothing was read and nothing was sent. Use ' +
185
+ 'cms_import_file_from_url with a URL the operator allowed.', { tool: 'cms_upload_file', mode: config.mode });
186
+ }
187
+ return runUpload({
188
+ session,
189
+ toolName: 'cms_upload_file',
190
+ args,
191
+ source: { path, root: config.upload.root },
192
+ load: () => readLocalUpload({ path, root: config.upload.root, maxBytes: config.upload.maxBytes }),
193
+ });
194
+ });
195
+ server.registerTool('cms_import_file_from_url', {
196
+ title: 'Import a file from a URL',
197
+ description: 'Fetch one file over http(s) and upload it to the instance in a single step — the usual "take the image from the customer\'s site into the CMS" move. Requires --allow=write, is audited, and supports dryRun. Addresses resolving to private or loopback networks are refused, and in remote mode an operator allowlist is required. Pass "template" (the numeric id of a /template-previews record) or the file is stored with no preview.',
198
+ inputSchema: {
199
+ url: z.string().min(1).describe('Absolute http or https address of the file.'),
200
+ filename: z
201
+ .string()
202
+ .optional()
203
+ .describe('Override the stored file name; by default it comes from the URL.'),
204
+ ...targetingSchema,
205
+ },
206
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
207
+ }, async ({ url, filename, ...args }) => {
208
+ const session = getSession();
209
+ const { config } = session.shared;
210
+ if (config.mode === 'remote' && config.upload.allowedHosts.length === 0) {
211
+ return errorResult('cms_import_file_from_url is disabled in remote mode until the operator sets ' +
212
+ '--upload-allowed-hosts: a URL supplied over a session would otherwise make this ' +
213
+ 'server fetch anything reachable from where it runs. Nothing was fetched.', { tool: 'cms_import_file_from_url', mode: config.mode });
214
+ }
215
+ return runUpload({
216
+ session,
217
+ toolName: 'cms_import_file_from_url',
218
+ args,
219
+ source: { url, ...(filename !== undefined ? { filename } : {}) },
220
+ load: () => fetchRemoteUpload({
221
+ url,
222
+ allowedHosts: config.upload.allowedHosts,
223
+ maxBytes: config.upload.maxBytes,
224
+ timeoutMs: config.requestTimeoutMs,
225
+ ...(filename !== undefined ? { filename } : {}),
226
+ }),
227
+ });
228
+ });
229
+ };