ppxans-harness 2.4.0 → 2.6.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/README.md +29 -32
- package/config/ppx.json +133 -151
- package/package.json +2 -5
- package/src/agent/index.js +3 -3
- package/src/agent/prompts.js +1 -1
- package/src/channels/http.js +76 -17
- package/src/config/providers.js +5 -11
- package/src/llm/client.js +187 -446
- package/src/llm/fence.js +8 -61
- package/src/llm/index.js +1 -1
- package/src/llm/router.js +5 -13
- package/src/mcp/admin.js +292 -0
- package/src/mcp/client.js +107 -5
- package/src/mcp/http.js +217 -0
- package/src/mcp/server.js +387 -0
- package/src/mcp/tasks.js +138 -0
- package/src/plugin/builtin.js +4 -1
package/src/mcp/http.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/mcp/http.js - MCP Streamable HTTP 传输层 (零依赖)
|
|
2
|
+
// 2026-07-28 语义:
|
|
3
|
+
// - 单端点只收 POST; 每个 JSON-RPC 请求/通知一次 POST
|
|
4
|
+
// - 必带头: MCP-Protocol-Version (现代 era, 与 body _meta 校验一致)
|
|
5
|
+
// - 响应: application/json 单对象 或 text/event-stream (SSE 流, 请求级)
|
|
6
|
+
// - 通知 (无 id) -> 202 Accepted 无 body
|
|
7
|
+
// - 取消 = 关闭 SSE 流 (无需 notifications/cancelled)
|
|
8
|
+
// - Origin 校验防 DNS rebinding; 本地默认只绑 127.0.0.1
|
|
9
|
+
import { McpServer, McpError, MCP_ERROR, MODERN_PROTOCOL_VERSION, SERVER_INFO_META_KEY } from "./server.js";
|
|
10
|
+
|
|
11
|
+
const MAX_BODY = 1024 * 1024; // 1MB
|
|
12
|
+
|
|
13
|
+
// ---- SSE 序列化 ----
|
|
14
|
+
function sseMessage(obj) {
|
|
15
|
+
return `event: message\ndata: ${JSON.stringify(obj)}\n\n`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 读取请求体
|
|
19
|
+
async function readBody(req) {
|
|
20
|
+
let body = "";
|
|
21
|
+
for await (const chunk of req) {
|
|
22
|
+
body += chunk;
|
|
23
|
+
if (body.length > MAX_BODY) throw new McpError(MCP_ERROR.INVALID_REQUEST, "request too large");
|
|
24
|
+
}
|
|
25
|
+
return body;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---- 传输错误 -> HTTP 状态码映射 (2026-07-28) ----
|
|
29
|
+
function statusFor(err) {
|
|
30
|
+
switch (err.code) {
|
|
31
|
+
case MCP_ERROR.UNSUPPORTED_PROTOCOL_VERSION: return 400; // Bad Request
|
|
32
|
+
case MCP_ERROR.HEADER_MISMATCH: return 400;
|
|
33
|
+
case MCP_ERROR.MISSING_REQUIRED_CLIENT_CAPABILITY: return 400;
|
|
34
|
+
case MCP_ERROR.METHOD_NOT_FOUND: return 404; // 未知方法
|
|
35
|
+
case MCP_ERROR.INVALID_PARAMS: return 400;
|
|
36
|
+
case MCP_ERROR.INVALID_REQUEST: return 400;
|
|
37
|
+
case MCP_ERROR.PARSE_ERROR: return 400;
|
|
38
|
+
default: return 500;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 创建 MCP Streamable HTTP 请求处理器。
|
|
44
|
+
* @param {McpServer} server
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {(req:any)=>boolean} [opts.authenticated] 鉴权钩子: 返回 false 则 401 (可选, 未提供则放行)
|
|
47
|
+
* @param {(req:any, res:any)=>boolean} [opts.rateLimit] 限流钩子: 返回 false 则已响应 429 (可选)
|
|
48
|
+
* @param {string[]} [opts.allowedOrigins] CORS/Origin 白名单; 空 = 仅无 Origin 或本地回环
|
|
49
|
+
* @param {boolean} [opts.skipOriginCheck] 宿主已统一处理 CORS/Origin 时跳过 (避免双重标准)
|
|
50
|
+
* @returns {(req:any,res:any)=>Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export function createMcpHttpHandler(server, opts = {}) {
|
|
53
|
+
return async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
// 1. 方法限制: 只收 POST
|
|
56
|
+
if (req.method !== "POST") {
|
|
57
|
+
res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
|
|
58
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. Origin 校验 (防 DNS rebinding; 宿主已统一处理时可跳过)
|
|
63
|
+
const origin = req.headers.origin;
|
|
64
|
+
if (origin && !opts.skipOriginCheck) {
|
|
65
|
+
const allowed = opts.allowedOrigins && opts.allowedOrigins.length
|
|
66
|
+
? opts.allowedOrigins.includes(origin)
|
|
67
|
+
: /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
|
68
|
+
if (!allowed) {
|
|
69
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32600, message: "origin not allowed" } }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. 可选鉴权 / 限流 (由宿主通道注入)
|
|
76
|
+
if (opts.authenticated && !opts.authenticated(req, res)) return;
|
|
77
|
+
if (opts.rateLimit && !opts.rateLimit(req, res)) return;
|
|
78
|
+
|
|
79
|
+
// 4. 读 body + 解析
|
|
80
|
+
let msg;
|
|
81
|
+
try {
|
|
82
|
+
const body = await readBody(req);
|
|
83
|
+
msg = body ? JSON.parse(body) : {};
|
|
84
|
+
} catch (e) {
|
|
85
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
86
|
+
res.end(JSON.stringify({
|
|
87
|
+
jsonrpc: "2.0",
|
|
88
|
+
id: null,
|
|
89
|
+
error: { code: MCP_ERROR.PARSE_ERROR, message: `Parse error: ${e.message}` },
|
|
90
|
+
}));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. 版本头校验 (现代 era): MCP-Protocol-Version 必须与 body _meta 一致
|
|
95
|
+
const headerVersion = req.headers["mcp-protocol-version"];
|
|
96
|
+
const bodyVersion = msg && msg.params && msg.params._meta && msg.params._meta["io.modelcontextprotocol/protocolVersion"];
|
|
97
|
+
if (bodyVersion && headerVersion && headerVersion !== bodyVersion) {
|
|
98
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify({
|
|
100
|
+
jsonrpc: "2.0",
|
|
101
|
+
id: msg.id ?? null,
|
|
102
|
+
error: { code: MCP_ERROR.HEADER_MISMATCH, message: "Header MCP-Protocol-Version does not match body _meta", data: { header: headerVersion, body: bodyVersion } },
|
|
103
|
+
}));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 现代请求 (有 bodyVersion) 但缺 header: 允许 (兼容未带头的老客户端), 记录即可
|
|
107
|
+
if (bodyVersion && !headerVersion) {
|
|
108
|
+
// 2026-07-28 规范要求必带; 宽松兼容: 直接按 body 版本处理
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 6. 通知 (无 id) -> 202
|
|
112
|
+
if (msg && (msg.id === undefined || msg.id === null)) {
|
|
113
|
+
// 通知: 只处理 notifications/* 或 ping 通知语义; 直接受理
|
|
114
|
+
await server.handle(msg, {}).catch((e) => {
|
|
115
|
+
// 通知失败仅记日志, 不影响 202 语义
|
|
116
|
+
if (opts.onError) opts.onError(e);
|
|
117
|
+
});
|
|
118
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 7. 请求: 是否 SSE (客户端 Accept 两者之一; 流式工具请求开 SSE 流)
|
|
124
|
+
const accept = String(req.headers.accept || "");
|
|
125
|
+
const isStreamTool = msg && msg.method === "tools/call" && /stream/i.test(String(msg.params && msg.params.name || ""));
|
|
126
|
+
const wantsSSE = accept.includes("text/event-stream") && isStreamTool;
|
|
127
|
+
|
|
128
|
+
if (wantsSSE) {
|
|
129
|
+
// SSE 响应流: 进度通知 + 最终响应, 关闭流 = 取消
|
|
130
|
+
res.writeHead(200, {
|
|
131
|
+
"Content-Type": "text/event-stream",
|
|
132
|
+
"Cache-Control": "no-cache",
|
|
133
|
+
"Connection": "keep-alive",
|
|
134
|
+
"X-Accel-Buffering": "no",
|
|
135
|
+
});
|
|
136
|
+
res.write(": keep-alive\n\n");
|
|
137
|
+
const progressToken = msg.params && msg.params._meta && msg.params._meta.progressToken;
|
|
138
|
+
const streamCtx = {
|
|
139
|
+
stream: {
|
|
140
|
+
onDelta: (delta, extra = {}) => {
|
|
141
|
+
if (res.writableEnded) return;
|
|
142
|
+
const notif = { jsonrpc: "2.0", method: "notifications/progress", params: { progress: extra.progress, total: extra.total, message: String(delta).slice(0, 4000) } };
|
|
143
|
+
if (progressToken !== undefined) notif.params.progressToken = progressToken;
|
|
144
|
+
res.write(sseMessage(notif));
|
|
145
|
+
},
|
|
146
|
+
onMessage: (text) => {
|
|
147
|
+
if (res.writableEnded) return;
|
|
148
|
+
res.write(sseMessage({ jsonrpc: "2.0", method: "notifications/message", params: { level: "info", data: String(text).slice(0, 4000) } }));
|
|
149
|
+
},
|
|
150
|
+
// v2.6.0: 结构化工具事件透传 (web 前端渲染工具卡片): notifications/message 带 type=tool
|
|
151
|
+
onTool: (ev) => {
|
|
152
|
+
if (res.writableEnded || !ev) return;
|
|
153
|
+
res.write(sseMessage({ jsonrpc: "2.0", method: "notifications/message", params: { level: "info", data: { type: "tool", tool: ev.tool, status: ev.type || ev.status, ok: ev.ok, durationMs: ev.durationMs } } }));
|
|
154
|
+
},
|
|
155
|
+
// 推理轮次事件
|
|
156
|
+
onStep: (ev) => {
|
|
157
|
+
if (res.writableEnded || !ev) return;
|
|
158
|
+
res.write(sseMessage({ jsonrpc: "2.0", method: "notifications/message", params: { level: "info", data: { type: "step", round: ev.round, maxRounds: ev.maxRounds } } }));
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const { result } = await server.handle(msg, streamCtx);
|
|
164
|
+
// 流式工具: 结果即最终 delta (chat.stream 已通过 onDelta 推送过; 这里补发完整结果)
|
|
165
|
+
res.write(sseMessage({ jsonrpc: "2.0", id: msg.id, result: result ?? {} }));
|
|
166
|
+
} catch (e) {
|
|
167
|
+
const err = e instanceof McpError ? e : new McpError(MCP_ERROR.INTERNAL_ERROR, e.message || "internal error");
|
|
168
|
+
res.write(sseMessage({ jsonrpc: "2.0", id: msg.id, error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) } }));
|
|
169
|
+
} finally {
|
|
170
|
+
try { res.end(); } catch {}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 8. 普通 JSON 响应
|
|
176
|
+
try {
|
|
177
|
+
const { result } = await server.handle(msg, {});
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: result ?? {} }));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
const err = e instanceof McpError ? e : new McpError(MCP_ERROR.INTERNAL_ERROR, e.message || "internal error");
|
|
182
|
+
res.writeHead(statusFor(err), { "Content-Type": "application/json" });
|
|
183
|
+
res.end(JSON.stringify({
|
|
184
|
+
jsonrpc: "2.0",
|
|
185
|
+
id: msg.id ?? null,
|
|
186
|
+
error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) },
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// 外层兜底 (不应发生)
|
|
191
|
+
try {
|
|
192
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
193
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: e.message || "internal error" } }));
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- 便捷工厂: 从 agent 构建标准 McpServer + HTTP handler ----
|
|
200
|
+
export function createMcpEndpoint(agent, opts = {}) {
|
|
201
|
+
const server = new McpServer(agent, {
|
|
202
|
+
name: opts.name,
|
|
203
|
+
version: opts.version,
|
|
204
|
+
supportedVersions: opts.supportedVersions,
|
|
205
|
+
extraTools: opts.extraTools,
|
|
206
|
+
prompts: opts.prompts,
|
|
207
|
+
});
|
|
208
|
+
const handler = createMcpHttpHandler(server, {
|
|
209
|
+
authenticated: opts.authenticated,
|
|
210
|
+
rateLimit: opts.rateLimit,
|
|
211
|
+
allowedOrigins: opts.allowedOrigins,
|
|
212
|
+
onError: opts.onError,
|
|
213
|
+
});
|
|
214
|
+
return { server, handler };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { McpServer, McpError, MCP_ERROR, MODERN_PROTOCOL_VERSION, SERVER_INFO_META_KEY };
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// src/mcp/server.js - MCP (Model Context Protocol) 服务器核心 (零依赖)
|
|
2
|
+
// 把皮皮虾的 43+ 内置工具 + 记忆/轨迹/统计/会话资源 + 方法型技能 暴露为标准 MCP 服务。
|
|
3
|
+
// 双 era 支持:
|
|
4
|
+
// - 现代 2026-07-28: 无握手, 每请求 _meta 携带 protocolVersion/clientInfo/clientCapabilities,
|
|
5
|
+
// server/discover 探测, 结果带 resultType, 响应 _meta 带 serverInfo 身份戳。
|
|
6
|
+
// - legacy 2025-06-18 及更早: initialize 握手 + capabilities 协商 (兼容存量 MCP 客户端)。
|
|
7
|
+
// 传输无关: 仅做 JSON-RPC 分发, stdio/HTTP 传输由上层 (src/mcp/http.js 等) 接入。
|
|
8
|
+
import { TOOL_ERROR_PREFIX } from "../tools/catalog.js";
|
|
9
|
+
import { warn } from "../utils/logger.js";
|
|
10
|
+
import { sanitizeMcpName } from "./index.js";
|
|
11
|
+
|
|
12
|
+
// 协议版本常量 (与官方 schema/2026-07-28 对齐)
|
|
13
|
+
export const MODERN_PROTOCOL_VERSION = "2026-07-28"; // 现代 era (每请求 _meta)
|
|
14
|
+
export const LEGACY_PROTOCOL_VERSION = "2025-06-18"; // legacy era (initialize 握手) 最高支持版本
|
|
15
|
+
export const SUPPORTED_VERSIONS = [MODERN_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
|
16
|
+
export const SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo";
|
|
17
|
+
|
|
18
|
+
// ---- MCP 标准错误码 (JSON-RPC 保留段 + MCP 定义段) ----
|
|
19
|
+
export const MCP_ERROR = {
|
|
20
|
+
PARSE_ERROR: -32700,
|
|
21
|
+
INVALID_REQUEST: -32600,
|
|
22
|
+
METHOD_NOT_FOUND: -32601,
|
|
23
|
+
INVALID_PARAMS: -32602,
|
|
24
|
+
INTERNAL_ERROR: -32603,
|
|
25
|
+
HEADER_MISMATCH: -32020, // HTTP 头与 body _meta 不一致
|
|
26
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY: -32021,
|
|
27
|
+
UNSUPPORTED_PROTOCOL_VERSION: -32022, // 版本不支持
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// MCP 请求级错误: 携带 code/message/data, 由分发层转成 JSON-RPC error 响应
|
|
31
|
+
export class McpError extends Error {
|
|
32
|
+
constructor(code, message, data = null) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.data = data;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isModernRequest(msg) {
|
|
40
|
+
return !!(msg && msg.params && msg.params._meta && msg.params._meta["io.modelcontextprotocol/protocolVersion"]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isLegacyInitialize(msg) {
|
|
44
|
+
return !!(msg && msg.method === "initialize" && !isModernRequest(msg));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 结果统一加 serverInfo 身份戳 (2026-07-28: 身份在 result._meta 而非 body)
|
|
48
|
+
function stampServerInfo(result, serverInfo) {
|
|
49
|
+
if (!result || typeof result !== "object") return result;
|
|
50
|
+
return { ...result, _meta: { ...(result._meta || {}), [SERVER_INFO_META_KEY]: serverInfo } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- 工具名清洗: MCP 规范只允许 [A-Za-z0-9_.-] ----
|
|
54
|
+
function safeToolName(name) {
|
|
55
|
+
const n = sanitizeMcpName(name); // 只留 \w.-, 截断 64
|
|
56
|
+
return n || "unnamed";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class McpServer {
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} agent - PPXAgent 实例 (提供 tools/facts/scenes/traces/sessionStore/stats 等)
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {string} [opts.name] serverInfo.name (默认 agent 名)
|
|
64
|
+
* @param {string} [opts.version] serverInfo.version
|
|
65
|
+
* @param {string[]} [opts.supportedVersions] 支持的协议版本列表
|
|
66
|
+
* @param {object[]} [opts.extraTools] 额外虚拟工具: [{name,title,description,inputSchema,execute}]
|
|
67
|
+
* @param {object[]} [opts.prompts] 额外提示模板: [{name,description,arguments:[{name,required,description}]}]
|
|
68
|
+
*/
|
|
69
|
+
constructor(agent, opts = {}) {
|
|
70
|
+
this.agent = agent;
|
|
71
|
+
this.serverInfo = {
|
|
72
|
+
name: opts.name || agent?.config?.agent?.name || "ppxans-harness",
|
|
73
|
+
version: opts.version || "2.5.0",
|
|
74
|
+
};
|
|
75
|
+
this.supportedVersions = opts.supportedVersions || SUPPORTED_VERSIONS;
|
|
76
|
+
// 对话虚拟工具 (MCP 客户端驱动 agent 对话的入口, 不进 catalog 避免污染 LLM 工具列表)
|
|
77
|
+
const chatTools = [
|
|
78
|
+
{
|
|
79
|
+
name: "ppx.chat.send",
|
|
80
|
+
title: "对话 (非流式)",
|
|
81
|
+
description: "发送消息给皮皮虾 agent, 返回完整回复。内部会执行完整工具调用循环 (记忆/搜索/命令等)。sessionId 用于区分会话上下文, 默认 default。",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
message: { type: "string", description: "用户消息" },
|
|
86
|
+
sessionId: { type: "string", description: "会话标识 (默认 default)" },
|
|
87
|
+
},
|
|
88
|
+
required: ["message"],
|
|
89
|
+
},
|
|
90
|
+
execute: async (args, ctx, agent) => {
|
|
91
|
+
const reply = await agent.chat(String(args.message || ""), { sessionKey: args.sessionId || "default" });
|
|
92
|
+
return { content: [{ type: "text", text: String(reply) }] };
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "ppx.chat.stream",
|
|
97
|
+
title: "对话 (流式)",
|
|
98
|
+
description: "发送消息给皮皮虾 agent, 流式返回回复 (SSE 进度通知)。内部会执行完整工具调用循环。sessionId 用于区分会话上下文, 默认 default。",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
message: { type: "string", description: "用户消息" },
|
|
103
|
+
sessionId: { type: "string", description: "会话标识 (默认 default)" },
|
|
104
|
+
},
|
|
105
|
+
required: ["message"],
|
|
106
|
+
},
|
|
107
|
+
execute: async (args, ctx, agent) => {
|
|
108
|
+
let full = "";
|
|
109
|
+
const reply = await agent.chatStream(String(args.message || ""), {
|
|
110
|
+
sessionKey: args.sessionId || "default",
|
|
111
|
+
onDelta: (d) => { full += d; ctx?.stream?.onDelta?.(d); },
|
|
112
|
+
// v2.6.0: 结构化工具/推理事件透传 (web 前端渲染工具卡片 + 轮次进度)
|
|
113
|
+
onTool: (ev) => { ctx?.stream?.onTool?.(ev); },
|
|
114
|
+
onStep: (ev) => { ctx?.stream?.onStep?.(ev); },
|
|
115
|
+
});
|
|
116
|
+
const text = full || String(reply);
|
|
117
|
+
return { content: [{ type: "text", text }] };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
this.extraTools = opts.extraTools ? [...chatTools, ...opts.extraTools] : chatTools;
|
|
122
|
+
this.extraPrompts = opts.prompts || [];
|
|
123
|
+
// 工具名 -> 实现映射 (虚拟工具), 冲突时优先内置工具 (工具名唯一性在 server 内)
|
|
124
|
+
this._virtualTools = new Map(this.extraTools.map((t) => [safeToolName(t.name), t]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- 对外分发入口: 接收 JSON-RPC 消息, 返回 { result } 或抛 McpError ----
|
|
128
|
+
async handle(msg, ctx = {}) {
|
|
129
|
+
if (!msg || typeof msg !== "object") throw new McpError(MCP_ERROR.INVALID_REQUEST, "无效请求: 非 JSON-RPC 对象");
|
|
130
|
+
if (msg.jsonrpc !== "2.0") throw new McpError(MCP_ERROR.INVALID_REQUEST, "jsonrpc 字段必须为 2.0");
|
|
131
|
+
const method = msg.method;
|
|
132
|
+
if (typeof method !== "string" || !method) throw new McpError(MCP_ERROR.INVALID_REQUEST, "缺少 method");
|
|
133
|
+
|
|
134
|
+
// 版本协商 (现代 era): 请求版本不在支持列表 → UnsupportedProtocolVersionError
|
|
135
|
+
const reqMeta = (msg.params && msg.params._meta) || {};
|
|
136
|
+
const reqVersion = reqMeta["io.modelcontextprotocol/protocolVersion"];
|
|
137
|
+
if (reqVersion && !this.supportedVersions.includes(reqVersion)) {
|
|
138
|
+
throw new McpError(MCP_ERROR.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
|
|
139
|
+
supported: this.supportedVersions,
|
|
140
|
+
requested: reqVersion,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 分发
|
|
145
|
+
switch (method) {
|
|
146
|
+
// ---- 现代 era 核心 ----
|
|
147
|
+
case "server/discover": {
|
|
148
|
+
if (!isModernRequest(msg)) throw new McpError(MCP_ERROR.INVALID_REQUEST, "server/discover 需现代 _meta");
|
|
149
|
+
return { result: stampServerInfo({
|
|
150
|
+
resultType: "complete",
|
|
151
|
+
supportedVersions: this.supportedVersions,
|
|
152
|
+
capabilities: await this._capabilities(),
|
|
153
|
+
instructions: this._instructions(),
|
|
154
|
+
ttlMs: 3600000,
|
|
155
|
+
cacheScope: "public",
|
|
156
|
+
}, this.serverInfo) };
|
|
157
|
+
}
|
|
158
|
+
case "ping":
|
|
159
|
+
return { result: stampServerInfo({ resultType: "complete" }, this.serverInfo) };
|
|
160
|
+
|
|
161
|
+
// ---- tools ----
|
|
162
|
+
case "tools/list": {
|
|
163
|
+
const tools = await this._listTools();
|
|
164
|
+
// 分页: 单页返回全部 (cursor 非空时返回空, 表示无更多页)
|
|
165
|
+
const cursor = msg.params && msg.params.cursor;
|
|
166
|
+
const result = {
|
|
167
|
+
resultType: "complete",
|
|
168
|
+
tools,
|
|
169
|
+
ttlMs: 300000, // 工具列表 5 分钟缓存 (2026-07-28 缓存语义)
|
|
170
|
+
cacheScope: "public",
|
|
171
|
+
};
|
|
172
|
+
if (cursor) result.nextCursor = null;
|
|
173
|
+
return { result: stampServerInfo(result, this.serverInfo) };
|
|
174
|
+
}
|
|
175
|
+
case "tools/call": {
|
|
176
|
+
const name = msg.params && msg.params.name;
|
|
177
|
+
if (!name) throw new McpError(MCP_ERROR.INVALID_PARAMS, "tools/call 缺少 name");
|
|
178
|
+
const args = (msg.params && msg.params.arguments) || {};
|
|
179
|
+
const raw = await this._callTool(name, args, ctx);
|
|
180
|
+
// raw 可能是 { content, structuredContent, isError, stream? } 或字符串 (内置工具返回)
|
|
181
|
+
const result = { resultType: "complete" };
|
|
182
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && ("content" in raw || "stream" in raw)) {
|
|
183
|
+
Object.assign(result, raw);
|
|
184
|
+
delete result.stream; // stream 由传输层消费
|
|
185
|
+
if (raw.stream) result.streamHandle = raw.stream;
|
|
186
|
+
} else {
|
|
187
|
+
const text = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
188
|
+
// isError 判定: 皮皮虾 [工具错误] 前缀 或 工具内部返回的 {"error":...} JSON
|
|
189
|
+
let isError = typeof raw === "string" && raw.startsWith(TOOL_ERROR_PREFIX);
|
|
190
|
+
if (!isError && typeof raw === "string") {
|
|
191
|
+
const trimmed = raw.trim();
|
|
192
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
193
|
+
try { isError = !!(JSON.parse(trimmed).error); } catch { /* 非 JSON 不管 */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
result.content = [{ type: "text", text }];
|
|
197
|
+
result.isError = isError;
|
|
198
|
+
}
|
|
199
|
+
return { result: stampServerInfo(result, this.serverInfo), stream: raw && raw.stream ? raw.stream : null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- resources ----
|
|
203
|
+
case "resources/list": {
|
|
204
|
+
const result = { resultType: "complete", resources: await this._listResources() };
|
|
205
|
+
return { result: stampServerInfo(result, this.serverInfo) };
|
|
206
|
+
}
|
|
207
|
+
case "resources/read": {
|
|
208
|
+
const uri = msg.params && msg.params.uri;
|
|
209
|
+
if (!uri) throw new McpError(MCP_ERROR.INVALID_PARAMS, "resources/read 缺少 uri");
|
|
210
|
+
const contents = await this._readResource(uri);
|
|
211
|
+
if (!contents) throw new McpError(MCP_ERROR.INVALID_PARAMS, `资源不存在: ${uri}`);
|
|
212
|
+
return { result: stampServerInfo({ resultType: "complete", contents }, this.serverInfo) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- prompts ----
|
|
216
|
+
case "prompts/list": {
|
|
217
|
+
const result = { resultType: "complete", prompts: this._listPrompts() };
|
|
218
|
+
return { result: stampServerInfo(result, this.serverInfo) };
|
|
219
|
+
}
|
|
220
|
+
case "prompts/get": {
|
|
221
|
+
const name = msg.params && msg.params.name;
|
|
222
|
+
if (!name) throw new McpError(MCP_ERROR.INVALID_PARAMS, "prompts/get 缺少 name");
|
|
223
|
+
const messages = this._getPrompt(name, (msg.params && msg.params.arguments) || {});
|
|
224
|
+
if (!messages) throw new McpError(MCP_ERROR.INVALID_PARAMS, `提示模板不存在: ${name}`);
|
|
225
|
+
return { result: stampServerInfo({ resultType: "complete", messages }, this.serverInfo) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- legacy era (initialize 握手) ----
|
|
229
|
+
case "initialize": {
|
|
230
|
+
if (isLegacyInitialize(msg)) {
|
|
231
|
+
const clientVersion = (msg.params && msg.params.protocolVersion) || "2024-11-05";
|
|
232
|
+
const negotiated = this.supportedVersions.includes(clientVersion) ? clientVersion : LEGACY_PROTOCOL_VERSION;
|
|
233
|
+
return { result: {
|
|
234
|
+
protocolVersion: negotiated,
|
|
235
|
+
capabilities: await this._capabilities(),
|
|
236
|
+
serverInfo: this.serverInfo,
|
|
237
|
+
instructions: this._instructions(),
|
|
238
|
+
} };
|
|
239
|
+
}
|
|
240
|
+
throw new McpError(MCP_ERROR.INVALID_REQUEST, "initialize 需 legacy 格式 (无现代 _meta)");
|
|
241
|
+
}
|
|
242
|
+
case "notifications/initialized":
|
|
243
|
+
case "notifications/cancelled":
|
|
244
|
+
return { result: null }; // 通知无响应, 传输层处理为 202/忽略
|
|
245
|
+
|
|
246
|
+
default:
|
|
247
|
+
throw new McpError(MCP_ERROR.METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- 能力声明 ----
|
|
252
|
+
async _capabilities() {
|
|
253
|
+
return {
|
|
254
|
+
tools: { listChanged: false },
|
|
255
|
+
resources: { subscribe: false, listChanged: false },
|
|
256
|
+
prompts: {},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_instructions() {
|
|
261
|
+
return "PPXANS-Harness (皮皮虾) 智能体内核。可用 tools 执行文件/命令/搜索/记忆/文档/治理等操作; " +
|
|
262
|
+
"resources 暴露记忆 (memory://)、轨迹 (traces://)、统计 (stats://)、会话 (sessions://) 数据; " +
|
|
263
|
+
"prompts 提供方法型技能模板 (humanize/plan/debug 等)。" +
|
|
264
|
+
"对话入口: 使用工具 ppx.chat.send 发送消息给 agent (含完整工具调用循环)。";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- tools 实现 ----
|
|
268
|
+
async _listTools() {
|
|
269
|
+
const out = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
// 1. 内置 + MCP 注册工具 (catalog 全量)
|
|
272
|
+
const detailed = this.agent && this.agent.tools && typeof this.agent.tools.listDetailed === "function"
|
|
273
|
+
? this.agent.tools.listDetailed() : [];
|
|
274
|
+
for (const t of detailed) {
|
|
275
|
+
const name = safeToolName(t.name);
|
|
276
|
+
if (!t.enabled || seen.has(name)) continue;
|
|
277
|
+
seen.add(name);
|
|
278
|
+
const tool = {
|
|
279
|
+
name,
|
|
280
|
+
title: t.title || t.name,
|
|
281
|
+
description: t.description || `MCP 工具: ${name}`,
|
|
282
|
+
inputSchema: t.parameters || { type: "object", properties: {} },
|
|
283
|
+
};
|
|
284
|
+
if (t.timeoutMs) tool.annotations = { timeoutMs: t.timeoutMs, idempotent: !!t.idempotent, readOnlyHint: false };
|
|
285
|
+
out.push(tool);
|
|
286
|
+
}
|
|
287
|
+
// 2. 虚拟工具 (对话等)
|
|
288
|
+
for (const [name, t] of this._virtualTools) {
|
|
289
|
+
if (seen.has(name)) continue;
|
|
290
|
+
seen.add(name);
|
|
291
|
+
out.push({
|
|
292
|
+
name,
|
|
293
|
+
title: t.title || name,
|
|
294
|
+
description: t.description || "",
|
|
295
|
+
inputSchema: t.inputSchema || { type: "object", properties: {} },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async _callTool(name, args, ctx) {
|
|
302
|
+
// 虚拟工具优先 (对话/流式等非 catalog 能力)
|
|
303
|
+
if (this._virtualTools.has(name)) {
|
|
304
|
+
return this._virtualTools.get(name).execute(args, ctx, this.agent);
|
|
305
|
+
}
|
|
306
|
+
// catalog 工具: 走统一策略链 (命令守卫/审计/超时)
|
|
307
|
+
if (this.agent && this.agent.tools && typeof this.agent.tools.call === "function") {
|
|
308
|
+
// 未知工具需在进入 catalog.call 前拦截 (catalog.call 返回错误串而非抛错)
|
|
309
|
+
if (typeof this.agent.tools.has === "function" && !this.agent.tools.has(name)) {
|
|
310
|
+
throw new McpError(MCP_ERROR.INVALID_PARAMS, `未知工具: ${name}`);
|
|
311
|
+
}
|
|
312
|
+
return this.agent.tools.call(name, args || {}, {});
|
|
313
|
+
}
|
|
314
|
+
throw new McpError(MCP_ERROR.INVALID_PARAMS, `未知工具: ${name}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- resources 实现 ----
|
|
318
|
+
async _listResources() {
|
|
319
|
+
const out = [];
|
|
320
|
+
const add = (uri, name, mimeType = "application/json", description = "") => out.push({ uri, name, mimeType, description });
|
|
321
|
+
add("memory://facts", "L1 原子记忆", "application/json", "长期记忆事实库 (含分数/类型)");
|
|
322
|
+
add("memory://scenes", "L2 场景", "application/json", "记忆聚类场景");
|
|
323
|
+
add("traces://recent", "最近工具轨迹", "application/json", "最近 N 条工具调用轨迹");
|
|
324
|
+
add("stats://overview", "运行统计", "application/json", "记忆/轨迹/工具/会话聚合统计");
|
|
325
|
+
add("sessions://list", "会话列表", "application/json", "全部会话 key");
|
|
326
|
+
// 动态: 会话历史按 key (sessions://<key>/history)
|
|
327
|
+
if (this.agent && this.agent.sessionStore && typeof this.agent.sessionStore.list === "function") {
|
|
328
|
+
for (const s of this.agent.sessionStore.list()) {
|
|
329
|
+
const key = typeof s === "string" ? s : (s && (s.key || s.id)) || "";
|
|
330
|
+
if (key) add(`sessions://${encodeURIComponent(key)}/history`, `会话历史: ${key}`, "application/json");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async _readResource(uri) {
|
|
337
|
+
const json = (obj) => [{ uri, mimeType: "application/json", text: JSON.stringify(obj, null, 2) }];
|
|
338
|
+
const a = this.agent;
|
|
339
|
+
try {
|
|
340
|
+
if (uri === "memory://facts") return json(a.facts ? a.facts.list() : []);
|
|
341
|
+
if (uri === "memory://scenes") return json(a.scenes && typeof a.scenes.listWithDesc === "function" ? a.scenes.listWithDesc() : []);
|
|
342
|
+
if (uri === "traces://recent") return json(a.traces && typeof a.traces.read === "function" ? a.traces.read(undefined, 50) : []);
|
|
343
|
+
if (uri === "stats://overview") return json(typeof a.stats === "function" ? a.stats() : (a.traces && a.traces.stats ? a.traces.stats() : {}));
|
|
344
|
+
if (uri === "sessions://list") return json(a.sessionStore && typeof a.sessionStore.list === "function" ? a.sessionStore.list() : []);
|
|
345
|
+
const m = uri.match(/^sessions:\/\/([^/]+)\/history$/);
|
|
346
|
+
if (m && a.sessionStore && typeof a.sessionStore.deriveMessages === "function") {
|
|
347
|
+
const key = decodeURIComponent(m[1]);
|
|
348
|
+
return json(a.sessionStore.deriveMessages(key));
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
warn(`[mcp] 资源读取失败 ${uri}: ${e.message}`);
|
|
352
|
+
return json({ error: e.message });
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- prompts 实现 ----
|
|
358
|
+
_listPrompts() {
|
|
359
|
+
const builtins = [
|
|
360
|
+
{ name: "humanize", description: "去 AI 味: 检查并改写长文, 消除虚高意义/虚假深度/广告腔等 AI 模式残留", arguments: [{ name: "text", required: true, description: "待检查文本" }] },
|
|
361
|
+
{ name: "plan", description: "精确计划: 把模糊目标拆成可执行步骤 (P1 目标/P2 输入/P3 输出/P4 约束/P5 模式)", arguments: [{ name: "goal", required: true, description: "目标" }] },
|
|
362
|
+
{ name: "debug", description: "五步调试: 复现 → 二分 → 根因 → 修复 → 验证", arguments: [{ name: "problem", required: true, description: "问题描述" }] },
|
|
363
|
+
{ name: "verify", description: "验证优先: 任何声称完成前产出可复现证据", arguments: [{ name: "claim", required: true, description: "待验证的声称" }] },
|
|
364
|
+
{ name: "write_article", description: "分阶段写作: 大纲 → 初稿 → 打磨", arguments: [{ name: "topic", required: true, description: "主题" }] },
|
|
365
|
+
];
|
|
366
|
+
const extra = this.extraPrompts.map((p) => ({
|
|
367
|
+
name: safeToolName(p.name),
|
|
368
|
+
description: p.description || "",
|
|
369
|
+
arguments: (p.arguments || []).map((a) => ({ name: a.name, required: !!a.required, description: a.description || "" })),
|
|
370
|
+
}));
|
|
371
|
+
return [...builtins, ...extra];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_getPrompt(name, args) {
|
|
375
|
+
const builtin = {
|
|
376
|
+
humanize: () => [{ role: "user", content: { type: "text", text: `请对以下文本执行去 AI 味检查并改写 (消除虚高意义/虚假深度/广告腔/模糊归因/AI 词汇/规则三连/破折号狂魔/空洞结尾):\n\n${args.text || ""}` } }],
|
|
377
|
+
plan: () => [{ role: "user", content: { type: "text", text: `请为以下目标制定精确计划 (P1 目标 / P2 输入 / P3 输出 / P4 约束 / P5 模式):\n\n${args.goal || ""}` } }],
|
|
378
|
+
debug: () => [{ role: "user", content: { type: "text", text: `请用五步调试法 (复现 → 二分 → 根因 → 修复 → 验证) 处理:\n\n${args.problem || ""}` } }],
|
|
379
|
+
verify: () => [{ role: "user", content: { type: "text", text: `请验证以下声称, 给出可复现证据或明确"未验证+原因":\n\n${args.claim || ""}` } }],
|
|
380
|
+
write_article: () => [{ role: "user", content: { type: "text", text: `请分阶段写作 (大纲 → 初稿 → 打磨), 主题:\n\n${args.topic || ""}` } }],
|
|
381
|
+
}[name];
|
|
382
|
+
if (builtin) return builtin();
|
|
383
|
+
const extra = this.extraPrompts.find((p) => safeToolName(p.name) === name);
|
|
384
|
+
if (extra && typeof extra.build === "function") return extra.build(args);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|