@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/README.md CHANGED
@@ -71,7 +71,7 @@ arguments, so a prompt-injected instruction cannot swap identities:
71
71
 
72
72
  Sessions never share tokens or confirm tokens. `GET /health` reports liveness and session count.
73
73
 
74
- ## The seven tools
74
+ ## The nine tools
75
75
 
76
76
  | tool | what it does |
77
77
  |---|---|
@@ -79,12 +79,39 @@ Sessions never share tokens or confirm tokens. `GET /health` reports liveness an
79
79
  | `cms_docs_search` | search the knowledge base, returns sections with snippets |
80
80
  | `cms_docs_read` | read one section, with its sibling sections listed for paging |
81
81
  | `cms_api_search` | find operations by keyword / tag / method / mutating |
82
- | `cms_api_describe` | params, body schema, required permission, risk, confirm gating |
82
+ | `cms_api_describe` | params, body schema, required permission, risk, confirm gating, and `curatedBody` where a shape has been verified on a live instance |
83
83
  | `cms_api_call` | execute one operation; `dryRun` and confirm-gating for mutations |
84
+ | `cms_upload_file` | upload one file from this machine as `multipart/form-data` (local mode only) |
85
+ | `cms_import_file_from_url` | fetch one file over http(s) and upload it in a single step |
84
86
  | `cms_whoami` | mode, base URL, admin + permissions, knowledge commit, catalog state and warnings |
85
87
 
86
88
  Plus two MCP resources: `oneentry://knowledge/mcp/operating-rules` and `oneentry://knowledge/index`.
87
89
 
90
+ ### Uploading files
91
+
92
+ `cms_api_call` sends JSON only, and the upload endpoint wants `multipart/form-data` — which is why
93
+ the two upload tools exist rather than a note telling the agent to go around the server. They run
94
+ through the same gate as any other write: `--allow=write`, the local permission check, `dryRun`, and
95
+ the audit line.
96
+
97
+ Both bound their **source**, because a path or a URL arrives as a tool argument and a tool argument
98
+ can be prompt-injected:
99
+
100
+ | flag | env | default | what it bounds |
101
+ |---|---|---|---|
102
+ | `--upload-root` | `ONEENTRY_MCP_UPLOAD_ROOT` | the process working directory | `cms_upload_file` reads nothing outside this directory, symlinks resolved first |
103
+ | `--upload-max-bytes` | `ONEENTRY_MCP_UPLOAD_MAX_BYTES` | 25 MiB | size of one file, checked on disk and on the wire |
104
+ | `--upload-allowed-hosts` | `ONEENTRY_MCP_UPLOAD_ALLOWED_HOSTS` | empty | hosts `cms_import_file_from_url` may fetch from |
105
+
106
+ `cms_upload_file` is refused in remote mode: there is no shared filesystem, and a session-supplied
107
+ path would read the host's files. `cms_import_file_from_url` refuses any address resolving to a
108
+ loopback, private, link-local or carrier-grade-NAT range, re-checks every redirect hop, and in remote
109
+ mode stays disabled until the operator sets an allowlist.
110
+
111
+ Pass `template` — the **numeric id** of a `/template-previews` record — on the first upload. Without
112
+ it the file is stored with no `previewLink`, nothing reports the omission, and the only repair is
113
+ uploading the file again.
114
+
88
115
  ## Write safety
89
116
 
90
117
  Read-only by default. `--allow` (or `ONEENTRY_MCP_ALLOW`) raises it:
@@ -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,14 @@ 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?.example !== undefined ? { curatedExample: notes.example } : {}),
221
+ ...(notes?.verifyWith ? { verifyWith: notes.verifyWith } : {}),
222
+ ...(notes?.silentNoOp ? { silentNoOp: notes.silentNoOp } : {}),
141
223
  searchText: '',
142
224
  };
143
225
  entry.searchText = [opId, method.toUpperCase(), path, tag, summary, permission ?? '']
@@ -156,6 +238,26 @@ export const buildCatalog = (params) => {
156
238
  warnings.push(`${String(withoutOpId.length)} operation(s) have no operationId and cannot be called ` +
157
239
  `(e.g. ${withoutOpId.slice(0, 3).join(', ')}). Report it against the CMS.`);
158
240
  }
241
+ if (unresolvedBodies.length > 0) {
242
+ warnings.push(`${String(unresolvedBodies.length)} operation(s) declare a request body whose schema ` +
243
+ `does not resolve in the document (${unresolvedBodies.slice(0, 5).join(', ')}). ` +
244
+ 'Their body schema is marked "x-unresolved": true — build the body from the example ' +
245
+ 'cms_api_describe returns, and never from the empty schema. Report it against the CMS.');
246
+ }
247
+ if (contradictoryExamples.length > 0) {
248
+ warnings.push(`${String(contradictoryExamples.length)} field(s) carry an example that contradicts their ` +
249
+ `own declared type (${contradictoryExamples.slice(0, 3).join(' | ')}). ` +
250
+ 'Those fields are marked "x-example-mismatch" and listed by cms_api_describe — ' +
251
+ 'copy the example, not the type. Report it against the CMS.');
252
+ }
253
+ const noted = Object.keys(OPERATION_NOTES);
254
+ const notedButAbsent = noted.filter((opId) => !operations.some((operation) => operation.opId === opId));
255
+ if (notedButAbsent.length > 0 && notedButAbsent.length <= noted.length / 2) {
256
+ warnings.push(`${String(notedButAbsent.length)} operation(s) carry a curated behaviour note but are ` +
257
+ `absent from this catalog (${notedButAbsent.slice(0, 5).join(', ')}). ` +
258
+ 'Either this instance does not expose them, or the note table has gone stale — ' +
259
+ 'the note is not shown for an operation that is not there.');
260
+ }
159
261
  const known = new Set(operations.map((o) => o.opId));
160
262
  const missing = Object.keys(byOpId).filter((opId) => !known.has(opId) && !opId.startsWith('Developer') && !opId.startsWith('Content'));
161
263
  if (missing.length > 0) {
@@ -173,6 +275,8 @@ export const buildCatalog = (params) => {
173
275
  : 'The bundled permission map was generated from a different platform revision than ' +
174
276
  'this instance runs — update the package if the catalog looks incomplete.'));
175
277
  }
278
+ const mappedPermissions = new Set(Object.values(byOpId));
279
+ const permissionsWithoutOperation = permissions.filter((permission) => !mappedPermissions.has(permission));
176
280
  const knownPermissions = new Set(permissions);
177
281
  const orphanPermissions = [
178
282
  ...new Set(operations
@@ -191,6 +295,7 @@ export const buildCatalog = (params) => {
191
295
  swaggerHash: createHash('sha256').update(rawSwagger).digest('hex').slice(0, 16),
192
296
  permissions,
193
297
  warnings,
298
+ coverage: { unexposedOpIds: missing, permissionsWithoutOperation },
194
299
  operations,
195
300
  };
196
301
  };
@@ -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;
@@ -32,5 +33,11 @@ export declare class AdminApiClient {
32
33
  timeoutMs: number;
33
34
  });
34
35
  call(operation: Operation, args: CallArgs): Promise<CallResult>;
36
+ upload(operation: Operation, args: CallArgs, payload: {
37
+ bytes: Uint8Array;
38
+ filename: string;
39
+ contentType: string;
40
+ }): Promise<CallResult>;
41
+ private send;
35
42
  fetchPermissions(adminId: number): Promise<string[]>;
36
43
  }
@@ -16,6 +16,21 @@ 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
+ const base = `Operation ${operation.opId} expects a "${contentType}" request body, and cms_api_call ` +
25
+ 'can only send application/json. The call is not executable through cms_api_call — no ' +
26
+ 'body shape will help.';
27
+ if (contentType === 'multipart/form-data') {
28
+ return (`${base} Upload through cms_upload_file (a file on the machine running this server) or ` +
29
+ 'cms_import_file_from_url (this server fetches it). Both keep the allow level, the ' +
30
+ 'confirm gate and the audit line.');
31
+ }
32
+ return `${base} Report it to the human and use another route to that instance.`;
33
+ };
19
34
  export const buildUrl = (baseUrl, operation, args) => {
20
35
  let path = operation.path;
21
36
  const provided = args.path ?? {};
@@ -48,6 +63,10 @@ const hintFor = (status, operation) => {
48
63
  if (status === 400 || status === 422) {
49
64
  const loose = operation.body?.schema['x-loose'] === true;
50
65
  return (`Validation rejected the payload. ${loose ? 'This body has loosely typed fields — copy the shape from the example in cms_api_describe. ' : ''}` +
66
+ (operation.curatedExample !== undefined
67
+ ? 'A body shape verified on a live instance is in cms_api_describe under "curatedBody" — ' +
68
+ 'compare yours with it before changing anything else. '
69
+ : '') +
51
70
  'Search the knowledge base with cms_docs_search before retrying.');
52
71
  }
53
72
  if (status === 403) {
@@ -75,6 +94,12 @@ const readBody = async (response) => {
75
94
  return text.slice(0, 2000);
76
95
  }
77
96
  };
97
+ const isLoginPage = (response, body) => {
98
+ if ((response.headers.get('content-type') ?? '').includes('text/html')) {
99
+ return true;
100
+ }
101
+ return typeof body === 'string' && body.trimStart().startsWith('<');
102
+ };
78
103
  const extractMessage = (body, fallback) => {
79
104
  if (typeof body === 'string') {
80
105
  return body.slice(0, 500);
@@ -101,7 +126,7 @@ export class AdminApiClient {
101
126
  }
102
127
  async call(operation, args) {
103
128
  const url = buildUrl(this.baseUrl, operation, args);
104
- const send = async (token) => {
129
+ return this.send(operation, url, (token) => {
105
130
  const headers = { authorization: `Bearer ${token}` };
106
131
  let payload;
107
132
  if (args.body !== undefined && operation.method !== 'get') {
@@ -114,12 +139,46 @@ export class AdminApiClient {
114
139
  ...(payload !== undefined ? { body: payload } : {}),
115
140
  signal: AbortSignal.timeout(this.timeoutMs),
116
141
  });
117
- };
142
+ });
143
+ }
144
+ async upload(operation, args, payload) {
145
+ const url = buildUrl(this.baseUrl, operation, args);
146
+ return this.send(operation, url, (token) => {
147
+ const form = new FormData();
148
+ form.append('file', new Blob([payload.bytes], { type: payload.contentType }), payload.filename);
149
+ return fetch(url, {
150
+ method: operation.method.toUpperCase(),
151
+ headers: { authorization: `Bearer ${token}` },
152
+ body: form,
153
+ signal: AbortSignal.timeout(this.timeoutMs),
154
+ });
155
+ });
156
+ }
157
+ async send(operation, url, attempt) {
118
158
  let response;
159
+ let body;
119
160
  try {
120
- response = await send(await this.tokens.accessToken());
161
+ response = await attempt(await this.tokens.accessToken());
121
162
  if (response.status === 401) {
122
- response = await send(await this.tokens.refresh());
163
+ response = await attempt(await this.tokens.refresh());
164
+ }
165
+ body = await readBody(response);
166
+ if (response.ok && isLoginPage(response, body)) {
167
+ response = await attempt(await this.tokens.refresh());
168
+ body = await readBody(response);
169
+ if (response.ok && isLoginPage(response, body)) {
170
+ return {
171
+ ok: false,
172
+ error: {
173
+ status: response.status,
174
+ message: `${operation.method.toUpperCase()} ${url} answered ${String(response.status)} with ` +
175
+ 'the instance login page instead of a result: the admin session is not valid and ' +
176
+ 're-authenticating did not change that. Nothing was written by this call.',
177
+ hint: 'Check the credentials this server runs with, then retry. Treat every write since ' +
178
+ 'the last verified read as unconfirmed and read those entities back.',
179
+ },
180
+ };
181
+ }
123
182
  }
124
183
  }
125
184
  catch (error) {
@@ -132,7 +191,6 @@ export class AdminApiClient {
132
191
  },
133
192
  };
134
193
  }
135
- const body = await readBody(response);
136
194
  if (!response.ok) {
137
195
  const hint = hintFor(response.status, operation);
138
196
  return {
@@ -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,11 @@
1
+ import type { VerifyWith } from './types.js';
2
+ export interface OperationNote {
3
+ note?: string;
4
+ example?: unknown;
5
+ verifyWith?: VerifyWith;
6
+ silentNoOp?: string;
7
+ readOnly?: true;
8
+ }
9
+ export declare const ALWAYS_CONFIRM_PREFIXES: readonly string[];
10
+ export declare const OPERATION_NOTES: Readonly<Record<string, OperationNote>>;
11
+ export declare const readOnlyOpIds: () => string[];