@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 +2 -2
- package/src/error-response.js +1 -0
- package/src/mcp/config.js +154 -0
- package/src/mcp/settings-store.js +78 -0
- package/src/mcp.js +49 -4
- package/src/server.js +4 -2
- package/studio-dist/assets/index-B5vuZY2c.js +11 -0
- package/studio-dist/assets/{index-CeImMX2E.css → index-sB3p3QRk.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-CNSmrNm2.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-api",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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",
|
package/src/error-response.js
CHANGED
|
@@ -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
|
|
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 });
|
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
|
|
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
|
+
});
|