@yunsoft/yuncms-api 0.1.5 → 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/src/mcp.js ADDED
@@ -0,0 +1,364 @@
1
+ import express from 'express';
2
+ import { toNodeHandler } from '@modelcontextprotocol/node';
3
+ import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
4
+ import {
5
+ readManyWithRelations,
6
+ readOneWithRelations,
7
+ } from '@yunsoft/yuncms-core';
8
+ import * as z from 'zod/v4';
9
+
10
+ import { serviceOptionsFromRequest } from './service-options.js';
11
+
12
+ const READ_TOOL_NAMES = Object.freeze([
13
+ 'schema.list_collections',
14
+ 'schema.describe_collection',
15
+ 'items.read_many',
16
+ 'items.read_one',
17
+ ]);
18
+ const WRITE_TOOL_NAMES = Object.freeze([
19
+ 'items.create',
20
+ 'items.update',
21
+ 'items.delete',
22
+ ]);
23
+ const SAFE_TOOL_ERROR_CODES = new Set([
24
+ 'FORBIDDEN',
25
+ 'FORBIDDEN_FIELD',
26
+ 'COLLECTION_NOT_FOUND',
27
+ 'INVALID_QUERY',
28
+ 'QUERY_COST_LIMIT',
29
+ 'QUERY_RELATION_DEPTH_LIMIT',
30
+ 'INVALID_PAYLOAD',
31
+ 'REQUIRED_FIELD_MISSING',
32
+ 'FIELD_READ_ONLY',
33
+ 'FILTER_REQUIRED',
34
+ 'VALIDATION_FAILED',
35
+ 'VALIDATION_BULK_LIMIT',
36
+ ]);
37
+
38
+ const collectionSchema = z.string()
39
+ .min(1)
40
+ .max(64)
41
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
42
+ const delimitedSchema = z.union([
43
+ z.string().min(1),
44
+ z.array(z.string().min(1)).max(100),
45
+ ]);
46
+ const filterSchema = z.record(z.string(), z.unknown());
47
+ const aggregateSchema = z.record(
48
+ z.string(),
49
+ z.union([z.string().min(1), z.array(z.string().min(1)).max(20)]),
50
+ );
51
+
52
+ function mcpError(code, message) {
53
+ const error = new Error(message);
54
+ error.code = code;
55
+ return error;
56
+ }
57
+
58
+ function safeToolError(error) {
59
+ const code = SAFE_TOOL_ERROR_CODES.has(error?.code) ? error.code : 'INTERNAL_ERROR';
60
+ return {
61
+ code,
62
+ message: code === 'INTERNAL_ERROR' ? 'Tool execution failed' : String(error?.message ?? 'Request failed'),
63
+ ...(code !== 'INTERNAL_ERROR' && error?.path ? { path: error.path } : {}),
64
+ };
65
+ }
66
+
67
+ export function createToolResult(data, maxResultBytes = 1_000_000) {
68
+ const payload = { data };
69
+ const text = JSON.stringify(payload);
70
+ if (Buffer.byteLength(text, 'utf8') > maxResultBytes) {
71
+ return {
72
+ content: [{
73
+ type: 'text',
74
+ text: JSON.stringify({
75
+ error: {
76
+ code: 'MCP_RESULT_TOO_LARGE',
77
+ message: `MCP result exceeds the configured ${maxResultBytes} byte limit`,
78
+ },
79
+ }),
80
+ }],
81
+ isError: true,
82
+ };
83
+ }
84
+ return {
85
+ content: [{ type: 'text', text }],
86
+ structuredContent: payload,
87
+ };
88
+ }
89
+
90
+ function toolHandler(handler, maxResultBytes) {
91
+ return async (args) => {
92
+ try {
93
+ return createToolResult(await handler(args), maxResultBytes);
94
+ } catch (error) {
95
+ return {
96
+ content: [{ type: 'text', text: JSON.stringify({ error: safeToolError(error) }) }],
97
+ isError: true,
98
+ };
99
+ }
100
+ };
101
+ }
102
+
103
+ function permissionService(req) {
104
+ const Service = req.context.services.PermissionsService;
105
+ return new Service(serviceOptionsFromRequest(req));
106
+ }
107
+
108
+ function itemService(req, collection) {
109
+ const Service = req.context.services.ItemsService;
110
+ return new Service(collection, serviceOptionsFromRequest(req));
111
+ }
112
+
113
+ async function actionAllowed(permissions, action, collection) {
114
+ try {
115
+ await permissions.resolve(action, collection);
116
+ return true;
117
+ } catch (error) {
118
+ if (error?.code === 'FORBIDDEN') return false;
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ export async function listReadableCollections(req, { maxItems = 100 } = {}) {
124
+ const snapshot = req.context.schema;
125
+ const candidates = Object.values(snapshot?.collections ?? {})
126
+ .filter((collection) => !collection.system)
127
+ .sort((left, right) => left.collection.localeCompare(right.collection));
128
+ const permissions = permissionService(req);
129
+ const visible = [];
130
+
131
+ for (const collection of candidates) {
132
+ if (visible.length >= maxItems) break;
133
+ if (!await actionAllowed(permissions, 'read', collection.collection)) continue;
134
+ visible.push({
135
+ collection: collection.collection,
136
+ name: collection.name ?? collection.collection,
137
+ primary_key: collection.primary_key,
138
+ });
139
+ }
140
+ return visible;
141
+ }
142
+
143
+ export async function describeReadableCollection(req, collection) {
144
+ const snapshot = req.context.schema;
145
+ const schema = snapshot?.collections?.[collection];
146
+ if (!schema || schema.system) throw mcpError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
147
+
148
+ const permissions = permissionService(req);
149
+ const readPermission = await permissions.resolve('read', collection);
150
+ const visibleFields = readPermission.fields == null
151
+ ? Object.keys(schema.fields ?? {})
152
+ : readPermission.fields.filter((field) => schema.fields?.[field]);
153
+ const fields = visibleFields.map((field) => {
154
+ const metadata = schema.fields[field];
155
+ const relation = snapshot.relationByManyField?.get(`${collection}.${field}`) ?? null;
156
+ return {
157
+ field,
158
+ name: metadata.name ?? field,
159
+ type: metadata.type,
160
+ required: Boolean(metadata.required),
161
+ readonly: Boolean(metadata.readonly),
162
+ ...(relation ? {
163
+ relation: {
164
+ collection: relation.one_collection,
165
+ field: relation.one_field ?? snapshot.collections?.[relation.one_collection]?.primary_key ?? 'id',
166
+ },
167
+ } : {}),
168
+ };
169
+ });
170
+
171
+ return {
172
+ collection,
173
+ name: schema.name ?? collection,
174
+ primary_key: schema.primary_key,
175
+ capabilities: {
176
+ read: true,
177
+ create: await actionAllowed(permissions, 'create', collection),
178
+ update: await actionAllowed(permissions, 'update', collection),
179
+ delete: await actionAllowed(permissions, 'delete', collection),
180
+ },
181
+ fields,
182
+ };
183
+ }
184
+
185
+ function readManyInput(maxItems) {
186
+ return z.object({
187
+ collection: collectionSchema,
188
+ fields: delimitedSchema.optional(),
189
+ expand: delimitedSchema.optional(),
190
+ filter: filterSchema.optional(),
191
+ search: z.string().max(200).optional(),
192
+ sort: delimitedSchema.optional(),
193
+ aggregate: aggregateSchema.optional(),
194
+ groupBy: delimitedSchema.optional(),
195
+ limit: z.number().int().min(1).max(maxItems).optional(),
196
+ offset: z.number().int().min(0).max(1_000_000).optional(),
197
+ });
198
+ }
199
+
200
+ export function registerMcpTools(server, req, {
201
+ writesEnabled = false,
202
+ maxItems = 100,
203
+ maxResultBytes = 1_000_000,
204
+ } = {}) {
205
+ const wrap = (handler) => toolHandler(handler, maxResultBytes);
206
+
207
+ server.registerTool('schema.list_collections', {
208
+ title: 'List readable YunCMS collections',
209
+ description: 'List non-system collections the current YunCMS identity may read.',
210
+ inputSchema: z.object({}),
211
+ annotations: { readOnlyHint: true, destructiveHint: false },
212
+ }, wrap(async () => ({ collections: await listReadableCollections(req, { maxItems }) })));
213
+
214
+ server.registerTool('schema.describe_collection', {
215
+ title: 'Describe a YunCMS collection',
216
+ description: 'Describe fields, direct relations and allowed CRUD actions for a readable collection.',
217
+ inputSchema: z.object({ collection: collectionSchema }),
218
+ annotations: { readOnlyHint: true, destructiveHint: false },
219
+ }, wrap(async ({ collection }) => describeReadableCollection(req, collection)));
220
+
221
+ server.registerTool('items.read_many', {
222
+ title: 'Read YunCMS items',
223
+ description: 'Read items with YunCMS filters, search, sorting, aggregates and relation-aware fields. All normal RBAC and query-cost limits apply.',
224
+ inputSchema: readManyInput(maxItems),
225
+ annotations: { readOnlyHint: true, destructiveHint: false },
226
+ }, wrap(async ({ collection, ...query }) => readManyWithRelations({
227
+ collection,
228
+ query: {
229
+ ...query,
230
+ limit: query.limit ?? Math.min(100, maxItems),
231
+ },
232
+ options: serviceOptionsFromRequest(req),
233
+ ItemsServiceClass: req.context.services.ItemsService,
234
+ })));
235
+
236
+ server.registerTool('items.read_one', {
237
+ title: 'Read one YunCMS item',
238
+ description: 'Read one item by id with relation-aware field selection. Normal read permissions apply.',
239
+ inputSchema: z.object({
240
+ collection: collectionSchema,
241
+ id: z.string().min(1).max(191),
242
+ fields: delimitedSchema.optional(),
243
+ expand: delimitedSchema.optional(),
244
+ }),
245
+ annotations: { readOnlyHint: true, destructiveHint: false },
246
+ }, wrap(async ({ collection, id, fields, expand }) => readOneWithRelations({
247
+ collection,
248
+ id,
249
+ query: { ...(fields ? { fields } : {}), ...(expand ? { expand } : {}) },
250
+ options: serviceOptionsFromRequest(req),
251
+ ItemsServiceClass: req.context.services.ItemsService,
252
+ })));
253
+
254
+ if (!writesEnabled) return server;
255
+
256
+ server.registerTool('items.create', {
257
+ title: 'Create a YunCMS item',
258
+ description: 'Create one item through ItemsService. Create field permissions, validation and hooks apply.',
259
+ inputSchema: z.object({
260
+ collection: collectionSchema,
261
+ data: z.record(z.string(), z.unknown()),
262
+ }),
263
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
264
+ }, wrap(async ({ collection, data }) => itemService(req, collection).createOne(data)));
265
+
266
+ server.registerTool('items.update', {
267
+ title: 'Update a YunCMS item',
268
+ description: 'Update one item by id through ItemsService. Update field permissions, validation and hooks apply.',
269
+ inputSchema: z.object({
270
+ collection: collectionSchema,
271
+ id: z.string().min(1).max(191),
272
+ data: z.record(z.string(), z.unknown()),
273
+ }),
274
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
275
+ }, wrap(async ({ collection, id, data }) => itemService(req, collection).updateOne(id, data)));
276
+
277
+ server.registerTool('items.delete', {
278
+ title: 'Delete a YunCMS item',
279
+ description: 'Delete one item by id through ItemsService. Delete permissions and hooks apply.',
280
+ inputSchema: z.object({
281
+ collection: collectionSchema,
282
+ id: z.string().min(1).max(191),
283
+ }),
284
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
285
+ }, wrap(async ({ collection, id }) => ({
286
+ deleted: await itemService(req, collection).deleteOne(id),
287
+ })));
288
+
289
+ return server;
290
+ }
291
+
292
+ export function createRequestMcpServer(req, mcpConfig = {}) {
293
+ const server = new McpServer(
294
+ { name: 'yuncms', version: '0.1.6' },
295
+ {
296
+ instructions: mcpConfig.writesEnabled
297
+ ? 'Use schema tools before item tools when the collection shape is unknown. YunCMS RBAC applies to every tool call.'
298
+ : 'This YunCMS MCP endpoint is read-only. Use schema tools before item tools when the collection shape is unknown. YunCMS RBAC applies to every tool call.',
299
+ },
300
+ );
301
+ registerMcpTools(server, req, mcpConfig);
302
+ return server;
303
+ }
304
+
305
+ export function createMcpAccessGuard(mcpConfig = {}) {
306
+ const allowedOrigins = new Set((mcpConfig.allowedOrigins ?? []).map((value) => String(value).toLowerCase()));
307
+ const allowedHosts = new Set((mcpConfig.allowedHosts ?? []).map((value) => String(value).toLowerCase()));
308
+ return (req, res, next) => {
309
+ const host = String(req.get?.('host') ?? '').trim().toLowerCase();
310
+ if (allowedHosts.size > 0 && (!host || !allowedHosts.has(host))) {
311
+ return res.status(403).json({
312
+ errors: [{ code: 'MCP_HOST_FORBIDDEN', message: 'MCP request host is not allowed', request_id: req.id ?? null }],
313
+ });
314
+ }
315
+ const origin = req.get?.('origin') ?? null;
316
+ if (origin && !allowedOrigins.has(String(origin).toLowerCase())) {
317
+ return res.status(403).json({
318
+ errors: [{ code: 'MCP_ORIGIN_FORBIDDEN', message: 'MCP request origin is not allowed', request_id: req.id ?? null }],
319
+ });
320
+ }
321
+ if (mcpConfig.requireAuthentication !== false && req.authMethod === 'public') {
322
+ return res.status(401).json({
323
+ errors: [{ code: 'UNAUTHORIZED', message: 'MCP requires authenticated YunCMS access', request_id: req.id ?? null }],
324
+ });
325
+ }
326
+ return next();
327
+ };
328
+ }
329
+
330
+ export function createMcpRouter({ config, logger = console } = {}) {
331
+ if (!config?.mcp?.enabled) return null;
332
+ const router = express.Router();
333
+ router.use(createMcpAccessGuard(config.mcp));
334
+ router.all('/', (req, res, next) => {
335
+ if (req.method === 'POST') return next();
336
+ res.set('allow', 'POST');
337
+ return res.status(405).json({
338
+ errors: [{ code: 'METHOD_NOT_ALLOWED', message: 'MCP accepts POST requests only', request_id: req.id ?? null }],
339
+ });
340
+ });
341
+ router.post('/', async (req, res, next) => {
342
+ const reportError = (error) => logger.error?.('YunCMS MCP request failed', {
343
+ requestId: req.id ?? null,
344
+ code: error?.code ?? null,
345
+ });
346
+ const handler = createMcpHandler(
347
+ () => createRequestMcpServer(req, config.mcp),
348
+ { legacy: 'stateless', onerror: reportError },
349
+ );
350
+ const nodeHandler = toNodeHandler(handler, { onerror: reportError });
351
+ try {
352
+ await nodeHandler(req, res, req.body);
353
+ } catch (error) {
354
+ if (!res.headersSent) return next(error);
355
+ reportError(error);
356
+ } finally {
357
+ await handler.close().catch(reportError);
358
+ }
359
+ return undefined;
360
+ });
361
+ return router;
362
+ }
363
+
364
+ export { READ_TOOL_NAMES, WRITE_TOOL_NAMES };
package/src/rate-limit.js CHANGED
@@ -5,55 +5,85 @@ function rateLimitError(retryAfterSeconds) {
5
5
  return error;
6
6
  }
7
7
 
8
- export function createFixedWindowRateLimit({
9
- windowMs,
10
- max,
11
- key = (req) => req.ip || req.socket?.remoteAddress || 'unknown',
12
- maxBuckets = 10_000,
13
- now = () => Date.now(),
14
- } = {}) {
15
- if (!Number.isInteger(windowMs) || windowMs < 1000) throw new Error('Rate-limit windowMs must be at least 1000');
16
- if (!Number.isInteger(max) || max < 1) throw new Error('Rate-limit max must be a positive integer');
17
- if (!Number.isInteger(maxBuckets) || maxBuckets < 1) throw new Error('Rate-limit maxBuckets must be a positive integer');
8
+ function sharedStateError() {
9
+ const error = new Error('Shared rate-limit state is unavailable');
10
+ error.code = 'SHARED_RATE_LIMIT_UNAVAILABLE';
11
+ error.status = 503;
12
+ return error;
13
+ }
18
14
 
15
+ function createMemoryConsumer({ windowMs, maxBuckets, now }) {
19
16
  const buckets = new Map();
20
-
21
17
  function ensureCapacity(timestamp) {
22
18
  for (const [bucketKey, bucket] of buckets) {
23
19
  if (bucket.resetAt <= timestamp) buckets.delete(bucketKey);
24
20
  }
25
-
26
21
  while (buckets.size >= maxBuckets) {
27
22
  const oldestKey = buckets.keys().next().value;
28
23
  if (oldestKey === undefined) break;
29
24
  buckets.delete(oldestKey);
30
25
  }
31
26
  }
32
-
33
- return (req, res, next) => {
27
+ return (bucketKey) => {
34
28
  const timestamp = now();
35
- const bucketKey = String(key(req));
36
29
  let bucket = buckets.get(bucketKey);
37
-
38
30
  if (!bucket || bucket.resetAt <= timestamp) {
39
31
  if (!bucket) ensureCapacity(timestamp);
40
32
  bucket = { count: 0, resetAt: timestamp + windowMs };
41
33
  buckets.set(bucketKey, bucket);
42
34
  }
43
-
44
35
  bucket.count += 1;
36
+ return { count: bucket.count, resetAt: bucket.resetAt, retryAfterMs: Math.max(1, bucket.resetAt - timestamp) };
37
+ };
38
+ }
39
+
40
+ export function createFixedWindowRateLimit({
41
+ windowMs,
42
+ max,
43
+ key = (req) => req.ip || req.socket?.remoteAddress || 'unknown',
44
+ maxBuckets = 10_000,
45
+ now = () => Date.now(),
46
+ store = null,
47
+ scope = 'api',
48
+ failureMode = 'best-effort',
49
+ logger = console,
50
+ } = {}) {
51
+ if (!Number.isInteger(windowMs) || windowMs < 1000) throw new Error('Rate-limit windowMs must be at least 1000');
52
+ if (!Number.isInteger(max) || max < 1) throw new Error('Rate-limit max must be a positive integer');
53
+ if (!Number.isInteger(maxBuckets) || maxBuckets < 1) throw new Error('Rate-limit maxBuckets must be a positive integer');
54
+ if (!['best-effort', 'required'].includes(failureMode)) throw new Error('Invalid rate-limit failure mode');
55
+
56
+ const consumeMemory = createMemoryConsumer({ windowMs, maxBuckets, now });
57
+
58
+ return async (req, res, next) => {
59
+ const bucketKey = String(key(req));
60
+ let bucket;
61
+ if (store) {
62
+ try {
63
+ bucket = await store.consume(bucketKey, { windowMs, max, scope });
64
+ } catch (error) {
65
+ if (failureMode === 'required') return next(sharedStateError());
66
+ logger?.warn?.('Shared rate limiter unavailable; using process-local fallback', {
67
+ scope,
68
+ requestId: req.id ?? null,
69
+ code: error?.code ?? null,
70
+ });
71
+ bucket = consumeMemory(bucketKey);
72
+ }
73
+ } else {
74
+ bucket = consumeMemory(bucketKey);
75
+ }
76
+
45
77
  const remaining = Math.max(0, max - bucket.count);
46
- const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - timestamp) / 1000));
78
+ const retryAfterSeconds = Math.max(1, Math.ceil(bucket.retryAfterMs / 1000));
47
79
  res.set('x-ratelimit-limit', String(max));
48
80
  res.set('x-ratelimit-remaining', String(remaining));
49
81
  res.set('x-ratelimit-reset', String(Math.ceil(bucket.resetAt / 1000)));
50
82
 
51
83
  if (bucket.count > max) {
52
84
  res.set('retry-after', String(retryAfterSeconds));
53
- next(rateLimitError(retryAfterSeconds));
54
- return;
85
+ return next(rateLimitError(retryAfterSeconds));
55
86
  }
56
-
57
- next();
87
+ return next();
58
88
  };
59
89
  }