From c8206275412927e630d86d84f6196d3ca037cdc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 05:33:10 +0000 Subject: [PATCH 1/2] fix(plugin): survive missing Xcode git and ship 2.1.0 Cursor's plugin page showed Error loading plugin because macOS without Command Line Tools turns /usr/bin/git into a stub that fails git init. Hooks now detect repos on the filesystem, refuse init/clone, and never spawn that stub. Also add Configure variables, search query, evolver_report_reuse, and tests. Co-authored-by: autogame-17 --- .cursor-plugin/marketplace.json | 10 +- .cursor-plugin/plugin.json | 44 +++++- .github/workflows/test.yml | 19 +++ CHANGELOG.md | 36 +++++ README.md | 47 +++++- commands/distill.md | 1 + commands/evolve.md | 1 + commands/review.md | 1 + commands/run.md | 1 + commands/search.md | 17 ++- commands/solidify.md | 1 + commands/status.md | 1 + commands/sync.md | 1 + hooks/_paths.js | 249 +++++++++++++++++++++++++++++--- hooks/hooks.json | 7 + hooks/session-end.js | 25 +--- hooks/session-start.js | 23 ++- mcp.json | 8 +- mcp/evolver-proxy.mjs | 63 ++++++-- skills/evolver/SKILL.md | 7 +- test/manifest.test.js | 110 ++++++++++++++ test/mcp-bridge.test.js | 90 ++++++++++++ test/paths.test.js | 98 +++++++++++++ 23 files changed, 781 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 test/manifest.test.js create mode 100644 test/mcp-bridge.test.js create mode 100644 test/paths.test.js diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 07b477c..868c0bf 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -1,13 +1,19 @@ { "name": "evolver", "owner": { - "name": "EvoMap" + "name": "EvoMap", + "email": "team@evomap.ai" + }, + "metadata": { + "description": "Official Evolver plugin for Cursor — persistent, auditable evolution memory for the agent, powered by the Genome Evolution Protocol (GEP).", + "version": "2.1.0" }, "plugins": [ { "name": "evolver", - "source": "./", + "source": ".", "description": "Persistent, auditable evolution memory for the agent — recalls what worked, detects improvement signals, records outcomes. Powered by the Genome Evolution Protocol (GEP).", + "version": "2.1.0", "author": { "name": "EvoMap" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 72498d8..598bba4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "evolver", "displayName": "Evolver — Self-Evolving Agent Memory", - "description": "Gives the agent a persistent, auditable evolution memory plus a bridge to the EvoMap network. Recalls what worked at session start, detects improvement signals while you edit, records outcomes when a task ends, and exposes the network's genes/capsules as MCP tools — powered by the Genome Evolution Protocol (GEP). The hooks degrade gracefully without a local install; add the `@evomap/evolver` npm package to unlock the full review-and-solidify pipeline and EvoMap Hub sync.", - "version": "2.0.0", + "description": "Gives the agent a persistent, auditable evolution memory plus a bridge to the EvoMap network. Recalls what worked at session start, detects improvement signals while you edit, records outcomes when a task ends, and exposes the network's genes/capsules as MCP tools — powered by the Genome Evolution Protocol (GEP). The hooks degrade gracefully without a local install or a working git binary; add the `@evomap/evolver` npm package to unlock the full review-and-solidify pipeline and EvoMap Hub sync.", + "version": "2.1.0", "author": { "name": "EvoMap", "email": "team@evomap.ai" @@ -31,5 +31,43 @@ "commands": "./commands/", "rules": "./rules/", "hooks": "./hooks/hooks.json", - "mcpServers": "./mcp.json" + "mcpServers": "./mcp.json", + "variables": { + "type": "object", + "properties": { + "EVOMAP_NODE_ID": { + "type": "string", + "title": "EvoMap Node ID — leave blank for automatic setup", + "description": "Leave this blank (recommended). On first run the local Proxy registers a fresh node for you and prints a link to claim it on evomap.ai — you never paste an id or a secret here. Only fill this in to point the install at a node you already run yourself.", + "default": "" + }, + "EVOMAP_HUB_URL": { + "type": "string", + "title": "EvoMap Hub URL", + "description": "Base URL of the EvoMap Hub the Proxy talks to.", + "default": "https://evomap.ai" + }, + "EVOMAP_PROXY_PORT": { + "type": "string", + "title": "Proxy port", + "description": "Local Proxy mailbox port that the MCP bridge connects to.", + "default": "19820" + }, + "EVOLVE_STRATEGY": { + "type": "string", + "title": "Evolution strategy", + "description": "balanced | innovate | harden | repair-only | early-stabilize | steady-state | auto", + "default": "balanced", + "enum": [ + "balanced", + "innovate", + "harden", + "repair-only", + "early-stabilize", + "steady-state", + "auto" + ] + } + } + } } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..058edcf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: node tests + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Run tests + run: node --test test/*.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45a9f10 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to the Evolver Cursor plugin are documented here. +This project adheres to [Semantic Versioning](https://semver.org/). + +## [2.1.0] — 2026-08-18 + +### Fixed +- Hooks no longer spawn Apple's `/usr/bin/git` stub on macOS without Xcode + Command Line Tools. That stub prints `No developer tools were found` and was + the failure behind Cursor's **Error loading plugin** banner + (`git ... init` / `xcode-select`). Repo detection is filesystem-only; git is + resolved without executing the stub; `git init` / `git clone` are refused. +- Session-start no longer tells the agent to run `git init` (a string Cursor or + the agent could treat as an actionable command). Missing-repo and unusable-git + notices are separate, throttled, and name the CLT / Homebrew fix. +- `evolver_fetch_asset` now puts the reuse nudge on the returned **data** so + the model actually sees it. + +### Added +- Cursor **Plugins → Configure** variables: Node ID (leave blank), Hub URL, + proxy port, evolution strategy — wired into the MCP bridge env. +- MCP: free-text `query` on `evolver_search_assets` (query and/or signals). +- MCP: `evolver_report_reuse` so reused genes/capsules credit their authors. +- `sessionEnd` hook alias (same recorder as `stop`, deduped). +- Command frontmatter `name` fields; `/search` prefers a natural-language query. + +### Notes +- Local memory still needs a working git to *record* diffs. The plugin itself + loads and the MCP bridge still runs when git is missing. + +## [2.0.0] — 2026-08-16 + +### Changed +- Manifest version 2.0.0; bundled skill renamed from `capability-evolver` to + `evolver`; CLI fallbacks pin to `@evomap/evolver@2`. diff --git a/README.md b/README.md index 203b9d3..6cddd0c 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ turned out — so the next session starts smarter. Powered by the [Genome Evolution Protocol (GEP)](https://evomap.ai) and the [`@evomap/evolver`](https://github.com/EvoMap/evolver) engine. -> **Status:** v2.0.0 — aligned with `@evomap/evolver` 2.x. Hooks + `evolver` +> **Status:** v2.1.0 — aligned with `@evomap/evolver` 2.x. Hooks + `evolver` > skill + commands + MCP bridge. Works standalone (local memory) and, when the > Proxy is running, exposes the EvoMap mailbox (genes/capsules) as MCP tools. +> Hooks never spawn Apple's Xcode `git` stub, so a missing Command Line Tools +> install cannot take the plugin down. ## What it does @@ -24,14 +26,15 @@ Three hooks run automatically — you don't invoke them: |---|---|---| | `session-start.js` | `sessionStart` | Injects a summary of recent **successful** outcomes (score ≥ 0.5, < 7 days, max 3) as context. Also, when a node has been registered locally but not yet connected to the network, gives a one-time (throttled) nudge to claim it. | | `signal-detect.js` | `afterFileEdit` | Detects improvement signals (`log_error`, `perf_bottleneck`, `capability_gap`, …) in edits. | -| `session-end.js` | `stop` | Classifies the current working-tree/staged git diff once per session and appends the outcome to the evolution memory graph. | +| `session-end.js` | `stop` / `sessionEnd` | Classifies the current working-tree/staged git diff once per session and appends the outcome to the evolution memory graph. Duplicate firings are suppressed. | It also ships: - An **`evolver` skill** describing the recall → work → record loop. - An **MCP bridge** (`evolver-proxy`) exposing the local Proxy mailbox as tools: - `evolver_search_assets`, `evolver_status`, `evolver_fetch_asset`, - `evolver_publish_asset`, `evolver_distill_conversation`, `evolver_poll`. + `evolver_search_assets` (free-text `query` and/or `signals`), `evolver_status`, + `evolver_fetch_asset`, `evolver_report_reuse`, `evolver_publish_asset`, + `evolver_distill_conversation`, `evolver_poll`. - Slash commands: **`/evolve`** (checkpoint), **`/search`** (find network assets), **`/status`** (health), and engine wrappers **`/run`**, **`/solidify`**, **`/review`**, **`/sync`**, **`/distill`** (use the `@evomap/evolver` CLI when @@ -45,7 +48,31 @@ It also ships: Search for **Evolver** in the Cursor plugin marketplace and install. Reload Cursor. That's it — **local memory works with zero config**: no account, -no key, nothing to fill in. +no key, nothing to fill in. Optional Hub settings (node id, hub URL, proxy port, +strategy) live under **Plugins → Configure**; leave Node ID blank. + +### Plugin failed to load (`git init` / `xcode-select`) + +Cursor's plugin installer itself runs `git` when it copies the plugin into +`~/.cursor/plugins/cache`. On macOS without Xcode Command Line Tools, Apple's +`/usr/bin/git` is a stub that prints `No developer tools were found` and the +plugin page shows **Error loading plugin**. + +Fix the installer side (pick one): + +```bash +xcode-select --install +# or +brew install git +``` + +Then restart Cursor and reinstall / refresh the plugin. + +This plugin's own hooks never run `git init` or `git clone`, and they refuse to +spawn `/usr/bin/git` when it is that stub, so once a real git is on PATH the +hooks stay silent instead of re-triggering the dialog. Local memory still +degrades: if git is unusable, recall/record waits until git works; MCP and +commands keep loading. ### Connecting to the EvoMap network (optional) @@ -83,7 +110,9 @@ Reload Cursor. The hooks activate on the next session. ## Requirements - **Node.js** (the hooks are Node scripts; Cursor invokes them via `node`). -- Nothing else for local memory. +- A working `git` for recording session diffs (Homebrew or Xcode Command Line + Tools on macOS). The plugin still loads without it; memory recording stays + inactive until git works. ## Modes @@ -143,8 +172,10 @@ node registration. | Variable | Default | Purpose | |---|---|---| | `MEMORY_GRAPH_PATH` | (auto) | Override the memory graph file location. | -| `EVOMAP_PROXY_PORT` | `19820` | Proxy port the MCP bridge falls back to (live url read from `~/.evolver/settings.json`). | -| `EVOMAP_HUB_URL` / `EVOMAP_API_KEY` / `EVOMAP_NODE_ID` | (unset) | Enable Hub recording. | +| `EVOMAP_PROXY_PORT` | `19820` | Proxy port the MCP bridge falls back to (live url read from `~/.evolver/settings.json`). Also settable under **Plugins → Configure**. | +| `EVOMAP_HUB_URL` / `EVOMAP_API_KEY` / `EVOMAP_NODE_ID` | (unset) | Enable Hub recording. Node ID and Hub URL are also plugin Configure fields; leave Node ID blank for automatic setup. | +| `EVOLVE_STRATEGY` | `balanced` | Default strategy for `/run`. Plugin Configure field. | +| `EVOLVER_GIT_BINARY` | (auto) | Absolute path to git. Set empty to force "git unavailable" (hooks will not spawn git). | | `EVOLVER_HOOK_VERBOSE` | `0` | Set `1` to surface the session-end receipt inline (suppressed on Cursor by default). | ## License diff --git a/commands/distill.md b/commands/distill.md index d9754e7..d3f5863 100644 --- a/commands/distill.md +++ b/commands/distill.md @@ -1,4 +1,5 @@ --- +name: distill description: Distill a reusable skill/gene from recent run history (optionally from an LLM response file). --- diff --git a/commands/evolve.md b/commands/evolve.md index c7dd5f7..40f1c65 100644 --- a/commands/evolve.md +++ b/commands/evolve.md @@ -1,4 +1,5 @@ --- +name: evolve description: Run an evolution cycle — recall relevant past outcomes, reflect on the current task, and record what was learned. --- diff --git a/commands/review.md b/commands/review.md index c88b0f5..84f78ec 100644 --- a/commands/review.md +++ b/commands/review.md @@ -1,4 +1,5 @@ --- +name: review description: Review Evolver's pending evolved changes, then approve (solidify) or reject (roll back). --- diff --git a/commands/run.md b/commands/run.md index 55bd8af..19dbc6f 100644 --- a/commands/run.md +++ b/commands/run.md @@ -1,4 +1,5 @@ --- +name: run description: Run one Evolver self-evolution cycle on the current repo (collect signals → select/mutate genes → propose changes). --- diff --git a/commands/search.md b/commands/search.md index 3d5f933..9f3f9a1 100644 --- a/commands/search.md +++ b/commands/search.md @@ -1,5 +1,6 @@ --- -description: Search the EvoMap network for reusable evolution assets (genes/capsules) matching signals, via the evolver-proxy MCP tools. +name: search +description: Search the EvoMap network for reusable evolution assets (genes/capsules) matching a query or signals, via the evolver-proxy MCP tools. --- # /search — search EvoMap @@ -7,16 +8,18 @@ description: Search the EvoMap network for reusable evolution assets (genes/caps Before doing substantive work from scratch, search the network for proven approaches. -Treat the arguments as space-separated signal keywords (e.g. -`log_error perf_bottleneck`). If none are given, infer 2–4 from the current task. -Valid signals: `log_error`, `perf_bottleneck`, `test_failure`, `capability_gap`, -`user_feature_request`, `deployment_issue`, `recurring_error`. +Treat the arguments as a free-text query describing the current task (preferred), +or as space-separated signal keywords (e.g. `log_error perf_bottleneck`). If none +are given, infer 2–4 signals from the current task. Valid signals: `log_error`, +`perf_bottleneck`, `test_failure`, `capability_gap`, `user_feature_request`, +`deployment_issue`, `recurring_error`. 1. Call the `evolver_search_assets` MCP tool (from the `evolver-proxy` server) - with those signals. + with a `query` and/or those `signals`. 2. Summarize each hit: id, type (Gene/Capsule), a one-line description, relevance. 3. If a hit applies, fetch its full content with `evolver_fetch_asset` and adapt - it to the current task. + it to the current task. After you actually reuse it, call `evolver_report_reuse` + with those asset IDs so the original author gets credit. If the tool reports the Proxy is unreachable, tell the user to run `evolver` once in a git repo to start it — the local memory hooks keep working regardless. diff --git a/commands/solidify.md b/commands/solidify.md index bb6ef94..11cdfec 100644 --- a/commands/solidify.md +++ b/commands/solidify.md @@ -1,4 +1,5 @@ --- +name: solidify description: Solidify the current working changes into a durable Evolver gene/capsule (with rollback safety). --- diff --git a/commands/status.md b/commands/status.md index e7564d1..7888192 100644 --- a/commands/status.md +++ b/commands/status.md @@ -1,4 +1,5 @@ --- +name: status description: Show Evolver health — Proxy/MCP status, evolution memory, workspace id, and whether the full engine is installed. --- diff --git a/commands/sync.md b/commands/sync.md index c00d127..3167025 100644 --- a/commands/sync.md +++ b/commands/sync.md @@ -1,4 +1,5 @@ --- +name: sync description: Sync evolution assets (genes/capsules) between the local store and the EvoMap Hub. --- diff --git a/hooks/_paths.js b/hooks/_paths.js index 1d83819..e1da6de 100644 --- a/hooks/_paths.js +++ b/hooks/_paths.js @@ -18,6 +18,66 @@ const { spawnSync } = require('child_process'); // hex string of at least 32 characters. Keep this in sync with the contract. const WORKSPACE_ID_PATTERN = /^[a-f0-9]{32,}$/i; +const GIT_PROBE_TIMEOUT_MS = 2000; +const GIT_TIMEOUT_MS = 5000; +const GIT_MAX_BUFFER = 10 * 1024 * 1024; + +// Cached result of git-binary resolution. `undefined` = not yet probed; +// `null` = git must not be spawned (missing, or macOS Xcode stub); +// string = argv0 to pass to spawnSync. +let resolvedGitBinary = undefined; + +const DARWIN_GIT_FALLBACKS = ['/opt/homebrew/bin/git', '/usr/local/bin/git']; +const FORBIDDEN_GIT_SUBCOMMANDS = new Set(['init', 'clone', 'daemon']); +// Global git options that consume the following argv entry. Needed so +// `git -c credential.interactive=false init` is still recognized as `init` +// (Cursor's plugin installer uses this shape). +const GIT_OPTIONS_WITH_VALUE = new Set([ + '-c', + '-C', + '-o', + '--git-dir', + '--work-tree', + '--namespace', + '--config-env', +]); + +function gitSubcommand(args) { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (typeof arg !== 'string') { + continue; + } + if (arg === '--') { + const next = args[i + 1]; + return typeof next === 'string' ? next : ''; + } + if (GIT_OPTIONS_WITH_VALUE.has(arg)) { + i += 1; + continue; + } + if (arg.startsWith('-')) { + continue; + } + return arg; + } + return ''; +} + +/** + * Return true when `candidate` is a string pointing at an existing regular file. + */ +function looksLikeFile(candidate) { + if (typeof candidate !== 'string' || candidate.length === 0) { + return false; + } + try { + return fs.statSync(candidate).isFile(); + } catch (_err) { + return false; + } +} + /** * Return true when `candidate` is a string pointing at an existing directory. * Any stat failure is swallowed and treated as "not a directory". @@ -33,6 +93,164 @@ function looksLikeDir(candidate) { } } +/** + * Reset the cached git-binary probe. Exported for tests only. + */ +function _resetGitBinaryCache() { + resolvedGitBinary = undefined; +} + +/** + * Look up `cmd` on PATH without executing it. Returns an absolute path or null. + */ +function lookUpOnPath(cmd) { + if (typeof cmd !== 'string' || cmd.length === 0) { + return null; + } + const pathVar = process.env.PATH || ''; + const sep = process.platform === 'win32' ? ';' : ':'; + const exts = + process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const dir of pathVar.split(sep)) { + if (!dir) { + continue; + } + for (const ext of exts) { + const candidate = path.join(dir, cmd + ext); + if (looksLikeFile(candidate)) { + return candidate; + } + } + } + return null; +} + +/** + * True when macOS has no developer directory configured, so `/usr/bin/git` is + * the Xcode stub that prints "No developer tools were found" and pops the + * install dialog. `xcode-select -p` reports this without triggering the GUI. + */ +function darwinDeveloperDirMissing() { + if (process.platform !== 'darwin') { + return false; + } + try { + const result = spawnSync('xcode-select', ['-p'], { + shell: false, + timeout: GIT_PROBE_TIMEOUT_MS, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return result.status !== 0; + } catch (_err) { + return true; + } +} + +/** + * Resolve a git binary that is safe to spawn. Never returns `/usr/bin/git` + * when that path is the Xcode CLT stub — spawning it is what produces the + * Cursor "Error loading plugin" / `git init` + xcode-select failure. + * + * Override: set `EVOLVER_GIT_BINARY` to an absolute path, or to empty to + * force "git unavailable". + * + * @returns {string|null} + */ +function resolveGitBinary() { + if (resolvedGitBinary !== undefined) { + return resolvedGitBinary; + } + + if (Object.prototype.hasOwnProperty.call(process.env, 'EVOLVER_GIT_BINARY')) { + const override = process.env.EVOLVER_GIT_BINARY; + resolvedGitBinary = + typeof override === 'string' && override.length > 0 ? override : null; + return resolvedGitBinary; + } + + const pathGit = lookUpOnPath('git'); + + if (darwinDeveloperDirMissing()) { + if (pathGit && pathGit !== '/usr/bin/git') { + resolvedGitBinary = pathGit; + return resolvedGitBinary; + } + for (const candidate of DARWIN_GIT_FALLBACKS) { + if (looksLikeFile(candidate)) { + resolvedGitBinary = candidate; + return resolvedGitBinary; + } + } + resolvedGitBinary = null; + return null; + } + + resolvedGitBinary = pathGit || 'git'; + return resolvedGitBinary; +} + +/** + * Spawn git with non-interactive env. Never runs `init` / `clone`. + * Returns { status, stdout, stderr }. status is 1 on any failure, including + * "git is not available". + */ +function runGit(args, cwd, options) { + const opts = options && typeof options === 'object' ? options : {}; + if (!Array.isArray(args) || args.length === 0) { + return { status: 1, stdout: '', stderr: 'git: missing arguments' }; + } + const sub = gitSubcommand(args); + if (FORBIDDEN_GIT_SUBCOMMANDS.has(sub)) { + return { + status: 1, + stdout: '', + stderr: `evolver hooks never run git ${sub}`, + }; + } + + const bin = resolveGitBinary(); + if (!bin) { + return { status: 1, stdout: '', stderr: 'git is not available' }; + } + + try { + const result = spawnSync(bin, args, { + cwd: looksLikeDir(cwd) ? cwd : undefined, + shell: false, + timeout: typeof opts.timeout === 'number' ? opts.timeout : GIT_TIMEOUT_MS, + maxBuffer: + typeof opts.maxBuffer === 'number' ? opts.maxBuffer : GIT_MAX_BUFFER, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: Object.assign({}, process.env, { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + }), + }); + return { + status: typeof result.status === 'number' ? result.status : 1, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + }; + } catch (_err) { + return { status: 1, stdout: '', stderr: '' }; + } +} + +/** + * Diagnose git usability for a directory without spawning the Xcode stub. + */ +function gitUsability(dir) { + const repoRoot = findRepoRoot(dir); + const binary = resolveGitBinary(); + return { + hasRepo: repoRoot !== null, + gitBinary: binary, + usable: repoRoot !== null && binary !== null, + }; +} + /** * Resolve the directory of the user's current project. * @@ -55,29 +273,13 @@ function resolveProjectDir() { /** * Determine whether `dir` lives inside a git working tree. - * Shells out to `git rev-parse --is-inside-work-tree`. Returns false on any - * problem (git missing, not a repo, timeout, etc.). + * + * Uses a filesystem walk for `.git` (directory or worktree file) so we never + * spawn git — on macOS without Xcode CLT, spawning `/usr/bin/git` pops the + * developer-tools dialog and can surface as Cursor's "Error loading plugin". */ function isGitWorkspace(dir) { - try { - const result = spawnSync( - 'git', - ['rev-parse', '--is-inside-work-tree'], - { - cwd: looksLikeDir(dir) ? dir : undefined, - shell: false, - timeout: 5000, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - if (result.status !== 0 || typeof result.stdout !== 'string') { - return false; - } - return result.stdout.trim() === 'true'; - } catch (_err) { - return false; - } + return findRepoRoot(dir) !== null; } /** @@ -317,6 +519,11 @@ function resolveWorkspaceId(projectDir) { module.exports = { resolveProjectDir, isGitWorkspace, + findRepoRoot, findMemoryGraph, resolveWorkspaceId, + resolveGitBinary, + runGit, + gitUsability, + _resetGitBinaryCache, }; diff --git a/hooks/hooks.json b/hooks/hooks.json index 99c4da3..0290c7d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -19,6 +19,13 @@ "timeout": 8, "loop_limit": 1 } + ], + "sessionEnd": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-end.js\"", + "timeout": 8, + "loop_limit": 1 + } ] } } diff --git a/hooks/session-end.js b/hooks/session-end.js index 9b542cb..332375f 100644 --- a/hooks/session-end.js +++ b/hooks/session-end.js @@ -15,9 +15,8 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); -const { spawnSync } = require('child_process'); -const { resolveProjectDir, findMemoryGraph, resolveWorkspaceId } = require('./_paths'); +const { resolveProjectDir, findMemoryGraph, resolveWorkspaceId, runGit } = require('./_paths'); const { detectSignals } = require('./_signals'); const STDIN_WATCHDOG_MS = parsePositiveInt( @@ -77,22 +76,10 @@ function parsePositiveInt(value, fallback) { /** Run a git subcommand in `cwd`, returning { status, stdout } (stdout = ''). */ function git(args, cwd) { - try { - const result = spawnSync('git', args, { - cwd, - shell: false, - timeout: GIT_TIMEOUT_MS, - maxBuffer: GIT_MAX_BUFFER, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - return { - status: typeof result.status === 'number' ? result.status : 1, - stdout: typeof result.stdout === 'string' ? result.stdout : '', - }; - } catch (_err) { - return { status: 1, stdout: '' }; - } + return runGit(args, cwd, { + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }); } /** @@ -324,7 +311,7 @@ async function finish(projectDir, diff, input) { if (!hasChanges) { const reason = diff.isRepo ? 'no changes detected this session' - : 'not a git workspace'; + : 'git unavailable or not a git workspace'; appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`); emit({}); return; diff --git a/hooks/session-start.js b/hooks/session-start.js index 7ad6a2b..97b2f26 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -16,7 +16,7 @@ const path = require('path'); const { resolveProjectDir, - isGitWorkspace, + gitUsability, findMemoryGraph, resolveWorkspaceId, } = require('./_paths'); @@ -30,8 +30,16 @@ const THROTTLE_PRUNE_MS = 24 * 60 * 60 * 1000; const NONGIT_NOTICE = '[Evolver] This folder is not a git repository, so evolution memory is ' + - 'inactive (outcomes are derived from git diffs). Run `git init` here, or ' + - 'open a git project, to enable recall and recording.'; + 'inactive (outcomes are derived from git diffs). Open a git project to ' + + 'enable recall and recording.'; + +const NOGIT_BINARY_NOTICE = + '[Evolver] A git repository is present but git itself is not usable, so ' + + 'evolution memory cannot record diffs. On macOS this usually means Xcode ' + + 'Command Line Tools are missing: run xcode-select --install, or install ' + + 'Git with Homebrew (brew install git) so /opt/homebrew/bin/git exists. ' + + 'Do not use Apple\'s /usr/bin/git stub — it pops a developer-tools dialog ' + + 'and Cursor reports "Error loading plugin".'; // The hook's own timeout is 3s; give stdin a slightly shorter window to drain. const STDIN_WATCHDOG_MS = 2000; @@ -244,12 +252,17 @@ function main() { const parts = []; const currentDir = resolveProjectDir(); - // 1. Non-git notice (throttled per directory). + // 1. Non-git / unusable-git notice (throttled per directory). try { - if (!isGitWorkspace(currentDir)) { + const git = gitUsability(currentDir); + if (!git.hasRepo) { if (!throttled(`nongit:${currentDir}`, NONGIT_TTL_MS)) { parts.push(NONGIT_NOTICE); } + } else if (!git.gitBinary) { + if (!throttled(`nogitbin:${currentDir}`, NONGIT_TTL_MS)) { + parts.push(NOGIT_BINARY_NOTICE); + } } } catch (_err) { // ignore — notice is optional diff --git a/mcp.json b/mcp.json index 58d00cf..de6abec 100644 --- a/mcp.json +++ b/mcp.json @@ -2,7 +2,13 @@ "mcpServers": { "evolver-proxy": { "command": "node", - "args": ["${CURSOR_PLUGIN_ROOT}/mcp/evolver-proxy.mjs"] + "args": ["${CURSOR_PLUGIN_ROOT}/mcp/evolver-proxy.mjs"], + "env": { + "EVOMAP_PROXY_PORT": "${EVOMAP_PROXY_PORT}", + "EVOMAP_HUB_URL": "${EVOMAP_HUB_URL}", + "EVOMAP_NODE_ID": "${EVOMAP_NODE_ID}", + "EVOLVE_STRATEGY": "${EVOLVE_STRATEGY}" + } } } } diff --git a/mcp/evolver-proxy.mjs b/mcp/evolver-proxy.mjs index 0c975ac..5124dd4 100644 --- a/mcp/evolver-proxy.mjs +++ b/mcp/evolver-proxy.mjs @@ -19,7 +19,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; -const SERVER = { name: 'evolver-proxy', version: '0.1.0' }; +const SERVER = { name: 'evolver-proxy', version: '2.1.0' }; const DEFAULT_PROTOCOL = '2025-06-18'; function log(...a) { process.stderr.write('[evolver-proxy-mcp] ' + a.join(' ') + '\n'); } @@ -120,31 +120,74 @@ const TOOLS = [ }, { name: 'evolver_search_assets', - description: 'Search the EvoMap network for reusable evolution assets (Genes and Capsules) that match the given signals. Call this BEFORE starting substantive work to reuse proven approaches instead of reinventing them.', + description: 'Search the EvoMap network for reusable evolution assets (Genes and Capsules). Pass `query` to describe your current task/situation in natural language (semantic search — recommended when you are unsure which signal keywords apply) and/or `signals` to match on known signal keywords; provide at least one. Call this BEFORE starting substantive work to reuse proven approaches instead of reinventing them.', inputSchema: { type: 'object', properties: { - signals: { type: 'array', items: { type: 'string' }, description: 'Signal keywords, e.g. ["log_error","perf_bottleneck","test_failure"].' }, + query: { type: 'string', description: 'Free-text description of the current task/situation. Runs natural-language semantic search over the network. Provide query and/or signals.' }, + signals: { type: 'array', items: { type: 'string' }, description: 'Signal keywords, e.g. ["log_error","perf_bottleneck","test_failure"]. Provide query and/or signals.' }, mode: { type: 'string', enum: ['semantic', 'exact'], default: 'semantic' }, limit: { type: 'integer', minimum: 1, maximum: 25, default: 5 }, }, - required: ['signals'], additionalProperties: false, }, - handler: (a) => proxyFetch('POST', '/asset/search', { - signals: a.signals, mode: a.mode || 'semantic', limit: a.limit || 5, - }), + handler: (a) => { + const query = typeof a.query === 'string' ? a.query.trim() : ''; + const signals = Array.isArray(a.signals) ? a.signals : []; + if (!query && signals.length === 0) { + return Promise.resolve({ + ok: false, + error: 'Provide a free-text `query` and/or a `signals` array.', + }); + } + return proxyFetch('POST', '/asset/search', { + query: query || undefined, + signals: signals.length ? signals : undefined, + mode: a.mode || 'semantic', + limit: a.limit || 5, + }); + }, }, { name: 'evolver_fetch_asset', - description: 'Fetch the full content of one or more evolution assets by their IDs (e.g. "sha256:abc..."), as returned by evolver_search_assets.', + description: 'Fetch the full content of one or more evolution assets by their IDs (e.g. "sha256:abc..."), as returned by evolver_search_assets. After you actually reuse any of these in your work, call evolver_report_reuse with their IDs so the original author gets credit.', inputSchema: { type: 'object', properties: { asset_ids: { type: 'array', items: { type: 'string' }, minItems: 1 } }, required: ['asset_ids'], additionalProperties: false, }, - handler: (a) => proxyFetch('POST', '/asset/fetch', { asset_ids: a.asset_ids }), + handler: async (a) => { + const res = await proxyFetch('POST', '/asset/fetch', { asset_ids: a.asset_ids }); + if (!res.ok) return res; + const data = res.data && typeof res.data === 'object' ? res.data : { value: res.data }; + return { + ok: true, + data: { + ...data, + _reuse_hint: 'If you build on any of these assets, call evolver_report_reuse with the asset_ids you reused so the author gets credit.', + }, + }; + }, + }, + { + name: 'evolver_report_reuse', + description: 'Report that you actually REUSED one or more fetched Gene/Capsule assets in your work (not just viewed them). This credits the original authors and feeds the reuse-reward network. Call it after you build on an asset fetched via evolver_fetch_asset; pass the asset_ids you genuinely reused.', + inputSchema: { + type: 'object', + properties: { + asset_ids: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'The asset IDs you reused (as returned by evolver_fetch_asset).' }, + outcome: { type: 'string', enum: ['success', 'failed'], description: 'Whether reusing them worked out. Defaults to success.' }, + signals: { type: 'array', items: { type: 'string' }, description: 'Optional signal keywords describing the task you reused them on.' }, + }, + required: ['asset_ids'], + additionalProperties: false, + }, + handler: (a) => proxyFetch('POST', '/asset/report-reuse', { + used_asset_ids: a.asset_ids, + status: a.outcome || 'success', + signals: a.signals, + }), }, { name: 'evolver_publish_asset', @@ -243,7 +286,7 @@ async function dispatch(req) { protocolVersion: params?.protocolVersion || DEFAULT_PROTOCOL, capabilities: { tools: {} }, serverInfo: SERVER, - instructions: 'Evolver Proxy bridge. Use evolver_search_assets before substantive work to reuse proven genes/capsules; evolver_status to check the Proxy; evolver_publish_asset to contribute new ones.', + instructions: 'Evolver Proxy bridge. Use evolver_search_assets before substantive work (query and/or signals); evolver_status to check the Proxy; evolver_report_reuse after you actually reuse a fetched asset; evolver_publish_asset to contribute new ones.', }); case 'notifications/initialized': case 'initialized': diff --git a/skills/evolver/SKILL.md b/skills/evolver/SKILL.md index 3479c44..9aeae70 100644 --- a/skills/evolver/SKILL.md +++ b/skills/evolver/SKILL.md @@ -77,10 +77,11 @@ README for connecting an EvoMap Hub node for community strategies. This plugin bundles a lightweight MCP bridge (`evolver-proxy`) exposing the local EvoMap Proxy mailbox: -- `evolver_search_assets` — find reusable genes/capsules by signal. **Call this - before substantive work** to reuse proven approaches instead of reinventing them. +- `evolver_search_assets` — find reusable genes/capsules by free-text `query` and/or signal keywords. **Call this before substantive work** to reuse proven approaches instead of reinventing them. - `evolver_status` — Proxy state (node id, pending counts, last sync). -- `evolver_fetch_asset` / `evolver_publish_asset` / `evolver_distill_conversation` / `evolver_poll`. +- `evolver_fetch_asset` / `evolver_report_reuse` / `evolver_publish_asset` / `evolver_distill_conversation` / `evolver_poll`. + +After `evolver_fetch_asset`, if you actually reuse an asset, call `evolver_report_reuse` with those IDs so the original author gets credit. Use `evolver_distill_conversation` only when the current Cursor conversation produced a concrete reusable capability. Include a summary, strategy steps, artifact paths/links, and validation evidence so the Proxy can reject weak or noisy candidates. diff --git a/test/manifest.test.js b/test/manifest.test.js new file mode 100644 index 0000000..0420e73 --- /dev/null +++ b/test/manifest.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(ROOT, rel), 'utf8'); +} + +function readJson(rel) { + return JSON.parse(read(rel)); +} + +test('plugin.json is v2.1.0 with optional Configure variables', () => { + const manifest = readJson('.cursor-plugin/plugin.json'); + assert.equal(manifest.version, '2.1.0'); + assert.equal(manifest.variables.type, 'object'); + const props = manifest.variables.properties; + for (const key of [ + 'EVOMAP_NODE_ID', + 'EVOMAP_HUB_URL', + 'EVOMAP_PROXY_PORT', + 'EVOLVE_STRATEGY', + ]) { + assert.ok(props[key], `missing variable ${key}`); + } + assert.ok( + !manifest.variables.required || manifest.variables.required.length === 0, + 'Configure fields must be optional so a blank Node ID still loads' + ); +}); + +test('marketplace.json points at the repo root and matches version', () => { + const market = readJson('.cursor-plugin/marketplace.json'); + assert.equal(market.metadata.version, '2.1.0'); + assert.equal(market.plugins[0].source, '.'); + assert.equal(market.plugins[0].version, '2.1.0'); +}); + +test('mcp.json env placeholders match plugin variables', () => { + const mcp = readJson('mcp.json'); + const env = mcp.mcpServers['evolver-proxy'].env; + assert.equal(env.EVOMAP_PROXY_PORT, '${EVOMAP_PROXY_PORT}'); + assert.equal(env.EVOMAP_HUB_URL, '${EVOMAP_HUB_URL}'); + assert.equal(env.EVOMAP_NODE_ID, '${EVOMAP_NODE_ID}'); + assert.equal(env.EVOLVE_STRATEGY, '${EVOLVE_STRATEGY}'); +}); + +test('hooks.json records on both stop and sessionEnd', () => { + const hooks = readJson('hooks/hooks.json'); + assert.ok(hooks.hooks.stop); + assert.ok(hooks.hooks.sessionEnd); +}); + +test('session-start source never tells the agent to run git init', () => { + const src = read('hooks/session-start.js'); + assert.equal(src.includes('git init'), false); +}); + +test('hooks do not spawn git by literal argv0', () => { + const files = [ + 'hooks/_paths.js', + 'hooks/session-start.js', + 'hooks/session-end.js', + 'hooks/signal-detect.js', + ]; + for (const rel of files) { + const src = read(rel); + assert.equal( + /spawnSync\(\s*['"]git['"]/.test(src), + false, + `${rel} must not spawnSync('git', ...)` + ); + } +}); + +test('session-start on a non-git folder emits the nongit notice, not git init', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-ss-')); + const state = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-ss-state-')); + const proc = spawn('node', [path.join(ROOT, 'hooks', 'session-start.js')], { + cwd: dir, + env: Object.assign({}, process.env, { + CURSOR_PROJECT_DIR: dir, + EVOLVER_SESSION_STATE_DIR: state, + EVOLVER_GIT_BINARY: '', + }), + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + proc.stdout.on('data', (c) => { + stdout += c.toString('utf8'); + }); + proc.stderr.on('data', () => {}); + proc.stdin.end('{}\n'); + const status = await new Promise((resolve) => { + proc.on('close', resolve); + }); + assert.equal(status, 0); + const parsed = JSON.parse(stdout); + assert.match(parsed.additionalContext || '', /not a git repository/); + assert.equal((parsed.additionalContext || '').includes('git init'), false); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(state, { recursive: true, force: true }); +}); diff --git a/test/mcp-bridge.test.js b/test/mcp-bridge.test.js new file mode 100644 index 0000000..54e30d5 --- /dev/null +++ b/test/mcp-bridge.test.js @@ -0,0 +1,90 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +const BRIDGE = path.join(__dirname, '..', 'mcp', 'evolver-proxy.mjs'); + +function startBridge() { + const proc = spawn('node', [BRIDGE], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + proc.stderr.on('data', () => {}); + return proc; +} + +function rpc(proc, msg, timeoutMs) { + const limit = timeoutMs || 4000; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + proc.stdout.off('data', onData); + reject(new Error(`RPC timeout waiting for id=${msg.id}`)); + }, limit); + let buf = ''; + function onData(chunk) { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch (_err) { + continue; + } + if (parsed && parsed.id === msg.id) { + clearTimeout(timer); + proc.stdout.off('data', onData); + resolve(parsed); + } + } + } + proc.stdout.on('data', onData); + proc.stdin.write(`${JSON.stringify(msg)}\n`); + }); +} + +test('MCP bridge lists query search and report_reuse, rejects empty search', async () => { + const proc = startBridge(); + try { + await rpc(proc, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }, + }); + const listed = await rpc(proc, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + }); + const tools = listed.result && listed.result.tools; + assert.ok(Array.isArray(tools)); + const names = tools.map((t) => t.name); + assert.ok(names.includes('evolver_report_reuse')); + assert.ok(names.includes('evolver_search_assets')); + const search = tools.find((t) => t.name === 'evolver_search_assets'); + assert.ok(search.inputSchema.properties.query); + assert.ok( + !search.inputSchema.required || + !search.inputSchema.required.includes('signals') + ); + const empty = await rpc(proc, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'evolver_search_assets', arguments: {} }, + }); + assert.equal(empty.result.isError, true); + assert.match(empty.result.content[0].text, /query/); + } finally { + proc.kill('SIGTERM'); + } +}); diff --git a/test/paths.test.js b/test/paths.test.js new file mode 100644 index 0000000..9cdd236 --- /dev/null +++ b/test/paths.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const paths = require('../hooks/_paths'); + +const ORIGINAL_GIT = process.env.EVOLVER_GIT_BINARY; + +beforeEach(() => { + paths._resetGitBinaryCache(); +}); + +afterEach(() => { + if (ORIGINAL_GIT === undefined) { + delete process.env.EVOLVER_GIT_BINARY; + } else { + process.env.EVOLVER_GIT_BINARY = ORIGINAL_GIT; + } + paths._resetGitBinaryCache(); +}); + +test('isGitWorkspace is filesystem-only: empty dir is not a repo', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-nongit-')); + assert.equal(paths.isGitWorkspace(dir), false); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('isGitWorkspace treats a .git directory as a repo without spawning git', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-gitdir-')); + fs.mkdirSync(path.join(dir, '.git')); + process.env.EVOLVER_GIT_BINARY = ''; + paths._resetGitBinaryCache(); + assert.equal(paths.isGitWorkspace(dir), true); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('gitUsability: repo without a usable git binary is not usable', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-nousable-')); + fs.mkdirSync(path.join(dir, '.git')); + process.env.EVOLVER_GIT_BINARY = ''; + paths._resetGitBinaryCache(); + const u = paths.gitUsability(dir); + assert.equal(u.hasRepo, true); + assert.equal(u.gitBinary, null); + assert.equal(u.usable, false); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('runGit refuses git init even when a binary is available', () => { + delete process.env.EVOLVER_GIT_BINARY; + paths._resetGitBinaryCache(); + const r = paths.runGit(['init', '/tmp/evolver-should-not-init']); + assert.equal(r.status, 1); + assert.match(r.stderr, /never run git init/); +}); + +test('runGit refuses the Cursor installer argv (flags then init)', () => { + delete process.env.EVOLVER_GIT_BINARY; + paths._resetGitBinaryCache(); + const r = paths.runGit([ + '-c', + 'credential.interactive=false', + '-c', + 'core.fsmonitor=false', + 'init', + ]); + assert.equal(r.status, 1); + assert.match(r.stderr, /never run git init/); +}); + +test('runGit refuses clone', () => { + const r = paths.runGit(['clone', 'https://example.invalid/repo.git']); + assert.equal(r.status, 1); + assert.match(r.stderr, /never run git clone/); +}); + +test('empty EVOLVER_GIT_BINARY makes git unavailable without spawning', () => { + process.env.EVOLVER_GIT_BINARY = ''; + paths._resetGitBinaryCache(); + assert.equal(paths.resolveGitBinary(), null); + const r = paths.runGit(['rev-parse', '--is-inside-work-tree']); + assert.equal(r.status, 1); + assert.match(r.stderr, /not available/); +}); + +test('runGit rev-parse works in this repo when git is available', () => { + delete process.env.EVOLVER_GIT_BINARY; + paths._resetGitBinaryCache(); + const bin = paths.resolveGitBinary(); + assert.ok(bin); + const r = paths.runGit(['rev-parse', '--is-inside-work-tree'], __dirname); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), 'true'); +}); From 42aeae51ea690230fac21974b39dbc2f9029e398 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 05:34:25 +0000 Subject: [PATCH 2/2] fix(mcp): ignore unexpanded Cursor Configure placeholders Empty or literal ${EVOMAP_PROXY_PORT} from plugin variables must not become the Proxy port; fall back to 19820. Co-authored-by: autogame-17 --- CHANGELOG.md | 2 ++ mcp/evolver-proxy.mjs | 7 ++++++- test/mcp-bridge.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a9f10..6bb3361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This project adheres to [Semantic Versioning](https://semver.org/). notices are separate, throttled, and name the CLT / Homebrew fix. - `evolver_fetch_asset` now puts the reuse nudge on the returned **data** so the model actually sees it. +- MCP bridge ignores unexpanded `${EVOMAP_PROXY_PORT}` placeholders from + Cursor Configure (empty/placeholder falls back to 19820). ### Added - Cursor **Plugins → Configure** variables: Node ID (leave blank), Hub URL, diff --git a/mcp/evolver-proxy.mjs b/mcp/evolver-proxy.mjs index 5124dd4..bfce55b 100644 --- a/mcp/evolver-proxy.mjs +++ b/mcp/evolver-proxy.mjs @@ -25,7 +25,12 @@ const DEFAULT_PROTOCOL = '2025-06-18'; function log(...a) { process.stderr.write('[evolver-proxy-mcp] ' + a.join(' ') + '\n'); } function defaultProxyUrl() { - return `http://127.0.0.1:${process.env.EVOMAP_PROXY_PORT || '19820'}`; + const raw = process.env.EVOMAP_PROXY_PORT; + const port = + typeof raw === 'string' && raw.length > 0 && !raw.includes('${') + ? raw + : '19820'; + return `http://127.0.0.1:${port}`; } function isLoopbackHost(hostname) { diff --git a/test/mcp-bridge.test.js b/test/mcp-bridge.test.js index 54e30d5..ad360db 100644 --- a/test/mcp-bridge.test.js +++ b/test/mcp-bridge.test.js @@ -47,6 +47,35 @@ function rpc(proc, msg, timeoutMs) { }); } +test('MCP bridge ignores unexpanded ${EVOMAP_PROXY_PORT} placeholders', async () => { + const proc = spawn('node', [BRIDGE], { + stdio: ['pipe', 'pipe', 'pipe'], + env: Object.assign({}, process.env, { + EVOMAP_PROXY_PORT: '${EVOMAP_PROXY_PORT}', + }), + }); + let stderr = ''; + proc.stderr.on('data', (c) => { + stderr += c.toString('utf8'); + }); + try { + await rpc(proc, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }, + }); + assert.match(stderr, /127\.0\.0\.1:19820/); + assert.equal(stderr.includes('${EVOMAP_PROXY_PORT}'), false); + } finally { + proc.kill('SIGTERM'); + } +}); + test('MCP bridge lists query search and report_reuse, rejects empty search', async () => { const proc = startBridge(); try {