feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -23,7 +23,7 @@ import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import type { ToolRegistry } from '../harness/tools/registry';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
||||
import type { MetonaToolDef } from '../harness/types';
|
||||
import type { MetonaToolDef, MetonaParamField } from '../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
||||
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用)
|
||||
import { buildSafeChildEnv } from '../utils/safe-env';
|
||||
@@ -289,24 +289,53 @@ class MCPToolAdapter implements IMetonaTool {
|
||||
name: this.mcpTool.name,
|
||||
arguments: args,
|
||||
});
|
||||
return result.content;
|
||||
const content = result.content as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
// v0.8.2 P2-7 根治: MCP 返回的 image block 此前被直接透传 —— registry 的
|
||||
// 内联图片白名单只识别顶层 dataUrl/image 字段,嵌套在 content 数组中的图片
|
||||
// 块不匹配白名单,被 50KB 截断为破损 base64。现将 text/image block 归并:
|
||||
// text 拼接为顶层文本,首个 image block 提升为顶层 `image` 字段(data URI,
|
||||
// 命中 registry 白名单整段放行 → 渲染端可内联预览)。
|
||||
if (Array.isArray(content)) {
|
||||
const texts: string[] = [];
|
||||
let imageDataUri: string | null = null;
|
||||
for (const item of content) {
|
||||
const type = item?.type;
|
||||
if (type === 'text' && typeof item.text === 'string') {
|
||||
texts.push(item.text);
|
||||
} else if (type === 'image' && !imageDataUri) {
|
||||
const data = typeof item.data === 'string' ? item.data : '';
|
||||
const mimeType =
|
||||
typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'image/png';
|
||||
if (data) {
|
||||
imageDataUri = `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imageDataUri || texts.length > 0) {
|
||||
return {
|
||||
...(texts.length > 0 ? { text: texts.join('\n\n') } : {}),
|
||||
...(imageDataUri ? { image: imageDataUri } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||||
*
|
||||
* v0.8.2 P2-7 根治: 旧实现只保留顶层 properties 的 type/description ——
|
||||
* 丢弃 enum/anyOf/oneOf/嵌套对象/items/default,复杂 MCP 工具的参数约束
|
||||
* 对 LLM 不可见,易产生非法参数。现递归保留 IR 支持的全部结构(见
|
||||
* MetonaParamField 的 P2-7 扩展字段)。
|
||||
*/
|
||||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||||
const properties: Record<
|
||||
string,
|
||||
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
|
||||
> = {};
|
||||
const properties: Record<string, MetonaToolDef['parameters']['properties'][string]> = {};
|
||||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
for (const [key, prop] of Object.entries(schemaProps)) {
|
||||
properties[key] = {
|
||||
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
|
||||
description: (prop.description as string) ?? '',
|
||||
};
|
||||
properties[key] = this.convertSchemaField(prop);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -315,6 +344,43 @@ class MCPToolAdapter implements IMetonaTool {
|
||||
required: schema.required as string[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单个参数字段递归转换(P2-7 schema 保真) */
|
||||
private convertSchemaField(prop: Record<string, unknown>): MetonaParamField {
|
||||
const field: MetonaParamField = {
|
||||
type: (prop.type as MetonaParamField['type']) ?? 'string',
|
||||
description: (prop.description as string) ?? '',
|
||||
};
|
||||
if (Array.isArray(prop.enum)) {
|
||||
field.enum = prop.enum.map((v) => String(v));
|
||||
}
|
||||
if (prop.items && typeof prop.items === 'object') {
|
||||
field.items = this.convertSchemaField(prop.items as Record<string, unknown>);
|
||||
}
|
||||
if (prop.properties && typeof prop.properties === 'object') {
|
||||
const nested: Record<string, MetonaParamField> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
prop.properties as Record<string, Record<string, unknown>>,
|
||||
)) {
|
||||
nested[k] = this.convertSchemaField(v);
|
||||
}
|
||||
field.properties = nested;
|
||||
}
|
||||
if (Array.isArray(prop.required)) {
|
||||
field.required = prop.required.map((v) => String(v));
|
||||
}
|
||||
for (const combinator of ['anyOf', 'oneOf'] as const) {
|
||||
if (Array.isArray(prop[combinator])) {
|
||||
field[combinator] = (prop[combinator] as Array<Record<string, unknown>>).map((v) =>
|
||||
this.convertSchemaField(v),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (prop.default !== undefined) {
|
||||
field.default = prop.default;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MCP Manager =====
|
||||
@@ -819,6 +885,11 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* 获取所有 Server 状态
|
||||
*
|
||||
* v0.8.2 P2-7 根治: 已配置但**禁用**的 server 此前不出现在列表中 ——
|
||||
* initialize() 只连 enabled=1,getServerStates 只映射 servers Map,设置面板
|
||||
* 无法展示"已配置但禁用"的完整清单。现从 DB 补齐缺失条目(status=disconnected、
|
||||
* enabled=false),并为每个条目标注 enabled。
|
||||
*/
|
||||
getServerStates(): Array<{
|
||||
name: string;
|
||||
@@ -827,15 +898,45 @@ export class MCPManager {
|
||||
error?: string;
|
||||
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */
|
||||
reconnectAttempt?: number;
|
||||
/** v0.8.2 P2-7: 是否为启用状态(DB enabled=1)—— 禁用 server 以 disconnected 呈现 */
|
||||
enabled: boolean;
|
||||
}> {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
// DB 全量配置(enabled 标注 + 补齐禁用条目);DB 不可用时退回仅已连接集合
|
||||
let enabledMap = new Map<string, boolean>();
|
||||
try {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT name, enabled FROM mcp_servers').all() as Array<{
|
||||
name: string;
|
||||
enabled: number;
|
||||
}>;
|
||||
enabledMap = new Map(rows.map((r) => [r.name, r.enabled === 1]));
|
||||
} catch (err) {
|
||||
log.warn(`[MCPManager] getServerStates DB lookup failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const states = Array.from(this.servers.values()).map((s) => ({
|
||||
name: s.config.name,
|
||||
status: s.status,
|
||||
toolCount: s.tools.length,
|
||||
error: s.error,
|
||||
reconnectAttempt:
|
||||
s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined,
|
||||
enabled: enabledMap.get(s.config.name) ?? true,
|
||||
}));
|
||||
|
||||
for (const [name, enabled] of enabledMap) {
|
||||
if (!enabled && !states.some((s) => s.name === name)) {
|
||||
states.push({
|
||||
name,
|
||||
status: 'disconnected' as MCPServerStatus,
|
||||
toolCount: 0,
|
||||
error: undefined,
|
||||
reconnectAttempt: undefined,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user