@mebular/mcp 0.0.0-stage → 0.1.0

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/server.mjs ADDED
@@ -0,0 +1,416 @@
1
+ // MCP server 装配(G6.2 + R1):stdio 传输 + **27 工具**(记忆 11 + 任务 16)+ memory_policy prompt。
2
+ // 全部委托 MemoryService(D33 单一实现)。
3
+
4
+ import { McpServer } from '@modelcontextprotocol/server';
5
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile, rm, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { MemoryService } from '@mebular/core';
10
+ import { createMebular, homeDir, loadConfigFile } from './config.mjs';
11
+ import { registerTools } from './tools.mjs';
12
+ import { startHttpServer, acquireLock, pidAlive } from './serve.mjs';
13
+ import { createJoinServer } from './jointoken.mjs';
14
+ import { clearApplyPending, readApplyPending, writeApplyResult, writeServeReady } from './config-apply.mjs';
15
+ import { buildSettings } from './admin.mjs';
16
+ import { isProvisionHome } from './provision.mjs';
17
+ import { acquireActiveLock, profileIdForHome, resolveCloudRoot } from './profiles.mjs';
18
+ import { advertiseHost, endpointHostname, isLoopbackHost } from './lan-host.mjs';
19
+
20
+ const MEMORY_POLICY = `# Mebular 记忆使用规约(memory_policy)
21
+ 1. 先查后写:写入前先用 memory_query/memory_search 查重,避免重复。
22
+ 2. 类型选择:稳定事实→fact;用户偏好→preference;会话/任务过程→episode;可复用步骤→skill;观察→observation。
23
+ 3. 时效:有有效期的用 metadata.expiresAt;过期事实不要当现状。
24
+ 4. 关系:相关对象用 metadata.relatedTo 精确关联(仅链接已存在节点)。
25
+ 5. 隐私:不要写入密钥、口令、完整身份证件等高敏感信息。
26
+ 6. 无结果别编:召回为空就如实说明,不要臆造记忆。`;
27
+
28
+ export function buildServer(service, options = {}) {
29
+ const server = new McpServer({ name: 'mebular', version: '0.1.0' });
30
+ registerTools(server, service, options);
31
+ server.registerPrompt(
32
+ 'memory_policy',
33
+ {
34
+ title: 'Mebular 记忆使用规约',
35
+ description: '先查后写 / 类型选择 / 时效 / 隐私 / 关系 / 无结果别编',
36
+ },
37
+ () => ({
38
+ messages: [
39
+ {
40
+ role: 'user',
41
+ content: { type: 'text', text: MEMORY_POLICY },
42
+ },
43
+ ],
44
+ }),
45
+ );
46
+ return server;
47
+ }
48
+
49
+ /**
50
+ * G2:本次启动是否为「保存即生效」的候选实例?是 → 用**新实例的运行时**计算本次改动字段的
51
+ * 三元组(已配置/实际),写 <home>/config-apply.result.json(status=applied + verify[])。
52
+ * 校验不一致(如环境变量优先)不影响启动,交由前端红色提示。
53
+ */
54
+ async function finalizePendingApply({ home, app, service, config, runtime }) {
55
+ const pending = await readApplyPending(home);
56
+ if (!pending || !Array.isArray(pending.fields) || pending.fields.length === 0) return;
57
+ // 监督进程已判定回滚(新配置启动失败):本实例是「回滚后的旧配置」——不得写 applied
58
+ if (pending.rollingBack === true) return;
59
+ let verify = [];
60
+ try {
61
+ const settings = await buildSettings({ app, service, config, runtime, home });
62
+ const rows = new Map((settings.effective ?? []).map((row) => [row.path, row]));
63
+ verify = pending.fields.map((path) => {
64
+ const row = rows.get(path);
65
+ if (!row) return { path, ok: null, note: '无三元组(非可编辑/只读项)' };
66
+ const ok = JSON.stringify(row.configured ?? null) === JSON.stringify(row.actual ?? null);
67
+ return { path, configured: row.configured ?? null, actual: row.actual ?? null, ok, reason: row.reason ?? null };
68
+ });
69
+ } catch (error) {
70
+ verify = [{ path: '*', ok: null, note: `校验失败:${String(error?.message ?? error)}` }];
71
+ }
72
+ await writeApplyResult(home, {
73
+ status: 'applied',
74
+ attempt: pending.attempt ?? null,
75
+ at: new Date().toISOString(),
76
+ fields: pending.fields,
77
+ verify,
78
+ waitedMs: null,
79
+ }).catch(() => undefined);
80
+ await clearApplyPending(home);
81
+ }
82
+
83
+ /**
84
+ * 启动 stdio MCP server;返回句柄用于收尾。
85
+ * F-CLI-2:守护(`serve`)在运行时拒绝启动 stdio —— 否则第二写者会同库写、并抢 P2P 监听端口。
86
+ * 客户端应指向守护的 HTTP `/mcp`(`mebular print-config --client <id> --url <daemon>/mcp`),或先停止守护。
87
+ */
88
+ export async function startStdioServer() {
89
+ const home = homeDir();
90
+ const lockPath = join(home, 'lock');
91
+ if (existsSync(lockPath)) {
92
+ let held = null;
93
+ try {
94
+ held = JSON.parse(await readFile(lockPath, 'utf-8'));
95
+ } catch {
96
+ held = null;
97
+ }
98
+ if (held?.pid && pidAlive(held.pid)) {
99
+ const error = new Error(
100
+ `守护已在运行(pid ${held.pid}):stdio 会与它争用存储与监听端口。`
101
+ + '请把客户端指向守护的 HTTP /mcp(mebular print-config --client <id> --url http://<host>:<port>/mcp),或先停止守护。',
102
+ );
103
+ error.code = 'MCP_STORAGE_LOCKED';
104
+ throw error;
105
+ }
106
+ }
107
+ const { app } = await createMebular();
108
+ const service = new MemoryService(app);
109
+ // stdio 也是「本进程唯一写者」:任务工具复用同一个 app(同一 EventLog),不再另开离线实例。
110
+ const handle = serveStdio(() => buildServer(service, { toolContext: { home, mebular: app } }));
111
+
112
+ const shutdown = async () => {
113
+ await handle.close().catch(() => undefined);
114
+ await app.shutdown().catch(() => undefined);
115
+ process.exit(0);
116
+ };
117
+ process.on('SIGINT', shutdown);
118
+ process.on('SIGTERM', shutdown);
119
+ return { handle, app, service };
120
+ }
121
+
122
+ function runtimeMcpPort(config, options = {}) {
123
+ return options.port ?? config?.mcp?.http?.port ?? 7331;
124
+ }
125
+
126
+ /**
127
+ * P1:统一 serve 生命周期(UNINITIALIZED → INITIALIZING → READY)。
128
+ *
129
+ * - 无论空 home 还是已初始化 home,都只启动**一个** HTTP server、只取**一次** <home>/lock;
130
+ * - 空 home 的 create/join 由同一进程热初始化(app / join service / 运行时快照就地切换),不重启、不换端口;
131
+ * - 失败由 serve.mjs 回滚本次落盘产物,再回到 UNINITIALIZED(锁不释放、不重抢)。
132
+ *
133
+ * 启动 Streamable HTTP server(G6.3)。
134
+ * 单实例:先取 <home>/lock;被占抛 MCP_STORAGE_LOCKED。
135
+ */
136
+ export async function startServeServer(options = {}) {
137
+ const home = homeDir();
138
+ const provisionStart = isProvisionHome(home, { storagePath: process.env.MEBULAR_STORAGE_PATH });
139
+ // F-NET-1(C):**先取单实例锁,再初始化网络**——第二写者应在绑定监听端口前就得到
140
+ // 可读的 MCP_STORAGE_LOCKED(此前 createMebular 先启网络 → 抢 4001 → 裸 EADDRINUSE)。
141
+ const preConfig = provisionStart ? {} : await loadConfigFile(home).catch(() => ({}));
142
+ const preStoragePath = process.env.MEBULAR_STORAGE_PATH
143
+ ?? preConfig.storagePath
144
+ ?? join(home, (preConfig.storageAdapter ?? 'json') === 'sqlite' ? 'store.sqlite' : 'store.jsonl');
145
+ const lock = await acquireLock(home, preStoragePath);
146
+
147
+ // 热初始化 / 正常启动共用:app、join 服务、运行时快照与生命周期资源。
148
+ let current = null;
149
+ let httpServer = null;
150
+
151
+ const stopBroadcast = (context) => {
152
+ if (context?.broadcastInterval) {
153
+ clearInterval(context.broadcastInterval);
154
+ context.broadcastInterval = null;
155
+ }
156
+ };
157
+
158
+ const stopRuntime = async (context) => {
159
+ if (!context) return;
160
+ stopBroadcast(context);
161
+ await context.joinServer?.close().catch(() => undefined);
162
+ await context.activeLock?.release?.().catch(() => undefined);
163
+ await context.app?.shutdown().catch(() => undefined);
164
+ };
165
+
166
+ const startBroadcast = (context) => {
167
+ if (typeof context.app.getNetEndpointsStatus !== 'function' || !context.app.getNetEndpointsStatus().enabled) {
168
+ return null;
169
+ }
170
+ // C5:地址自动广播的「可达性/地址变化」复查(app 侧节奏;库不装计时器)。
171
+ // 只在开启广播时挂载;10 分钟一次(unref,不拖住宿主退出);地址未变时 publish 自身去抖。
172
+ const interval = setInterval(() => {
173
+ void context.app.publishNetEndpoints?.('periodic').catch(() => undefined);
174
+ }, 600_000);
175
+ interval.unref?.();
176
+ return interval;
177
+ };
178
+
179
+ const finalizeRuntime = async (context) => {
180
+ await writeServeReady(home).catch(() => undefined);
181
+ await finalizePendingApply({
182
+ home,
183
+ app: context.app,
184
+ service: context.service,
185
+ config: context.config,
186
+ runtime: context.runtime,
187
+ }).catch(() => undefined);
188
+ context.broadcastInterval = startBroadcast(context);
189
+ };
190
+
191
+ /**
192
+ * 创建 app + join 服务 + 运行时快照。
193
+ * hot=true:create/join 已写盘,须在同一进程内接管;此时额外取 root 级单活锁并立即写 ready。
194
+ */
195
+ const startRuntime = async ({ serverContext, hot = false } = {}) => {
196
+ let context = null;
197
+ try {
198
+ const created = await createMebular();
199
+ const { app, storagePath, deviceId, config, effective } = created;
200
+ const service = new MemoryService(app);
201
+ const httpCfg = config?.mcp?.http ?? {};
202
+ const tlsKey = serverContext?.tlsKey ?? options.tlsKey ?? httpCfg.tlsKey;
203
+ const tlsCert = serverContext?.tlsCert ?? options.tlsCert ?? httpCfg.tlsCert;
204
+ const tlsRequested = serverContext
205
+ ? Boolean(serverContext.tls)
206
+ : (options.tls === true || httpCfg.tls === true || Boolean(tlsKey && tlsCert));
207
+ const tlsReady = Boolean(tlsKey && tlsCert);
208
+ if (!serverContext && tlsRequested && !tlsReady) {
209
+ const error = new Error(
210
+ 'MCP_INSECURE_CONFIG:mcp.http.tls=true 但缺少证书——请在 config.json 填写 mcp.http.tlsKey 与 mcp.http.tlsCert,或关闭 tls',
211
+ );
212
+ error.code = 'MCP_INSECURE_CONFIG';
213
+ throw error;
214
+ }
215
+ const mcpHost = serverContext?.host ?? options.host ?? httpCfg.host ?? '127.0.0.1';
216
+ const mcpPort = serverContext?.port ?? runtimeMcpPort(config, options);
217
+ const mcpAuth = serverContext?.auth ?? options.auth ?? httpCfg.auth ?? 'none';
218
+ const runtime = {
219
+ ...effective,
220
+ storagePath,
221
+ mcp: {
222
+ host: mcpHost,
223
+ port: mcpPort,
224
+ auth: mcpAuth,
225
+ tls: Boolean(tlsRequested && tlsReady),
226
+ tlsKeyConfigured: Boolean(tlsKey),
227
+ tlsCertConfigured: Boolean(tlsCert),
228
+ },
229
+ };
230
+ const joinConf = config.joinService;
231
+ const joinBind = joinConf?.bind ?? '0.0.0.0';
232
+ // F-C6:令牌里的 endpoint 必须是新设备可直达地址——bind 通配时取本机 LAN IPv4(无则回环并告警),
233
+ // 也允许 joinService.endpoint 显式固定(跨网段/NAT 场景)。
234
+ const joinAdvertise = advertiseHost(joinBind);
235
+ const joinEndpointDemo = joinConf?.enabled
236
+ ? (typeof joinConf.endpoint === 'string' && joinConf.endpoint
237
+ ? joinConf.endpoint
238
+ : `http://${joinAdvertise}:${joinConf.port ?? 4002}`)
239
+ : undefined;
240
+ const joinEndpointHost = joinEndpointDemo !== undefined ? endpointHostname(joinEndpointDemo) : null;
241
+ const joinEndpointLoopback = joinEndpointHost !== null && isLoopbackHost(joinEndpointHost);
242
+
243
+ context = {
244
+ app,
245
+ service,
246
+ config,
247
+ runtime,
248
+ deviceId,
249
+ storagePath,
250
+ namespace: config.sync?.namespaces?.[0] ?? 'tasks',
251
+ joinEndpoint: joinEndpointDemo,
252
+ joinServer: null,
253
+ joinPort: null,
254
+ broadcastInterval: null,
255
+ activeLock: null,
256
+ };
257
+
258
+ // W2 A4:join 服务由守护托管(令牌 → 委派证书)。fleet 不再托管生产 join。
259
+ if (joinConf?.enabled) {
260
+ try {
261
+ context.joinServer = await createJoinServer({
262
+ mebular: app,
263
+ deviceId,
264
+ storagePath,
265
+ bind: joinConf.bind ?? '0.0.0.0',
266
+ port: joinConf.port ?? 4002,
267
+ log: (m) => console.error(m),
268
+ });
269
+ } catch (error) {
270
+ // E:启动失败根因化——不笼统报「serve 起不来」,明确指出是可执行的修复动作
271
+ const bind = joinConf.bind ?? '0.0.0.0';
272
+ const port = joinConf.port ?? 4002;
273
+ const detail = {
274
+ at: new Date().toISOString(),
275
+ code: error?.code === 'EADDRINUSE' ? 'JOIN_PORT_IN_USE' : (error?.code ?? 'JOIN_START_FAILED'),
276
+ bind,
277
+ port,
278
+ message: error?.code === 'EADDRINUSE'
279
+ ? `joinService.port ${port} 被占(bind=${bind}):改端口(如 joinService.port=0 由系统分配)或释放占用`
280
+ : `joinService 启动失败(${error?.code ?? 'ERROR'},bind=${bind}:${port}):${error?.message ?? error}`,
281
+ };
282
+ await writeFile(join(home, 'join.error.json'), JSON.stringify(detail, null, 2), 'utf-8').catch(() => undefined);
283
+ const wrapped = new Error(detail.message);
284
+ wrapped.code = detail.code;
285
+ wrapped.cause = error;
286
+ throw wrapped;
287
+ }
288
+ await rm(join(home, 'join.error.json'), { force: true }).catch(() => undefined);
289
+ context.joinPort = context.joinServer.port;
290
+ runtime.join = {
291
+ enabled: true,
292
+ bind: joinBind,
293
+ port: context.joinServer.port,
294
+ endpoint: joinEndpointDemo,
295
+ endpointLoopback: joinEndpointLoopback,
296
+ };
297
+ console.error(`JOIN_READY ${JSON.stringify({ endpoint: joinEndpointDemo, port: context.joinServer.port, loopback: joinEndpointLoopback })}`);
298
+ }
299
+
300
+ if (hot) {
301
+ // P1:同一进程切换,先确认 root 级没有别的活实例,再写 ready。
302
+ const cloud = resolveCloudRoot({ home, env: process.env });
303
+ if (cloud.available) {
304
+ const cloudId = await profileIdForHome(cloud.root, home).catch(() => null);
305
+ context.activeLock = await acquireActiveLock({ root: cloud.root, home, profileId: cloudId });
306
+ }
307
+ await finalizeRuntime(context);
308
+ }
309
+ return context;
310
+ } catch (error) {
311
+ await stopRuntime(context);
312
+ throw error;
313
+ }
314
+ };
315
+
316
+ // TLS 以**真开关**为准——config.mcp.http.tls===true,或显式提供证书;缺证书即启动报错(不静默降级)。
317
+ const initialHttpCfg = preConfig?.mcp?.http ?? {};
318
+ const initialTlsKey = options.tlsKey ?? initialHttpCfg.tlsKey;
319
+ const initialTlsCert = options.tlsCert ?? initialHttpCfg.tlsCert;
320
+ const initialTlsRequested = options.tls === true || initialHttpCfg.tls === true
321
+ || Boolean(initialTlsKey && initialTlsCert);
322
+ const initialTlsReady = Boolean(initialTlsKey && initialTlsCert);
323
+ if (!provisionStart && initialTlsRequested && !initialTlsReady) {
324
+ await lock.release();
325
+ const error = new Error(
326
+ 'MCP_INSECURE_CONFIG:mcp.http.tls=true 但缺少证书——请在 config.json 填写 mcp.http.tlsKey 与 mcp.http.tlsCert,或关闭 tls',
327
+ );
328
+ error.code = 'MCP_INSECURE_CONFIG';
329
+ throw error;
330
+ }
331
+ const serverHost = options.host ?? initialHttpCfg.host ?? '127.0.0.1';
332
+ if (provisionStart) {
333
+ const loopback = serverHost === '127.0.0.1' || serverHost === 'localhost' || serverHost === '::1';
334
+ if (!loopback) {
335
+ await lock.release();
336
+ const error = new Error(`引导态仅允许 loopback(当前 host=${serverHost}):请用 --host 127.0.0.1 完成初始化`);
337
+ error.code = 'MCP_PROVISION_LOOPBACK_REQUIRED';
338
+ throw error;
339
+ }
340
+ }
341
+
342
+ // 引导态强制 loopback + auth none(旧 startProvisionServer 契约):初始化完成后热切换为
343
+ // create/join 写入的正常配置;避免 --auth bearer 等 CLI 覆盖让 READY 后自己的 admin API 不可用。
344
+ const serverAuth = provisionStart ? 'none' : (options.auth ?? initialHttpCfg.auth ?? 'none');
345
+ const serverTls = provisionStart ? false : Boolean(initialTlsRequested && initialTlsReady);
346
+ const serverTlsKey = provisionStart ? undefined : initialTlsKey;
347
+ const serverTlsCert = provisionStart ? undefined : initialTlsCert;
348
+
349
+ try {
350
+ if (!provisionStart) {
351
+ current = await startRuntime({ hot: false });
352
+ }
353
+ httpServer = await startHttpServer({
354
+ home,
355
+ app: current?.app ?? null,
356
+ service: current?.service ?? null,
357
+ config: current?.config ?? {},
358
+ buildServer,
359
+ host: serverHost,
360
+ port: options.port ?? initialHttpCfg.port,
361
+ auth: serverAuth,
362
+ tls: serverTls,
363
+ tlsKey: serverTlsKey,
364
+ tlsCert: serverTlsCert,
365
+ tokensFile: options.tokensFile ?? initialHttpCfg.tokensFile,
366
+ deviceId: current?.deviceId ?? null,
367
+ namespace: current?.namespace ?? 'tasks',
368
+ ...(current?.joinEndpoint !== undefined ? { joinEndpoint: current.joinEndpoint } : {}),
369
+ runtime: current?.runtime ?? null,
370
+ // D2:写端点默认开启(仍需 memory.admin scope + CSRF 双提交);`MEBULAR_CONSOLE_WRITES=0` 可降级为 D1 只读
371
+ writesEnabled: options.writesEnabled ?? process.env.MEBULAR_CONSOLE_WRITES !== '0',
372
+ provision: provisionStart,
373
+ initializeRuntime: async ({ action, result, server }) => {
374
+ const next = await startRuntime({ action, result, serverContext: server, hot: true });
375
+ current = next;
376
+ return next;
377
+ },
378
+ onClose: async () => {
379
+ const context = current;
380
+ current = null;
381
+ await stopRuntime(context);
382
+ },
383
+ });
384
+ // 监听端口 0 / 默认值时以实际绑定为准
385
+ if (current) {
386
+ current.runtime.mcp.host = httpServer.host;
387
+ current.runtime.mcp.port = httpServer.port;
388
+ current.runtime.mcp.auth = httpServer.auth;
389
+ // G:装配完成后写「已监听」标记——supervisor 用它判定新实例健康(join 失败则不写)
390
+ await finalizeRuntime(current);
391
+ }
392
+
393
+ const shutdown = async () => {
394
+ await httpServer?.close().catch(() => undefined);
395
+ await lock.release();
396
+ process.exit(0);
397
+ };
398
+ process.on('SIGINT', shutdown);
399
+ process.on('SIGTERM', shutdown);
400
+ return {
401
+ ...httpServer,
402
+ lock,
403
+ provision: provisionStart,
404
+ getRuntime: () => current,
405
+ getState: () => ({ ...httpServer.getState(), runtimeReady: current !== null }),
406
+ };
407
+ } catch (error) {
408
+ await httpServer?.close().catch(() => undefined);
409
+ if (!httpServer) {
410
+ await stopRuntime(current);
411
+ current = null;
412
+ }
413
+ await lock.release();
414
+ throw error;
415
+ }
416
+ }
@@ -0,0 +1,107 @@
1
+ // H-1 · 「常驻运行」服务托管(动作式):状态 / 安装 / 卸载 / 重启计划
2
+ //
3
+ // 复用 `@mebular/service`(不重造单元生成/生命周期)。硬性要求:
4
+ // **服务命令必须带守护自身 home**(`MEBULAR_HOME=<home>` + workingDir=<home> + heartbeatDir=<home>),
5
+ // 禁止用 cwd —— 否则守护起来后读错 home(历史 F-ONB 的坑)。
6
+ //
7
+ // 两套 home 必须分开(H-1 假阴性根因):
8
+ // - 实例 home(`home`):Mebular 身份,写 descriptor 的 `MEBULAR_HOME`/workingDir/heartbeatDir;
9
+ // - 服务 home(`serviceHome`):**用户 OS home**,决定 unit/manifest/日志落盘位置。
10
+ // 旧实现把实例 home 同时当服务 home,控制台于是去 `~/.mebular/Library/LaunchAgents` 找 unit,
11
+ // 而 CLI 服务实际装在 `~/Library/LaunchAgents` → 「临时运行」假阴性,install 也会写错位置。
12
+ //
13
+ // 可注入:`run`(命令执行器)与 `now`/`platform` 透传,CI 用替身断言命令序列与状态机(不真装服务)。
14
+
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import {
19
+ installService,
20
+ uninstallService,
21
+ serviceStatus,
22
+ restartPlanFor,
23
+ } from '../../service/dist/index.js';
24
+
25
+ export const SERVICE_KIND = 'mebular-serve';
26
+
27
+ /**
28
+ * 服务单元/manifest 的落盘 home:默认**用户 OS home**(与实例 home 无关)。
29
+ * `MEBULAR_SERVICE_UNIT_HOME` 只用于测试/隔离,生产不设置。
30
+ */
31
+ export function resolveServiceHome(serviceHome) {
32
+ return serviceHome ?? process.env.MEBULAR_SERVICE_UNIT_HOME ?? homedir();
33
+ }
34
+
35
+ /** 本 CLI 入口(`mebular serve` 的服务命令指回它)。 */
36
+ export function cliBinPath() {
37
+ // packages/mcp/src/service-control.mjs → packages/mcp/bin/mebular.mjs
38
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'mebular.mjs');
39
+ }
40
+
41
+ /** 守护服务描述符:**必须**携带守护自身 home(env + workingDir + heartbeatDir)。 */
42
+ export function serviceDescriptor({ home, execPath = process.execPath, extraArgs = [] } = {}) {
43
+ return {
44
+ kind: SERVICE_KIND,
45
+ args: [cliBinPath(), 'serve', ...extraArgs],
46
+ heartbeatDir: home,
47
+ workingDir: home,
48
+ execPath,
49
+ env: { MEBULAR_HOME: home },
50
+ };
51
+ }
52
+
53
+ /** 读取该实例的常驻状态(unit/manifest 落在 serviceHome;心跳来自实例 home)。 */
54
+ export function serviceStatusForHome({ home, serviceHome, platform, run, now } = {}) {
55
+ const opts = {
56
+ home: resolveServiceHome(serviceHome),
57
+ ...(platform ? { platform } : {}),
58
+ ...(run ? { run } : {}),
59
+ ...(now ? { now } : {}),
60
+ };
61
+ return serviceStatus(serviceDescriptor({ home }), opts);
62
+ }
63
+
64
+ /**
65
+ * 该实例是否已登记常驻(用于「一键安装」入口的初值)。
66
+ * descriptor 的实例身份在 `env.MEBULAR_HOME` 而非 args,`registeredServicesForDir` 无法据此匹配;
67
+ * 故以 serviceStatus 的 unitPath/manifest 判定(两者都在 serviceHome 下)。
68
+ */
69
+ export function registeredForHome({ home, serviceHome, platform, run, now } = {}) {
70
+ const status = serviceStatusForHome({ home, serviceHome, platform, run, now });
71
+ return status.registered ? [status.kind] : [];
72
+ }
73
+
74
+ /** 安装常驻:服务命令带守护自身 home;unit/manifest 落 serviceHome。 */
75
+ export function installServiceForHome({ home, serviceHome, autostart = true, platform, run, now, label } = {}) {
76
+ const opts = {
77
+ home: resolveServiceHome(serviceHome),
78
+ autostart,
79
+ ...(platform ? { platform } : {}),
80
+ ...(run ? { run } : {}),
81
+ ...(now ? { now } : {}),
82
+ ...(label ? { label } : {}),
83
+ };
84
+ return installService(serviceDescriptor({ home }), opts);
85
+ }
86
+
87
+ /** 卸载常驻(按 serviceHome 定位本实例的 unit/manifest)。 */
88
+ export function uninstallServiceForHome({ home, serviceHome, platform, run, now } = {}) {
89
+ const opts = {
90
+ home: resolveServiceHome(serviceHome),
91
+ ...(platform ? { platform } : {}),
92
+ ...(run ? { run } : {}),
93
+ ...(now ? { now } : {}),
94
+ };
95
+ return uninstallService(serviceDescriptor({ home }), opts);
96
+ }
97
+
98
+ /** 重启计划(复用 @mebular/service 的平台计划;执行由既有 apply 通道负责)。 */
99
+ export function restartPlanForHome({ home, serviceHome, platform, run, now } = {}) {
100
+ const opts = {
101
+ home: resolveServiceHome(serviceHome),
102
+ ...(platform ? { platform } : {}),
103
+ ...(run ? { run } : {}),
104
+ ...(now ? { now } : {}),
105
+ };
106
+ return restartPlanFor(serviceDescriptor({ home }), opts);
107
+ }
@@ -0,0 +1,43 @@
1
+ // MCP 服务代理:允许 HTTP /mcp 在 UNINITIALIZED 就先装配(initialize / tools/list / prompts/list),
2
+ // 热初始化完成后把同一代理的目标切到真实 MemoryService——同一 session / 同一 server 不需要重连。
3
+ //
4
+ // 代理只在调用发生时解析目标;未就绪时抛 NOT_INITIALIZED,由 registerTools 的统一 guard
5
+ // 转成稳定的业务错误信封(不返回 HTTP 409/500)。
6
+
7
+ export const NOT_INITIALIZED_CODE = 'NOT_INITIALIZED';
8
+ export const NOT_INITIALIZED_MESSAGE = '请先新建或加入忆云';
9
+
10
+ export function notInitializedError() {
11
+ const error = new Error(NOT_INITIALIZED_MESSAGE);
12
+ error.code = NOT_INITIALIZED_CODE;
13
+ return error;
14
+ }
15
+
16
+ /**
17
+ * @param {{ getService?: () => unknown, isReady?: () => boolean }} options
18
+ * @returns 透明转发到当前 MemoryService 的代理;未就绪时所有方法调用都抛 NOT_INITIALIZED。
19
+ */
20
+ export function createServiceFacade({ getService, isReady } = {}) {
21
+ const resolve = () => {
22
+ if (typeof isReady === 'function' && !isReady()) throw notInitializedError();
23
+ const service = typeof getService === 'function' ? getService() : null;
24
+ if (!service) throw notInitializedError();
25
+ return service;
26
+ };
27
+ return new Proxy({}, {
28
+ get(_target, property) {
29
+ // 防止 Promise 解析把代理当 thenable 调用。
30
+ if (property === 'then') return undefined;
31
+ return (...args) => {
32
+ const service = resolve();
33
+ const method = service[property];
34
+ if (typeof method !== 'function') {
35
+ const error = new Error(`MemoryService 不支持 ${String(property)}`);
36
+ error.code = 'E_INTERNAL';
37
+ throw error;
38
+ }
39
+ return method.apply(service, args);
40
+ };
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,45 @@
1
+ // 任务面工具(R1/F-UNI):把 `@mebular/fleet` 的 `TASK_TOOLS`(16 个,含 handler 与 JSON Schema)
2
+ // 适配为统一 MCP 工具面的规格——**不复制任何业务逻辑**:handler 直接用 fleet 的那一份,
3
+ // 守护本机 home 即工具上下文(fleet.config.json 或 config.json,见 fleet 的 loadToolConfig)。
4
+ //
5
+ // scope(R1.3):读取类 task.read / 写入类 task.write。
6
+ //
7
+ // live app(运行中 daemon):server 通过第三个参数注入 `{ home, mebular }`。
8
+ // 有 `mebular` 时 fleet 复用它(同一 EventLog:本地 append / push-on-write / SSE 都能感知);
9
+ // 缺省时沿用离线 `dir` 路径(CLI / 无 daemon 的 stdio)。
10
+
11
+ import { TASK_TOOLS } from '../../fleet/dist/index.js';
12
+ import { homeDir } from './config.mjs';
13
+ import { json, fail, codeFor, jsonSchemaAdapter } from './tool-envelope.mjs';
14
+
15
+ /** 任务工具的 OAuth scope(R1.3 + 评审 H1):由 fleet surface **逐工具显式声明**,不按名字推断。 */
16
+ export const TASK_TOOL_SCOPES = Object.fromEntries(
17
+ TASK_TOOLS.map((tool) => [tool.name, tool.scope]),
18
+ );
19
+
20
+ /**
21
+ * 任务工具规格:与记忆面同形(name/title/description/inputSchema/handler(service,args,context))。
22
+ *
23
+ * `context.home`:工具上下文目录(默认 homeDir())。
24
+ * `context.mebular`:运行中 daemon 的 live app(缺省 = 离线新建)。
25
+ */
26
+ export const TASK_TOOL_SPECS = TASK_TOOLS.map((tool) => ({
27
+ name: tool.name,
28
+ title: tool.name,
29
+ description: tool.description,
30
+ inputSchema: jsonSchemaAdapter(tool.inputSchema),
31
+ surface: 'task',
32
+ handler: async (_service, args, context = {}) => {
33
+ try {
34
+ const result = await tool.handler(args ?? {}, {
35
+ dir: context.home ?? homeDir(),
36
+ agent: 'board',
37
+ ...(context.mebular ? { mebular: context.mebular } : {}),
38
+ });
39
+ return json(result);
40
+ } catch (error) {
41
+ const message = String(error?.message ?? error);
42
+ return fail(message, codeFor(message));
43
+ }
44
+ },
45
+ }));
@@ -0,0 +1,50 @@
1
+ // 工具结果信封(R3.3):成功 = content + structuredContent;失败 = isError + structuredContent.error{code,message}。
2
+ //
3
+ // 记忆面与任务面**共用**同一信封(`json` / `fail` / `codeFor`),并被 MCP 适配器、`mebular <tool>` CLI
4
+ // 与任务工具复用;错误码为机器可判定(E_INPUT / E_NOT_FOUND / E_CONFLICT / E_INTERNAL)。
5
+
6
+ /** 成功信封:content[0].text = JSON,structuredContent = 原对象(CLI 直接取 structuredContent)。 */
7
+ export function json(data) {
8
+ return {
9
+ content: [{ type: 'text', text: JSON.stringify(data) }],
10
+ structuredContent: data,
11
+ };
12
+ }
13
+
14
+ /** 失败码 → 判据(从错误文本推断;显式 code 优先)。 */
15
+ export function codeFor(message) {
16
+ const text = String(message ?? '');
17
+ if (/不存在|未找到|not found|ENOENT|unknown|未知/.test(text)) return 'E_NOT_FOUND';
18
+ if (/已存在|冲突|占用|锁定|locked|conflict|already|重复|过期|expired/.test(text)) return 'E_CONFLICT';
19
+ if (/必须|必填|需要|缺少|非法|无效|invalid|requires|must|missing|格式|形状|超出/.test(text)) return 'E_INPUT';
20
+ return 'E_INTERNAL';
21
+ }
22
+
23
+ /** 失败信封:`{ isError:true, content:[{type:'text',text}], structuredContent:{ ok:false, error:{ code, message } } }` */
24
+ export function fail(message, code) {
25
+ const text = String(message ?? 'error');
26
+ const resolved = typeof code === 'string' && code.length > 0 ? code : codeFor(text);
27
+ return {
28
+ isError: true,
29
+ content: [{ type: 'text', text }],
30
+ structuredContent: { ok: false, error: { code: resolved, message: text } },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Standard Schema 适配器:把 fleet 的 JSON Schema 直接交给 MCP SDK(tools/list 广告用),
36
+ * 校验交给工具 handler 自身(其错误消息已可读)——避免为 16 个任务工具再抄一份 zod 形状。
37
+ */
38
+ export function jsonSchemaAdapter(schema) {
39
+ return {
40
+ '~standard': {
41
+ version: 1,
42
+ vendor: 'mebular-json-schema',
43
+ validate: (value) => ({ value }),
44
+ jsonSchema: {
45
+ input: () => schema,
46
+ output: () => schema,
47
+ },
48
+ },
49
+ };
50
+ }