@evomap/evolver-mcp 2.0.0-beta.2 → 2.0.0-beta.22
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/dist/antigravityInstaller.d.ts +1 -1
- package/dist/antigravityInstaller.js +17 -29
- package/dist/codexInstaller.d.ts +8 -3
- package/dist/codexInstaller.js +217 -53
- package/dist/cursorRulesInstaller.d.ts +1 -1
- package/dist/cursorRulesInstaller.js +66 -17
- package/dist/envFile.d.ts +2 -1
- package/dist/envFile.js +6 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/injection.d.ts +2 -1
- package/dist/injection.js +10 -7
- package/dist/installer.d.ts +52 -24
- package/dist/installer.js +268 -157
- package/dist/installerShared.d.ts +103 -0
- package/dist/installerShared.js +99 -0
- package/dist/jsonMcpInstaller.d.ts +79 -0
- package/dist/jsonMcpInstaller.js +857 -0
- package/dist/kiroInstaller.d.ts +10 -0
- package/dist/kiroInstaller.js +146 -0
- package/dist/opencodeInstaller.d.ts +18 -0
- package/dist/opencodeInstaller.js +531 -0
- package/dist/primer.js +8 -5
- package/dist/productBridge.d.ts +37 -0
- package/dist/productBridge.js +250 -0
- package/dist/productBridgeShim.d.ts +52 -0
- package/dist/productBridgeShim.js +338 -0
- package/dist/proxyClient.d.ts +27 -0
- package/dist/proxyClient.js +135 -26
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +10 -4
- package/dist/tools.js +71 -7
- package/package.json +13 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Unique writer for the EvoX product-tools MCP server (`evox-product`).
|
|
2
|
+
// This is not the evolver gene/memory server. Desktop publishes a loopback
|
|
3
|
+
// grant at ~/.evox/product-bridge.json; this module only registers a stdio
|
|
4
|
+
// shim that fails closed until that file exists. Uninstall removes only a
|
|
5
|
+
// managed evox-product entry.
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
10
|
+
import { util } from '@evomap/evolver-core';
|
|
11
|
+
import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
|
|
12
|
+
export const PRODUCT_BRIDGE_SERVER_ID = 'evox-product';
|
|
13
|
+
export const PRODUCT_BRIDGE_MANAGED_KEY = '_evox_product_managed';
|
|
14
|
+
export const PRODUCT_BRIDGE_PREVIOUS_KEY = '_evox_product_previous';
|
|
15
|
+
export const PRODUCT_BRIDGE_GRANT_SCHEMA = 'evox.product_bridge.grant.v1';
|
|
16
|
+
const CONFIG_WRITE_RETRIES = 5;
|
|
17
|
+
const CONFIG_MODE = 0o600;
|
|
18
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
/** Resolve only a compiled JavaScript shim. Source TypeScript is never written into runtime config. */
|
|
20
|
+
export function productBridgeShimPath() {
|
|
21
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
22
|
+
const candidates = [
|
|
23
|
+
fileURLToPath(new URL('./productBridgeShim.js', import.meta.url)),
|
|
24
|
+
join(dirname(modulePath), '../dist/productBridgeShim.js'),
|
|
25
|
+
];
|
|
26
|
+
const built = candidates.find((candidate) => existsSync(candidate));
|
|
27
|
+
if (!built) {
|
|
28
|
+
throw new Error('[setup-hooks] product-bridge shim is not built; run the evolver-mcp build before installing the bridge.');
|
|
29
|
+
}
|
|
30
|
+
return built;
|
|
31
|
+
}
|
|
32
|
+
const NO_PREVIOUS_ENTRY = Symbol('no previous product bridge entry');
|
|
33
|
+
function productBridgeServerEntry(previous = NO_PREVIOUS_ENTRY) {
|
|
34
|
+
return {
|
|
35
|
+
command: process.execPath,
|
|
36
|
+
args: [productBridgeShimPath()],
|
|
37
|
+
[PRODUCT_BRIDGE_MANAGED_KEY]: true,
|
|
38
|
+
...(previous === NO_PREVIOUS_ENTRY ? {} : { [PRODUCT_BRIDGE_PREVIOUS_KEY]: previous }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function previousEntryForForce(existing, hasExisting) {
|
|
42
|
+
if (!hasExisting)
|
|
43
|
+
return NO_PREVIOUS_ENTRY;
|
|
44
|
+
if (!isOwnedProductBridge(existing))
|
|
45
|
+
return existing;
|
|
46
|
+
return isObj(existing) && Object.prototype.hasOwnProperty.call(existing, PRODUCT_BRIDGE_PREVIOUS_KEY)
|
|
47
|
+
? existing[PRODUCT_BRIDGE_PREVIOUS_KEY]
|
|
48
|
+
: NO_PREVIOUS_ENTRY;
|
|
49
|
+
}
|
|
50
|
+
/** Ownership is explicit. A matching filename alone is never enough to delete a user's server. */
|
|
51
|
+
export function isOwnedProductBridge(entry) {
|
|
52
|
+
return isObj(entry) && entry[PRODUCT_BRIDGE_MANAGED_KEY] === true;
|
|
53
|
+
}
|
|
54
|
+
/** Restore a user entry previously preserved by an explicit force takeover. */
|
|
55
|
+
export function restoreProductBridgeEntry(entry) {
|
|
56
|
+
if (!isOwnedProductBridge(entry) || !isObj(entry) || !Object.prototype.hasOwnProperty.call(entry, PRODUCT_BRIDGE_PREVIOUS_KEY)) {
|
|
57
|
+
return { restored: false };
|
|
58
|
+
}
|
|
59
|
+
return { restored: true, entry: entry[PRODUCT_BRIDGE_PREVIOUS_KEY] };
|
|
60
|
+
}
|
|
61
|
+
/** Merge a managed evox-product server into a parsed MCP JSON object (project .mcp.json or ~/.claude.json). */
|
|
62
|
+
export function withClaudeProductBridge(data, force = false) {
|
|
63
|
+
if (Object.prototype.hasOwnProperty.call(data, 'mcpServers') && !isObj(data['mcpServers'])) {
|
|
64
|
+
throw new Error('[setup-hooks] refusing to overwrite MCP configuration: mcpServers must be an object.');
|
|
65
|
+
}
|
|
66
|
+
const servers = isObj(data['mcpServers']) ? { ...data['mcpServers'] } : {};
|
|
67
|
+
const hasExisting = Object.prototype.hasOwnProperty.call(servers, PRODUCT_BRIDGE_SERVER_ID);
|
|
68
|
+
const existing = servers[PRODUCT_BRIDGE_SERVER_ID];
|
|
69
|
+
if (hasExisting && !isOwnedProductBridge(existing) && !force) {
|
|
70
|
+
return { changed: false, skipped: true, data };
|
|
71
|
+
}
|
|
72
|
+
const previous = previousEntryForForce(existing, hasExisting);
|
|
73
|
+
const next = productBridgeServerEntry(previous);
|
|
74
|
+
if (JSON.stringify(existing) === JSON.stringify(next))
|
|
75
|
+
return { changed: false, data };
|
|
76
|
+
return { changed: true, data: { ...data, mcpServers: { ...servers, [PRODUCT_BRIDGE_SERVER_ID]: next } } };
|
|
77
|
+
}
|
|
78
|
+
/** Merge a managed evox-product table into parsed Codex TOML. */
|
|
79
|
+
export function withCodexProductBridge(data, force = false) {
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(data, 'mcp_servers') && !isObj(data['mcp_servers'])) {
|
|
81
|
+
throw new Error('[setup-hooks] refusing to overwrite MCP configuration: mcp_servers must be an object.');
|
|
82
|
+
}
|
|
83
|
+
const servers = isObj(data['mcp_servers']) ? { ...data['mcp_servers'] } : {};
|
|
84
|
+
const hasExisting = Object.prototype.hasOwnProperty.call(servers, PRODUCT_BRIDGE_SERVER_ID);
|
|
85
|
+
const existing = servers[PRODUCT_BRIDGE_SERVER_ID];
|
|
86
|
+
if (hasExisting && !isOwnedProductBridge(existing) && !force) {
|
|
87
|
+
return { changed: false, skipped: true, data };
|
|
88
|
+
}
|
|
89
|
+
const previous = previousEntryForForce(existing, hasExisting);
|
|
90
|
+
const next = productBridgeServerEntry(previous);
|
|
91
|
+
if (JSON.stringify(existing) === JSON.stringify(next))
|
|
92
|
+
return { changed: false, data };
|
|
93
|
+
return { changed: true, data: { ...data, mcp_servers: { ...servers, [PRODUCT_BRIDGE_SERVER_ID]: next } } };
|
|
94
|
+
}
|
|
95
|
+
function assertNotSymlink(path, label) {
|
|
96
|
+
let st;
|
|
97
|
+
try {
|
|
98
|
+
st = lstatSync(path);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT')
|
|
102
|
+
return;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (st.isSymbolicLink()) {
|
|
106
|
+
throw new Error(`[setup-hooks] refusing to operate: ${label} ${path} is a symbolic link.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function assertConfigPathSafe(path, label) {
|
|
110
|
+
assertNotSymlink(path, label);
|
|
111
|
+
assertNotSymlink(`${path}.evolver.lock`, `${label} lock`);
|
|
112
|
+
const parent = lstatSync(dirname(path));
|
|
113
|
+
if (parent.isSymbolicLink())
|
|
114
|
+
throw new Error(`[setup-hooks] refusing to operate: ${label} parent directory is a symbolic link.`);
|
|
115
|
+
if (!parent.isDirectory())
|
|
116
|
+
throw new Error(`[setup-hooks] refusing to operate: ${label} parent is not a directory.`);
|
|
117
|
+
}
|
|
118
|
+
function readConfigSnapshot(path, label, parse) {
|
|
119
|
+
assertConfigPathSafe(path, label);
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(path, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT')
|
|
126
|
+
return { data: {}, raw: undefined, mode: CONFIG_MODE };
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
if (!raw.trim())
|
|
130
|
+
throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the existing file is empty.`);
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = parse(raw.trim());
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the existing document is malformed.`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
if (!isObj(data))
|
|
139
|
+
throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the existing document must be an object.`);
|
|
140
|
+
const mode = statSync(path).mode & 0o777;
|
|
141
|
+
return { data, raw, mode: mode === 0 ? CONFIG_MODE : mode & 0o700 };
|
|
142
|
+
}
|
|
143
|
+
function readRawIfExists(path) {
|
|
144
|
+
try {
|
|
145
|
+
return readFileSync(path, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code === 'ENOENT')
|
|
149
|
+
return undefined;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function releaseConfigLock(lockPath) {
|
|
154
|
+
const released = util.releaseLock(lockPath);
|
|
155
|
+
if (!released.released)
|
|
156
|
+
throw new util.LockReleaseError(released.reason);
|
|
157
|
+
}
|
|
158
|
+
/** Lock, compare, and commit a product bridge update without clobbering concurrent runtime writes. */
|
|
159
|
+
function updateConfigFile(path, label, parse, serialize, update) {
|
|
160
|
+
const lockPath = `${path}.evolver.lock`;
|
|
161
|
+
assertConfigPathSafe(path, label);
|
|
162
|
+
util.acquireLock(lockPath);
|
|
163
|
+
try {
|
|
164
|
+
for (let attempt = 1; attempt <= CONFIG_WRITE_RETRIES; attempt += 1) {
|
|
165
|
+
const snapshot = readConfigSnapshot(path, label, parse);
|
|
166
|
+
const next = update(snapshot.data);
|
|
167
|
+
if (!next.changed)
|
|
168
|
+
return { changed: false, ...(next.skipped ? { skipped: true } : {}) };
|
|
169
|
+
assertConfigPathSafe(path, label);
|
|
170
|
+
if (readRawIfExists(path) !== snapshot.raw)
|
|
171
|
+
continue;
|
|
172
|
+
try {
|
|
173
|
+
commitSharedFile({
|
|
174
|
+
path,
|
|
175
|
+
expectedRaw: snapshot.raw,
|
|
176
|
+
nextRaw: serialize(next.data),
|
|
177
|
+
mode: snapshot.mode,
|
|
178
|
+
});
|
|
179
|
+
return { changed: true };
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof SharedFileConflictError)
|
|
183
|
+
continue;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the file changed repeatedly while the product bridge was being installed.`);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
releaseConfigLock(lockPath);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function installClaudeProductBridge(configRoot, force = false) {
|
|
194
|
+
const path = join(configRoot, '.mcp.json');
|
|
195
|
+
const result = updateConfigFile(path, '.mcp.json', (raw) => JSON.parse(raw), (data) => `${JSON.stringify(data, null, 2)}\n`, (data) => withClaudeProductBridge(data, force));
|
|
196
|
+
return { ...result, path };
|
|
197
|
+
}
|
|
198
|
+
export function uninstallClaudeProductBridge(configRoot) {
|
|
199
|
+
const path = join(configRoot, '.mcp.json');
|
|
200
|
+
if (!existsSync(path))
|
|
201
|
+
return false;
|
|
202
|
+
const result = updateConfigFile(path, '.mcp.json', (raw) => JSON.parse(raw), (data) => `${JSON.stringify(data, null, 2)}\n`, (data) => {
|
|
203
|
+
const servers = data['mcpServers'];
|
|
204
|
+
if (!isObj(servers) || !isOwnedProductBridge(servers[PRODUCT_BRIDGE_SERVER_ID]))
|
|
205
|
+
return { changed: false, data };
|
|
206
|
+
const next = { ...servers };
|
|
207
|
+
const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
|
|
208
|
+
if (restored.restored)
|
|
209
|
+
next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
|
|
210
|
+
else
|
|
211
|
+
delete next[PRODUCT_BRIDGE_SERVER_ID];
|
|
212
|
+
const out = { ...data };
|
|
213
|
+
if (Object.keys(next).length > 0)
|
|
214
|
+
out['mcpServers'] = next;
|
|
215
|
+
else
|
|
216
|
+
delete out['mcpServers'];
|
|
217
|
+
return { changed: true, data: out };
|
|
218
|
+
});
|
|
219
|
+
return result.changed;
|
|
220
|
+
}
|
|
221
|
+
export function installCodexProductBridge(configRoot, force = false) {
|
|
222
|
+
const path = join(configRoot, '.codex', 'config.toml');
|
|
223
|
+
assertNotSymlink(join(configRoot, '.codex'), '.codex');
|
|
224
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
225
|
+
const result = updateConfigFile(path, '.codex/config.toml', (raw) => parseToml(raw), (data) => `${stringifyToml(data)}\n`, (data) => withCodexProductBridge(data, force));
|
|
226
|
+
return { ...result, path };
|
|
227
|
+
}
|
|
228
|
+
export function uninstallCodexProductBridge(configRoot) {
|
|
229
|
+
const path = join(configRoot, '.codex', 'config.toml');
|
|
230
|
+
if (!existsSync(path))
|
|
231
|
+
return false;
|
|
232
|
+
const result = updateConfigFile(path, '.codex/config.toml', (raw) => parseToml(raw), (data) => `${stringifyToml(data)}\n`, (data) => {
|
|
233
|
+
const servers = data['mcp_servers'];
|
|
234
|
+
if (!isObj(servers) || !isOwnedProductBridge(servers[PRODUCT_BRIDGE_SERVER_ID]))
|
|
235
|
+
return { changed: false, data };
|
|
236
|
+
const next = { ...servers };
|
|
237
|
+
const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
|
|
238
|
+
if (restored.restored)
|
|
239
|
+
next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
|
|
240
|
+
else
|
|
241
|
+
delete next[PRODUCT_BRIDGE_SERVER_ID];
|
|
242
|
+
const out = { ...data };
|
|
243
|
+
if (Object.keys(next).length > 0)
|
|
244
|
+
out['mcp_servers'] = next;
|
|
245
|
+
else
|
|
246
|
+
delete out['mcp_servers'];
|
|
247
|
+
return { changed: true, data: out };
|
|
248
|
+
});
|
|
249
|
+
return result.changed;
|
|
250
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export declare const GRANT_SCHEMA = "evox.product_bridge.grant.v1";
|
|
3
|
+
export declare const MAX_STDIO_FRAME_BYTES: number;
|
|
4
|
+
export declare const MAX_IN_FLIGHT_REQUESTS = 32;
|
|
5
|
+
export declare const MAX_PENDING_STDIO_FRAMES = 64;
|
|
6
|
+
export declare const MAX_PENDING_STDIO_BYTES: number;
|
|
7
|
+
interface JsonRpcMessage {
|
|
8
|
+
jsonrpc?: string;
|
|
9
|
+
id?: unknown;
|
|
10
|
+
method?: string;
|
|
11
|
+
params?: unknown;
|
|
12
|
+
result?: unknown;
|
|
13
|
+
error?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare function grantFilePath(env?: NodeJS.ProcessEnv): string;
|
|
16
|
+
export declare function isLoopbackHttp(raw: string): boolean;
|
|
17
|
+
export declare function readGrant(filePath?: string): {
|
|
18
|
+
url: string;
|
|
19
|
+
grant: string;
|
|
20
|
+
};
|
|
21
|
+
/** 将请求、通知和客户端响应转发到回环 product bridge。 */
|
|
22
|
+
export declare function dispatch(req: JsonRpcMessage): Promise<Record<string, unknown> | null>;
|
|
23
|
+
export interface DecodedStdioChunk {
|
|
24
|
+
readonly frames: string[];
|
|
25
|
+
readonly oversizedFrames: number;
|
|
26
|
+
}
|
|
27
|
+
/** MCP stdio 使用换行分隔 JSON;解码器限制单帧大小,并排出当前缓冲区中的全部完整帧。 */
|
|
28
|
+
export declare class StdioLineDecoder {
|
|
29
|
+
private pending;
|
|
30
|
+
private discardingOversized;
|
|
31
|
+
push(chunk: Buffer | string): DecodedStdioChunk;
|
|
32
|
+
finish(): {
|
|
33
|
+
incomplete: boolean;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** 对等待转发的完整帧同时施加数量和字节上限。 */
|
|
37
|
+
export declare class BoundedStdioFrameQueue {
|
|
38
|
+
private readonly frames;
|
|
39
|
+
private queuedBytes;
|
|
40
|
+
get length(): number;
|
|
41
|
+
get bytes(): number;
|
|
42
|
+
enqueue(frame: string): boolean;
|
|
43
|
+
shift(): string | undefined;
|
|
44
|
+
isBelowResumeWatermark(): boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface StdioBridgeOutput {
|
|
47
|
+
write(message: string): boolean;
|
|
48
|
+
on(event: 'drain', listener: () => void): unknown;
|
|
49
|
+
}
|
|
50
|
+
/** 启动可注入流和转发函数的 stdio bridge,便于验证背压与协议行为。 */
|
|
51
|
+
export declare function runStdioBridge(input?: NodeJS.ReadableStream, output?: StdioBridgeOutput, dispatchMessage?: typeof dispatch): void;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// EvoX 产品工具的按需 stdio 代理;本进程绝不监听端口。
|
|
3
|
+
// Desktop 通过 ~/.evox/product-bridge.json(或 EVOX_PRODUCT_BRIDGE_GRANT_FILE)发布授权。
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
export const GRANT_SCHEMA = 'evox.product_bridge.grant.v1';
|
|
11
|
+
const GRANT_HEADER = 'X-Evox-Product-Bridge-Grant';
|
|
12
|
+
const NONCE_HEADER = 'X-Evox-Product-Bridge-Nonce';
|
|
13
|
+
const MAX_GRANT_BYTES = 64 * 1024;
|
|
14
|
+
export const MAX_STDIO_FRAME_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
export const MAX_IN_FLIGHT_REQUESTS = 32;
|
|
16
|
+
export const MAX_PENDING_STDIO_FRAMES = 64;
|
|
17
|
+
export const MAX_PENDING_STDIO_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
19
|
+
export function grantFilePath(env = process.env) {
|
|
20
|
+
const override = String(env['EVOX_PRODUCT_BRIDGE_GRANT_FILE'] ?? '').trim();
|
|
21
|
+
if (override)
|
|
22
|
+
return override;
|
|
23
|
+
return join(homedir(), '.evox', 'product-bridge.json');
|
|
24
|
+
}
|
|
25
|
+
export function isLoopbackHttp(raw) {
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(raw);
|
|
28
|
+
const host = url.hostname.toLowerCase();
|
|
29
|
+
return url.protocol === 'http:' && (host === '127.0.0.1' || host === '::1' || host === '[::1]');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function readGrant(filePath = grantFilePath()) {
|
|
36
|
+
if (!existsSync(filePath)) {
|
|
37
|
+
throw new Error('EvoX Desktop is not publishing a product-bridge grant. Start EvoX Desktop and retry.');
|
|
38
|
+
}
|
|
39
|
+
const st = lstatSync(filePath);
|
|
40
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_GRANT_BYTES) {
|
|
41
|
+
throw new Error('product-bridge grant file is not a regular file');
|
|
42
|
+
}
|
|
43
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
44
|
+
if (data.schema !== GRANT_SCHEMA) {
|
|
45
|
+
throw new Error(`product-bridge grant schema is not ${GRANT_SCHEMA}`);
|
|
46
|
+
}
|
|
47
|
+
if (!isLoopbackHttp(String(data.url ?? '')) || !String(data.grant ?? '').trim()) {
|
|
48
|
+
throw new Error('product-bridge grant is missing a loopback URL or token');
|
|
49
|
+
}
|
|
50
|
+
return { url: String(data.url).trim(), grant: String(data.grant).trim() };
|
|
51
|
+
}
|
|
52
|
+
function postJson(url, body, headers) {
|
|
53
|
+
return new Promise((resolvePromise, reject) => {
|
|
54
|
+
const target = new URL(url);
|
|
55
|
+
const payload = Buffer.from(JSON.stringify(body), 'utf8');
|
|
56
|
+
if (payload.length > MAX_STDIO_FRAME_BYTES) {
|
|
57
|
+
reject(new Error('product-bridge request is too large'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const req = http.request({
|
|
61
|
+
protocol: target.protocol,
|
|
62
|
+
hostname: target.hostname.replace(/^\[(.*)\]$/, '$1'),
|
|
63
|
+
port: target.port,
|
|
64
|
+
path: `${target.pathname}${target.search}`,
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', 'content-length': payload.length, ...headers },
|
|
67
|
+
}, (res) => {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let size = 0;
|
|
70
|
+
let oversized = false;
|
|
71
|
+
res.on('data', (chunk) => {
|
|
72
|
+
if (oversized)
|
|
73
|
+
return;
|
|
74
|
+
size += chunk.length;
|
|
75
|
+
if (size > MAX_STDIO_FRAME_BYTES) {
|
|
76
|
+
oversized = true;
|
|
77
|
+
req.destroy(new Error('product-bridge response is too large'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
});
|
|
82
|
+
res.on('end', () => {
|
|
83
|
+
if (oversized)
|
|
84
|
+
return;
|
|
85
|
+
if ((res.statusCode ?? 500) < 200 || (res.statusCode ?? 500) >= 300) {
|
|
86
|
+
reject(new Error(`product-bridge returned HTTP ${res.statusCode ?? 500}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
90
|
+
if (!raw) {
|
|
91
|
+
resolvePromise(undefined);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
97
|
+
reject(new Error('product-bridge returned a non-object JSON-RPC message'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
resolvePromise(parsed);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
reject(new Error('product-bridge returned invalid JSON'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
108
|
+
req.destroy(new Error('product-bridge request timed out'));
|
|
109
|
+
});
|
|
110
|
+
req.on('error', reject);
|
|
111
|
+
req.end(payload);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function rpcError(id, message, code = -32000) {
|
|
115
|
+
return { jsonrpc: '2.0', id: id ?? null, error: { code, message } };
|
|
116
|
+
}
|
|
117
|
+
function isJsonRpcResponse(req) {
|
|
118
|
+
return req.id !== undefined && ('result' in req || 'error' in req) && req.method === undefined;
|
|
119
|
+
}
|
|
120
|
+
/** 将请求、通知和客户端响应转发到回环 product bridge。 */
|
|
121
|
+
export async function dispatch(req) {
|
|
122
|
+
if (!req || req.jsonrpc !== '2.0')
|
|
123
|
+
return rpcError(req?.id, 'invalid JSON-RPC request', -32600);
|
|
124
|
+
const isRequest = typeof req.method === 'string' && req.method.length > 0;
|
|
125
|
+
const isResponse = isJsonRpcResponse(req);
|
|
126
|
+
if (!isRequest && !isResponse)
|
|
127
|
+
return rpcError(req.id, 'invalid JSON-RPC request', -32600);
|
|
128
|
+
const expectsResponse = isRequest && req.id !== undefined;
|
|
129
|
+
let grant;
|
|
130
|
+
try {
|
|
131
|
+
grant = readGrant();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return expectsResponse ? rpcError(req.id, error instanceof Error ? error.message : String(error)) : null;
|
|
135
|
+
}
|
|
136
|
+
const headers = { [GRANT_HEADER]: grant.grant };
|
|
137
|
+
if (req.method === 'tools/call')
|
|
138
|
+
headers[NONCE_HEADER] = randomBytes(16).toString('hex');
|
|
139
|
+
try {
|
|
140
|
+
const response = await postJson(grant.url, req, headers);
|
|
141
|
+
if (!expectsResponse)
|
|
142
|
+
return null;
|
|
143
|
+
if (!response)
|
|
144
|
+
return rpcError(req.id, 'product-bridge returned an empty response');
|
|
145
|
+
response['id'] = req.id;
|
|
146
|
+
response['jsonrpc'] = '2.0';
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return expectsResponse ? rpcError(req.id, error instanceof Error ? error.message : String(error)) : null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** MCP stdio 使用换行分隔 JSON;解码器限制单帧大小,并排出当前缓冲区中的全部完整帧。 */
|
|
154
|
+
export class StdioLineDecoder {
|
|
155
|
+
pending = Buffer.alloc(0);
|
|
156
|
+
discardingOversized = false;
|
|
157
|
+
push(chunk) {
|
|
158
|
+
const incoming = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
159
|
+
const frames = [];
|
|
160
|
+
let oversizedFrames = 0;
|
|
161
|
+
let offset = 0;
|
|
162
|
+
while (offset < incoming.length) {
|
|
163
|
+
if (this.discardingOversized) {
|
|
164
|
+
const newline = incoming.indexOf(0x0a, offset);
|
|
165
|
+
if (newline === -1)
|
|
166
|
+
break;
|
|
167
|
+
this.discardingOversized = false;
|
|
168
|
+
offset = newline + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const newline = incoming.indexOf(0x0a, offset);
|
|
172
|
+
if (newline === -1) {
|
|
173
|
+
const tail = incoming.subarray(offset);
|
|
174
|
+
if (this.pending.length + tail.length > MAX_STDIO_FRAME_BYTES) {
|
|
175
|
+
this.pending = Buffer.alloc(0);
|
|
176
|
+
this.discardingOversized = true;
|
|
177
|
+
oversizedFrames += 1;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.pending = Buffer.concat([this.pending, tail]);
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
let line = Buffer.concat([this.pending, incoming.subarray(offset, newline)]);
|
|
185
|
+
this.pending = Buffer.alloc(0);
|
|
186
|
+
offset = newline + 1;
|
|
187
|
+
if (line.at(-1) === 0x0d)
|
|
188
|
+
line = line.subarray(0, -1);
|
|
189
|
+
if (line.length === 0)
|
|
190
|
+
continue;
|
|
191
|
+
if (line.length > MAX_STDIO_FRAME_BYTES) {
|
|
192
|
+
oversizedFrames += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
frames.push(line.toString('utf8'));
|
|
196
|
+
}
|
|
197
|
+
return { frames, oversizedFrames };
|
|
198
|
+
}
|
|
199
|
+
finish() {
|
|
200
|
+
const incomplete = this.pending.length > 0;
|
|
201
|
+
this.pending = Buffer.alloc(0);
|
|
202
|
+
return { incomplete };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** 对等待转发的完整帧同时施加数量和字节上限。 */
|
|
206
|
+
export class BoundedStdioFrameQueue {
|
|
207
|
+
frames = [];
|
|
208
|
+
queuedBytes = 0;
|
|
209
|
+
get length() { return this.frames.length; }
|
|
210
|
+
get bytes() { return this.queuedBytes; }
|
|
211
|
+
enqueue(frame) {
|
|
212
|
+
const bytes = Buffer.byteLength(frame, 'utf8');
|
|
213
|
+
if (this.frames.length >= MAX_PENDING_STDIO_FRAMES || this.queuedBytes + bytes > MAX_PENDING_STDIO_BYTES) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
this.frames.push({ frame, bytes });
|
|
217
|
+
this.queuedBytes += bytes;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
shift() {
|
|
221
|
+
const item = this.frames.shift();
|
|
222
|
+
if (!item)
|
|
223
|
+
return undefined;
|
|
224
|
+
this.queuedBytes -= item.bytes;
|
|
225
|
+
return item.frame;
|
|
226
|
+
}
|
|
227
|
+
isBelowResumeWatermark() {
|
|
228
|
+
return this.frames.length <= Math.floor(MAX_PENDING_STDIO_FRAMES / 2)
|
|
229
|
+
&& this.queuedBytes <= Math.floor(MAX_PENDING_STDIO_BYTES / 2);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** 启动可注入流和转发函数的 stdio bridge,便于验证背压与协议行为。 */
|
|
233
|
+
export function runStdioBridge(input = process.stdin, output = process.stdout, dispatchMessage = dispatch) {
|
|
234
|
+
const decoder = new StdioLineDecoder();
|
|
235
|
+
const pendingFrames = new BoundedStdioFrameQueue();
|
|
236
|
+
let inFlight = 0;
|
|
237
|
+
let inputPaused = false;
|
|
238
|
+
let outputBlocked = false;
|
|
239
|
+
const writeMessage = (message) => {
|
|
240
|
+
if (!output.write(`${JSON.stringify(message)}\n`)) {
|
|
241
|
+
outputBlocked = true;
|
|
242
|
+
pauseInput();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const processFrame = async (frame) => {
|
|
246
|
+
let req;
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(frame);
|
|
249
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
250
|
+
writeMessage(rpcError(null, 'invalid JSON-RPC request', -32600));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
req = parsed;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
writeMessage(rpcError(null, error instanceof Error ? error.message : String(error), -32700));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const response = await dispatchMessage(req);
|
|
260
|
+
if (response)
|
|
261
|
+
writeMessage(response);
|
|
262
|
+
};
|
|
263
|
+
const processFrameSafely = (frame) => {
|
|
264
|
+
void processFrame(frame).catch((error) => {
|
|
265
|
+
writeMessage(rpcError(null, error instanceof Error ? error.message : String(error)));
|
|
266
|
+
}).finally(() => {
|
|
267
|
+
inFlight -= 1;
|
|
268
|
+
drainFrames();
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
const pauseInput = () => {
|
|
272
|
+
if (inputPaused)
|
|
273
|
+
return;
|
|
274
|
+
inputPaused = true;
|
|
275
|
+
input.pause();
|
|
276
|
+
};
|
|
277
|
+
const resumeInputIfSafe = () => {
|
|
278
|
+
if (!inputPaused || outputBlocked || !pendingFrames.isBelowResumeWatermark())
|
|
279
|
+
return;
|
|
280
|
+
inputPaused = false;
|
|
281
|
+
input.resume();
|
|
282
|
+
};
|
|
283
|
+
const writeOverloadForRequest = (frame) => {
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(frame);
|
|
286
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
287
|
+
return;
|
|
288
|
+
const request = parsed;
|
|
289
|
+
if (typeof request.method === 'string' && request.id !== undefined) {
|
|
290
|
+
writeMessage(rpcError(request.id, 'product-bridge stdio queue is full', -32001));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// 语法错误会由正常解析路径报告;过载路径不再保留该帧。
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
function drainFrames() {
|
|
298
|
+
while (!outputBlocked && inFlight < MAX_IN_FLIGHT_REQUESTS && pendingFrames.length > 0) {
|
|
299
|
+
const frame = pendingFrames.shift();
|
|
300
|
+
if (frame === undefined)
|
|
301
|
+
return;
|
|
302
|
+
inFlight += 1;
|
|
303
|
+
processFrameSafely(frame);
|
|
304
|
+
}
|
|
305
|
+
resumeInputIfSafe();
|
|
306
|
+
}
|
|
307
|
+
output.on('drain', () => {
|
|
308
|
+
outputBlocked = false;
|
|
309
|
+
drainFrames();
|
|
310
|
+
});
|
|
311
|
+
input.on('data', (chunk) => {
|
|
312
|
+
const decoded = decoder.push(chunk);
|
|
313
|
+
for (let index = 0; index < decoded.oversizedFrames; index += 1) {
|
|
314
|
+
writeMessage(rpcError(null, 'product-bridge stdio frame is too large', -32600));
|
|
315
|
+
}
|
|
316
|
+
for (const frame of decoded.frames) {
|
|
317
|
+
if (!outputBlocked && inFlight < MAX_IN_FLIGHT_REQUESTS && pendingFrames.length === 0) {
|
|
318
|
+
inFlight += 1;
|
|
319
|
+
processFrameSafely(frame);
|
|
320
|
+
}
|
|
321
|
+
else if (!pendingFrames.enqueue(frame)) {
|
|
322
|
+
pauseInput();
|
|
323
|
+
writeOverloadForRequest(frame);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
drainFrames();
|
|
327
|
+
});
|
|
328
|
+
input.on('end', () => {
|
|
329
|
+
if (decoder.finish().incomplete)
|
|
330
|
+
writeMessage(rpcError(null, 'incomplete newline-delimited JSON-RPC frame', -32700));
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function isEntrypoint() {
|
|
334
|
+
const argvPath = process.argv[1];
|
|
335
|
+
return argvPath !== undefined && resolve(argvPath) === resolve(fileURLToPath(import.meta.url));
|
|
336
|
+
}
|
|
337
|
+
if (isEntrypoint())
|
|
338
|
+
runStdioBridge();
|