@yugabytedb/meko-mcp 1.0.8 → 1.1.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.
Files changed (36) hide show
  1. package/README.md +181 -32
  2. package/assets/hooks-handlers/lib/capture.js +176 -77
  3. package/assets/hooks-handlers/lib/package.json +1 -0
  4. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  5. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  6. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  7. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  8. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  9. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  10. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  11. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  12. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  13. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  14. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  15. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  16. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  17. package/bin/create-meko-setup.mjs +147 -18
  18. package/lib/clients/claude-code.mjs +378 -68
  19. package/lib/clients/claude-desktop.mjs +35 -1
  20. package/lib/clients/codex.mjs +977 -0
  21. package/lib/clients/common.mjs +142 -1
  22. package/lib/clients/cursor.mjs +71 -3
  23. package/lib/clients/index.mjs +2 -0
  24. package/lib/config.mjs +30 -0
  25. package/lib/detect.mjs +52 -44
  26. package/lib/installer.mjs +9 -2
  27. package/lib/mcp-transport.mjs +61 -2
  28. package/lib/migrate/adapters/memory-md.mjs +13 -6
  29. package/lib/migrate/datapack.mjs +167 -0
  30. package/lib/migrate/id.mjs +127 -9
  31. package/lib/migrate/orchestrator.mjs +68 -10
  32. package/lib/migrate.mjs +346 -3
  33. package/lib/prompt.mjs +22 -4
  34. package/lib/validate.mjs +313 -9
  35. package/lib/version-check.mjs +178 -0
  36. package/package.json +2 -1
@@ -1,5 +1,15 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync, mkdirSync, symlinkSync, cpSync, lstatSync, unlinkSync, rmSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ symlinkSync,
6
+ cpSync,
7
+ lstatSync,
8
+ unlinkSync,
9
+ rmSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { dirname, join, resolve } from "node:path";
4
14
  import { fileURLToPath } from "node:url";
5
15
 
@@ -131,3 +141,134 @@ export function removePath(targetPath) {
131
141
  // no-op
132
142
  }
133
143
  }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Installer version markers
147
+ // ---------------------------------------------------------------------------
148
+ //
149
+ // Each artifact the installer owns (a deployed skill dir, a built .skill
150
+ // bundle, the Claude Code meko-capture dir, etc.) gets a `.meko-installer`
151
+ // marker file stamped with the installer's version. On re-run we compare the
152
+ // marker's version against the current installer to decide whether we need
153
+ // to force-refresh a managed artifact. Without a marker the artifact is
154
+ // treated as user-managed and left alone — we never clobber a dir we didn't
155
+ // produce ourselves.
156
+ //
157
+ // The marker format is deliberately plain text, one `key=value` per line,
158
+ // so a human inspecting it with `cat` can immediately see what produced
159
+ // the artifact and when.
160
+
161
+ const MARKER_FILENAME = ".meko-installer";
162
+
163
+ /**
164
+ * Read the installer's package.json `version` field. Mirrors the read
165
+ * performed in bin/create-meko-setup.mjs:56-59 so the marker and the
166
+ * CLI's --version report agree.
167
+ *
168
+ * @returns {string}
169
+ */
170
+ export function getInstallerVersion() {
171
+ const libDir = dirname(fileURLToPath(import.meta.url));
172
+ const pkgPath = resolve(libDir, "..", "..", "package.json");
173
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
174
+ return pkg.version;
175
+ }
176
+
177
+ /**
178
+ * Write a `.meko-installer` marker inside `dir`. Format is one `key=value`
179
+ * line per field, e.g.
180
+ *
181
+ * version=1.0.8
182
+ * installedAt=2026-04-30T14:22:11.123Z
183
+ *
184
+ * Extra key/value pairs can be appended via `extra`. `dir` must exist; this
185
+ * function will not create it (the caller has already done mkdir).
186
+ *
187
+ * @param {string} dir
188
+ * @param {{extra?: Record<string,string>}} [opts]
189
+ * @returns {{path: string, version: string, installedAt: string}}
190
+ */
191
+ export function writeInstallerMarker(dir, { extra = {} } = {}) {
192
+ const version = getInstallerVersion();
193
+ const installedAt = new Date().toISOString();
194
+ const lines = [`version=${version}`, `installedAt=${installedAt}`];
195
+ for (const [key, value] of Object.entries(extra)) {
196
+ lines.push(`${key}=${value}`);
197
+ }
198
+ const markerPath = join(dir, MARKER_FILENAME);
199
+ writeFileSync(markerPath, lines.join("\n") + "\n", "utf8");
200
+ return { path: markerPath, version, installedAt };
201
+ }
202
+
203
+ /**
204
+ * Parse a `.meko-installer` marker from `dir`. Returns null if the marker
205
+ * is missing or unreadable. Callers treat null as "not ours / user-managed".
206
+ *
207
+ * Marker format evolved across installer versions:
208
+ * - pre-1.0.9: a single line containing the word "managed". No version.
209
+ * - 1.0.9+: `key=value` lines, including `version=` and `installedAt=`.
210
+ *
211
+ * Both shapes indicate "we own this artifact"; the caller needs to know
212
+ * which shape it got so it can decide whether to force a refresh. Legacy
213
+ * markers have no version to compare against, so they are returned with
214
+ * `legacy: true` and no `version` — `markerIsStale` then treats them as
215
+ * stale (any current version mismatches) and triggers a refresh. That's
216
+ * exactly the behavior users hitting the "stale skill" bug from 1.0.8
217
+ * need on their next re-install.
218
+ *
219
+ * @param {string} dir
220
+ * @returns {{version?: string, installedAt?: string, legacy?: boolean, [k: string]: string|boolean|undefined}|null}
221
+ */
222
+ export function readInstallerMarker(dir) {
223
+ const markerPath = join(dir, MARKER_FILENAME);
224
+ let raw;
225
+ try {
226
+ raw = readFileSync(markerPath, "utf8");
227
+ } catch {
228
+ return null;
229
+ }
230
+ const out = {};
231
+ for (const line of raw.split(/\r?\n/)) {
232
+ const trimmed = line.trim();
233
+ if (!trimmed) continue;
234
+ const eq = trimmed.indexOf("=");
235
+ if (eq <= 0) continue;
236
+ const key = trimmed.slice(0, eq).trim();
237
+ const value = trimmed.slice(eq + 1).trim();
238
+ if (!key) continue;
239
+ out[key] = value;
240
+ }
241
+ if (out.version) return out;
242
+
243
+ // Legacy marker path: pre-1.0.9 installs wrote the literal text
244
+ // `managed\n` (no `=`). Recognize it so we don't mistake an older
245
+ // managed dir for a user-managed one. Returning a parsed object with
246
+ // `legacy: true` lets markerIsStale do the right thing (stale → refresh).
247
+ if (raw.trim() === "managed") {
248
+ return { legacy: true };
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * True when the artifact at `dir` was produced by a different installer
255
+ * version than `currentVersion`. Also true when the marker is missing or
256
+ * unparseable — in that case the caller still needs to check whether the
257
+ * artifact dir exists (missing marker + missing dir = fresh install; missing
258
+ * marker + existing dir = user-managed, handle separately).
259
+ *
260
+ * Strict inequality by design — any version change forces a refresh, even
261
+ * a downgrade. We don't want to play semver-comparison games when the
262
+ * artifact content may have changed in either direction.
263
+ *
264
+ * @param {string} dir
265
+ * @param {string} currentVersion
266
+ * @returns {boolean}
267
+ */
268
+ export function markerIsStale(dir, currentVersion) {
269
+ const marker = readInstallerMarker(dir);
270
+ if (!marker) return true;
271
+ // Legacy `managed\n` marker has no version recorded — always stale.
272
+ if (marker.legacy) return true;
273
+ return marker.version !== currentVersion;
274
+ }
@@ -2,7 +2,6 @@ import {
2
2
  existsSync,
3
3
  lstatSync,
4
4
  readlinkSync,
5
- writeFileSync,
6
5
  mkdirSync,
7
6
  } from "node:fs";
8
7
  import { homedir } from "node:os";
@@ -17,10 +16,14 @@ import {
17
16
  writeJsonFile,
18
17
  } from "../config.mjs";
19
18
  import {
19
+ getInstallerVersion,
20
+ markerIsStale,
21
+ readInstallerMarker,
20
22
  resolveHooksRoot,
21
23
  resolveSkillSource,
22
24
  removePath,
23
25
  symlinkOrCopy,
26
+ writeInstallerMarker,
24
27
  } from "./common.mjs";
25
28
 
26
29
  const SKILLS = [{ name: "meko-mcp-tools" }];
@@ -118,8 +121,10 @@ export function createCursorAdapter(customPaths = {}) {
118
121
  },
119
122
  installSkills({ dryRun, log, scope, projectRoot }) {
120
123
  const skillDir = paths.getSkillsDir(scope, projectRoot);
124
+ const currentVersion = getInstallerVersion();
121
125
  if (dryRun) {
122
126
  log.dim(` Would install skills into ${skillDir}`);
127
+ log.dim(` Would write .meko-installer marker (version=${currentVersion})`);
123
128
  return { marketplace: "skipped", skills: "dry-run" };
124
129
  }
125
130
 
@@ -127,10 +132,42 @@ export function createCursorAdapter(customPaths = {}) {
127
132
  for (const skill of SKILLS) {
128
133
  const source = resolveSkillSource(skill.name);
129
134
  const destination = join(skillDir, skill.name);
130
- if (existsSync(destination)) continue;
135
+
136
+ if (existsSync(destination)) {
137
+ // Symlinks pointing at our own source directory are already live —
138
+ // the source tree IS the latest content, so there's nothing to
139
+ // refresh. Treat them the same as a fresh-current-version install.
140
+ if (isOwnSymlink(destination, source)) continue;
141
+
142
+ const marker = readInstallerMarker(destination);
143
+ if (!marker) {
144
+ // No marker AND not a symlink we own — user-managed dir or a
145
+ // pre-marker install. Leave it alone to avoid clobbering local
146
+ // edits; the user can delete it themselves for a fresh deploy.
147
+ log.dim(
148
+ ` Skipping ${destination}: no .meko-installer marker (user-managed).`,
149
+ );
150
+ continue;
151
+ }
152
+ if (markerIsStale(destination, currentVersion)) {
153
+ const from = marker.version ?? "pre-1.0.9";
154
+ log.dim(
155
+ ` Upgrading cursor skill from ${from} -> ${currentVersion}`,
156
+ );
157
+ removePath(destination);
158
+ } else {
159
+ // Same version already deployed — nothing to do.
160
+ continue;
161
+ }
162
+ }
163
+
131
164
  const strategy = symlinkOrCopy(source, destination);
165
+ // Only stamp a marker when we materialized a real copy. Writing into
166
+ // a symlinked source dir would mutate the shipped asset tree, which
167
+ // is confusing at best and a permission error at worst. Symlinks
168
+ // re-resolve via isOwnSymlink on the next run.
132
169
  if (strategy === "copy") {
133
- writeFileSync(join(destination, ".meko-installer"), "managed\n", "utf8");
170
+ writeInstallerMarker(destination);
134
171
  }
135
172
  }
136
173
 
@@ -217,6 +254,20 @@ function installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths }) {
217
254
  writeJsonFile(hooksPath, cfg);
218
255
  }
219
256
 
257
+ // Cursor's claude-compat mode writes absolute-path Meko hook entries into
258
+ // the shared settings.json (on scope=user, the same file Claude Code owns)
259
+ // and de-dupes them via upsertClaudeCompatHook. The Claude Code adapter's
260
+ // pruneMekoHookEntries (issue #60) is narrowed to preserve any entry whose
261
+ // command references the current resolveHooksRoot() — so the entry this
262
+ // function writes survives a subsequent Claude Code install from the
263
+ // SAME install root.
264
+ //
265
+ // TRADE-OFF: if Cursor installed from a different root (e.g. repo clone)
266
+ // than a later Claude Code install (e.g. npx cache), Cursor's entries look
267
+ // byte-identical to the stale Claude Code entries issue #60 targets. They
268
+ // will be pruned; the user must re-run the Cursor installer to restore
269
+ // them. pruneMekoHookEntries emits a warning on any removal so the user
270
+ // has a chance to notice before hooks silently disappear.
220
271
  function installClaudeCompatHooks({ scope, projectRoot, url, apiKey, paths }) {
221
272
  const settingsPath = paths.getClaudeCompatSettingsPath(scope, projectRoot);
222
273
  const cfg = readJsonFile(settingsPath);
@@ -314,6 +365,23 @@ function uninstallCursorHooks({ scope, projectRoot, mode, paths }) {
314
365
  }
315
366
  }
316
367
 
368
+ /**
369
+ * True when `destination` is a symlink that resolves to `expectedSource`.
370
+ * Used to recognize in-place symlink installs on re-run: the live source is
371
+ * the latest content, so there's nothing to refresh.
372
+ */
373
+ function isOwnSymlink(destination, expectedSource) {
374
+ try {
375
+ const stat = lstatSync(destination);
376
+ if (!stat.isSymbolicLink()) return false;
377
+ const linkTarget = readlinkSync(destination);
378
+ const resolvedTarget = resolve(dirname(destination), linkTarget);
379
+ return resolvedTarget === expectedSource;
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
317
385
  function uninstallCursorSkills({ scope, projectRoot, paths }) {
318
386
  const skillsDir = paths.getSkillsDir(scope, projectRoot);
319
387
 
@@ -1,5 +1,6 @@
1
1
  import { createClaudeCodeAdapter } from "./claude-code.mjs";
2
2
  import { createClaudeDesktopAdapter } from "./claude-desktop.mjs";
3
+ import { createCodexAdapter } from "./codex.mjs";
3
4
  import { createCursorAdapter } from "./cursor.mjs";
4
5
 
5
6
  /**
@@ -10,5 +11,6 @@ export function getClientAdapters() {
10
11
  "claude-code": createClaudeCodeAdapter(),
11
12
  "claude-desktop": createClaudeDesktopAdapter(),
12
13
  cursor: createCursorAdapter(),
14
+ codex: createCodexAdapter(),
13
15
  };
14
16
  }
package/lib/config.mjs CHANGED
@@ -96,6 +96,36 @@ export function getClaudeCompatSettingsPath(scope, projectRoot) {
96
96
  return getClaudeCodeSettingsPath();
97
97
  }
98
98
 
99
+ /**
100
+ * Return path to Codex's user-level config (TOML).
101
+ * Honors $CODEX_HOME if set, else defaults to ~/.codex/config.toml.
102
+ * Codex has no project-level config — caller should warn and degrade
103
+ * if a project scope was requested.
104
+ * @returns {string}
105
+ */
106
+ export function getCodexConfigPath() {
107
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
108
+ return join(codexHome, "config.toml");
109
+ }
110
+
111
+ /**
112
+ * Return path to Codex's user-level AGENTS.md.
113
+ * @returns {string}
114
+ */
115
+ export function getCodexAgentsMdPath() {
116
+ const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
117
+ return join(codexHome, "AGENTS.md");
118
+ }
119
+
120
+ /**
121
+ * Return path to the shared agent-skills directory used by Codex
122
+ * (and other agent runtimes that follow the convention).
123
+ * @returns {string}
124
+ */
125
+ export function getCodexSkillsRoot() {
126
+ return join(homedir(), ".agents", "skills");
127
+ }
128
+
99
129
  // ---------------------------------------------------------------------------
100
130
  // Read / Write primitives
101
131
  // ---------------------------------------------------------------------------
package/lib/detect.mjs CHANGED
@@ -8,6 +8,10 @@
8
8
  */
9
9
 
10
10
  import { execSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { readJsonFile } from "./config.mjs";
11
15
 
12
16
  // ---------------------------------------------------------------------------
13
17
  // Internal helpers
@@ -70,56 +74,60 @@ export function detectClaude() {
70
74
  // detectExistingMeko
71
75
  // ---------------------------------------------------------------------------
72
76
 
77
+ /**
78
+ * Canonical location of Claude Code's user-scope MCP registry. This is the
79
+ * file `claude mcp add` writes to — we read it directly rather than shelling
80
+ * out to `claude mcp list`, which takes 18–28s on real user machines and
81
+ * was timing out against our 10s guard (issue #80).
82
+ *
83
+ * @returns {string}
84
+ */
85
+ export function getClaudeCodeConfigPath() {
86
+ return join(homedir(), ".claude.json");
87
+ }
88
+
73
89
  /**
74
90
  * Check if a Meko MCP server is already registered in Claude Code.
75
91
  *
76
- * Runs `claude mcp list` and scans the output for the given server name.
77
- * The output format may vary between Claude CLI versions; we look for a
78
- * line that starts with `<serverName>:` (e.g. "meko: http://... - Connected")
79
- * and extract a status keyword from it.
92
+ * Reads `~/.claude.json` directly and inspects either `mcpServers[serverName]`
93
+ * (user scope) or `projects[projectRoot].mcpServers[serverName]` (project
94
+ * scope). This mirrors the approach the Claude Desktop adapter already uses
95
+ * against its own config (installer/lib/clients/claude-desktop.mjs:detectExisting)
96
+ * and replaces the previous `claude mcp list` shell-out, which silently
97
+ * returned `found: false` whenever the CLI exceeded the 10s run() timeout.
98
+ *
99
+ * Scope handling: `claude mcp add --scope project` writes under
100
+ * `projects[<absolute projectRoot>].mcpServers`, not the top-level
101
+ * `mcpServers`. A project-scope re-install must detect the existing entry
102
+ * in that nested location, otherwise the overwrite prompt is skipped and
103
+ * `claude mcp add --scope project` rejects the duplicate with "already
104
+ * exists". User-scope installs read the top-level `mcpServers` object
105
+ * (which is where the CLI writes entries added with `--scope user`).
80
106
  *
81
107
  * @param {string} [serverName="meko"] - The MCP server name to look for.
108
+ * @param {{ configPath?: string, scope?: "user"|"project", projectRoot?: string }} [options]
109
+ * `configPath` — test-only injection point for the config file path; defaults to `~/.claude.json`.
110
+ * `scope` — "user" (default) or "project". Project scope looks under `projects[projectRoot].mcpServers`.
111
+ * `projectRoot` — absolute path used as the key into `projects[...]` when `scope === "project"`.
112
+ * Ignored for user scope. Must be supplied by the caller for project scope.
82
113
  * @returns {{ found: boolean, status: string }}
83
- * `found` - true if a line matching `serverName` was found.
84
- * `status` - the status value from that line (e.g. "Connected", "Failed"),
85
- * or "" if not found.
114
+ * `found` - true if a matching MCP entry exists at the scope-appropriate location.
115
+ * `status` - "Configured" when found, "" otherwise. (Kept as a string for
116
+ * API compatibility with the prior implementation; callers use
117
+ * this only for logging, never parsing.)
86
118
  */
87
- export function detectExistingMeko(serverName = "meko") {
88
- const result = { found: false, status: "" };
89
-
90
- const output = run("claude mcp list");
91
- if (!output) return result;
92
-
93
- // Scan each line for one that begins with the server name.
94
- const lines = output.split("\n");
95
- for (const line of lines) {
96
- const trimmed = line.trim();
97
-
98
- // Match "serverName:" prefix (colon-delimited format) or a table row
99
- // where the first whitespace-separated token equals the server name.
100
- const isMatch =
101
- trimmed.startsWith(`${serverName}:`) ||
102
- trimmed.split(/\s+/)[0] === serverName;
103
-
104
- if (!isMatch) continue;
105
-
106
- result.found = true;
107
-
108
- // Extract status from known keywords anywhere on the line.
109
- if (/\bConnected\b/i.test(trimmed)) {
110
- result.status = "Connected";
111
- } else if (/\bFailed\b/i.test(trimmed)) {
112
- result.status = "Failed";
113
- } else if (/\bError\b/i.test(trimmed)) {
114
- result.status = "Error";
115
- } else if (/\bDisconnected\b/i.test(trimmed)) {
116
- result.status = "Disconnected";
117
- } else {
118
- result.status = "Unknown";
119
- }
120
-
121
- return result;
119
+ export function detectExistingMeko(serverName = "meko", options = {}) {
120
+ const { scope = "user", projectRoot } = options;
121
+ const configPath = options.configPath ?? getClaudeCodeConfigPath();
122
+ const cfg = readJsonFile(configPath);
123
+
124
+ const entry =
125
+ scope === "project"
126
+ ? cfg?.projects?.[projectRoot]?.mcpServers?.[serverName]
127
+ : cfg?.mcpServers?.[serverName];
128
+
129
+ if (!entry) {
130
+ return { found: false, status: "" };
122
131
  }
123
-
124
- return result;
132
+ return { found: true, status: "Configured" };
125
133
  }
package/lib/installer.mjs CHANGED
@@ -32,6 +32,7 @@ export async function install(options) {
32
32
  name = "meko",
33
33
  skipSkills = false,
34
34
  skipValidation = false,
35
+ skipCanary = false,
35
36
  dryRun = false,
36
37
  verbose = false,
37
38
  yes = false,
@@ -107,7 +108,7 @@ export async function install(options) {
107
108
 
108
109
  if (!skipMcpRegistration) {
109
110
  try {
110
- adapter.registerServer({
111
+ await adapter.registerServer({
111
112
  name,
112
113
  url,
113
114
  scope,
@@ -116,6 +117,7 @@ export async function install(options) {
116
117
  verbose,
117
118
  log: LOG,
118
119
  projectRoot,
120
+ yes,
119
121
  });
120
122
  stepOk(currentStep, totalSteps, dryRun ? "Registering MCP server (dry-run)." : "MCP server registered.");
121
123
  } catch (err) {
@@ -151,6 +153,7 @@ export async function install(options) {
151
153
  if (!skipSkills) {
152
154
  try {
153
155
  const result = adapter.installSkills({
156
+ name,
154
157
  scope,
155
158
  dryRun,
156
159
  verbose,
@@ -176,7 +179,8 @@ export async function install(options) {
176
179
  try {
177
180
  const results = await runValidation(url, apiKey, name, {
178
181
  validateConfig: async ({ serverName }) =>
179
- adapter.validateConfig({ serverName, scope, projectRoot }),
182
+ adapter.validateConfig({ serverName, scope, projectRoot, url, apiKey }),
183
+ skipCanary,
180
184
  });
181
185
 
182
186
  const checks = [
@@ -184,6 +188,9 @@ export async function install(options) {
184
188
  { label: "Connectivity", ...results.connectivity },
185
189
  { label: "Tools", ...results.tools },
186
190
  ];
191
+ if (results.canary) {
192
+ checks.push({ label: "Canary", ...results.canary });
193
+ }
187
194
  for (const check of checks) {
188
195
  if (check.ok) success(`${check.label}: ${check.message}`);
189
196
  else warn(`${check.label}: ${check.message}`);
@@ -29,9 +29,16 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
29
29
  const parsed = new URL(url);
30
30
  const transport = parsed.protocol === "https:" ? https : http;
31
31
 
32
+ // MCP Streamable HTTP requires the client to advertise both content
33
+ // types: a non-streaming JSON response uses application/json, a
34
+ // streaming response (which is what FastMCP returns for `initialize`
35
+ // and any other call when stateless_http isn't `json_response=True`)
36
+ // uses text/event-stream. Sending only `application/json` produces a
37
+ // 406 Not Acceptable from spec-compliant servers (see meko-mcp-server
38
+ // PR #97 / MEKO-137 which turned this on for prod).
32
39
  const headers = {
33
40
  "Content-Type": "application/json",
34
- Accept: "application/json",
41
+ Accept: "application/json, text/event-stream",
35
42
  };
36
43
  if (apiKey) {
37
44
  headers["Authorization"] = `Bearer ${apiKey}`;
@@ -56,10 +63,19 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
56
63
  const chunks = [];
57
64
  res.on("data", (chunk) => chunks.push(chunk));
58
65
  res.on("end", () => {
66
+ const raw = Buffer.concat(chunks).toString("utf8");
67
+ // If the server answered with text/event-stream (the MCP spec's
68
+ // streaming response shape), extract the JSON-RPC payload from
69
+ // the first SSE `data:` field. Callers downstream want a plain
70
+ // JSON-RPC body — they don't care which framing the wire used.
71
+ const contentType = String(res.headers["content-type"] ?? "").toLowerCase();
72
+ const body = contentType.includes("text/event-stream")
73
+ ? extractSseData(raw)
74
+ : raw;
59
75
  resolve({
60
76
  statusCode: res.statusCode,
61
77
  headers: res.headers,
62
- body: Buffer.concat(chunks).toString("utf8"),
78
+ body,
63
79
  });
64
80
  });
65
81
  },
@@ -75,6 +91,49 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
75
91
  });
76
92
  }
77
93
 
94
+ /**
95
+ * Extract the JSON-RPC payload from an SSE-framed response body.
96
+ *
97
+ * MCP servers running Streamable HTTP wrap each JSON-RPC response in an
98
+ * SSE event of the form:
99
+ *
100
+ * event: message
101
+ * data: {"jsonrpc":"2.0","id":1,"result":{...}}
102
+ *
103
+ * For a single non-streaming JSON-RPC response (initialize, tools/list,
104
+ * tools/call) there's exactly one event in the body. SSE allows the
105
+ * payload to span multiple `data:` lines; per the spec they are joined
106
+ * with `\n`. We concatenate every `data:` line we see in the first event
107
+ * and return that. Returns the raw body unchanged if no `data:` line is
108
+ * found (defensive fallback so a misbehaving server still produces a
109
+ * useful "non-JSON response" error downstream rather than an empty
110
+ * string).
111
+ *
112
+ * @param {string} raw - Raw response body (already utf8-decoded).
113
+ * @returns {string}
114
+ */
115
+ function extractSseData(raw) {
116
+ const dataLines = [];
117
+ // Split on all three SSE-permitted line terminators per the WHATWG SSE
118
+ // spec: LF, CRLF, and bare CR. Most servers (including FastMCP) emit
119
+ // LF or CRLF, but the spec is explicit about CR-only being valid and
120
+ // we shouldn't break on a producer that uses it.
121
+ for (const rawLine of raw.split(/\r\n|\r|\n/)) {
122
+ if (rawLine === "") {
123
+ // Blank line terminates the event. We only need the first event for
124
+ // a JSON-RPC response.
125
+ if (dataLines.length > 0) break;
126
+ continue;
127
+ }
128
+ // `data:` field — capture the value, stripping the optional single
129
+ // leading space the spec allows after the colon. Anything else
130
+ // (`event:`, `id:`, `retry:`, comments starting with `:`) we ignore.
131
+ const match = /^data: ?(.*)$/.exec(rawLine);
132
+ if (match) dataLines.push(match[1]);
133
+ }
134
+ return dataLines.length > 0 ? dataLines.join("\n") : raw;
135
+ }
136
+
78
137
  /**
79
138
  * Produce a human-friendly message from a network error.
80
139
  *
@@ -64,9 +64,9 @@ export async function enumerateMemoryItems(opts) {
64
64
  if (!cwd) continue;
65
65
  const agentId = agentIdForClaude(cwd);
66
66
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
67
- items.push(...readClaudeMd(projMd, agentId, "project"));
67
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd));
68
68
  const memDir = path.join(dir, "memory");
69
- items.push(...readMemoryDir(memDir, agentId, "project"));
69
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd));
70
70
  }
71
71
  }
72
72
  } else {
@@ -74,10 +74,10 @@ export async function enumerateMemoryItems(opts) {
74
74
  if (!cwd) return [];
75
75
  const agentId = agentIdForClaude(cwd);
76
76
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
77
- items.push(...readClaudeMd(projMd, agentId, "project"));
77
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd));
78
78
  const slug = slugifyCwd(cwd);
79
79
  const memDir = path.join(home, ".claude", "projects", slug, "memory");
80
- items.push(...readMemoryDir(memDir, agentId, "project"));
80
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd));
81
81
  }
82
82
 
83
83
  return items;
@@ -90,9 +90,13 @@ export async function enumerateMemoryItems(opts) {
90
90
  * @param {string} filePath
91
91
  * @param {string} agentId
92
92
  * @param {"user"|"project"} scope
93
+ * @param {string|null} [cwd] - Absolute project path. Attached to metadata
94
+ * so downstream consumers can correlate even after the agent_id is
95
+ * sanitized down to a basename. Null for user-scope items (which use the
96
+ * `meko_agent` common bucket).
93
97
  * @returns {MemoryItem[]}
94
98
  */
95
- export function readClaudeMd(filePath, agentId, scope) {
99
+ export function readClaudeMd(filePath, agentId, scope, cwd = null) {
96
100
  let raw;
97
101
  try {
98
102
  raw = fs.readFileSync(filePath, "utf8");
@@ -115,6 +119,7 @@ export function readClaudeMd(filePath, agentId, scope) {
115
119
  source: path.basename(filePath),
116
120
  scope,
117
121
  sourcePath: filePath,
122
+ ...(cwd ? { cwd } : {}),
118
123
  ...(heading ? { heading } : {}),
119
124
  },
120
125
  };
@@ -160,9 +165,10 @@ export function splitTopLevelSections(md) {
160
165
  * @param {string} memDir
161
166
  * @param {string} agentId
162
167
  * @param {"user"|"project"} scope
168
+ * @param {string|null} [cwd] - Absolute project path; attached to metadata.
163
169
  * @returns {MemoryItem[]}
164
170
  */
165
- export function readMemoryDir(memDir, agentId, scope) {
171
+ export function readMemoryDir(memDir, agentId, scope, cwd = null) {
166
172
  let entries;
167
173
  try {
168
174
  entries = fs.readdirSync(memDir);
@@ -193,6 +199,7 @@ export function readMemoryDir(memDir, agentId, scope) {
193
199
  source: `memory/${entry}`,
194
200
  scope,
195
201
  sourcePath: full,
202
+ ...(cwd ? { cwd } : {}),
196
203
  ...(frontmatter.name ? { name: frontmatter.name } : {}),
197
204
  ...(frontmatter.description ? { description: frontmatter.description } : {}),
198
205
  ...(frontmatter.type ? { type: frontmatter.type } : {}),