@yunsoft/yuncms-api 0.1.9 → 0.1.11

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.
@@ -0,0 +1,304 @@
1
+ import {
2
+ readManyWithRelations,
3
+ readOneWithRelations,
4
+ } from '@yunsoft/yuncms-core';
5
+ import * as z from 'zod/v4';
6
+
7
+ import {
8
+ describeReadableCollection,
9
+ listReadableCollections,
10
+ } from '../mcp.js';
11
+ import { serviceOptionsFromRequest } from '../service-options.js';
12
+
13
+ const SAFE_TOOL_ERROR_CODES = new Set([
14
+ 'FORBIDDEN',
15
+ 'FORBIDDEN_FIELD',
16
+ 'COLLECTION_NOT_FOUND',
17
+ 'INVALID_QUERY',
18
+ 'QUERY_COST_LIMIT',
19
+ 'QUERY_RELATION_DEPTH_LIMIT',
20
+ 'QUERY_RELATION_ROW_LIMIT',
21
+ 'INVALID_PAYLOAD',
22
+ 'REQUIRED_FIELD_MISSING',
23
+ 'FIELD_READ_ONLY',
24
+ 'FILTER_REQUIRED',
25
+ 'VALIDATION_FAILED',
26
+ 'VALIDATION_BULK_LIMIT',
27
+ 'FOREIGN_KEY_MISSING',
28
+ 'FOREIGN_KEY_RESTRICTED',
29
+ 'DUPLICATE_KEY',
30
+ ]);
31
+
32
+ const collectionSchema = z.string()
33
+ .min(1)
34
+ .max(64)
35
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
36
+ const idSchema = z.union([z.string().min(1).max(191), z.number().finite()]);
37
+ const delimitedSchema = z.union([
38
+ z.string().min(1).max(2_000),
39
+ z.array(z.string().min(1).max(191)).max(100),
40
+ ]);
41
+ const filterSchema = z.record(z.string(), z.unknown());
42
+ const aggregateSchema = z.record(
43
+ z.string(),
44
+ z.union([z.string().min(1), z.array(z.string().min(1)).max(20)]),
45
+ );
46
+
47
+ function toolError(code, message) {
48
+ const error = new Error(message);
49
+ error.code = code;
50
+ return error;
51
+ }
52
+
53
+ function parseArguments(schema, args) {
54
+ const result = schema.safeParse(args ?? {});
55
+ if (result.success) return result.data;
56
+ throw toolError('AI_TOOL_ARGUMENTS_INVALID', 'The assistant generated invalid tool arguments');
57
+ }
58
+
59
+ function itemService(req, collection) {
60
+ const Service = req.context.services.ItemsService;
61
+ return new Service(collection, serviceOptionsFromRequest(req));
62
+ }
63
+
64
+ function readManySchema(maxItems) {
65
+ return z.object({
66
+ collection: collectionSchema,
67
+ fields: delimitedSchema.optional(),
68
+ expand: delimitedSchema.optional(),
69
+ filter: filterSchema.optional(),
70
+ search: z.string().max(200).optional(),
71
+ sort: delimitedSchema.optional(),
72
+ aggregate: aggregateSchema.optional(),
73
+ groupBy: delimitedSchema.optional(),
74
+ limit: z.number().int().min(1).max(maxItems).optional(),
75
+ offset: z.number().int().min(0).max(1_000_000).optional(),
76
+ }).strict();
77
+ }
78
+
79
+ const describeSchema = z.object({ collection: collectionSchema }).strict();
80
+ const readOneSchema = z.object({
81
+ collection: collectionSchema,
82
+ id: idSchema,
83
+ fields: delimitedSchema.optional(),
84
+ expand: delimitedSchema.optional(),
85
+ }).strict();
86
+ const createSchema = z.object({
87
+ collection: collectionSchema,
88
+ data: z.record(z.string(), z.unknown()),
89
+ }).strict();
90
+ const updateSchema = z.object({
91
+ collection: collectionSchema,
92
+ id: idSchema,
93
+ data: z.record(z.string(), z.unknown()),
94
+ }).strict();
95
+ const deleteSchema = z.object({
96
+ collection: collectionSchema,
97
+ id: idSchema,
98
+ }).strict();
99
+
100
+ function toolDefinition(name, description, parameters) {
101
+ return {
102
+ type: 'function',
103
+ function: {
104
+ name,
105
+ description,
106
+ parameters: {
107
+ additionalProperties: false,
108
+ ...parameters,
109
+ },
110
+ },
111
+ };
112
+ }
113
+
114
+ function readToolDefinitions(maxItems) {
115
+ return [
116
+ toolDefinition(
117
+ 'schema_list_collections',
118
+ 'List project collections the current YunCMS user is allowed to read. Use this before guessing collection names.',
119
+ { type: 'object', properties: {}, required: [] },
120
+ ),
121
+ toolDefinition(
122
+ 'schema_describe_collection',
123
+ 'Describe readable fields, direct relations and CRUD capabilities for one collection using the current user permissions.',
124
+ {
125
+ type: 'object',
126
+ properties: { collection: { type: 'string' } },
127
+ required: ['collection'],
128
+ },
129
+ ),
130
+ toolDefinition(
131
+ 'items_read_many',
132
+ 'Read collection rows with YunCMS filtering, search, sorting, aggregates and relation-aware fields. Normal RBAC and query limits always apply.',
133
+ {
134
+ type: 'object',
135
+ properties: {
136
+ collection: { type: 'string' },
137
+ fields: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
138
+ expand: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
139
+ filter: { type: 'object' },
140
+ search: { type: 'string', maxLength: 200 },
141
+ sort: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
142
+ aggregate: { type: 'object' },
143
+ groupBy: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
144
+ limit: { type: 'integer', minimum: 1, maximum: maxItems },
145
+ offset: { type: 'integer', minimum: 0, maximum: 1_000_000 },
146
+ },
147
+ required: ['collection'],
148
+ },
149
+ ),
150
+ toolDefinition(
151
+ 'items_read_one',
152
+ 'Read one item by id with optional field and relation selection. Normal YunCMS read permissions apply.',
153
+ {
154
+ type: 'object',
155
+ properties: {
156
+ collection: { type: 'string' },
157
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
158
+ fields: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
159
+ expand: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
160
+ },
161
+ required: ['collection', 'id'],
162
+ },
163
+ ),
164
+ ];
165
+ }
166
+
167
+ function writeToolDefinitions({ deletesEnabled = false } = {}) {
168
+ const tools = [
169
+ toolDefinition(
170
+ 'items_create',
171
+ 'Create one YunCMS item. Use only when the user clearly asks to create data. Field permissions, validation, hooks and auditing apply.',
172
+ {
173
+ type: 'object',
174
+ properties: { collection: { type: 'string' }, data: { type: 'object' } },
175
+ required: ['collection', 'data'],
176
+ },
177
+ ),
178
+ toolDefinition(
179
+ 'items_update',
180
+ 'Update one YunCMS item by id. Use only when the user clearly asks to change data. Normal permissions and validation apply.',
181
+ {
182
+ type: 'object',
183
+ properties: {
184
+ collection: { type: 'string' },
185
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
186
+ data: { type: 'object' },
187
+ },
188
+ required: ['collection', 'id', 'data'],
189
+ },
190
+ ),
191
+ ];
192
+ if (deletesEnabled) tools.push(toolDefinition(
193
+ 'items_delete',
194
+ 'Delete one YunCMS item by id. Use only when the user explicitly asks to delete that item. Normal delete permissions and hooks apply.',
195
+ {
196
+ type: 'object',
197
+ properties: {
198
+ collection: { type: 'string' },
199
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
200
+ },
201
+ required: ['collection', 'id'],
202
+ },
203
+ ));
204
+ return tools;
205
+ }
206
+
207
+ export function aiToolDefinitions({
208
+ writesEnabled = false,
209
+ deletesEnabled = false,
210
+ maxItems = 100,
211
+ } = {}) {
212
+ return writesEnabled
213
+ ? [...readToolDefinitions(maxItems), ...writeToolDefinitions({ deletesEnabled })]
214
+ : readToolDefinitions(maxItems);
215
+ }
216
+
217
+ export async function executeAiTool(req, name, args, {
218
+ writesEnabled = false,
219
+ deletesEnabled = false,
220
+ maxItems = 100,
221
+ } = {}) {
222
+ if (name === 'schema_list_collections') {
223
+ parseArguments(z.object({}).strict(), args);
224
+ return { collections: await listReadableCollections(req, { maxItems }) };
225
+ }
226
+ if (name === 'schema_describe_collection') {
227
+ const input = parseArguments(describeSchema, args);
228
+ return describeReadableCollection(req, input.collection);
229
+ }
230
+ if (name === 'items_read_many') {
231
+ const { collection, ...query } = parseArguments(readManySchema(maxItems), args);
232
+ return readManyWithRelations({
233
+ collection,
234
+ query: { ...query, limit: query.limit ?? Math.min(100, maxItems) },
235
+ options: serviceOptionsFromRequest(req),
236
+ ItemsServiceClass: req.context.services.ItemsService,
237
+ });
238
+ }
239
+ if (name === 'items_read_one') {
240
+ const input = parseArguments(readOneSchema, args);
241
+ return readOneWithRelations({
242
+ collection: input.collection,
243
+ id: input.id,
244
+ query: {
245
+ ...(input.fields ? { fields: input.fields } : {}),
246
+ ...(input.expand ? { expand: input.expand } : {}),
247
+ },
248
+ options: serviceOptionsFromRequest(req),
249
+ ItemsServiceClass: req.context.services.ItemsService,
250
+ });
251
+ }
252
+
253
+ if (!writesEnabled) throw toolError('AI_TOOL_FORBIDDEN', 'Data-changing assistant tools are disabled');
254
+
255
+ if (name === 'items_create') {
256
+ const input = parseArguments(createSchema, args);
257
+ return itemService(req, input.collection).createOne(input.data);
258
+ }
259
+ if (name === 'items_update') {
260
+ const input = parseArguments(updateSchema, args);
261
+ return itemService(req, input.collection).updateOne(input.id, input.data);
262
+ }
263
+ if (name === 'items_delete') {
264
+ if (!deletesEnabled) throw toolError('AI_TOOL_FORBIDDEN', 'Assistant delete tools are disabled');
265
+ const input = parseArguments(deleteSchema, args);
266
+ return { deleted: await itemService(req, input.collection).deleteOne(input.id) };
267
+ }
268
+
269
+ throw toolError('AI_TOOL_UNKNOWN', 'The assistant requested an unknown tool');
270
+ }
271
+
272
+ export function safeAiToolError(error) {
273
+ const code = SAFE_TOOL_ERROR_CODES.has(error?.code)
274
+ || ['AI_TOOL_ARGUMENTS_INVALID', 'AI_TOOL_FORBIDDEN', 'AI_TOOL_UNKNOWN'].includes(error?.code)
275
+ ? error.code
276
+ : 'INTERNAL_ERROR';
277
+ return {
278
+ error: {
279
+ code,
280
+ message: code === 'INTERNAL_ERROR'
281
+ ? 'Tool execution failed'
282
+ : String(error?.message ?? 'Tool execution failed'),
283
+ },
284
+ };
285
+ }
286
+
287
+ export function serializeAiToolResult(value, maxBytes = 250_000) {
288
+ const text = JSON.stringify({ data: value });
289
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text;
290
+ return JSON.stringify({
291
+ error: {
292
+ code: 'AI_TOOL_RESULT_TOO_LARGE',
293
+ message: `Tool result exceeds the configured ${maxBytes} byte limit. Narrow the query and try again.`,
294
+ },
295
+ });
296
+ }
297
+
298
+ export function operationSummary(name, args = {}, success = true) {
299
+ return {
300
+ operation: name,
301
+ collection: typeof args.collection === 'string' ? args.collection : null,
302
+ success,
303
+ };
304
+ }
package/src/app.js CHANGED
@@ -16,6 +16,7 @@ import { createItemsRouter } from './routes/items.js';
16
16
  import { createPermissionsRouter } from './routes/permissions.js';
17
17
  import { createRolesRouter } from './routes/roles.js';
18
18
  import { createSchemaRouter } from './routes/schema.js';
19
+ import { createStudioNavigationRouter } from './routes/studio-navigation.js';
19
20
  import { createStudioSettingsRouter } from './routes/studio-settings.js';
20
21
  import { createSystemSchemaRouter } from './routes/system-schema.js';
21
22
  import { createUsersRouter } from './routes/users.js';
@@ -127,6 +128,7 @@ export function createApp({
127
128
  redisClient = null,
128
129
  externalAuthRegistry = null,
129
130
  endpointExtensions = [],
131
+ aiRouter = null,
130
132
  mcpRouter = null,
131
133
  studioRoot = undefined,
132
134
  }) {
@@ -187,7 +189,9 @@ export function createApp({
187
189
  const requestEvents = createRequestEvents(emitter);
188
190
  if (requestEvents) app.use(requestEvents);
189
191
 
192
+ if (aiRouter) app.use('/ai', aiRouter);
190
193
  if (mcpRouter) app.use('/mcp', mcpRouter);
194
+ app.use('/studio-navigation', createStudioNavigationRouter());
191
195
  app.use('/studio-settings', createStudioSettingsRouter());
192
196
  app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
193
197
  app.use('/items', createItemsRouter());
@@ -15,6 +15,7 @@ const STATUS_BY_CODE = new Map([
15
15
  ['ROLE_NOT_FOUND', 404],
16
16
  ['PERMISSION_NOT_FOUND', 404],
17
17
  ['FILE_NOT_FOUND', 404],
18
+ ['NAVIGATION_GROUP_NOT_FOUND', 404],
18
19
  ['NOT_FOUND', 404],
19
20
  ['COLLECTION_EXISTS', 409],
20
21
  ['FIELD_EXISTS', 409],
@@ -28,6 +29,9 @@ const STATUS_BY_CODE = new Map([
28
29
  ['DUPLICATE_KEY', 409],
29
30
  ['FOREIGN_KEY_MISSING', 409],
30
31
  ['FOREIGN_KEY_RESTRICTED', 409],
32
+ ['SINGLETON_ITEM_EXISTS', 409],
33
+ ['SINGLETON_MULTIPLE_ITEMS', 409],
34
+ ['SINGLETON_BULK_CREATE_FORBIDDEN', 409],
31
35
  ['RATE_LIMITED', 429],
32
36
  ['PAYLOAD_TOO_LARGE', 413],
33
37
  ['INVALID_QUERY', 400],
@@ -43,6 +47,10 @@ const STATUS_BY_CODE = new Map([
43
47
  ['INVALID_FILE_CONTENT', 400],
44
48
  ['FILE_MIME_MISMATCH', 400],
45
49
  ['INVALID_MAIL_MESSAGE', 400],
50
+ ['INVALID_AI_REQUEST', 400],
51
+ ['INVALID_AI_CONFIG', 400],
52
+ ['INVALID_MCP_CONFIG', 400],
53
+ ['AI_TOOL_ARGUMENTS_INVALID', 400],
46
54
  ['STORAGE_NOT_FOUND', 400],
47
55
  ['STORAGE_INVENTORY_UNSUPPORTED', 400],
48
56
  ['UNSUPPORTED_SCHEMA_UPDATE', 400],
@@ -58,6 +66,14 @@ const STATUS_BY_CODE = new Map([
58
66
  ['FILTER_REQUIRED', 400],
59
67
  ['VALIDATION_FAILED', 400],
60
68
  ['VALIDATION_BULK_LIMIT', 400],
69
+ ['AI_PROVIDER_UNAVAILABLE', 502],
70
+ ['AI_PROVIDER_RESPONSE_INVALID', 502],
71
+ ['AI_TOOL_ROUND_LIMIT', 502],
72
+ ['AI_TOOL_CALL_LIMIT', 502],
73
+ ['AI_PROVIDER_TIMEOUT', 504],
74
+ ['AI_NOT_CONFIGURED', 503],
75
+ ['AI_SETTINGS_KEY_INVALID', 503],
76
+ ['AI_SECRET_DECRYPT_FAILED', 503],
61
77
  ['MAIL_NOT_CONFIGURED', 503],
62
78
  ['STORAGE_INVENTORY_FAILED', 503],
63
79
  ['DATABASE_MIGRATION_REQUIRED', 503],
@@ -0,0 +1,154 @@
1
+ export const DEFAULT_MCP_SETTINGS = Object.freeze({
2
+ enabled: false,
3
+ writesEnabled: false,
4
+ requireAuthentication: true,
5
+ allowedOrigins: Object.freeze([]),
6
+ allowedHosts: Object.freeze([]),
7
+ maxItems: 100,
8
+ maxResultBytes: 1_000_000,
9
+ });
10
+
11
+ const PATCH_KEYS = new Set([
12
+ 'enabled',
13
+ 'writes_enabled',
14
+ 'require_authentication',
15
+ 'allowed_origins',
16
+ 'allowed_hosts',
17
+ 'max_items',
18
+ 'max_result_bytes',
19
+ ]);
20
+
21
+ function configError(message) {
22
+ const error = new Error(message);
23
+ error.code = 'INVALID_MCP_CONFIG';
24
+ return error;
25
+ }
26
+
27
+ function normalizeBoolean(value, name) {
28
+ if (typeof value !== 'boolean') throw configError(`${name} must be a boolean`);
29
+ return value;
30
+ }
31
+
32
+ function normalizeInteger(value, name, { min, max }) {
33
+ const parsed = Number(value);
34
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
35
+ throw configError(`${name} must be an integer between ${min} and ${max}`);
36
+ }
37
+ return parsed;
38
+ }
39
+
40
+ function listValues(value, name) {
41
+ let source = value;
42
+ if (typeof source === 'string') {
43
+ const trimmed = source.trim();
44
+ if (trimmed.startsWith('[')) {
45
+ try {
46
+ source = JSON.parse(trimmed);
47
+ } catch {
48
+ throw configError(`${name} must be a list`);
49
+ }
50
+ } else {
51
+ source = source.split(/[\n,]/);
52
+ }
53
+ }
54
+ if (!Array.isArray(source)) throw configError(`${name} must be a list`);
55
+ const entries = source.map((entry) => {
56
+ if (typeof entry !== 'string') throw configError(`${name} entries must be strings`);
57
+ return entry.trim();
58
+ }).filter(Boolean);
59
+ if (entries.length > 50) throw configError(`${name} cannot contain more than 50 entries`);
60
+ return entries;
61
+ }
62
+
63
+ export function normalizeMcpOrigins(value) {
64
+ return [...new Set(listValues(value, 'allowed_origins').map((entry) => {
65
+ let url;
66
+ try {
67
+ url = new URL(entry);
68
+ } catch {
69
+ throw configError(`allowed_origins contains an invalid URL: ${entry}`);
70
+ }
71
+ if (!['http:', 'https:'].includes(url.protocol)) {
72
+ throw configError(`allowed_origins must use http or https: ${entry}`);
73
+ }
74
+ if (url.username || url.password) {
75
+ throw configError(`allowed_origins cannot include credentials: ${entry}`);
76
+ }
77
+ return url.origin.toLowerCase();
78
+ }))];
79
+ }
80
+
81
+ export function normalizeMcpHosts(value) {
82
+ return [...new Set(listValues(value, 'allowed_hosts').map((entry) => {
83
+ if (/[:][/][/]|[\\/?#@\s\0]/.test(entry)) {
84
+ throw configError(`allowed_hosts contains an invalid host: ${entry}`);
85
+ }
86
+ let url;
87
+ try {
88
+ url = new URL(`http://${entry}`);
89
+ } catch {
90
+ throw configError(`allowed_hosts contains an invalid host: ${entry}`);
91
+ }
92
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash || !url.host) {
93
+ throw configError(`allowed_hosts contains an invalid host: ${entry}`);
94
+ }
95
+ return url.host.toLowerCase();
96
+ }))];
97
+ }
98
+
99
+ export function normalizeMcpSettingsPatch(patch = {}) {
100
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
101
+ throw configError('MCP settings patch must be an object');
102
+ }
103
+ const keys = Object.keys(patch);
104
+ if (keys.length === 0 || keys.some((key) => !PATCH_KEYS.has(key))) {
105
+ throw configError('MCP settings patch contains unsupported properties');
106
+ }
107
+ const normalized = {};
108
+ if (Object.hasOwn(patch, 'enabled')) normalized.enabled = normalizeBoolean(patch.enabled, 'enabled');
109
+ if (Object.hasOwn(patch, 'writes_enabled')) normalized.writesEnabled = normalizeBoolean(patch.writes_enabled, 'writes_enabled');
110
+ if (Object.hasOwn(patch, 'require_authentication')) normalized.requireAuthentication = normalizeBoolean(patch.require_authentication, 'require_authentication');
111
+ if (Object.hasOwn(patch, 'allowed_origins')) normalized.allowedOrigins = normalizeMcpOrigins(patch.allowed_origins);
112
+ if (Object.hasOwn(patch, 'allowed_hosts')) normalized.allowedHosts = normalizeMcpHosts(patch.allowed_hosts);
113
+ if (Object.hasOwn(patch, 'max_items')) normalized.maxItems = normalizeInteger(patch.max_items, 'max_items', { min: 1, max: 500 });
114
+ if (Object.hasOwn(patch, 'max_result_bytes')) normalized.maxResultBytes = normalizeInteger(patch.max_result_bytes, 'max_result_bytes', { min: 10_000, max: 10_000_000 });
115
+ return normalized;
116
+ }
117
+
118
+ function rowList(value, name, normalize) {
119
+ try {
120
+ return normalize(value);
121
+ } catch (error) {
122
+ if (error?.code === 'INVALID_MCP_CONFIG') throw error;
123
+ throw configError(`${name} contains invalid saved data`);
124
+ }
125
+ }
126
+
127
+ export function mcpRuntimeSettingsFromRow(row) {
128
+ if (!row) throw configError('MCP settings are missing; run YunCMS bootstrap');
129
+ return Object.freeze({
130
+ enabled: Boolean(row.enabled),
131
+ writesEnabled: Boolean(row.writes_enabled),
132
+ requireAuthentication: Boolean(row.require_authentication),
133
+ allowedOrigins: Object.freeze(rowList(row.allowed_origins, 'allowed_origins', normalizeMcpOrigins)),
134
+ allowedHosts: Object.freeze(rowList(row.allowed_hosts, 'allowed_hosts', normalizeMcpHosts)),
135
+ maxItems: normalizeInteger(row.max_items, 'max_items', { min: 1, max: 500 }),
136
+ maxResultBytes: normalizeInteger(row.max_result_bytes, 'max_result_bytes', { min: 10_000, max: 10_000_000 }),
137
+ });
138
+ }
139
+
140
+ export function mcpAdminSettingsFromRow(row) {
141
+ const runtime = mcpRuntimeSettingsFromRow(row);
142
+ return {
143
+ enabled: runtime.enabled,
144
+ writes_enabled: runtime.writesEnabled,
145
+ require_authentication: runtime.requireAuthentication,
146
+ allowed_origins: [...runtime.allowedOrigins],
147
+ allowed_hosts: [...runtime.allowedHosts],
148
+ max_items: runtime.maxItems,
149
+ max_result_bytes: runtime.maxResultBytes,
150
+ updated_at: row.updated_at ?? null,
151
+ };
152
+ }
153
+
154
+ export { configError };
@@ -0,0 +1,78 @@
1
+ import {
2
+ mcpAdminSettingsFromRow,
3
+ mcpRuntimeSettingsFromRow,
4
+ normalizeMcpSettingsPatch,
5
+ } from './config.js';
6
+
7
+ function settingsError(code, message) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ function columnAssignments(normalized) {
14
+ const assignments = [];
15
+ const params = [];
16
+ const add = (column, value) => {
17
+ assignments.push(`${column} = ?`);
18
+ params.push(value);
19
+ };
20
+ if (Object.hasOwn(normalized, 'enabled')) add('enabled', normalized.enabled ? 1 : 0);
21
+ if (Object.hasOwn(normalized, 'writesEnabled')) add('writes_enabled', normalized.writesEnabled ? 1 : 0);
22
+ if (Object.hasOwn(normalized, 'requireAuthentication')) add('require_authentication', normalized.requireAuthentication ? 1 : 0);
23
+ if (Object.hasOwn(normalized, 'allowedOrigins')) add('allowed_origins', JSON.stringify(normalized.allowedOrigins));
24
+ if (Object.hasOwn(normalized, 'allowedHosts')) add('allowed_hosts', JSON.stringify(normalized.allowedHosts));
25
+ if (Object.hasOwn(normalized, 'maxItems')) add('max_items', normalized.maxItems);
26
+ if (Object.hasOwn(normalized, 'maxResultBytes')) add('max_result_bytes', normalized.maxResultBytes);
27
+ return { assignments, params };
28
+ }
29
+
30
+ export class McpSettingsStore {
31
+ constructor({ database } = {}) {
32
+ if (!database) throw new Error('MCP settings store requires database');
33
+ this.database = database;
34
+ }
35
+
36
+ async #row() {
37
+ const [rows] = await this.database.query(
38
+ `SELECT id, enabled, writes_enabled, require_authentication, allowed_origins,
39
+ allowed_hosts, max_items, max_result_bytes, updated_at
40
+ FROM yuncms_mcp_settings
41
+ WHERE id = 1
42
+ LIMIT 1`,
43
+ );
44
+ const row = rows[0];
45
+ if (!row) throw settingsError('DATABASE_MIGRATION_REQUIRED', 'MCP settings are missing; run YunCMS bootstrap');
46
+ return row;
47
+ }
48
+
49
+ async readAdmin() {
50
+ return mcpAdminSettingsFromRow(await this.#row());
51
+ }
52
+
53
+ async readRuntime() {
54
+ return mcpRuntimeSettingsFromRow(await this.#row());
55
+ }
56
+
57
+ async update(patch = {}) {
58
+ const normalized = normalizeMcpSettingsPatch(patch);
59
+ const current = await this.#row();
60
+ const { assignments, params } = columnAssignments(normalized);
61
+ if (assignments.length === 0) throw settingsError('INVALID_MCP_CONFIG', 'MCP settings patch has no effective changes');
62
+
63
+ const effectiveEnabled = Object.hasOwn(normalized, 'enabled') ? normalized.enabled : Boolean(current.enabled);
64
+ const effectiveAllowedHosts = Object.hasOwn(normalized, 'allowedHosts')
65
+ ? normalized.allowedHosts
66
+ : mcpRuntimeSettingsFromRow(current).allowedHosts;
67
+ if (effectiveEnabled && effectiveAllowedHosts.length === 0) {
68
+ throw settingsError('INVALID_MCP_CONFIG', 'Add at least one allowed host before enabling MCP');
69
+ }
70
+
71
+ params.push(1);
72
+ await this.database.query(
73
+ `UPDATE yuncms_mcp_settings SET ${assignments.join(', ')} WHERE id = ?`,
74
+ params,
75
+ );
76
+ return this.readAdmin();
77
+ }
78
+ }
package/src/mcp.js CHANGED
@@ -327,10 +327,55 @@ export function createMcpAccessGuard(mcpConfig = {}) {
327
327
  };
328
328
  }
329
329
 
330
- export function createMcpRouter({ config, logger = console } = {}) {
331
- if (!config?.mcp?.enabled) return null;
330
+ export function requireMcpAdministrator(req) {
331
+ if (req?.authMethod === 'public' || !req?.accountability?.user) {
332
+ const error = new Error('MCP settings require an authenticated YunCMS account');
333
+ error.code = 'UNAUTHORIZED';
334
+ throw error;
335
+ }
336
+ if (req.accountability?.admin === true || req.accountability?.system === true) return;
337
+ const error = new Error('MCP settings require administrator access');
338
+ error.code = 'FORBIDDEN';
339
+ throw error;
340
+ }
341
+
342
+ export function createMcpRouter({ settingsStore, logger = console } = {}) {
343
+ if (!settingsStore) throw new Error('MCP settings store is required');
332
344
  const router = express.Router();
333
- router.use(createMcpAccessGuard(config.mcp));
345
+
346
+ router.get('/settings', async (req, res, next) => {
347
+ try {
348
+ requireMcpAdministrator(req);
349
+ return res.json({ data: await settingsStore.readAdmin(), request_id: req.id });
350
+ } catch (error) {
351
+ return next(error);
352
+ }
353
+ });
354
+
355
+ router.patch('/settings', async (req, res, next) => {
356
+ try {
357
+ requireMcpAdministrator(req);
358
+ return res.json({ data: await settingsStore.update(req.body ?? {}), request_id: req.id });
359
+ } catch (error) {
360
+ return next(error);
361
+ }
362
+ });
363
+
364
+ router.use(async (req, res, next) => {
365
+ try {
366
+ const mcpConfig = await settingsStore.readRuntime();
367
+ if (!mcpConfig.enabled) {
368
+ return res.status(404).json({
369
+ errors: [{ code: 'MCP_DISABLED', message: 'MCP is disabled', request_id: req.id ?? null }],
370
+ });
371
+ }
372
+ req.mcpConfig = mcpConfig;
373
+ return createMcpAccessGuard(mcpConfig)(req, res, next);
374
+ } catch (error) {
375
+ return next(error);
376
+ }
377
+ });
378
+
334
379
  router.all('/', (req, res, next) => {
335
380
  if (req.method === 'POST') return next();
336
381
  res.set('allow', 'POST');
@@ -344,7 +389,7 @@ export function createMcpRouter({ config, logger = console } = {}) {
344
389
  code: error?.code ?? null,
345
390
  });
346
391
  const handler = createMcpHandler(
347
- () => createRequestMcpServer(req, config.mcp),
392
+ () => createRequestMcpServer(req, req.mcpConfig),
348
393
  { legacy: 'stateless', onerror: reportError },
349
394
  );
350
395
  const nodeHandler = toNodeHandler(handler, { onerror: reportError });