From e3f8aeb0fd0ffad64087e6bf2b4142803f0bee45 Mon Sep 17 00:00:00 2001 From: Windsander Date: Mon, 14 Sep 2026 20:55:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20G6.2=20stdio=20MCP=20=E6=9C=8D?= =?UTF-8?q?=E5=8B=99=E8=88=87=E7=9C=9F=E5=AF=A6=20client=20=E9=A9=97?= =?UTF-8?q?=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(mcp): 新增 packages/mcp(@mebular/mcp,bin mebular);`mebular mcp` 走 serveStdio - feat(mcp): 11 工具 + memory_policy prompt,薄殼委託 MemoryService;入參鉗制 limit≤50/depth≤5/batch≤100 - feat(mcp): .mebular/config.json + env 配置載入;主密鑰缺失則生成並持久化(0600) - chore(core): 根包改 @mebular/core(workspaces + exports);@mebular/mcp 以 file:../.. 本地鏈接(D41) - test(mcp): scripts/verify-mcp-stdio.mjs(真實 @modelcontextprotocol/client + StdioClientTransport) - ci: build-test-lint job 增 stdio MCP 驗證步驟 對應 goal-v0.1.md G6.2 與 project-status.md D41。 --- .github/workflows/ci.yml | 3 + package-lock.json | 132 ++++++++++++++- package.json | 28 +++- packages/mcp/bin/mebular.mjs | 49 ++++++ packages/mcp/package.json | 26 +++ packages/mcp/src/config.mjs | 124 ++++++++++++++ packages/mcp/src/server.mjs | 53 ++++++ packages/mcp/src/tools.mjs | 315 +++++++++++++++++++++++++++++++++++ scripts/verify-mcp-stdio.mjs | 165 ++++++++++++++++++ 9 files changed, 889 insertions(+), 6 deletions(-) create mode 100755 packages/mcp/bin/mebular.mjs create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/config.mjs create mode 100644 packages/mcp/src/server.mjs create mode 100644 packages/mcp/src/tools.mjs create mode 100644 scripts/verify-mcp-stdio.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af0e98a..7d16c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Lint run: npm run lint + - name: stdio MCP (real client) + run: npm run verify:mcp:stdio + semantic-real: name: semantic recall (real model) runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index ee72ec7..074acf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,23 @@ { - "name": "mebular", + "name": "@mebular/core", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mebular", + "name": "@mebular/core", "version": "0.1.0", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "bonjour": "^3.5.1", "ulid": "^2.3.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@types/jest": "^29.5.12", "@types/node": "^20.11.24", "@typescript-eslint/eslint-plugin": "^7.0.2", @@ -21,7 +26,8 @@ "jest": "^29.7.0", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod": "^4.6.5" }, "engines": { "node": ">=20" @@ -1782,6 +1788,58 @@ "multiformats": "^14.0.0" } }, + "node_modules/@mebular/core": { + "resolved": "", + "link": true + }, + "node_modules/@mebular/mcp": { + "resolved": "packages/mcp", + "link": true + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@multiformats/dns": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.15.tgz", @@ -3521,6 +3579,29 @@ "license": "MIT", "optional": true }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5203,6 +5284,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5963,6 +6054,16 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -7211,6 +7312,31 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/mcp": { + "name": "@mebular/mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@mebular/core": "file:../..", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.6.5" + }, + "bin": { + "mebular": "bin/mebular.mjs" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/package.json b/package.json index 3cf18f2..22cbadc 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,20 @@ { - "name": "mebular", + "name": "@mebular/core", "version": "0.1.0", - "private": true, "description": "本地优先的图式记忆系统:为 Hermes 等 Agent 提供跨设备、可离线、可验证的记忆共享层", "license": "MIT", "author": "Windsander", + "workspaces": [ + "packages/*" + ], + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "https://github.com/Windsander/Mebular.git" @@ -25,13 +35,21 @@ "node": ">=20" }, "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "build": "tsc", "build:watch": "tsc --watch", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", - "lint": "eslint src tests tests-semantic scripts --ext .ts,.mjs", + "lint": "eslint src tests tests-semantic scripts packages/mcp --ext .ts,.mjs", "verify": "node scripts/verify.mjs", "verify:at-rest": "node scripts/scan-at-rest.mjs", "verify:semantic": "node scripts/verify-semantic.mjs", @@ -42,6 +60,7 @@ "verify:wan:cross:selftest": "node scripts/wan-sync.mjs selftest", "verify:wan:cross:selftest:isolated": "node scripts/wan-sync.mjs selftest-isolated", "bench:snapshot": "node scripts/bench-snapshot.mjs", + "verify:mcp:stdio": "node scripts/verify-mcp-stdio.mjs", "dev": "node --loader ts-node/esm src/index.ts" }, "dependencies": { @@ -68,6 +87,9 @@ } }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.6.5", "@types/jest": "^29.5.12", "@types/node": "^20.11.24", "@typescript-eslint/eslint-plugin": "^7.0.2", diff --git a/packages/mcp/bin/mebular.mjs b/packages/mcp/bin/mebular.mjs new file mode 100755 index 0000000..992c41b --- /dev/null +++ b/packages/mcp/bin/mebular.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// mebular CLI(@mebular/mcp) +// +// G6.2 实现 `mcp`(stdio MCP server);其余命令按阶段补齐(G6.3+)。 + +const command = process.argv[2]; + +async function main() { + switch (command) { + case 'mcp': { + const { startStdioServer } = await import('../src/server.mjs'); + await startStdioServer(); + return; + } + case '--help': + case '-h': + case undefined: + console.log( + [ + '用法:mebular ', + '', + '命令:', + ' mcp 启动 stdio MCP server(stdio 传输)', + ' serve 启动 Streamable HTTP server(G6.3 计划)', + ' init 初始化 .mebular 配置(G6.4 计划)', + ' keygen 生成用户主密钥(G6.4 计划)', + ' print-config 打印各 client 接入片段(G6.4 计划)', + ' status 打印记忆状态(G6.3 计划)', + ' token 管理访问令牌(G6.3 计划)', + ].join('\n'), + ); + process.exit(0); + return; + case 'serve': + case 'init': + case 'keygen': + case 'print-config': + case 'status': + case 'token': + console.error(`mebular ${command}:尚未实现(按 G6 计划补齐)`); + process.exit(2); + return; + default: + console.error(`未知命令:${command ?? '(空)'}`); + process.exit(2); + } +} + +await main(); diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..a0bd9e8 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mebular/mcp", + "version": "0.1.0", + "description": "Mebular MCP server(stdio + Streamable HTTP):把 Mebular 记忆能力暴露给任意 MCP client", + "license": "MIT", + "private": false, + "type": "module", + "bin": { + "mebular": "./bin/mebular.mjs" + }, + "files": [ + "bin", + "src" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@mebular/core": "file:../..", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.6.5" + } +} diff --git a/packages/mcp/src/config.mjs b/packages/mcp/src/config.mjs new file mode 100644 index 0000000..b81f07e --- /dev/null +++ b/packages/mcp/src/config.mjs @@ -0,0 +1,124 @@ +// MCP 侧配置加载(G6.2) +// +// 优先级:CLI > env > .mebular/config.json > 默认。 +// 密钥不落 config:主密钥来自 env / config.encryption.keyFile / /user-master-key.json, +// 都没有则生成并持久化到 /user-master-key.json(0600)。 + +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { Mebular, IdentityManager } from '@mebular/core'; + +export function homeDir() { + return process.env.MEBULAR_HOME ?? join(process.cwd(), '.mebular'); +} + +export function configPath(home) { + return process.env.MEBULAR_CONFIG ?? join(home, 'config.json'); +} + +export async function loadConfigFile(home) { + const path = configPath(home); + if (!existsSync(path)) return {}; + try { + return JSON.parse(await readFile(path, 'utf-8')); + } catch (error) { + throw new Error(`配置文件损坏:${path}(${error.message})`); + } +} + +function truthy(value, fallback) { + if (value === undefined) return fallback; + return value === true || value === 'true' || value === '1'; +} + +async function readKeyRecord(source) { + const data = existsSync(source) + ? JSON.parse(await readFile(source, 'utf-8')) + : JSON.parse(source); + return { + userMasterKey: new Uint8Array(Buffer.from(data.publicKey, 'base64')), + userMasterPrivateKey: await IdentityManager.importPrivateKey(data.privateKeyPkcs8), + }; +} + +/** 解析用户主密钥;按 env > config.keyFile > /user-master-key.json > 生成并持久化 */ +export async function resolveMasterKeys(home, config) { + const envFile = process.env.MEBULAR_USER_MASTER_KEY_FILE; + const envInline = process.env.MEBULAR_USER_MASTER_KEY; + if (envFile) return readKeyRecord(envFile); + if (envInline) return readKeyRecord(envInline); + + const configuredFile = config.encryption?.keyFile; + if (configuredFile) return readKeyRecord(configuredFile); + + const defaultFile = join(home, 'user-master-key.json'); + if (existsSync(defaultFile)) return readKeyRecord(defaultFile); + + const master = await new IdentityManager().generateUserMasterKey(); + const record = { + publicKey: Buffer.from(master.publicKey).toString('base64'), + privateKeyPkcs8: await IdentityManager.exportPrivateKey(master.privateKey), + createdAt: new Date().toISOString(), + }; + await mkdir(dirname(defaultFile), { recursive: true }); + await writeFile(defaultFile, JSON.stringify(record, null, 2), 'utf-8'); + await chmod(defaultFile, 0o600); + return { userMasterKey: master.publicKey, userMasterPrivateKey: master.privateKey }; +} + +/** 依配置构建并初始化 Mebular 门面 */ +export async function createMebular() { + const home = homeDir(); + const config = await loadConfigFile(home); + const storagePath = process.env.MEBULAR_STORAGE_PATH ?? config.storagePath ?? join(home, 'store.jsonl'); + const deviceId = process.env.MEBULAR_DEVICE_ID ?? config.deviceId ?? `device-${process.env.HOSTNAME ?? 'local'}`; + const deviceName = process.env.MEBULAR_DEVICE_NAME ?? config.deviceName; + const masterKeys = await resolveMasterKeys(home, config); + + const app = new Mebular({ + storagePath, + deviceId, + ...(deviceName ? { deviceName } : {}), + ...(config.storageAdapter ? { storageAdapter: config.storageAdapter } : {}), + encryption: { + level: config.encryption?.level ?? 'none', + userMasterKey: masterKeys.userMasterKey, + userMasterPrivateKey: masterKeys.userMasterPrivateKey, + ...(process.env[config.encryption?.passphraseEnv ?? 'MEBULAR_PASSPHRASE'] + ? { passphrase: process.env[config.encryption?.passphraseEnv ?? 'MEBULAR_PASSPHRASE'] } + : {}), + }, + network: { + enabled: truthy(process.env.MEBULAR_NETWORK_ENABLED, config.network?.enabled ?? false), + ...(config.network?.libp2p + ? { + libp2p: { + ...(config.network.libp2p.listen ? { listen: config.network.libp2p.listen } : {}), + ...(config.network.libp2p.relayServers ? { relayServers: config.network.libp2p.relayServers } : {}), + ...(config.network.libp2p.relayUnlimited !== undefined + ? { relayUnlimited: config.network.libp2p.relayUnlimited } + : {}), + }, + } + : {}), + }, + sync: { + autoSync: config.sync?.autoSync ?? true, + ...(config.sync?.snapshotThreshold !== undefined + ? { snapshotThreshold: config.sync.snapshotThreshold } + : {}), + }, + semantic: { + enabled: truthy(process.env.MEBULAR_SEMANTIC_ENABLED, config.semantic?.enabled ?? false), + ...(config.semantic?.model ? { model: config.semantic.model } : {}), + ...(config.semantic?.cacheDir ? { cacheDir: config.semantic.cacheDir } : {}), + ...(config.semantic?.minScore !== undefined ? { minScore: config.semantic.minScore } : {}), + }, + }); + await app.initialize(); + return { app, home, config }; +} + +export { homedir }; diff --git a/packages/mcp/src/server.mjs b/packages/mcp/src/server.mjs new file mode 100644 index 0000000..5542897 --- /dev/null +++ b/packages/mcp/src/server.mjs @@ -0,0 +1,53 @@ +// MCP server 装配(G6.2):stdio 传输 + 11 工具 + memory_policy prompt。 +// 全部委托 MemoryService(D33 单一实现)。 + +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { MemoryService } from '@mebular/core'; +import { createMebular } from './config.mjs'; +import { registerTools } from './tools.mjs'; + +const MEMORY_POLICY = `# Mebular 记忆使用规约(memory_policy) +1. 先查后写:写入前先用 memory_query/memory_search 查重,避免重复。 +2. 类型选择:稳定事实→fact;用户偏好→preference;会话/任务过程→episode;可复用步骤→skill;观察→observation。 +3. 时效:有有效期的用 metadata.expiresAt;过期事实不要当现状。 +4. 关系:相关对象用 metadata.relatedTo 精确关联(仅链接已存在节点)。 +5. 隐私:不要写入密钥、口令、完整身份证件等高敏感信息。 +6. 无结果别编:召回为空就如实说明,不要臆造记忆。`; + +function buildServer(service) { + const server = new McpServer({ name: 'mebular', version: '0.1.0' }); + registerTools(server, service); + server.registerPrompt( + 'memory_policy', + { + title: 'Mebular 记忆使用规约', + description: '先查后写 / 类型选择 / 时效 / 隐私 / 关系 / 无结果别编', + }, + () => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: MEMORY_POLICY }, + }, + ], + }), + ); + return server; +} + +/** 启动 stdio MCP server;返回句柄用于收尾 */ +export async function startStdioServer() { + const { app } = await createMebular(); + const service = new MemoryService(app); + const handle = serveStdio(() => buildServer(service)); + + const shutdown = async () => { + await handle.close().catch(() => undefined); + await app.shutdown().catch(() => undefined); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + return { handle, app, service }; +} diff --git a/packages/mcp/src/tools.mjs b/packages/mcp/src/tools.mjs new file mode 100644 index 0000000..98f8b52 --- /dev/null +++ b/packages/mcp/src/tools.mjs @@ -0,0 +1,315 @@ +// MCP 工具面(G6.2 / D35):11 个 agent 中立工具,薄壳委托 MemoryService。 +// +// 钳制:limit ≤ 50(默认 10)、depth ≤ 5、batch ≤ 100。 +// 输出:content:[{type:'text'}] + structuredContent(同一对象)。 + +import { z } from 'zod'; + +export const TOOL_NAMES = [ + 'memory_write', + 'memory_write_batch', + 'memory_query', + 'memory_search', + 'memory_profile', + 'memory_skills', + 'memory_history', + 'memory_graph', + 'memory_import', + 'memory_status', + 'memory_sync', +]; + +const MAX_BATCH = 100; +const MAX_LIMIT = 50; +const MAX_DEPTH = 5; + +export function clampLimit(value, fallback = 10) { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback; + return Math.min(Math.max(n, 1), MAX_LIMIT); +} +export function clampDepth(value, fallback = 1) { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback; + return Math.min(Math.max(n, 0), MAX_DEPTH); +} + +const memoryInputSchema = z.object({ + type: z.enum(['fact', 'episode', 'skill', 'preference', 'observation']), + content: z.string(), + metadata: z + .object({ + source: z.string().optional(), + confidence: z.number().min(0).max(1).optional(), + tags: z.array(z.string()).optional(), + relatedTo: z.array(z.string()).optional(), + expiresAt: z.number().optional(), + episodeType: z.enum(['conversation', 'task', 'decision', 'error', 'observation', 'other']).optional(), + category: z.string().optional(), + name: z.string().optional(), + preferenceType: z.string().optional(), + }) + .optional(), +}); + +const internalTypes = z.array(z.enum(['fact', 'episode', 'skill', 'preference', 'observation'])); + +function json(data) { + return { + content: [{ type: 'text', text: JSON.stringify(data) }], + structuredContent: data, + }; +} +function fail(message) { + return { isError: true, content: [{ type: 'text', text: message }] }; +} + +/** 把工具注册到 McpServer;service 为 MemoryService 实例 */ +export function registerTools(server, service) { + server.registerTool( + 'memory_write', + { + title: '写入记忆', + description: '写入单条记忆(fact/episode/skill/preference/observation)', + inputSchema: z.object({ items: z.array(memoryInputSchema).min(1) }), + }, + async ({ items }) => { + try { + return json({ stored: await service.write(items) }); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_write_batch', + { + title: '批量写入记忆', + description: `批量写入记忆(最多 ${MAX_BATCH} 条)`, + inputSchema: z.object({ items: z.array(memoryInputSchema).min(1) }), + }, + async ({ items }) => { + if (items.length > MAX_BATCH) return fail(`批量写入超过上限:${items.length} > ${MAX_BATCH}`); + try { + return json({ stored: await service.write(items) }); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_query', + { + title: '查询记忆', + description: '语义/关键词召回(配置向量索引时走语义)', + inputSchema: z.object({ + query: z.string().optional(), + types: internalTypes.optional(), + limit: z.number().optional(), + offset: z.number().optional(), + includeHistory: z.boolean().optional(), + filters: z + .object({ + tags: z.array(z.string()).optional(), + createdAfter: z.number().optional(), + createdBefore: z.number().optional(), + entity: z.string().optional(), + minConfidence: z.number().optional(), + }) + .optional(), + }), + }, + async (args) => { + try { + return json(await service.query({ + ...(args.query !== undefined ? { query: args.query } : {}), + ...(args.types ? { types: args.types } : {}), + limit: clampLimit(args.limit), + ...(args.offset !== undefined ? { offset: args.offset } : {}), + ...(args.includeHistory !== undefined ? { includeHistory: args.includeHistory } : {}), + ...(args.filters ? { filters: args.filters } : {}), + })); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_search', + { + title: '关键词搜索', + description: '关键词检索基线,可选一跳关系', + inputSchema: z.object({ + query: z.string(), + types: z.array(z.string()).optional(), + limit: z.number().optional(), + includeRelations: z.boolean().optional(), + filters: z + .object({ + tags: z.array(z.string()).optional(), + createdAfter: z.number().optional(), + createdBefore: z.number().optional(), + }) + .optional(), + }), + }, + async (args) => { + try { + return json(await service.search({ + query: args.query, + ...(args.types ? { types: args.types } : {}), + limit: clampLimit(args.limit), + ...(args.includeRelations !== undefined ? { includeRelations: args.includeRelations } : {}), + ...(args.filters ? { filters: args.filters } : {}), + })); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_profile', + { title: '用户画像', description: '返回用户偏好与属性', inputSchema: z.object({}) }, + async () => { + try { + return json(await service.profile()); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_skills', + { + title: '技能列表', + description: '按分类/关键词/标签筛选技能', + inputSchema: z.object({ + category: z.string().optional(), + search: z.string().optional(), + tags: z.array(z.string()).optional(), + }), + }, + async (args) => { + try { + const skills = await service.skills({ + ...(args.category ? { category: args.category } : {}), + ...(args.search ? { search: args.search } : {}), + ...(args.tags ? { tags: args.tags } : {}), + }); + return json({ skills }); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_history', + { + title: '会话历史', + description: '按会话/时间/主题筛选会话情节', + inputSchema: z.object({ + sessionIds: z.array(z.string()).optional(), + startTime: z.number().optional(), + endTime: z.number().optional(), + topic: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + }), + }, + async (args) => { + try { + return json(await service.history({ + ...(args.sessionIds ? { sessionIds: args.sessionIds } : {}), + ...(args.startTime !== undefined ? { startTime: args.startTime } : {}), + ...(args.endTime !== undefined ? { endTime: args.endTime } : {}), + ...(args.topic ? { topic: args.topic } : {}), + ...(args.limit !== undefined ? { limit: clampLimit(args.limit) } : {}), + ...(args.offset !== undefined ? { offset: args.offset } : {}), + })); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_graph', + { + title: '图遍历', + description: `从起点遍历(深度 ≤ ${MAX_DEPTH})`, + inputSchema: z.object({ + startId: z.string(), + direction: z.enum(['outgoing', 'incoming', 'both']).optional(), + maxDepth: z.number().optional(), + edgeTypes: z.array(z.string()).optional(), + }), + }, + async (args) => { + try { + return json(await service.graph(args.startId, { + ...(args.direction ? { direction: args.direction } : {}), + maxDepth: clampDepth(args.maxDepth), + ...(args.edgeTypes ? { edgeTypes: args.edgeTypes } : {}), + })); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_import', + { + title: '导入记忆', + description: '经适配器导入异构来源(kv/markdown/…)', + inputSchema: z.object({ + kind: z.string().optional(), + data: z.unknown(), + origin: z.string().optional(), + }), + }, + async (args) => { + try { + return json(await service.import({ + ...(args.kind ? { kind: args.kind } : {}), + data: args.data, + ...(args.origin ? { origin: args.origin } : {}), + })); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_status', + { title: '记忆状态', description: '返回设备/网络/计数/状态哈希/开关', inputSchema: z.object({}) }, + async () => { + try { + return json(await service.status()); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); + + server.registerTool( + 'memory_sync', + { + title: '同步对端', + description: '连接对端并等待一次双向同步(network.enabled 需为 true)', + inputSchema: z.object({ peerId: z.string(), address: z.string().optional() }), + }, + async (args) => { + try { + return json(await service.sync(args.peerId, args.address)); + } catch (error) { + return fail(String(error?.message ?? error)); + } + }, + ); +} diff --git a/scripts/verify-mcp-stdio.mjs b/scripts/verify-mcp-stdio.mjs new file mode 100644 index 0000000..2f2f771 --- /dev/null +++ b/scripts/verify-mcp-stdio.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// G6.2 stdio MCP 验证(真实 MCP client) +// +// 以真实 @modelcontextprotocol/client + StdioClientTransport 启动 `mebular mcp`, +// 断言 tools/list 为 11 个工具、逐个 tools/call、prompt memory_policy 存在。 +// 干净环境退出码 0。前置:npm run build(core dist)。 + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const serverBin = join(rootDir, 'packages', 'mcp', 'bin', 'mebular.mjs'); + +const EXPECTED = [ + 'memory_write', + 'memory_write_batch', + 'memory_query', + 'memory_search', + 'memory_profile', + 'memory_skills', + 'memory_history', + 'memory_graph', + 'memory_import', + 'memory_status', + 'memory_sync', +]; + +let passed = true; +const check = (label, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${label}${detail ? `(${detail})` : ''}`); + if (!ok) passed = false; +}; + +function textOf(result) { + const block = result?.content?.find((c) => c.type === 'text'); + return block?.text ?? ''; +} +function structuredOf(result) { + if (result?.structuredContent !== undefined) return result.structuredContent; + try { + return JSON.parse(textOf(result)); + } catch { + return null; + } +} + +console.log('Mebular G6.2 stdio MCP 验证'); +console.log('==========================='); + +const home = await mkdtemp(join(tmpdir(), 'mebular-mcp-stdio-')); +const client = new Client({ name: 'mebular-verify', version: '0.1.0' }); +let transport = null; + +try { + transport = new StdioClientTransport({ + command: process.execPath, + args: [serverBin, 'mcp'], + env: { + ...process.env, + MEBULAR_HOME: home, + MEBULAR_STORAGE_PATH: join(home, 'store.jsonl'), + MEBULAR_DEVICE_ID: 'device-mcp-smoke', + }, + stderr: 'pipe', + }); + await client.connect(transport); + check('真实 MCP client 连接 stdio server', true); + + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + check('tools/list 返回 11 个工具', tools.length === 11, `count=${tools.length}`); + check( + '工具名与冻结面一致', + JSON.stringify(names) === JSON.stringify([...EXPECTED].sort()), + names.join(','), + ); + + // 写入(单 + 批) + const w = structuredOf(await client.callTool({ name: 'memory_write', arguments: { items: [{ type: 'fact', content: 'mcp-smoke-memory' }] } })); + const idFromWrite = w?.stored?.[0]?.id; + check('memory_write 落图', Array.isArray(w?.stored) && w.stored.length === 1 && typeof idFromWrite === 'string'); + + const wb = structuredOf(await client.callTool({ name: 'memory_write_batch', arguments: { items: [{ type: 'preference', content: '深色主题', metadata: { preferenceType: 'theme' } }] } })); + check('memory_write_batch 落图', Array.isArray(wb?.stored) && wb.stored.length === 1); + + // 查询 / 搜索 + const q = structuredOf(await client.callTool({ name: 'memory_query', arguments: { query: 'mcp-smoke-memory' } })); + check('memory_query 命中', (q?.totalMatches ?? 0) >= 1, `totalMatches=${q?.totalMatches}`); + + const s = structuredOf(await client.callTool({ name: 'memory_search', arguments: { query: 'mcp-smoke-memory', includeRelations: true } })); + check('memory_search 命中且含 relations', (s?.memories?.length ?? 0) >= 1 && Array.isArray(s?.relations)); + + // 画像 / 技能 / 历史 + const p = structuredOf(await client.callTool({ name: 'memory_profile', arguments: {} })); + check('memory_profile 有偏好', (p?.preferences?.length ?? 0) >= 1, `prefs=${p?.preferences?.length}`); + + const sk = structuredOf(await client.callTool({ name: 'memory_skills', arguments: {} })); + check('memory_skills 返回数组', Array.isArray(sk?.skills)); + + const h = structuredOf(await client.callTool({ name: 'memory_history', arguments: {} })); + check('memory_history 返回 totalCount', typeof h?.totalCount === 'number'); + + // 图 + const g = structuredOf(await client.callTool({ name: 'memory_graph', arguments: { startId: idFromWrite, maxDepth: 1 } })); + check('memory_graph 遍历', Array.isArray(g?.visitedNodes) && g.visitedNodes.length >= 1); + + // 导入 + const im = structuredOf(await client.callTool({ name: 'memory_import', arguments: { kind: 'kv', data: { k1: 'v1' }, origin: 'stdio-smoke' } })); + check('memory_import 落图', (im?.nodesCreated ?? 0) >= 1, `nodesCreated=${im?.nodesCreated}`); + + // 状态 + const st = structuredOf(await client.callTool({ name: 'memory_status', arguments: {} })); + check('memory_status 出 deviceId/stateHash', st?.deviceId === 'device-mcp-smoke' && /^[0-9a-f]{64}$/.test(st?.stateHash ?? '')); + + // 同步:网络未启用 → 诚实失败(降级/失败路径) + let syncFailed = false; + try { + const r = await client.callTool({ name: 'memory_sync', arguments: { peerId: 'device-none' } }); + syncFailed = r?.isError === true; + } catch { + syncFailed = true; + } + check('memory_sync 网络未启用时诚实失败', syncFailed); + + // 参数钳制:depth 超限被钳(不报错);batch >100 被拒 + const g2 = structuredOf(await client.callTool({ name: 'memory_graph', arguments: { startId: idFromWrite, maxDepth: 99 } })); + check('memory_graph depth 超限被钳制(不报错)', Array.isArray(g2?.visitedNodes)); + + let batchRejected = false; + try { + const big = await client.callTool({ name: 'memory_write_batch', arguments: { items: Array.from({ length: 101 }, () => ({ type: 'fact', content: 'x' })) } }); + batchRejected = big?.isError === true; + } catch { + batchRejected = true; + } + check('memory_write_batch >100 被拒', batchRejected); + + // prompt + const prompts = await client.listPrompts(); + check('prompt memory_policy 已注册', prompts.prompts.some((p) => p.name === 'memory_policy')); + const prompt = await client.getPrompt({ name: 'memory_policy', arguments: {} }); + check('getPrompt(memory_policy) 返回文本', (prompt?.messages?.length ?? 0) >= 1); +} catch (error) { + check('stdio MCP 端到端', false, String(error?.message ?? error).substring(0, 300)); + if (transport?.stderr) { + // 打印子进程 stderr 辅助定位(仅失败时) + } +} finally { + await client.close().catch(() => undefined); + await rm(home, { recursive: true, force: true }).catch(() => undefined); +} + +console.log('==========================='); +if (passed) { + console.log('✓ G6.2 stdio MCP 验证通过'); + process.exit(0); +} else { + console.log('✗ G6.2 stdio MCP 验证失败'); + process.exit(1); +}