@yunsoft/yuncms-api 0.1.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-api",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Express API runtime and bundled Studio server for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "@modelcontextprotocol/node": "2.0.0",
36
36
  "@modelcontextprotocol/server": "2.0.0",
37
37
  "@node-saml/node-saml": "5.1.0",
38
- "@yunsoft/yuncms-core": "0.1.10",
38
+ "@yunsoft/yuncms-core": "0.1.11",
39
39
  "express": "5.2.1",
40
40
  "ldapts": "9.0.0",
41
41
  "openid-client": "6.8.7",
@@ -49,6 +49,7 @@ const STATUS_BY_CODE = new Map([
49
49
  ['INVALID_MAIL_MESSAGE', 400],
50
50
  ['INVALID_AI_REQUEST', 400],
51
51
  ['INVALID_AI_CONFIG', 400],
52
+ ['INVALID_MCP_CONFIG', 400],
52
53
  ['AI_TOOL_ARGUMENTS_INVALID', 400],
53
54
  ['STORAGE_NOT_FOUND', 400],
54
55
  ['STORAGE_INVENTORY_UNSUPPORTED', 400],
@@ -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 });
package/src/server.js CHANGED
@@ -28,6 +28,7 @@ import { loadExternalAuthConfig } from './external-auth/config.js';
28
28
  import { ExternalAuthProviderRegistry } from './external-auth/providers.js';
29
29
  import { loadExtensionRuntime } from './extensions/runtime.js';
30
30
  import { createMcpRouter } from './mcp.js';
31
+ import { McpSettingsStore } from './mcp/settings-store.js';
31
32
  import { createAiRouter } from './routes/ai.js';
32
33
 
33
34
  loadEnvFileIfPresent();
@@ -134,7 +135,8 @@ async function start() {
134
135
  });
135
136
  const aiAssistant = new AiAssistantService({ settingsStore: aiSettingsStore, logger });
136
137
  const aiRouter = createAiRouter({ assistant: aiAssistant, settingsStore: aiSettingsStore });
137
- const mcpRouter = createMcpRouter({ config, logger });
138
+ const mcpSettingsStore = new McpSettingsStore({ database: pool });
139
+ const mcpRouter = createMcpRouter({ settingsStore: mcpSettingsStore, logger });
138
140
  mailer?.setEmitter(emitter);
139
141
  registerInternalAudit({ emitter, services });
140
142
 
@@ -219,4 +221,4 @@ start().catch(async (error) => {
219
221
  await redisClient?.close().catch(() => {});
220
222
  await closeDatabasePool(pool).catch(() => {});
221
223
  process.exit(1);
222
- });
224
+ });