@yugabytedb/meko-mcp 1.2.0 → 2.0.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 +28 -19
- package/assets/hooks-handlers/before-submit-prompt.sh +2 -2
- package/assets/hooks-handlers/lib/capture.js +209 -144
- package/assets/hooks-handlers/lib/checkpoint-timer.js +23 -11
- package/assets/hooks-handlers/session-start.sh +6 -2
- package/assets/scripts/meko-statusline.sh +119 -0
- package/assets/scripts/package-desktop-skill.sh +27 -24
- package/assets/skills/meko-mcp-tools/SKILL.md +42 -50
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +75 -150
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +30 -36
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +6 -22
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +9 -8
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +35 -42
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -92
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +5 -9
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +1 -81
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +100 -107
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +41 -11
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +71 -150
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +12 -18
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +12 -15
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -42
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +28 -92
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +5 -9
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +1 -81
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +198 -0
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
- package/bin/create-meko-setup.mjs +29 -0
- package/lib/clients/claude-code.mjs +133 -6
- package/lib/clients/claude-desktop.mjs +37 -12
- package/lib/clients/codex.mjs +306 -5
- package/lib/clients/common.mjs +41 -1
- package/lib/clients/cursor.mjs +75 -19
- package/lib/config.mjs +26 -0
- package/lib/installer.mjs +24 -0
- package/lib/mcp-transport.mjs +5 -0
- package/lib/validate.mjs +0 -1
- package/package.json +1 -1
- package/scripts/bundle-assets.mjs +8 -0
- package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
- package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
package/lib/clients/codex.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { askYesNo } from "../prompt.mjs";
|
|
|
28
28
|
import {
|
|
29
29
|
getCodexAgentsMdPath,
|
|
30
30
|
getCodexConfigPath,
|
|
31
|
+
getCodexHooksInstallRoot,
|
|
31
32
|
getCodexHooksPath,
|
|
32
33
|
getCodexMekoEnvFilePath,
|
|
33
34
|
getCodexSkillsRoot,
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
} from "../config.mjs";
|
|
37
38
|
import {
|
|
38
39
|
getInstallerVersion,
|
|
40
|
+
installStableTree,
|
|
41
|
+
isPackagedInstall,
|
|
39
42
|
isDanglingSymlink,
|
|
40
43
|
markerIsStale,
|
|
41
44
|
readInstallerMarker,
|
|
@@ -50,6 +53,16 @@ import {
|
|
|
50
53
|
const CODEX_INSTALL_URL = "https://github.com/openai/codex";
|
|
51
54
|
const CODEX_APP_INSTALL_URL = "https://openai.com/index/codex";
|
|
52
55
|
|
|
56
|
+
// Cloud Meko (mcp.mekodata.ai) sits behind a WAF that returns an HTML 403 for
|
|
57
|
+
// any request with no User-Agent. Codex's native MCP client (codex_rmcp_client)
|
|
58
|
+
// sends none by default, so its `initialize` handshake 403s and the server
|
|
59
|
+
// fails to start. `codex mcp add` exposes no header flag, so we set a per-server
|
|
60
|
+
// `http_headers` entry in config.toml. Scoped to [mcp_servers.<name>] only — it
|
|
61
|
+
// affects requests to the Meko MCP server and nothing else. Local installs skip
|
|
62
|
+
// this (no WAF in front of localhost). See GH #174 and the capture.js UA fix.
|
|
63
|
+
const CODEX_MCP_USER_AGENT =
|
|
64
|
+
"codex-mcp/1.0 (+https://github.com/yugabyte/meko-mcp-server)";
|
|
65
|
+
|
|
53
66
|
// AGENTS.md sentinel markers. Idempotent upsert/strip relies on these being
|
|
54
67
|
// stable across versions — never rename without a migration story.
|
|
55
68
|
const AGENTS_MD_START = "<!-- meko:start -->";
|
|
@@ -80,6 +93,7 @@ const MEKO_HOOK_SCRIPTS = MEKO_HOOKS.map((hook) => hook.script);
|
|
|
80
93
|
* getHooksPath?: () => string,
|
|
81
94
|
* getSkillsRoot?: () => string,
|
|
82
95
|
* resolveHooksRoot?: () => string,
|
|
96
|
+
* getHooksInstallRoot?: () => string,
|
|
83
97
|
* runCommand?: (cmd: string) => string,
|
|
84
98
|
* probeCodex?: () => {found: boolean, version: string, path: string},
|
|
85
99
|
* probeCodexApp?: () => {found: boolean, path: string, platform: string, version: string} | null,
|
|
@@ -95,7 +109,10 @@ export function createCodexAdapter(options = {}) {
|
|
|
95
109
|
const resolveMekoEnvFilePath =
|
|
96
110
|
options.getMekoEnvFilePath ?? getCodexMekoEnvFilePath;
|
|
97
111
|
const resolveHooksRootPath = options.resolveHooksRoot ?? resolveHooksRoot;
|
|
112
|
+
const resolveHooksInstallRoot =
|
|
113
|
+
options.getHooksInstallRoot ?? getCodexHooksInstallRoot;
|
|
98
114
|
const resolveSkillsRoot = options.getSkillsRoot ?? getCodexSkillsRoot;
|
|
115
|
+
const packagedInstallDetector = options.isPackagedInstall ?? isPackagedInstall;
|
|
99
116
|
const runCommand = options.runCommand ?? defaultRunCommand;
|
|
100
117
|
const probeCodex = options.probeCodex ?? defaultProbeCodex;
|
|
101
118
|
const probeCodexApp = options.probeCodexApp ?? defaultProbeCodexApp;
|
|
@@ -107,6 +124,16 @@ export function createCodexAdapter(options = {}) {
|
|
|
107
124
|
|
|
108
125
|
let scopeWarned = false;
|
|
109
126
|
|
|
127
|
+
// A reinstall's remove→register sequence deletes the whole
|
|
128
|
+
// [mcp_servers.<name>] block (via `codex mcp remove` or removeCodexConfigBlock),
|
|
129
|
+
// including any http_headers in either inline or table form, before
|
|
130
|
+
// registerServer rewrites it — which would silently drop a user's custom
|
|
131
|
+
// headers. removeRegistration stashes the pre-existing headers (as a key→value
|
|
132
|
+
// map, form-agnostic) here so the later ensureCodexHttpHeaderUserAgent step
|
|
133
|
+
// can merge them back while guaranteeing a User-Agent. null = nothing to
|
|
134
|
+
// preserve.
|
|
135
|
+
let preservedHttpHeaders = null;
|
|
136
|
+
|
|
110
137
|
/** Returns the resolved config.toml path; warns once on project scope. */
|
|
111
138
|
function settingsPathFor(scope, log) {
|
|
112
139
|
if (scope === "project" && !scopeWarned) {
|
|
@@ -189,6 +216,11 @@ export function createCodexAdapter(options = {}) {
|
|
|
189
216
|
},
|
|
190
217
|
removeRegistration(name, { scope, log } = {}) {
|
|
191
218
|
const path = settingsPathFor(scope, log);
|
|
219
|
+
// Capture any existing http_headers (inline OR table form) BEFORE we
|
|
220
|
+
// delete the block, so a reinstall can restore the user's custom values
|
|
221
|
+
// (see preservedHttpHeaders). Done on both the CLI and TOML paths since
|
|
222
|
+
// `codex mcp remove` also drops the whole block and any subtable.
|
|
223
|
+
preservedHttpHeaders = readCodexHttpHeaders(safeReadFile(path), name);
|
|
192
224
|
if (surfaces.cli) {
|
|
193
225
|
try {
|
|
194
226
|
runCommand(`codex mcp remove ${shellQuote(name)}`);
|
|
@@ -209,6 +241,8 @@ export function createCodexAdapter(options = {}) {
|
|
|
209
241
|
const useApiKey = apiKey ? "MEKO_API_KEY" : null;
|
|
210
242
|
const cliCmd = buildCliRegisterCommand(name, url, useApiKey);
|
|
211
243
|
|
|
244
|
+
const cloud = !isLocalUrl(url);
|
|
245
|
+
|
|
212
246
|
if (dryRun) {
|
|
213
247
|
if (surfaces.cli) {
|
|
214
248
|
log.dim?.(` Would run: ${cliCmd}`);
|
|
@@ -220,6 +254,11 @@ export function createCodexAdapter(options = {}) {
|
|
|
220
254
|
" Codex resolves the bearer token from $MEKO_API_KEY at runtime; the key is not written to config.toml.",
|
|
221
255
|
);
|
|
222
256
|
}
|
|
257
|
+
if (cloud) {
|
|
258
|
+
log.dim?.(
|
|
259
|
+
` Would ensure [mcp_servers.${name}] has an http_headers User-Agent (Cloud WAF needs one; backs up ${path} first, preserves any existing header).`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
223
262
|
return;
|
|
224
263
|
}
|
|
225
264
|
|
|
@@ -257,6 +296,42 @@ export function createCodexAdapter(options = {}) {
|
|
|
257
296
|
if (verbose) log.dim?.(` Wrote [mcp_servers.${name}] to ${path}`);
|
|
258
297
|
}
|
|
259
298
|
|
|
299
|
+
// Cloud Meko's WAF 403s Codex's native MCP client when it sends no
|
|
300
|
+
// User-Agent on the initialize handshake. `codex mcp add` has no header
|
|
301
|
+
// flag, so patch the written block with an http_headers User-Agent.
|
|
302
|
+
// Reads the file first, preserves any existing http_headers, and backs
|
|
303
|
+
// up config.toml before writing. Local installs have no WAF, so skip.
|
|
304
|
+
if (cloud) {
|
|
305
|
+
// Merge back any custom http_headers captured by removeRegistration
|
|
306
|
+
// before the reinstall wiped the block, while guaranteeing a User-Agent.
|
|
307
|
+
const preserved = preservedHttpHeaders;
|
|
308
|
+
preservedHttpHeaders = null; // consume — don't leak into a later call
|
|
309
|
+
const outcome = ensureCodexHttpHeaderUserAgent(path, name, {
|
|
310
|
+
preserved,
|
|
311
|
+
});
|
|
312
|
+
if (outcome === "restored") {
|
|
313
|
+
log.dim?.(
|
|
314
|
+
` Restored your existing http_headers on [mcp_servers.${name}] (with a User-Agent) after re-registration.`,
|
|
315
|
+
);
|
|
316
|
+
} else if (outcome === "merged") {
|
|
317
|
+
log.dim?.(
|
|
318
|
+
` Added a User-Agent to your existing http_headers on [mcp_servers.${name}] so Codex's MCP client clears the Cloud WAF (backup written to ${path}.bak).`,
|
|
319
|
+
);
|
|
320
|
+
} else if (outcome === "added") {
|
|
321
|
+
log.dim?.(
|
|
322
|
+
` Set http_headers User-Agent on [mcp_servers.${name}] so Codex's MCP client clears the Cloud WAF (backup written to ${path}.bak).`,
|
|
323
|
+
);
|
|
324
|
+
} else if (outcome === "present" && verbose) {
|
|
325
|
+
log.dim?.(
|
|
326
|
+
` [mcp_servers.${name}] already has an http_headers User-Agent — left as-is.`,
|
|
327
|
+
);
|
|
328
|
+
} else if (outcome === "absent") {
|
|
329
|
+
log.warn?.(
|
|
330
|
+
` Could not find [mcp_servers.${name}] in ${path} to set the Cloud WAF User-Agent header; if Codex reports an HTTP 403 on startup, add http_headers = { "User-Agent" = "${CODEX_MCP_USER_AGENT}" } manually.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
260
335
|
// Cloud auth UX: env-var-only model. The key is never written to
|
|
261
336
|
// config.toml; the user's runtime env supplies it. Print actionable
|
|
262
337
|
// instructions and (when interactive) offer to wire things up.
|
|
@@ -300,9 +375,11 @@ export function createCodexAdapter(options = {}) {
|
|
|
300
375
|
},
|
|
301
376
|
setHookEnvironment({ url, apiKey, log, dryRun, verbose }) {
|
|
302
377
|
const hooksPath = resolveHooksPath();
|
|
303
|
-
const
|
|
378
|
+
const assetHooksRoot = resolveHooksRootPath();
|
|
379
|
+
const installedHooksRoot = resolveHooksInstallRoot();
|
|
304
380
|
const envFilePath = resolveMekoEnvFilePath();
|
|
305
381
|
if (dryRun) {
|
|
382
|
+
log?.dim?.(` Would copy Codex hook scripts to ${installedHooksRoot}`);
|
|
306
383
|
log?.dim?.(` Would write env file at ${envFilePath} (mode 0600)`);
|
|
307
384
|
log?.dim?.(` Would configure Codex hooks at ${hooksPath}`);
|
|
308
385
|
if (apiKey) {
|
|
@@ -313,8 +390,16 @@ export function createCodexAdapter(options = {}) {
|
|
|
313
390
|
return;
|
|
314
391
|
}
|
|
315
392
|
|
|
393
|
+
// Copy the hook scripts to a home-dir-stable location before wiring
|
|
394
|
+
// hooks.json at that path. Referencing the shipped asset root directly
|
|
395
|
+
// would pin an ephemeral npx cache dir that npx later GCs, leaving the
|
|
396
|
+
// hook a dangling `bash '<missing>'` that exits 127. See
|
|
397
|
+
// getCodexHooksInstallRoot for the full rationale.
|
|
398
|
+
installStableTree(assetHooksRoot, installedHooksRoot);
|
|
316
399
|
writeCodexMekoEnvFile(envFilePath, { url, apiKey });
|
|
317
|
-
const changed = upsertCodexMekoHooks(hooksPath,
|
|
400
|
+
const changed = upsertCodexMekoHooks(hooksPath, installedHooksRoot, {
|
|
401
|
+
envFilePath,
|
|
402
|
+
});
|
|
318
403
|
if (verbose || changed) {
|
|
319
404
|
log?.dim?.(` Updated Codex hooks at ${hooksPath}`);
|
|
320
405
|
}
|
|
@@ -374,7 +459,9 @@ export function createCodexAdapter(options = {}) {
|
|
|
374
459
|
removePath(destination);
|
|
375
460
|
}
|
|
376
461
|
|
|
377
|
-
const strategy = symlinkOrCopy(source, destination
|
|
462
|
+
const strategy = symlinkOrCopy(source, destination, {
|
|
463
|
+
preferCopy: packagedInstallDetector(),
|
|
464
|
+
});
|
|
378
465
|
if (strategy === "copy") writeInstallerMarker(destination);
|
|
379
466
|
}
|
|
380
467
|
|
|
@@ -434,7 +521,10 @@ export function createCodexAdapter(options = {}) {
|
|
|
434
521
|
try {
|
|
435
522
|
const hooksPath = resolveHooksPath();
|
|
436
523
|
const envFilePath = resolveMekoEnvFilePath();
|
|
437
|
-
const changed = removeCodexMekoHooks(hooksPath, {
|
|
524
|
+
const changed = removeCodexMekoHooks(hooksPath, {
|
|
525
|
+
envFilePath,
|
|
526
|
+
hooksInstallRoot: resolveHooksInstallRoot(),
|
|
527
|
+
});
|
|
438
528
|
if (changed) {
|
|
439
529
|
results.push({
|
|
440
530
|
key: "hooks",
|
|
@@ -576,7 +666,7 @@ function writeCodexMekoEnvFile(path, { url, apiKey }) {
|
|
|
576
666
|
chmodSync(path, 0o600);
|
|
577
667
|
}
|
|
578
668
|
|
|
579
|
-
function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
|
|
669
|
+
function removeCodexMekoHooks(hooksPath, { envFilePath, hooksInstallRoot } = {}) {
|
|
580
670
|
const cfg = readJsonFile(hooksPath);
|
|
581
671
|
let changed = false;
|
|
582
672
|
if (cfg.hooks && typeof cfg.hooks === "object") {
|
|
@@ -610,6 +700,17 @@ function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
|
|
|
610
700
|
// best-effort
|
|
611
701
|
}
|
|
612
702
|
}
|
|
703
|
+
|
|
704
|
+
// Remove the copied hook scripts (getCodexHooksInstallRoot). We remove the
|
|
705
|
+
// whole meko-hooks/ wrapper, not just the hooks-handlers leaf, so no empty
|
|
706
|
+
// parent is left behind. best-effort — never let cleanup fail uninstall.
|
|
707
|
+
if (hooksInstallRoot) {
|
|
708
|
+
try {
|
|
709
|
+
rmSync(dirname(hooksInstallRoot), { recursive: true, force: true });
|
|
710
|
+
} catch {
|
|
711
|
+
// best-effort
|
|
712
|
+
}
|
|
713
|
+
}
|
|
613
714
|
return changed;
|
|
614
715
|
}
|
|
615
716
|
|
|
@@ -764,6 +865,200 @@ function buildCliRegisterCommand(name, url, bearerEnv) {
|
|
|
764
865
|
return cmd;
|
|
765
866
|
}
|
|
766
867
|
|
|
868
|
+
/**
|
|
869
|
+
* True for localhost / loopback MCP URLs (no cloud WAF in front). Parses the
|
|
870
|
+
* URL and compares the HOSTNAME exactly — a substring test would wrongly
|
|
871
|
+
* classify `https://localhost.example.com` (a real remote host) as local and
|
|
872
|
+
* skip the WAF User-Agent header. Unparseable input is treated as non-local
|
|
873
|
+
* (cloud), i.e. we err toward setting the header.
|
|
874
|
+
*/
|
|
875
|
+
function isLocalUrl(url) {
|
|
876
|
+
let host;
|
|
877
|
+
try {
|
|
878
|
+
host = new URL(String(url ?? "")).hostname;
|
|
879
|
+
} catch {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
// `new URL` strips the brackets from IPv6 literals, so `[::1]` → `::1`.
|
|
883
|
+
host = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
884
|
+
return (
|
|
885
|
+
host === "localhost" ||
|
|
886
|
+
host === "::1" ||
|
|
887
|
+
// Entire 127.0.0.0/8 block is loopback, not just 127.0.0.1.
|
|
888
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const HTTP_HEADER_USER_AGENT_KEY = "User-Agent";
|
|
893
|
+
|
|
894
|
+
// Matches one `KEY = "VALUE"` (or single-quoted) pair. KEY may be
|
|
895
|
+
// double-quoted, single-quoted, or a bare TOML key (letters/digits/_/-).
|
|
896
|
+
// Used for both the inline `{ ... }` body and the table-form body.
|
|
897
|
+
const TOML_KV_PAIR =
|
|
898
|
+
/(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
899
|
+
|
|
900
|
+
/** True if `map` has a `User-Agent` key, case-insensitively (HTTP names are). */
|
|
901
|
+
function hasUserAgentKey(map) {
|
|
902
|
+
return Object.keys(map).some((k) => k.toLowerCase() === "user-agent");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Parse `KEY = "VALUE"` pairs from a chunk of TOML into an ordered object. */
|
|
906
|
+
function parseTomlKvPairs(text) {
|
|
907
|
+
const out = {};
|
|
908
|
+
TOML_KV_PAIR.lastIndex = 0;
|
|
909
|
+
let m;
|
|
910
|
+
while ((m = TOML_KV_PAIR.exec(text)) !== null) {
|
|
911
|
+
const key = m[1] ?? m[2] ?? m[3];
|
|
912
|
+
const value = m[4] ?? m[5] ?? "";
|
|
913
|
+
if (key !== undefined) out[key] = value;
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Locate a table-form `[mcp_servers.<name>.http_headers]` section, if present.
|
|
920
|
+
* `parseCodexConfigForServer`'s block ends at the first subtable, so this form
|
|
921
|
+
* lives OUTSIDE that block and needs its own lookup.
|
|
922
|
+
*
|
|
923
|
+
* @returns {{start: number, end: number, body: string}|null}
|
|
924
|
+
*/
|
|
925
|
+
function findHttpHeadersSubtable(raw, name) {
|
|
926
|
+
const header = `[mcp_servers.${name}.http_headers]`;
|
|
927
|
+
const idx = findHeader(raw, header);
|
|
928
|
+
if (idx === -1) return null;
|
|
929
|
+
const end = findNextHeader(raw, idx + header.length);
|
|
930
|
+
return { start: idx, end, body: raw.slice(idx + header.length, end) };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Read the effective `http_headers` for a server from EITHER form (inline
|
|
935
|
+
* `http_headers = { ... }` inside the block, or a `[….http_headers]` subtable),
|
|
936
|
+
* returning an ordered key→value map, or null when neither is present. Used to
|
|
937
|
+
* snapshot a user's headers before a reinstall wipes them.
|
|
938
|
+
*
|
|
939
|
+
* @param {string|null} raw
|
|
940
|
+
* @param {string} name
|
|
941
|
+
* @returns {Record<string,string>|null}
|
|
942
|
+
*/
|
|
943
|
+
function readCodexHttpHeaders(raw, name) {
|
|
944
|
+
if (raw === null || raw === undefined) return null;
|
|
945
|
+
const existing = parseCodexConfigForServer(raw, name);
|
|
946
|
+
if (!existing.found) return null;
|
|
947
|
+
|
|
948
|
+
const block = raw.slice(existing.start, existing.end);
|
|
949
|
+
const inline = block.match(/^[ \t]*http_headers[ \t]*=[ \t]*\{([^}]*)\}/m);
|
|
950
|
+
if (inline) return parseTomlKvPairs(inline[1]);
|
|
951
|
+
|
|
952
|
+
const subtable = findHttpHeadersSubtable(raw, name);
|
|
953
|
+
if (subtable) return parseTomlKvPairs(subtable.body);
|
|
954
|
+
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Render a headers map as a single canonical inline `http_headers` line. */
|
|
959
|
+
function renderInlineHttpHeaders(map) {
|
|
960
|
+
const parts = Object.entries(map).map(
|
|
961
|
+
([k, v]) => `"${escapeTomlString(k)}" = "${escapeTomlString(v)}"`,
|
|
962
|
+
);
|
|
963
|
+
return `http_headers = { ${parts.join(", ")} }`;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Ensure the `[mcp_servers.<name>]` block carries an `http_headers` User-Agent
|
|
968
|
+
* so Codex's native MCP client clears the Cloud WAF (see CODEX_MCP_USER_AGENT).
|
|
969
|
+
*
|
|
970
|
+
* This MERGES rather than all-or-nothing: it reads the effective headers from
|
|
971
|
+
* whichever form is present — inline `http_headers = { ... }` or a table-form
|
|
972
|
+
* `[mcp_servers.<name>.http_headers]` — plus any `preserved` map captured
|
|
973
|
+
* before a reinstall wiped the block, then guarantees a `User-Agent` entry
|
|
974
|
+
* exists while keeping every other header the user set. The result is written
|
|
975
|
+
* as a single canonical inline line, and any table-form section is removed so
|
|
976
|
+
* we never leave two conflicting `http_headers` definitions (invalid TOML).
|
|
977
|
+
*
|
|
978
|
+
* Contract — check, back up, then change:
|
|
979
|
+
* 1. Read config.toml (returns "absent" if the file or server block is gone).
|
|
980
|
+
* 2. Merge preserved + existing headers; if a User-Agent is already present
|
|
981
|
+
* AND no reshaping is needed (no table form to collapse), report "present"
|
|
982
|
+
* and write nothing.
|
|
983
|
+
* 3. Otherwise write the merged inline header via writeWithBackup (`.bak`).
|
|
984
|
+
*
|
|
985
|
+
* @param {string} configPath
|
|
986
|
+
* @param {string} name
|
|
987
|
+
* @param {{userAgent?: string, preserved?: Record<string,string>|null}} [opts]
|
|
988
|
+
* @returns {"added"|"restored"|"merged"|"present"|"absent"} what happened
|
|
989
|
+
*/
|
|
990
|
+
function ensureCodexHttpHeaderUserAgent(
|
|
991
|
+
configPath,
|
|
992
|
+
name,
|
|
993
|
+
{ userAgent, preserved = null } = {},
|
|
994
|
+
) {
|
|
995
|
+
const ua = userAgent ?? CODEX_MCP_USER_AGENT;
|
|
996
|
+
const raw = safeReadFile(configPath);
|
|
997
|
+
if (raw === null) return "absent";
|
|
998
|
+
const existing = parseCodexConfigForServer(raw, name);
|
|
999
|
+
if (!existing.found) return "absent";
|
|
1000
|
+
|
|
1001
|
+
const block = raw.slice(existing.start, existing.end);
|
|
1002
|
+
const inlineMatch = block.match(
|
|
1003
|
+
/^[ \t]*http_headers[ \t]*=[ \t]*\{([^}]*)\}[ \t]*$/m,
|
|
1004
|
+
);
|
|
1005
|
+
const subtable = findHttpHeadersSubtable(raw, name);
|
|
1006
|
+
|
|
1007
|
+
// Merge order: existing on-disk headers first (inline or table), then any
|
|
1008
|
+
// reinstall-preserved map layered on top. Preserved wins on key collisions
|
|
1009
|
+
// because it reflects the user's most recent intent before the wipe.
|
|
1010
|
+
const onDisk = inlineMatch
|
|
1011
|
+
? parseTomlKvPairs(inlineMatch[1])
|
|
1012
|
+
: subtable
|
|
1013
|
+
? parseTomlKvPairs(subtable.body)
|
|
1014
|
+
: {};
|
|
1015
|
+
const merged = { ...onDisk, ...(preserved ?? {}) };
|
|
1016
|
+
|
|
1017
|
+
const hadUserAgent = hasUserAgentKey(merged);
|
|
1018
|
+
const restoring = Boolean(preserved);
|
|
1019
|
+
|
|
1020
|
+
// Nothing to do only when a User-Agent is already present AND the on-disk
|
|
1021
|
+
// form is a single inline line (nothing to collapse or restore).
|
|
1022
|
+
if (hadUserAgent && inlineMatch && !subtable && !restoring) return "present";
|
|
1023
|
+
|
|
1024
|
+
if (!hadUserAgent) merged[HTTP_HEADER_USER_AGENT_KEY] = ua;
|
|
1025
|
+
|
|
1026
|
+
const line = renderInlineHttpHeaders(merged);
|
|
1027
|
+
|
|
1028
|
+
// Build the new server block: drop any existing inline line, then insert the
|
|
1029
|
+
// canonical inline line right under the table header.
|
|
1030
|
+
let newBlock = block;
|
|
1031
|
+
if (inlineMatch) {
|
|
1032
|
+
newBlock = newBlock.replace(
|
|
1033
|
+
/^[ \t]*http_headers[ \t]*=[ \t]*\{[^}]*\}[ \t]*\n?/m,
|
|
1034
|
+
"",
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
const headerLine = `[mcp_servers.${name}]`;
|
|
1038
|
+
const headerIdx = newBlock.indexOf(headerLine);
|
|
1039
|
+
const insertAt = headerIdx + headerLine.length;
|
|
1040
|
+
newBlock =
|
|
1041
|
+
newBlock.slice(0, insertAt) + `\n${line}` + newBlock.slice(insertAt);
|
|
1042
|
+
|
|
1043
|
+
// Reassemble the file, splicing the block back and excising any table-form
|
|
1044
|
+
// subtable (now folded into the inline line) to avoid a duplicate key.
|
|
1045
|
+
let next = raw.slice(0, existing.start) + newBlock + raw.slice(existing.end);
|
|
1046
|
+
if (subtable) {
|
|
1047
|
+
// Recompute the subtable bounds against the rewritten string — the block
|
|
1048
|
+
// edit shifted offsets. The subtable always sits after our block.
|
|
1049
|
+
const fresh = findHttpHeadersSubtable(next, name);
|
|
1050
|
+
if (fresh) {
|
|
1051
|
+
next = next.slice(0, fresh.start) + next.slice(fresh.end);
|
|
1052
|
+
next = next.replace(/\n{3,}/g, "\n\n");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
writeWithBackup(configPath, next);
|
|
1057
|
+
if (restoring) return "restored";
|
|
1058
|
+
if (subtable || (inlineMatch && !hadUserAgent)) return "merged";
|
|
1059
|
+
return "added";
|
|
1060
|
+
}
|
|
1061
|
+
|
|
767
1062
|
// ---------------------------------------------------------------------------
|
|
768
1063
|
// TOML helpers (App-only fallback path)
|
|
769
1064
|
// ---------------------------------------------------------------------------
|
|
@@ -1206,4 +1501,10 @@ export const __TEST__ = {
|
|
|
1206
1501
|
AGENTS_MD_END,
|
|
1207
1502
|
defaultDetectShellRc,
|
|
1208
1503
|
buildCliRegisterCommand,
|
|
1504
|
+
ensureCodexHttpHeaderUserAgent,
|
|
1505
|
+
readCodexHttpHeaders,
|
|
1506
|
+
findHttpHeadersSubtable,
|
|
1507
|
+
parseTomlKvPairs,
|
|
1508
|
+
isLocalUrl,
|
|
1509
|
+
CODEX_MCP_USER_AGENT,
|
|
1209
1510
|
};
|
package/lib/clients/common.mjs
CHANGED
|
@@ -9,11 +9,14 @@ import {
|
|
|
9
9
|
unlinkSync,
|
|
10
10
|
rmSync,
|
|
11
11
|
readFileSync,
|
|
12
|
+
renameSync,
|
|
12
13
|
writeFileSync,
|
|
13
14
|
} from "node:fs";
|
|
14
15
|
import { dirname, join, resolve } from "node:path";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
16
17
|
|
|
18
|
+
export const MEKO_MCP_USER_AGENT = "meko-mcp-installer/1.0";
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* @returns {string} Absolute repository root path.
|
|
19
22
|
*/
|
|
@@ -83,6 +86,16 @@ export function resolveHooksRoot() {
|
|
|
83
86
|
return join(getAssetRoot(), "hooks-handlers");
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* @returns {string} absolute path to the statusline script source.
|
|
91
|
+
* Lives under skills/scripts/ in source, mirrored under assets/scripts/ in
|
|
92
|
+
* the npm-packed install — both roots are siblings of skills/hooks-handlers/
|
|
93
|
+
* and skills/skills/.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveStatuslineSource() {
|
|
96
|
+
return join(getAssetRoot(), "scripts", "meko-statusline.sh");
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
/**
|
|
87
100
|
* Run a shell command and return stdout.
|
|
88
101
|
* @param {string} cmd
|
|
@@ -192,11 +205,15 @@ function unlinkAllowingMissing(targetPath) {
|
|
|
192
205
|
* @param {string} destination
|
|
193
206
|
* @returns {"symlink"|"copy"}
|
|
194
207
|
*/
|
|
195
|
-
export function symlinkOrCopy(source, destination) {
|
|
208
|
+
export function symlinkOrCopy(source, destination, { preferCopy = false } = {}) {
|
|
196
209
|
mkdirSync(dirname(destination), { recursive: true });
|
|
197
210
|
if (isDanglingSymlink(destination)) {
|
|
198
211
|
unlinkAllowingMissing(destination);
|
|
199
212
|
}
|
|
213
|
+
if (preferCopy) {
|
|
214
|
+
cpSync(source, destination, { recursive: true });
|
|
215
|
+
return "copy";
|
|
216
|
+
}
|
|
200
217
|
try {
|
|
201
218
|
symlinkSync(source, destination, "junction");
|
|
202
219
|
return "symlink";
|
|
@@ -206,6 +223,29 @@ export function symlinkOrCopy(source, destination) {
|
|
|
206
223
|
}
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
/** Atomically refresh a runtime-owned directory at a stable path. */
|
|
227
|
+
export function installStableTree(source, destination) {
|
|
228
|
+
if (resolve(source) === resolve(destination)) return;
|
|
229
|
+
const suffix = `${process.pid}-${Date.now()}`;
|
|
230
|
+
const staging = `${destination}.tmp-${suffix}`;
|
|
231
|
+
const backup = `${destination}.bak-${suffix}`;
|
|
232
|
+
|
|
233
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
234
|
+
try {
|
|
235
|
+
cpSync(source, staging, { recursive: true });
|
|
236
|
+
if (existsSync(destination)) renameSync(destination, backup);
|
|
237
|
+
try {
|
|
238
|
+
renameSync(staging, destination);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (existsSync(backup)) renameSync(backup, destination);
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
rmSync(backup, { recursive: true, force: true });
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(staging, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
209
249
|
/**
|
|
210
250
|
* Remove path if it is a symlink or directory/file.
|
|
211
251
|
* @param {string} targetPath
|