@evomap/evolver-mcp 2.0.23 → 2.0.25
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/codexInstaller.js +16 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/installer.js +30 -14
- 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/tools.js +14 -4
- package/package.json +6 -2
package/dist/codexInstaller.js
CHANGED
|
@@ -24,6 +24,7 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
|
24
24
|
import { util } from '@evomap/evolver-core';
|
|
25
25
|
import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
|
|
26
26
|
import { SymlinkRefusedError, DEFAULT_HOOK_COMMAND, DEFAULT_PROMPT_RECALL_HOOK_COMMAND, evolverManagedHookStatus, stripEvolverHookEntries, } from './installerShared.js';
|
|
27
|
+
import { withCodexProductBridge, isOwnedProductBridge, restoreProductBridgeEntry, PRODUCT_BRIDGE_SERVER_ID } from './productBridge.js';
|
|
27
28
|
/** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
|
|
28
29
|
export const CODEX_MCP_SERVER_ID = 'evolver';
|
|
29
30
|
/** SessionStart matcher: codex fires `startup` on a fresh thread and `resume` on a resumed one — cover both. */
|
|
@@ -228,10 +229,20 @@ export function stripCodexManaged(data) {
|
|
|
228
229
|
const out = { ...data };
|
|
229
230
|
const trustManagedStatus = isObj(out['mcp_servers'])
|
|
230
231
|
&& CODEX_MCP_SERVER_ID in out['mcp_servers'];
|
|
231
|
-
if (isObj(out['mcp_servers'])
|
|
232
|
+
if (isObj(out['mcp_servers'])) {
|
|
232
233
|
const next = { ...out['mcp_servers'] };
|
|
233
|
-
|
|
234
|
-
|
|
234
|
+
if (CODEX_MCP_SERVER_ID in next) {
|
|
235
|
+
delete next[CODEX_MCP_SERVER_ID];
|
|
236
|
+
changed = true;
|
|
237
|
+
}
|
|
238
|
+
if (PRODUCT_BRIDGE_SERVER_ID in next && isOwnedProductBridge(next[PRODUCT_BRIDGE_SERVER_ID])) {
|
|
239
|
+
const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
|
|
240
|
+
if (restored.restored)
|
|
241
|
+
next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
|
|
242
|
+
else
|
|
243
|
+
delete next[PRODUCT_BRIDGE_SERVER_ID];
|
|
244
|
+
changed = true;
|
|
245
|
+
}
|
|
235
246
|
if (Object.keys(next).length > 0)
|
|
236
247
|
out['mcp_servers'] = next;
|
|
237
248
|
else
|
|
@@ -290,11 +301,11 @@ export function installCodex(plan, opts) {
|
|
|
290
301
|
mkdirSync(codexDir, { recursive: true });
|
|
291
302
|
const changed = writeTomlWithRetry(configPath, (current) => {
|
|
292
303
|
if (!opts.force && codexAlreadyInstalled(current, hookCommand, promptRecallHookCommand)) {
|
|
293
|
-
return
|
|
304
|
+
return withCodexProductBridge(current, false);
|
|
294
305
|
}
|
|
295
306
|
return {
|
|
296
307
|
changed: true,
|
|
297
|
-
data: mergeCodexConfig(current, mcpServer, sessionStartHook, userPromptSubmitHook),
|
|
308
|
+
data: withCodexProductBridge(mergeCodexConfig(current, mcpServer, sessionStartHook, userPromptSubmitHook), opts.force === true).data,
|
|
298
309
|
};
|
|
299
310
|
}, assertSafe);
|
|
300
311
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,5 @@ export * from './antigravityInstaller.js';
|
|
|
13
13
|
export * from './jsonMcpInstaller.js';
|
|
14
14
|
export * from './opencodeInstaller.js';
|
|
15
15
|
export * from './kiroInstaller.js';
|
|
16
|
-
export * from './envFile.js';
|
|
16
|
+
export * from './envFile.js';
|
|
17
|
+
export * from './productBridge.js';
|
package/dist/index.js
CHANGED
|
@@ -13,4 +13,5 @@ export * from './antigravityInstaller.js';
|
|
|
13
13
|
export * from './jsonMcpInstaller.js';
|
|
14
14
|
export * from './opencodeInstaller.js';
|
|
15
15
|
export * from './kiroInstaller.js';
|
|
16
|
-
export * from './envFile.js';
|
|
16
|
+
export * from './envFile.js';
|
|
17
|
+
export * from './productBridge.js';
|
package/dist/installer.js
CHANGED
|
@@ -23,6 +23,7 @@ import { installCodex, uninstallCodex } from './codexInstaller.js';
|
|
|
23
23
|
// cursor injection is a different mechanism again (a project rules file, not a config/MCP writer): it renders
|
|
24
24
|
// top genes into .cursor/rules/evolver.mdc. It plugs into the same install/uninstall dispatch below.
|
|
25
25
|
import { installCursorRules, uninstallCursorRules } from './cursorRulesInstaller.js';
|
|
26
|
+
import { withClaudeProductBridge, isOwnedProductBridge, restoreProductBridgeEntry, PRODUCT_BRIDGE_SERVER_ID } from './productBridge.js';
|
|
26
27
|
import { installAntigravity, uninstallAntigravity } from './antigravityInstaller.js';
|
|
27
28
|
import { installOpenCode, uninstallOpenCode } from './opencodeInstaller.js';
|
|
28
29
|
import { installKiro, uninstallKiro } from './kiroInstaller.js';
|
|
@@ -389,12 +390,22 @@ export function stripManaged(data) {
|
|
|
389
390
|
delete out['hooks'];
|
|
390
391
|
}
|
|
391
392
|
}
|
|
392
|
-
// remove evolver MCP server registration
|
|
393
|
+
// remove evolver MCP server registration and a managed evox-product entry
|
|
393
394
|
const mcp = out['mcpServers'];
|
|
394
|
-
if (isObj(mcp)
|
|
395
|
+
if (isObj(mcp)) {
|
|
395
396
|
const next = { ...mcp };
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
if ('evolver' in next) {
|
|
398
|
+
delete next['evolver'];
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
if (PRODUCT_BRIDGE_SERVER_ID in next && isOwnedProductBridge(next[PRODUCT_BRIDGE_SERVER_ID])) {
|
|
402
|
+
const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
|
|
403
|
+
if (restored.restored)
|
|
404
|
+
next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
|
|
405
|
+
else
|
|
406
|
+
delete next[PRODUCT_BRIDGE_SERVER_ID];
|
|
407
|
+
changed = true;
|
|
408
|
+
}
|
|
398
409
|
if (Object.keys(next).length > 0)
|
|
399
410
|
out['mcpServers'] = next;
|
|
400
411
|
else {
|
|
@@ -444,11 +455,11 @@ function hasEvolverMcpRegistration(config) {
|
|
|
444
455
|
* silently do nothing outside $HOME). The SessionStart hook lands in ~/.claude/settings.json (already user-level).
|
|
445
456
|
* PROJECT scope keeps the legacy paths under configRoot.
|
|
446
457
|
*/
|
|
447
|
-
function claudeCodeTargets(scope, configRoot) {
|
|
458
|
+
function claudeCodeTargets(scope, configRoot, homeDir = homedir()) {
|
|
448
459
|
if (scope === 'user') {
|
|
449
|
-
const claudeDir = join(
|
|
460
|
+
const claudeDir = join(homeDir, '.claude');
|
|
450
461
|
return {
|
|
451
|
-
mcpConfigPath: join(
|
|
462
|
+
mcpConfigPath: join(homeDir, '.claude.json'),
|
|
452
463
|
mcpIsSharedUserConfig: true,
|
|
453
464
|
claudeDir,
|
|
454
465
|
settingsPath: join(claudeDir, 'settings.json'),
|
|
@@ -500,7 +511,7 @@ export function installInjection(plan, opts) {
|
|
|
500
511
|
const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
|
|
501
512
|
const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
|
|
502
513
|
const scope = opts.scope ?? 'project';
|
|
503
|
-
const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
|
|
514
|
+
const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot, opts.homeDir);
|
|
504
515
|
const mcpLabel = mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json';
|
|
505
516
|
const settingsLabel = mcpIsSharedUserConfig ? '~/.claude/settings.json' : '.claude/settings.json';
|
|
506
517
|
// project scope owns configRoot; user scope writes only home-anchored paths, so configRoot is irrelevant there.
|
|
@@ -527,22 +538,27 @@ export function installInjection(plan, opts) {
|
|
|
527
538
|
&& hasEvolverMcpRegistration(existingMcp)
|
|
528
539
|
&& hasExactHookCommand(existingSettings, 'SessionStart', hookCommand)
|
|
529
540
|
&& hasExactHookCommand(existingSettings, 'UserPromptSubmit', promptRecallHookCommand)) {
|
|
541
|
+
const product = withClaudeProductBridge(existingMcp, false);
|
|
542
|
+
if (product.changed) {
|
|
543
|
+
writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => withClaudeProductBridge(current, false));
|
|
544
|
+
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath], alreadyInstalled: true };
|
|
545
|
+
}
|
|
530
546
|
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
|
|
531
547
|
}
|
|
532
548
|
// MCP server registration. project → <root>/.mcp.json (stamped _evolver_managed). user → ~/.claude.json's
|
|
533
549
|
// top-level mcpServers (Claude Code's real user scope); we do NOT stamp the marker into ~/.claude.json because
|
|
534
550
|
// it's Claude Code's own shared config, so uninstall keys off the mcpServers.evolver entry there instead.
|
|
535
551
|
if (mcpIsSharedUserConfig) {
|
|
536
|
-
writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) =>
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
})
|
|
552
|
+
writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
|
|
553
|
+
const merged = withClaudeProductBridge(deepMerge(current, plan.config), opts.force === true);
|
|
554
|
+
return { changed: true, data: merged.data };
|
|
555
|
+
});
|
|
540
556
|
}
|
|
541
557
|
else {
|
|
542
558
|
writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
|
|
543
559
|
const mcpMerged = deepMerge(current, plan.config);
|
|
544
560
|
mcpMerged[MANAGED_MARKER] = true;
|
|
545
|
-
return { changed: true, data: mcpMerged };
|
|
561
|
+
return { changed: true, data: withClaudeProductBridge(mcpMerged, opts.force === true).data };
|
|
546
562
|
});
|
|
547
563
|
}
|
|
548
564
|
// .claude/settings.json ← SessionStart hook (hooks-union preserves the user's own hooks). For user scope this
|
|
@@ -591,7 +607,7 @@ export function uninstallInjection(runtime, opts) {
|
|
|
591
607
|
return { ok: false, runtime, mode: 'n/a', files: [], error: `uninstall not implemented for ${runtime}` };
|
|
592
608
|
}
|
|
593
609
|
const scope = opts.scope ?? 'project';
|
|
594
|
-
const { mcpConfigPath, mcpIsSharedUserConfig, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
|
|
610
|
+
const { mcpConfigPath, mcpIsSharedUserConfig, settingsPath } = claudeCodeTargets(scope, opts.configRoot, opts.homeDir);
|
|
595
611
|
const cleaned = [];
|
|
596
612
|
const targets = [
|
|
597
613
|
[mcpConfigPath, mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json'],
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const PRODUCT_BRIDGE_SERVER_ID = "evox-product";
|
|
2
|
+
export declare const PRODUCT_BRIDGE_MANAGED_KEY = "_evox_product_managed";
|
|
3
|
+
export declare const PRODUCT_BRIDGE_PREVIOUS_KEY = "_evox_product_previous";
|
|
4
|
+
export declare const PRODUCT_BRIDGE_GRANT_SCHEMA = "evox.product_bridge.grant.v1";
|
|
5
|
+
/** Resolve only a compiled JavaScript shim. Source TypeScript is never written into runtime config. */
|
|
6
|
+
export declare function productBridgeShimPath(): string;
|
|
7
|
+
/** Ownership is explicit. A matching filename alone is never enough to delete a user's server. */
|
|
8
|
+
export declare function isOwnedProductBridge(entry: unknown): boolean;
|
|
9
|
+
/** Restore a user entry previously preserved by an explicit force takeover. */
|
|
10
|
+
export declare function restoreProductBridgeEntry(entry: unknown): {
|
|
11
|
+
restored: boolean;
|
|
12
|
+
entry?: unknown;
|
|
13
|
+
};
|
|
14
|
+
/** Merge a managed evox-product server into a parsed MCP JSON object (project .mcp.json or ~/.claude.json). */
|
|
15
|
+
export declare function withClaudeProductBridge(data: Record<string, unknown>, force?: boolean): {
|
|
16
|
+
changed: boolean;
|
|
17
|
+
skipped?: boolean;
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
/** Merge a managed evox-product table into parsed Codex TOML. */
|
|
21
|
+
export declare function withCodexProductBridge(data: Record<string, unknown>, force?: boolean): {
|
|
22
|
+
changed: boolean;
|
|
23
|
+
skipped?: boolean;
|
|
24
|
+
data: Record<string, unknown>;
|
|
25
|
+
};
|
|
26
|
+
export declare function installClaudeProductBridge(configRoot: string, force?: boolean): {
|
|
27
|
+
changed: boolean;
|
|
28
|
+
skipped?: boolean;
|
|
29
|
+
path: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function uninstallClaudeProductBridge(configRoot: string): boolean;
|
|
32
|
+
export declare function installCodexProductBridge(configRoot: string, force?: boolean): {
|
|
33
|
+
changed: boolean;
|
|
34
|
+
skipped?: boolean;
|
|
35
|
+
path: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function uninstallCodexProductBridge(configRoot: string): boolean;
|
|
@@ -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();
|
package/dist/tools.js
CHANGED
|
@@ -171,7 +171,8 @@ export function buildEvolverTools(deps) {
|
|
|
171
171
|
const limit = optionalNonNegativeNumberArg(a, 'limit', 'evolver_recall limit must be a non-negative number') ?? 5;
|
|
172
172
|
const sessionId = typeof a['sessionId'] === 'string' && a['sessionId'].trim() ? a['sessionId'].trim() : undefined;
|
|
173
173
|
const review = assetstore.reviewLedgerForStore(deps.store);
|
|
174
|
-
const
|
|
174
|
+
const provenance = assetstore.provenanceStoreForStore(deps.store);
|
|
175
|
+
const genes = await assetstore.listApprovedGenes(deps.store, review, limit, provenance);
|
|
175
176
|
const primed = genes.map((g) => {
|
|
176
177
|
const r = g;
|
|
177
178
|
const id = typeof r['id'] === 'string' ? r['id'] : String(r['asset_id']);
|
|
@@ -199,7 +200,7 @@ export function buildEvolverTools(deps) {
|
|
|
199
200
|
},
|
|
200
201
|
...(deps.proxy ? [{
|
|
201
202
|
name: 'evolver_recipe_search',
|
|
202
|
-
description: '默认第一步:通过本机 evolver-proxy 搜索 Hub 已发布 Recipe(有序 Gene/Capsule DNA)。命中后调用 evolver_recipe_express。无匹配时再 fallback 到 evolver_asset_search。',
|
|
203
|
+
description: '默认第一步:通过本机 evolver-proxy 搜索 Hub 已发布 Recipe(有序 Gene/Capsule DNA)。命中后调用 evolver_recipe_express。无匹配时再 fallback 到 evolver_asset_search。旧客户端默认收到 Recipe 数组;设置 includePagination=true 可读取 nextCursor/hasMore。',
|
|
203
204
|
inputSchema: {
|
|
204
205
|
type: 'object',
|
|
205
206
|
properties: {
|
|
@@ -209,16 +210,25 @@ export function buildEvolverTools(deps) {
|
|
|
209
210
|
limit: { type: 'number' },
|
|
210
211
|
cursor: { type: 'string' },
|
|
211
212
|
sort: { type: 'string' },
|
|
213
|
+
includePagination: {
|
|
214
|
+
type: 'boolean',
|
|
215
|
+
description: 'Set true to return the recipe page envelope with nextCursor/hasMore. The default array shape remains backwards compatible.',
|
|
216
|
+
},
|
|
212
217
|
},
|
|
213
218
|
},
|
|
214
219
|
handler: async (a) => {
|
|
215
220
|
const q = [a['q'], a['query'], a['text']].find((value) => typeof value === 'string' && value.trim().length > 0);
|
|
221
|
+
const includePagination = a['includePagination'] === true;
|
|
216
222
|
const receipt = record(await deps.proxy.searchRecipes({
|
|
217
223
|
...(q ? { q } : {}),
|
|
218
224
|
...(typeof a['limit'] === 'number' ? { limit: a['limit'] } : {}),
|
|
219
225
|
...(typeof a['cursor'] === 'string' ? { cursor: a['cursor'] } : {}),
|
|
220
226
|
...(typeof a['sort'] === 'string' ? { sort: a['sort'] } : {}),
|
|
221
227
|
}));
|
|
228
|
+
if (includePagination && (Array.isArray(receipt['recipes'])
|
|
229
|
+
|| Array.isArray(receipt['results'])
|
|
230
|
+
|| Array.isArray(receipt['items'])))
|
|
231
|
+
return receipt;
|
|
222
232
|
if (Array.isArray(receipt['recipes']))
|
|
223
233
|
return receipt['recipes'];
|
|
224
234
|
if (Array.isArray(receipt['results']))
|
|
@@ -327,7 +337,7 @@ export function buildEvolverTools(deps) {
|
|
|
327
337
|
},
|
|
328
338
|
{
|
|
329
339
|
name: 'evolver_distill_conversation',
|
|
330
|
-
description: '从当前对话蒸馏可复用能力.
|
|
340
|
+
description: '从当前对话蒸馏可复用能力. persist=true 只在结果可发布(经宿主验证)或为失败/无轨迹草稿时才落 Gene/Capsule; 携带调用方自报 success 轨迹而未经宿主验证时仅返回草稿不落库. 需要具体 summary、strategy/evidence、artifacts、validation; 质量闸门会将弱信号保留为草稿而不会发布. provider 若不具备 putBundle 能力,成对持久化会 fail-closed 并返回 bundle_persistence_unsupported. Hub 默认优先发布成 Recipe, 不再优先上 Skill Store. publish=true 时默认 compose_recipe, 可用 publish_recipe=false 关掉.',
|
|
331
341
|
inputSchema: {
|
|
332
342
|
type: 'object',
|
|
333
343
|
required: ['summary'],
|
|
@@ -346,7 +356,7 @@ export function buildEvolverTools(deps) {
|
|
|
346
356
|
persist: { type: 'boolean' },
|
|
347
357
|
publish: { type: 'boolean' },
|
|
348
358
|
publish_recipe: { type: 'boolean', description: 'When publish=true, also compose and publish a Recipe. Defaults to true.' },
|
|
349
|
-
min_score: { type: 'integer', minimum:
|
|
359
|
+
min_score: { type: 'integer', minimum: 5, maximum: 10 },
|
|
350
360
|
},
|
|
351
361
|
},
|
|
352
362
|
handler: async (a) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evomap/evolver-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.25",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
"./stdio": {
|
|
21
21
|
"types": "./dist/stdio.d.ts",
|
|
22
22
|
"default": "./dist/stdio.js"
|
|
23
|
+
},
|
|
24
|
+
"./product-bridge": {
|
|
25
|
+
"types": "./dist/productBridgeShim.d.ts",
|
|
26
|
+
"default": "./dist/productBridgeShim.js"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"dependencies": {
|
|
26
|
-
"@evomap/evolver-core": "2.0.
|
|
30
|
+
"@evomap/evolver-core": "2.0.25",
|
|
27
31
|
"smol-toml": "^1.6.1"
|
|
28
32
|
},
|
|
29
33
|
"repository": {
|