feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -365,12 +365,64 @@ export class DatabaseService {
|
||||
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
|
||||
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
|
||||
|
||||
// v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http'
|
||||
// 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入
|
||||
try {
|
||||
const schemaRow = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'")
|
||||
.get() as { sql: string } | undefined;
|
||||
// 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建)
|
||||
if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) {
|
||||
log.info(
|
||||
'[DB] Migration: rebuilding mcp_servers table to support streamable-http transport',
|
||||
);
|
||||
|
||||
const rebuildMcpServers = db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mcp_servers_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
|
||||
command TEXT,
|
||||
args TEXT,
|
||||
url TEXT,
|
||||
headers TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_connected INTEGER,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
||||
);
|
||||
|
||||
INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at)
|
||||
SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at
|
||||
FROM mcp_servers;
|
||||
|
||||
DROP TABLE mcp_servers;
|
||||
ALTER TABLE mcp_servers_new RENAME TO mcp_servers;
|
||||
`);
|
||||
});
|
||||
rebuildMcpServers();
|
||||
|
||||
log.info(
|
||||
'[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = toErrorMessage(error);
|
||||
log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`);
|
||||
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
|
||||
}
|
||||
|
||||
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||
// SQLite 不支持 ALTER COLUMN,需要重建表
|
||||
try {
|
||||
// 检测 content 列是否有 NOT NULL 约束
|
||||
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
|
||||
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const contentCol = columns.find((c) => c.name === 'content');
|
||||
if (contentCol && contentCol.notnull === 1) {
|
||||
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
// v0.4.1: streamable HTTP 传输(MCP 当前主流远程传输方式)
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
@@ -41,9 +43,15 @@ function safeParseArgs(raw: string): string[] {
|
||||
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
|
||||
*/
|
||||
const ALLOWED_MCP_COMMANDS = new Set([
|
||||
'npx', 'node', 'npm',
|
||||
'python', 'python3', 'uv', 'uvx',
|
||||
'bun', 'deno',
|
||||
'npx',
|
||||
'node',
|
||||
'npm',
|
||||
'python',
|
||||
'python3',
|
||||
'uv',
|
||||
'uvx',
|
||||
'bun',
|
||||
'deno',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -58,12 +66,16 @@ const ALLOWED_MCP_COMMANDS = new Set([
|
||||
*/
|
||||
function validateMcpCommand(command: string, args: string[]): void {
|
||||
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
|
||||
const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command;
|
||||
const baseCmd =
|
||||
command
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.exe$/i, '') ?? command;
|
||||
|
||||
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
|
||||
throw new Error(
|
||||
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
|
||||
`For security reasons, only standard MCP runtimes are permitted.`,
|
||||
`For security reasons, only standard MCP runtimes are permitted.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,13 +104,22 @@ function validateMcpCommand(command: string, args: string[]): void {
|
||||
function buildSafeEnv(): Record<string, string> {
|
||||
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
||||
const SENSITIVE_SUFFIXES = [
|
||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_PASSWD',
|
||||
'_CREDENTIAL',
|
||||
'_CREDENTIALS',
|
||||
'_PRIVATE_KEY',
|
||||
];
|
||||
// 敏感变量名黑名单(精确匹配)
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
|
||||
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'AGNES_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'GITEA_PASSWORD',
|
||||
'DATABASE_PASSWORD',
|
||||
]);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
@@ -120,7 +141,8 @@ export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'err
|
||||
export interface MCPServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: 'stdio' | 'sse';
|
||||
/** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输);'sse' 保留向后兼容 */
|
||||
transport: 'stdio' | 'sse' | 'streamable-http';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
@@ -172,7 +194,10 @@ class MCPToolAdapter implements IMetonaTool {
|
||||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||||
*/
|
||||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||||
const properties: Record<string, { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }> = {};
|
||||
const properties: Record<
|
||||
string,
|
||||
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
|
||||
> = {};
|
||||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
for (const [key, prop] of Object.entries(schemaProps)) {
|
||||
@@ -212,11 +237,19 @@ export class MCPManager {
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM mcp_servers WHERE enabled = 1
|
||||
`).all() as Array<{
|
||||
id: string; name: string; transport: string;
|
||||
command: string | null; args: string | null; url: string | null;
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
command: string | null;
|
||||
args: string | null;
|
||||
url: string | null;
|
||||
}>;
|
||||
|
||||
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
|
||||
@@ -230,7 +263,8 @@ export class MCPManager {
|
||||
const config: MCPServerConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
// v0.4.1: 支持三种传输方式(stdio / sse / streamable-http)
|
||||
transport: row.transport as MCPServerConfig['transport'],
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
@@ -279,12 +313,15 @@ export class MCPManager {
|
||||
env: buildSafeEnv(),
|
||||
});
|
||||
} else if (config.transport === 'sse' && config.url) {
|
||||
// SSE 模式(远程 HTTP)
|
||||
// SSE 模式(远程 HTTP,旧式传输,保留向后兼容)
|
||||
transport = new SSEClientTransport(new URL(config.url));
|
||||
} else if (config.transport === 'streamable-http' && config.url) {
|
||||
// v0.4.1: streamable HTTP 模式(MCP 当前主流远程传输)
|
||||
transport = new StreamableHTTPClientTransport(new URL(config.url));
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported transport "${config.transport}". ` +
|
||||
`'stdio' requires 'command', 'sse' requires 'url'.`,
|
||||
`'stdio' requires 'command', 'sse'/'streamable-http' requires 'url'.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,9 +352,11 @@ export class MCPManager {
|
||||
|
||||
// 更新数据库
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ?
|
||||
`).run(Date.now(), name);
|
||||
`,
|
||||
).run(Date.now(), name);
|
||||
|
||||
log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`);
|
||||
} catch (error) {
|
||||
@@ -329,9 +368,11 @@ export class MCPManager {
|
||||
|
||||
// 更新数据库
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE mcp_servers SET error_message = ? WHERE name = ?
|
||||
`).run((error as Error).message, name);
|
||||
`,
|
||||
).run((error as Error).message, name);
|
||||
|
||||
log.error(`MCP server "${name}" connection failed:`, error);
|
||||
throw error;
|
||||
@@ -369,19 +410,28 @@ export class MCPManager {
|
||||
*/
|
||||
async toggleServer(name: string, enabled: boolean): Promise<void> {
|
||||
const db = this.getDB();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ?
|
||||
`).run(enabled ? 1 : 0, Date.now(), name);
|
||||
`,
|
||||
).run(enabled ? 1 : 0, Date.now(), name);
|
||||
|
||||
if (enabled) {
|
||||
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as {
|
||||
id: string; name: string; transport: string;
|
||||
command: string | null; args: string | null; url: string | null;
|
||||
} | undefined;
|
||||
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
command: string | null;
|
||||
args: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (row) {
|
||||
await this.connectServer({
|
||||
id: row.id, name: row.name,
|
||||
transport: row.transport as 'stdio' | 'sse',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
transport: row.transport as MCPServerConfig['transport'],
|
||||
command: row.command ?? undefined,
|
||||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
@@ -395,16 +445,30 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* 添加新的 MCP Server
|
||||
*
|
||||
* v0.4.1: 校验 transport 与对应字段匹配(stdio→command,sse/streamable-http→url)
|
||||
*/
|
||||
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
|
||||
const db = this.getDB();
|
||||
const id = `mcp_${nanoid(8)}`;
|
||||
|
||||
db.prepare(`
|
||||
// 校验传输方式与必填字段
|
||||
if (config.transport === 'stdio' && !config.command) {
|
||||
throw new Error('stdio transport requires "command"');
|
||||
}
|
||||
if ((config.transport === 'sse' || config.transport === 'streamable-http') && !config.url) {
|
||||
throw new Error(`${config.transport} transport requires "url"`);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(
|
||||
id, config.name, config.transport,
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
config.name,
|
||||
config.transport,
|
||||
config.command ?? null,
|
||||
config.args ? JSON.stringify(config.args) : null,
|
||||
config.url ?? null,
|
||||
|
||||
Reference in New Issue
Block a user