@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.
- package/package.json +2 -2
- package/src/ai/config.js +146 -0
- package/src/ai/secret-key.js +57 -0
- package/src/ai/service.js +268 -0
- package/src/ai/settings-store.js +135 -0
- package/src/ai/tools.js +304 -0
- package/src/app.js +4 -0
- package/src/error-response.js +16 -0
- package/src/mcp/config.js +154 -0
- package/src/mcp/settings-store.js +78 -0
- package/src/mcp.js +49 -4
- package/src/routes/ai.js +80 -0
- package/src/routes/studio-navigation.js +59 -0
- package/src/server.js +14 -1
- package/studio-dist/assets/index-B5vuZY2c.js +11 -0
- package/studio-dist/assets/{index-C1dF_B2k.css → index-sB3p3QRk.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-DqcKf_lG.js +0 -9
package/src/ai/tools.js
ADDED
|
@@ -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());
|
package/src/error-response.js
CHANGED
|
@@ -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
|
|
331
|
-
if (!
|
|
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
|
-
|
|
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,
|
|
392
|
+
() => createRequestMcpServer(req, req.mcpConfig),
|
|
348
393
|
{ legacy: 'stateless', onerror: reportError },
|
|
349
394
|
);
|
|
350
395
|
const nodeHandler = toNodeHandler(handler, { onerror: reportError });
|