diff --git a/.gitignore b/.gitignore index 6f1b1d9..64b9be8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,11 @@ venv*/ # Logs *.log +# Node +node_modules/ + #Others .DS_Store +*.skill .cursor/rules/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ae973d3 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "cursor-agent-orchestrator": { + "command": "node", + "args": [ + "/Users/thesunkid/Desktop/code/feedback-mcp/cursor-agent-mcp/orchestrator-mcp/server.js" + ], + "env": { + "BRIDGE_SESSION_DIR": "/tmp/cursor-bridge-session" + } + } + } +} diff --git a/cursor-agent-mcp/README.md b/cursor-agent-mcp/README.md new file mode 100644 index 0000000..eb4e046 --- /dev/null +++ b/cursor-agent-mcp/README.md @@ -0,0 +1,195 @@ +# Cursor Agent MCP — Bidirectional Claude Code ↔ Cursor CLI Orchestration + +MCP-based system for spawning a `cursor-agent` as a background subagent and communicating with it bidirectionally. One cursor-agent process, many turns, fully non-blocking. + +## Architecture + +``` +┌──────────────────────┐ MCP tools ┌───────────────────────┐ +│ Claude Code │◄─────────────────────────►│ Orchestrator MCP │ +│ (master agent) │ cursor_agent_spawn/ │ orchestrator-mcp/ │ +│ │ check/reply/status/ │ server.js │ +│ │ result/kill │ │ +└──────────────────────┘ └───────┬───────────────┘ + │ file IPC + │ /tmp/cursor-bridge-session/ + │ question_N.json ↔ answer_N.json +┌──────────────────────┐ MCP tool ┌───────┴───────────────┐ +│ cursor-agent │──────────────────────────►│ Bridge MCP │ +│ (subagent, 1 proc) │ report_to_orchestrator │ bridge-mcp/ │ +│ --print --yolo │ (blocks until answered) │ server.js │ +└──────────────────────┘ └───────────────────────┘ +``` + +### Flow + +1. Claude Code calls `cursor_agent_spawn(task, model)` → orchestrator MCP spawns `cursor-agent --print --yolo` in background +2. cursor-agent works on the task. When it needs to communicate, it calls `report_to_orchestrator` (bridge MCP tool) +3. Bridge MCP writes `question_N.json` to the session dir, then **blocks** polling for `answer_N.json` +4. Claude Code calls `cursor_agent_check()` → sees the question +5. Claude Code calls `cursor_agent_reply(answer)` → writes `answer_N.json` +6. Bridge MCP reads the answer, unblocks, returns it to cursor-agent +7. cursor-agent continues working, repeats from step 2 +8. When done, Claude Code calls `cursor_agent_result()` for final output + +## Components + +### 1. Orchestrator MCP (`orchestrator-mcp/server.js`) + +**Claude Code connects to this.** Provides 6 tools: + +| Tool | Description | +|------|-------------| +| `cursor_agent_spawn` | Spawn background cursor-agent with task + model. Auto-prepends bridge protocol. | +| `cursor_agent_check` | Check for pending message from cursor-agent | +| `cursor_agent_reply` | Send reply to cursor-agent's question | +| `cursor_agent_status` | Get agent status: working / waiting_for_reply / completed | +| `cursor_agent_result` | Get agent's final stdout output | +| `cursor_agent_kill` | Force-terminate the agent | + +Config for Claude Code (`.mcp.json` at project root): +```json +{ + "mcpServers": { + "cursor-agent-orchestrator": { + "command": "node", + "args": ["/path/to/cursor-agent-mcp/orchestrator-mcp/server.js"], + "env": { "BRIDGE_SESSION_DIR": "/tmp/cursor-bridge-session" } + } + } +} +``` + +### 2. Bridge MCP (`bridge-mcp/server.js`) + +**cursor-agent connects to this.** Single tool: `report_to_orchestrator(message)`. + +When called, writes a question file to the session dir, then blocks polling for an answer file. Returns the answer to cursor-agent when it appears. + +Config for cursor-agent (`~/.cursor/mcp.json`): +```json +{ + "mcpServers": { + "orchestrator-bridge": { + "command": "node", + "args": ["/path/to/cursor-agent-mcp/bridge-mcp/server.js"], + "env": { + "BRIDGE_SESSION_DIR": "/tmp/cursor-bridge-session", + "BRIDGE_POLL_MS": "500", + "BRIDGE_TIMEOUT_MS": "300000" + } + } + } +} +``` + +Must be enabled: `cursor-agent mcp enable orchestrator-bridge` + +### 3. Orchestrator CLI (`orchestrator.js`) + +Standalone CLI wrapper for the same file IPC, useful for testing or Bash-based workflows: + +```bash +node orchestrator.js spawn "task" --model composer-1 +node orchestrator.js check # pending question? +node orchestrator.js reply "answer" +node orchestrator.js status # working/waiting/completed +node orchestrator.js result # final output +node orchestrator.js kill # terminate +``` + +### 4. Legacy One-Shot Tools (`server.js`) + +The original single-shot MCP tools (`cursor_agent_chat`, `cursor_agent_edit_file`, etc.) for fire-and-forget delegation. Still work but don't support bidirectional communication. + +## Setup + +### Prerequisites + +- Node.js 18+ +- `cursor-agent` CLI installed and authenticated (`cursor-agent status`) + +### Install + +```bash +cd cursor-agent-mcp +npm install +``` + +### Configure bridge MCP for cursor-agent + +Add to `~/.cursor/mcp.json` (see config above), then: + +```bash +cursor-agent mcp enable orchestrator-bridge +cursor-agent mcp list-tools orchestrator-bridge +# Should show: report_to_orchestrator (message) +``` + +### Configure orchestrator MCP for Claude Code + +Add `.mcp.json` to the project root (see config above). Restart Claude Code to load. + +## Key Design Decisions + +- **One process per task.** cursor-agent runs as a single `--print --yolo` process. Its agentic loop calls `report_to_orchestrator` multiple times internally. +- **File-based IPC.** Simple, debuggable, no sockets. Question/answer JSON files in a shared directory. +- **Blocking bridge.** The bridge MCP blocks until the orchestrator answers. cursor-agent waits naturally — no polling from its side. +- **Non-blocking orchestrator.** Claude Code spawns the agent in background (`detached: true`) and checks on it whenever convenient. +- **Protocol preamble auto-injected.** `cursor_agent_spawn` automatically prepends the bridge communication rules to the task prompt. The user just provides the task. +- **`--model` not `-m`.** cursor-agent's `-m` short flag is broken. Always use `--model`. +- **`--yolo` / `-f` for trust.** Required to skip workspace trust prompts in non-interactive mode. + +## Testing + +Quick smoke test of the bridge: + +```bash +# Terminal 1: Spawn agent +node orchestrator.js spawn "Ask me what to build via report_to_orchestrator" --model composer-1 + +# Terminal 2: Watch for questions and answer +node orchestrator.js check +node orchestrator.js reply "Build a hello world function" +node orchestrator.js check +node orchestrator.js reply "Looks good, stop" +node orchestrator.js result +``` + +Full automated test (5 turns, 1 process): + +```bash +node test_5turn_bridge.mjs +``` + +## File Structure + +``` +cursor-agent-mcp/ +├── server.js # Legacy one-shot MCP tools (chat/edit/analyze/search/plan) +├── orchestrator.js # CLI wrapper for file IPC (spawn/check/reply/status/result/kill) +├── package.json +├── bridge-mcp/ +│ └── server.js # Bridge MCP: report_to_orchestrator (cursor-agent side) +├── orchestrator-mcp/ +│ └── server.js # Orchestrator MCP: cursor_agent_* tools (Claude Code side) +├── hooks/ +│ ├── post-tool-use.js # PostToolUse hook for auto-discovering pending questions +│ └── README.md +├── docs/ +│ ├── ARCHITECTURE.md # Detailed architecture documentation +│ └── TESTING.md # Comprehensive testing guide +├── test_5turn_bridge.mjs # 5-turn single-process e2e test +├── test_3turn.mjs # 3-turn session test +├── test_session_e2e.mjs # Session tools e2e test +├── test_session_feedback.mjs # 3-round feedback test +└── test_client.mjs # Legacy smoke test client +``` + +## Future Improvements + +- **Auto-answer mode.** Claude Code automatically decides answers without manual `reply` calls — full autonomous delegation. +- **Multiple concurrent agents.** Session-scoped IPC dirs to support parallel subagents. +- **Structured output.** Parse cursor-agent's stream-json output for richer status reporting. +- **Webhook/push notification.** Replace file polling with an HTTP callback or Unix socket for instant delivery. +- **Timeout + retry.** Auto-retry if bridge MCP times out, with exponential backoff. diff --git a/cursor-agent-mcp/bridge-mcp/server.js b/cursor-agent-mcp/bridge-mcp/server.js new file mode 100644 index 0000000..990c70f --- /dev/null +++ b/cursor-agent-mcp/bridge-mcp/server.js @@ -0,0 +1,101 @@ +/** + * Bridge MCP Server — file-based blocking IPC for orchestrator communication. + * + * Exposes a single tool `report_to_orchestrator` that cursor-agent calls + * when it needs to communicate with the orchestrating agent (e.g. Claude Code). + * + * Flow: + * 1. cursor-agent calls report_to_orchestrator(message) + * 2. This server writes the message to a question file + * 3. It polls for an answer file (blocks until one appears) + * 4. Returns the answer to cursor-agent + * + * The orchestrator watches for question files and writes answer files. + * + * Env: + * BRIDGE_SESSION_DIR — directory for IPC files (required, set by orchestrator) + * BRIDGE_POLL_MS — poll interval in ms (default: 500) + * BRIDGE_TIMEOUT_MS — max wait time in ms (default: 300000 = 5 min) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const SESSION_DIR = process.env.BRIDGE_SESSION_DIR; +if (!SESSION_DIR) { + console.error('BRIDGE_SESSION_DIR env is required'); + process.exit(1); +} + +const POLL_MS = parseInt(process.env.BRIDGE_POLL_MS || '500', 10); +const TIMEOUT_MS = parseInt(process.env.BRIDGE_TIMEOUT_MS || '300000', 10); + +let turnCounter = 0; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +const server = new McpServer( + { name: 'orchestrator-bridge', version: '1.0.0' }, + { + instructions: [ + 'This MCP provides a single tool: report_to_orchestrator.', + 'Use it to send messages to the orchestrating agent and receive replies.', + 'Call it whenever you need to:', + '- Ask a clarifying question', + '- Report progress or intermediate results', + '- Request feedback on your work', + '- Deliver your final result', + 'The orchestrator will reply through this same channel.', + 'Always wait for the orchestrator reply before continuing.', + ].join(' '), + } +); + +server.tool( + 'report_to_orchestrator', + 'Send a message to the orchestrating agent and wait for their reply. Use this for questions, progress updates, intermediate results, or final deliverables.', + { message: z.string().min(1, 'message is required') }, + async ({ message }) => { + turnCounter++; + const turn = turnCounter; + + const questionFile = join(SESSION_DIR, `question_${turn}.json`); + const answerFile = join(SESSION_DIR, `answer_${turn}.json`); + + // Write question + writeFileSync(questionFile, JSON.stringify({ turn, message, timestamp: Date.now() }), 'utf8'); + + // Poll for answer + const deadline = Date.now() + TIMEOUT_MS; + while (Date.now() < deadline) { + if (existsSync(answerFile)) { + try { + const data = JSON.parse(readFileSync(answerFile, 'utf8')); + // Clean up + try { unlinkSync(questionFile); } catch {} + try { unlinkSync(answerFile); } catch {} + return { content: [{ type: 'text', text: data.reply || '(empty reply)' }] }; + } catch { + // File not fully written yet, retry + } + } + await sleep(POLL_MS); + } + + return { + content: [{ type: 'text', text: `Orchestrator did not reply within ${TIMEOUT_MS}ms.` }], + isError: true, + }; + } +); + +const transport = new StdioServerTransport(); +server.connect(transport).catch((e) => { + console.error('Bridge MCP failed to start:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/docs/ARCHITECTURE.md b/cursor-agent-mcp/docs/ARCHITECTURE.md new file mode 100644 index 0000000..211f574 --- /dev/null +++ b/cursor-agent-mcp/docs/ARCHITECTURE.md @@ -0,0 +1,241 @@ +# Architecture: Bidirectional Claude Code ↔ Cursor CLI Sessions + +## Problem + +Claude Code (master) needs to delegate tasks to Cursor CLI (subagent) using Cursor's model catalog (GPT-5, Gemini, etc.) while allowing the subagent to **ask questions back** mid-task and receive answers before continuing. + +The constraint: `cursor-agent --print` is one-shot — it takes a prompt, runs, outputs, exits. There is no persistent session, no stdin after launch, no MCP client support. + +## Solution: Multi-Round One-Shot Invocations + +Bidirectional communication is achieved by running **multiple one-shot `cursor-agent --print` calls**, where the MCP server: + +1. Manages conversation history across rounds (server-side session state) +2. Injects a **prompt protocol** that teaches cursor-agent to signal questions vs. results +3. Rebuilds the full context (protocol + history + latest message) on each round +4. Parses cursor-agent's output for protocol markers +5. Returns structured status to Claude Code so it knows whether to reply or collect the result + +## How It Compares to Claude Code Agent Teams + +Claude Code's native agent teams use an **InboxPoller** at the runtime level: + +``` +Teammate writes to ~/.claude/teams/{team}/inboxes/lead.json + → Lead's Node.js runtime polls inbox between agentic loop turns + → Messages injected as XML in user-role turns + → Model never calls a "check inbox" tool — delivery is automatic +``` + +Our approach replicates this pattern at the MCP tool level: + +``` +cursor-agent outputs [CURSOR_QUESTION]...[/CURSOR_QUESTION] + → MCP server parses markers, saves session state to disk + → Tool result contains the question + session_id + → Claude Code's model sees [ACTION_REQUIRED] and calls session_reply + → (Optional) PostToolUse hook reinforces by injecting additionalContext +``` + +The key difference: agent teams have runtime-level message injection (zero model effort). Our approach requires Claude Code to explicitly call `session_reply` — but the structured tool result format makes this natural for the model. + +## Data Flow + +``` +Claude Code (Master) MCP Server (server.js) cursor-agent CLI + | | | + |-- session_start(task) ------>| | + | | 1. Create session object | + | | 2. buildSessionPrompt() | + | | (protocol + task) | + | | 3. invokeCursorAgent() | + | |------ --print prompt ------->| + | | |-- runs with gpt-5 + | | |-- detects ambiguity + | | |-- wraps in markers: + | | | [CURSOR_QUESTION] + | | | Which module? + | | | [/CURSOR_QUESTION] + | |<----- stdout ----------------| + | | 4. parseSessionOutput() | + | | → type: "question" | + | | 5. session.status = | + | | "waiting_for_answer" | + | | 6. writeSessionFile() | + |<-- tool result: -------------| | + | status: waiting | | + | question: "Which module?" | | + | ACTION_REQUIRED | | + | | | + | (Claude reads question, | | + | formulates answer) | | + | | | + |-- session_reply(id, ans) --->| | + | | 7. Append answer to history | + | | 8. buildSessionPrompt() | + | | (protocol + history | + | | + answer) | + | | 9. invokeCursorAgent() | + | |------ --print prompt ------->| + | | |-- runs with full context + | | |-- wraps in markers: + | | | [CURSOR_RESULT] + | | | Here's my review... + | | | [/CURSOR_RESULT] + | |<----- stdout ----------------| + | | 10. parseSessionOutput() | + | | → type: "result" | + | | 11. session.status = | + | | "completed" | + |<-- tool result: -------------| | + | status: completed | | + | result: "Here's my..." | | +``` + +## Key Components + +### Protocol Preamble (server.js:247-270) + +Injected at the start of every round's prompt. Teaches cursor-agent to use: +- `[CURSOR_QUESTION]...[/CURSOR_QUESTION]` — when it needs info to proceed +- `[CURSOR_RESULT]...[/CURSOR_RESULT]` — when it has a final answer + +Rules enforce exactly one marker pair per response and require all content inside markers. + +### Session State (in-memory Map + disk JSON) + +```json +{ + "session_id": "f47ac10b-...", + "status": "waiting_for_answer", + "model": "gpt-5", + "cwd": "/path/to/project", + "force": true, + "output_format": "text", + "max_rounds": 10, + "round": 2, + "history": [ + { "role": "user", "content": "Review auth module" }, + { "role": "assistant", "content": "Which auth module?" }, + { "role": "user", "content": "The one in src/auth/" } + ], + "pending_question": "Which auth module?", + "result": null, + "raw_outputs": ["[full stdout from round 1]", "[full stdout from round 2]"], + "created_at": 1706000000000, + "updated_at": 1706000060000 +} +``` + +Dual storage: +- **In-memory** `Map` for fast access during the MCP server's lifetime +- **On-disk** JSON at `$CURSOR_SESSION_DIR/.json` so PostToolUse hooks (separate processes) can read session status + +### Prompt Construction (buildSessionPrompt) + +Each round builds a composite prompt: + +``` +=== INTERACTIVE SESSION PROTOCOL === +[... rules about markers ...] +=== END PROTOCOL === + +=== CONVERSATION HISTORY === +[User]: Review the auth module for security issues +[Assistant]: Which auth module? I see two candidates... +=== END HISTORY === + +Current request: +The one in src/auth/legacy.ts. Focus on JWT validation. +``` + +History entries are truncated at 8000 chars to prevent context overflow. + +### Output Parsing (parseSessionOutput) + +Regex extraction with graceful degradation: +1. Check for `[CURSOR_QUESTION]...[/CURSOR_QUESTION]` → return `{type: "question"}` +2. Check for `[CURSOR_RESULT]...[/CURSOR_RESULT]` → return `{type: "result"}` +3. No markers found → treat entire output as result (graceful fallback) + +This means if cursor-agent ignores the protocol (e.g., a model that doesn't follow instructions well), the session still completes instead of hanging. + +### invokeSessionRound (server.js:396-449) + +The core orchestrator. Calls the **original** `invokeCursorAgent()` as a black box — no modifications to the existing executor. Steps: + +1. Increment round counter +2. Guard against max_rounds exceeded +3. Build composite prompt with protocol + history +4. Call `invokeCursorAgent()` with `print: true` +5. Parse output for markers +6. Update session status (`waiting_for_answer` or `completed`) +7. Write to disk and in-memory Map +8. Return formatted result + +### Tool Result Format + +Claude Code sees structured text that makes the next action obvious: + +**When waiting for answer:** +``` +[SESSION_STATUS] +session_id: f47ac10b-... +status: waiting_for_answer +round: 2 / 10 +model: gpt-5 + +[QUESTION_FROM_CURSOR_AGENT] +Which auth module should I refactor? + +[ACTION_REQUIRED] +Call cursor_agent_session_reply with: + session_id: "f47ac10b-..." + reply: "" +``` + +**When completed:** +``` +[SESSION_STATUS] +session_id: f47ac10b-... +status: completed +round: 3 / 10 + +[RESULT_FROM_CURSOR_AGENT] +Here is the complete security review... +``` + +## PostToolUse Hook (Phase 2) + +`hooks/post-tool-use.js` runs after every Claude Code tool call. It: +1. Scans all session JSON files in the session directory +2. If any session has `status: "waiting_for_answer"`, returns `additionalContext` +3. This gets injected into Claude Code's conversation, reminding it to reply + +This mimics the InboxPoller pattern — ensuring Claude Code doesn't "forget" about a pending question while doing other work. + +## Backward Compatibility + +All existing functionality is preserved: +- `invokeCursorAgent()` — untouched (session tools call it as-is) +- `runCursorAgent()` — untouched +- All 7 original tools — untouched +- New session tools are additive only + +## Environment Variables + +| Variable | Default | Purpose | +|---|---|---| +| `CURSOR_SESSION_DIR` | `$TMPDIR/cursor-agent-mcp-sessions` | Session state file directory | +| `CURSOR_SESSION_TTL_MS` | `1800000` (30 min) | Auto-cleanup threshold for idle sessions | +| `CURSOR_SESSION_MAX_ROUNDS` | `10` | Default max rounds if not specified per session | +| `CURSOR_AGENT_TIMEOUT_MS` | `30000` | Per-round timeout (recommend 60000+ for sessions) | +| `DEBUG_CURSOR_MCP` | `0` | Enable stderr debug logging | + +## Limitations & Future Work + +1. **Each round is a fresh process** — cursor-agent doesn't retain memory between rounds. Context is rebuilt from history each time, consuming tokens. +2. **Prompt protocol depends on model compliance** — smaller/weaker models may not produce markers reliably. The graceful fallback mitigates this. +3. **History grows linearly** — each round adds to the prompt. `max_rounds` (default 10) and `MAX_HISTORY_ENTRY_CHARS` (8000) provide guardrails. +4. **No async/background mode** — session tools are synchronous. Claude Code blocks during each `invokeCursorAgent()` call. +5. **Single-machine only** — session state is on the local filesystem. Not suitable for distributed setups. diff --git a/cursor-agent-mcp/docs/TESTING.md b/cursor-agent-mcp/docs/TESTING.md new file mode 100644 index 0000000..ef09977 --- /dev/null +++ b/cursor-agent-mcp/docs/TESTING.md @@ -0,0 +1,474 @@ +# Testing Guide: Cursor-Agent MCP Session Tools + +## Prerequisites + +1. **Pull the branch:** + ```bash + git fetch origin claude/claude-cli-agent-orchestration-Uxqrw + git checkout claude/claude-cli-agent-orchestration-Uxqrw + ``` + +2. **Install dependencies:** + ```bash + cd cursor-agent-mcp + npm ci + ``` + +3. **Ensure cursor-agent CLI is available:** + ```bash + cursor-agent --version + # If not on PATH, set: export CURSOR_AGENT_PATH=/path/to/cursor-agent + ``` + +4. **Recommended env for testing:** + ```bash + export CURSOR_AGENT_TIMEOUT_MS=60000 # 60s per round (sessions need more time) + export CURSOR_AGENT_FORCE=true # Allow file writes without confirmation + export DEBUG_CURSOR_MCP=1 # See spawn/exit logs on stderr + export CURSOR_AGENT_ECHO_PROMPT=1 # See the full prompt in output + ``` + +--- + +## Test Level 1: Syntax & Startup + +### Test 1.1: Server starts without errors +```bash +# Should hang waiting for MCP stdin — that means it started OK +# Press Ctrl+C to exit +node server.js +``` +Expected: No error output, process waits for input. + +### Test 1.2: Node syntax check +```bash +node --check server.js && echo "OK" +node --check hooks/post-tool-use.js && echo "OK" +``` +Expected: Both print "OK". + +### Test 1.3: Tool discovery via test client +```bash +node test_client.mjs "hello" +``` +Expected: Output includes all 11 tools: +``` +Tools: cursor_agent_chat, cursor_agent_edit_file, cursor_agent_analyze_files, +cursor_agent_search_repo, cursor_agent_plan_task, cursor_agent_raw, +cursor_agent_run, cursor_agent_session_start, cursor_agent_session_reply, +cursor_agent_session_status, cursor_agent_session_end +``` + +--- + +## Test Level 2: Original Tools (Backward Compat) + +### Test 2.1: One-shot chat still works +```bash +TEST_TOOL=cursor_agent_chat node test_client.mjs "What is 2+2?" +``` +Expected: Returns a response containing "4". No session state created. + +### Test 2.2: Raw tool still works +```bash +TEST_TOOL=cursor_agent_raw TEST_ARGV='["--version"]' node test_client.mjs +``` +Expected: Returns cursor-agent version string. + +### Test 2.3: Search tool still works +```bash +TEST_TOOL=cursor_agent_search_repo TEST_QUERY="import" node test_client.mjs +``` +Expected: Returns search results from the repo. + +--- + +## Test Level 3: Session Tools (Unit-Level) + +These tests verify the session tools work at the MCP protocol level. They require writing a small test script since `test_client.mjs` doesn't yet have session test cases. + +### Test 3.1: Session start (happy path) + +Create `test_session.mjs`: + +```javascript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +async function main() { + const transport = new StdioClientTransport({ + command: 'node', + args: ['./server.js'], + cwd: new URL('.', import.meta.url).pathname, + env: { + ...process.env, + CURSOR_AGENT_TIMEOUT_MS: '60000', + DEBUG_CURSOR_MCP: '1', + }, + }); + + const client = new Client({ name: 'session-test', version: '0.0.1' }); + await client.connect(transport); + + // List tools to verify session tools exist + const tools = await client.listTools({}); + const names = tools.tools.map(t => t.name); + console.log('Session tools present:', + names.filter(n => n.includes('session')).join(', ')); + + // Start a session + console.log('\n--- Starting session ---'); + const startResult = await client.callTool({ + name: 'cursor_agent_session_start', + arguments: { + prompt: 'I want you to review a codebase. Before you start, ask me which directory to focus on.', + output_format: 'text', + max_rounds: 5, + }, + }); + + const startText = startResult.content + .filter(c => c.type === 'text').map(c => c.text).join('\n'); + console.log('Start result:\n', startText.slice(0, 1000)); + + // Extract session_id + const idMatch = startText.match(/session_id:\s*([a-f0-9-]+)/); + if (!idMatch) { + console.error('No session_id found in output!'); + await client.close(); + return; + } + const sessionId = idMatch[1]; + console.log('\nSession ID:', sessionId); + + // Check if it's waiting for answer + if (startText.includes('waiting_for_answer')) { + console.log('\n--- Session is waiting, sending reply ---'); + const replyResult = await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { + session_id: sessionId, + reply: 'Focus on the src/ directory. Look for security issues.', + }, + }); + + const replyText = replyResult.content + .filter(c => c.type === 'text').map(c => c.text).join('\n'); + console.log('Reply result:\n', replyText.slice(0, 1000)); + } else { + console.log('Session completed in first round (no question asked).'); + } + + // Check status + console.log('\n--- Checking session status ---'); + const statusResult = await client.callTool({ + name: 'cursor_agent_session_status', + arguments: { session_id: sessionId }, + }); + const statusText = statusResult.content + .filter(c => c.type === 'text').map(c => c.text).join('\n'); + console.log('Status:\n', statusText.slice(0, 500)); + + // End session + console.log('\n--- Ending session ---'); + const endResult = await client.callTool({ + name: 'cursor_agent_session_end', + arguments: { session_id: sessionId }, + }); + console.log('End result:', + endResult.content.filter(c => c.type === 'text').map(c => c.text).join('\n')); + + await client.close(); +} + +main().catch(e => { console.error(e); process.exit(1); }); +``` + +Run: +```bash +node test_session.mjs +``` + +Expected: +1. Session tools listed +2. Session starts, cursor-agent either asks a question or delivers a result +3. If question: reply sends answer, next round runs +4. Status shows session state +5. End terminates cleanly + +### Test 3.2: Session with specific model +```bash +# Set model for the session +CURSOR_AGENT_MODEL=gpt-4o node test_session.mjs +``` +Expected: Same flow, but cursor-agent uses gpt-4o. + +### Test 3.3: Session not found (error case) +Add to your test script or run inline: +```javascript +const result = await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { session_id: 'nonexistent-id', reply: 'hello' }, +}); +// Expected: isError: true, message contains "not found" +``` + +### Test 3.4: Reply to non-waiting session (error case) +```javascript +// After a session completes: +const result = await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { session_id: completedSessionId, reply: 'hello' }, +}); +// Expected: isError: true, message contains "not waiting for an answer" +``` + +--- + +## Test Level 4: Integration with Claude Code + +These tests use a real Claude Code CLI session talking to the MCP server. + +### Test 4.1: Add MCP server to Claude Code + +Add to your `.claude/settings.json` (or use `claude mcp add`): + +```json +{ + "mcpServers": { + "cursor-agent": { + "command": "node", + "args": ["/absolute/path/to/cursor-agent-mcp/server.js"], + "env": { + "CURSOR_AGENT_TIMEOUT_MS": "60000", + "CURSOR_AGENT_FORCE": "true", + "CURSOR_AGENT_MODEL": "gpt-5", + "DEBUG_CURSOR_MCP": "1" + } + } + } +} +``` + +Or via CLI: +```bash +claude mcp add cursor-agent -- node /absolute/path/to/cursor-agent-mcp/server.js +``` + +### Test 4.2: Simple delegation (no questions) + +Prompt Claude Code: +``` +Use cursor_agent_session_start to ask cursor-agent: "What is the capital of France?" +``` + +Expected: +- Claude Code calls `cursor_agent_session_start` +- cursor-agent responds with `[CURSOR_RESULT]` containing the answer +- Session completes in 1 round + +### Test 4.3: Multi-round conversation + +Prompt Claude Code: +``` +Use cursor_agent_session_start to delegate this task to cursor-agent: +"I need you to refactor a module, but first ask me which one." +When cursor-agent asks you a question, answer: "Refactor the auth module in src/auth/." +``` + +Expected: +- Round 1: cursor-agent asks which module → status: `waiting_for_answer` +- Claude Code sees `[ACTION_REQUIRED]` and calls `session_reply` +- Round 2: cursor-agent delivers result → status: `completed` + +### Test 4.4: Model selection + +Prompt Claude Code: +``` +Start a session with cursor-agent using model "gpt-4o" and ask it to explain +how async/await works in JavaScript in 3 sentences. +``` + +Expected: Session starts with `model: "gpt-4o"` shown in the status output. + +### Test 4.5: Multi-round with 3+ rounds + +Prompt Claude Code: +``` +Start a session with cursor-agent for this task: +"Help me design a database schema. Ask me questions one at a time about +my requirements before proposing the schema." +Answer each question cursor-agent asks until it delivers a final schema. +``` + +Expected: +- Multiple rounds of Q&A +- Each round shows incrementing round counter +- Final round has status: `completed` with the schema + +--- + +## Test Level 5: PostToolUse Hook + +### Test 5.1: Install the hook + +Add to `.claude/settings.json`: +```json +{ + "hooks": { + "PostToolUse": [ + { + "command": "node /absolute/path/to/cursor-agent-mcp/hooks/post-tool-use.js", + "timeout": 5000 + } + ] + } +} +``` + +### Test 5.2: Hook fires on waiting session + +1. Start a session that will ask a question +2. Before replying, do some other work (e.g., read a file) +3. Observe that the hook injects a reminder about the waiting session + +Expected: After any tool call, Claude Code sees: +``` +[Cursor-Agent Sessions Awaiting Your Reply] + +Session: abc-123 (round 1/10) +Question: Which auth module? +``` + +### Test 5.3: Hook is silent when no sessions waiting + +Do normal Claude Code work without any active sessions. + +Expected: No additional context injected. Hook exits cleanly. + +--- + +## Test Level 6: Edge Cases + +### Test 6.1: Cursor-agent ignores protocol markers + +Use a prompt that's so simple the model might not use markers: +``` +Start a session: "Say hello" +``` + +Expected: `parseSessionOutput` fallback kicks in — treats entire output as result. Session completes normally. + +### Test 6.2: Max rounds exceeded + +```javascript +// Start session with max_rounds: 2 +const result = await client.callTool({ + name: 'cursor_agent_session_start', + arguments: { + prompt: 'Ask me a series of 5 questions about my project before starting.', + max_rounds: 2, + }, +}); +// Reply once, then the next round should hit the limit +``` + +Expected: After 2 rounds, status is `error` with message "Max rounds (2) exceeded." + +### Test 6.3: cursor-agent timeout + +```bash +CURSOR_AGENT_TIMEOUT_MS=1000 node test_session.mjs +``` + +Expected: Session status is `error` with timeout message. + +### Test 6.4: Session cleanup + +```bash +# Set very short TTL +CURSOR_SESSION_TTL_MS=5000 node test_session.mjs +# Wait 6 seconds +sleep 6 +# Check session dir — files should be cleaned on next session_start +``` + +### Test 6.5: Server restart mid-session + +1. Start a session, get a `waiting_for_answer` response +2. Kill and restart the MCP server +3. Call `session_reply` with the session_id + +Expected: Session is reloaded from disk via `resolveSession()`, conversation continues. + +--- + +## Test Level 7: Session File Inspection + +After any test, inspect the session state file: + +```bash +# Find session files +ls /tmp/cursor-agent-mcp-sessions/ + +# Read a session +cat /tmp/cursor-agent-mcp-sessions/.json | python3 -m json.tool +``` + +Verify: +- `session_id` matches what was returned +- `status` is correct (`waiting_for_answer`, `completed`, or `error`) +- `history` contains the full conversation +- `raw_outputs` contains the actual cursor-agent stdout per round +- `round` increments correctly +- `pending_question` is set when status is `waiting_for_answer` +- `result` is set when status is `completed` + +--- + +## Quick Test Matrix + +| # | Test | What to verify | Requires cursor-agent? | +|---|------|----------------|----------------------| +| 1.1 | Server starts | No crash on startup | No | +| 1.2 | Syntax check | `node --check` passes | No | +| 1.3 | Tool discovery | All 11 tools listed | No (but needs npm ci) | +| 2.1 | Chat backward compat | One-shot still works | Yes | +| 3.1 | Session happy path | Start → question → reply → result | Yes | +| 3.3 | Session not found | Error returned cleanly | No | +| 3.4 | Wrong state reply | Error returned cleanly | No | +| 4.2 | Claude Code simple | End-to-end with real Claude | Yes | +| 4.3 | Claude Code multi-round | Full bidirectional flow | Yes | +| 5.2 | Hook injection | additionalContext appears | Yes (needs active session) | +| 6.1 | No markers fallback | Graceful degradation | Yes | +| 6.2 | Max rounds | Error on overflow | Yes | +| 6.5 | Server restart | Disk recovery works | Yes | + +--- + +## Debugging Tips + +- **Enable debug mode:** + ```bash + export DEBUG_CURSOR_MCP=1 + ``` + This prints spawn args, exit codes, and session events to stderr. + +- **Echo prompts:** + ```bash + export CURSOR_AGENT_ECHO_PROMPT=1 + ``` + Shows the full prompt sent to cursor-agent in the tool output. + +- **Inspect session state:** + ```bash + cat /tmp/cursor-agent-mcp-sessions/*.json | python3 -m json.tool + ``` + +- **Watch session dir for changes:** + ```bash + watch -n 1 'ls -la /tmp/cursor-agent-mcp-sessions/' + ``` + +- **If cursor-agent hangs:** + Increase timeout: `CURSOR_AGENT_TIMEOUT_MS=120000` diff --git a/cursor-agent-mcp/hooks/README.md b/cursor-agent-mcp/hooks/README.md new file mode 100644 index 0000000..51cb09a --- /dev/null +++ b/cursor-agent-mcp/hooks/README.md @@ -0,0 +1,62 @@ +# PostToolUse Hook for Cursor-Agent Sessions + +This hook integrates with Claude Code's hook system to proactively remind Claude Code when a cursor-agent session is waiting for an answer. + +## What it does + +After every tool call, the hook: +1. Scans session state files on disk +2. If any session has `status: "waiting_for_answer"`, injects `additionalContext` into Claude Code's conversation +3. The context includes the pending question and instructions to call `cursor_agent_session_reply` + +This mimics how Claude Code's native agent teams use the InboxPoller to inject teammate messages between agentic loop iterations. + +## Installation + +Add to your Claude Code settings (`.claude/settings.json` or project-level): + +```json +{ + "hooks": { + "PostToolUse": [ + { + "command": "node /absolute/path/to/cursor-agent-mcp/hooks/post-tool-use.js", + "timeout": 5000 + } + ] + } +} +``` + +Replace `/absolute/path/to` with the actual path to this repository. + +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `CURSOR_SESSION_DIR` | `$TMPDIR/cursor-agent-mcp-sessions` | Where session state files are stored | + +The hook reads the same session directory that the MCP server writes to. + +## How it works with the session tools + +``` +Claude Code calls cursor_agent_session_start("Review auth module", model: "gpt-5") + → cursor-agent asks: "Which auth module?" + → Tool returns: status=waiting_for_answer, question="Which auth module?" + +Claude Code does other work (reads files, makes edits, etc.) + → [PostToolUse hook fires after each tool call] + → Hook detects waiting session + → Injects: "Session abc-123 needs your answer: Which auth module?" + → Claude Code sees the reminder and calls cursor_agent_session_reply + +Claude Code calls cursor_agent_session_reply("abc-123", "The one in src/auth/") + → cursor-agent continues and delivers final result +``` + +## Notes + +- The hook is optional — the session tools work without it. The hook just ensures Claude Code doesn't "forget" about a pending question while doing other work. +- The hook scans all session files, so it works even if multiple sessions are active. +- Hook timeout is set to 5 seconds — the file scan is fast (sub-100ms typically). diff --git a/cursor-agent-mcp/hooks/post-tool-use.js b/cursor-agent-mcp/hooks/post-tool-use.js new file mode 100644 index 0000000..2b722ab --- /dev/null +++ b/cursor-agent-mcp/hooks/post-tool-use.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// PostToolUse hook for cursor-agent session tools. +// Reads session state from disk and injects pending questions as additionalContext +// so Claude Code is reminded to reply when a cursor-agent session is waiting. +// +// Install in Claude Code settings (.claude/settings.json): +// { +// "hooks": { +// "PostToolUse": [{ +// "command": "node /absolute/path/to/cursor-agent-mcp/hooks/post-tool-use.js", +// "timeout": 5000 +// }] +// } +// } + +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let input; +try { + input = JSON.parse(readFileSync('/dev/stdin', 'utf8')); +} catch { + process.exit(0); +} + +const sessionDir = process.env.CURSOR_SESSION_DIR + || join(tmpdir(), 'cursor-agent-mcp-sessions'); + +// Scan all session files for any that are waiting for an answer +let waitingSessions = []; +try { + for (const file of readdirSync(sessionDir)) { + if (!file.endsWith('.json')) continue; + try { + const session = JSON.parse(readFileSync(join(sessionDir, file), 'utf8')); + if (session.status === 'waiting_for_answer' && session.pending_question) { + waitingSessions.push(session); + } + } catch {} + } +} catch { + // Session dir doesn't exist or can't be read — nothing to do + process.exit(0); +} + +if (waitingSessions.length === 0) { + process.exit(0); +} + +// Build additionalContext for all waiting sessions +const lines = ['[Cursor-Agent Sessions Awaiting Your Reply]', '']; +for (const session of waitingSessions) { + lines.push(`Session: ${session.session_id} (round ${session.round}/${session.max_rounds})`); + if (session.model) lines.push(`Model: ${session.model}`); + lines.push(`Question: ${session.pending_question}`); + lines.push(''); + lines.push(`To reply: call cursor_agent_session_reply with session_id "${session.session_id}" and your answer.`); + lines.push('---'); +} + +const response = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: lines.join('\n'), + }, +}; + +process.stdout.write(JSON.stringify(response)); +process.exit(0); diff --git a/cursor-agent-mcp/misc/claude-agent-instructions.md b/cursor-agent-mcp/misc/claude-agent-instructions.md new file mode 100644 index 0000000..0f084ee --- /dev/null +++ b/cursor-agent-mcp/misc/claude-agent-instructions.md @@ -0,0 +1,222 @@ +# Claude Code – Agent Instructions for Using cursor-agent MCP + +Audience: Claude (and advanced users configuring Claude Code) + +Goal: Minimize Claude’s token usage and cost by delegating repo-aware work to the cursor-agent CLI via this MCP server, while keeping Claude’s own context small and interactions deterministic. + +Reference implementation: [mcp-cursor-agent/server.js](mcp-cursor-agent/server.js) • [mcp-cursor-agent/README.md](mcp-cursor-agent/README.md) • [mcp-cursor-agent/test_client.mjs](mcp-cursor-agent/test_client.mjs) + + +## Why + +- Cost/Token control + - Asking Claude to read large codebases consumes context window and tokens. Redirect the heavy lifting (search, analysis, planning, edits) to `cursor-agent` with tight scopes and concise outputs. +- Purpose-built tools + - This MCP exposes focused tools: chat, edit, analyze, search, plan, and raw. They guide smaller prompts with narrower scopes, lowering token usage and noise. +- Deterministic process + - Tool calls return consistent shapes, timeouts, and diagnostics; easier to chain, summarize, and store without flooding Claude’s context. + + +## Where this is implemented + +- Tool registrations begin at [JavaScript.server.tool()](mcp-cursor-agent/server.js:273). +- Common executor that runs the CLI lives in [JavaScript.invokeCursorAgent()](mcp-cursor-agent/server.js:38). +- Legacy runner for single-shot chat is [JavaScript.runCursorAgent()](mcp-cursor-agent/server.js:153). +- Full user documentation is in [mcp-cursor-agent/README.md](mcp-cursor-agent/README.md). + + +## When to use cursor-agent (instead of having Claude read files directly) + +Use cursor-agent when: +- You need repository-wide analysis, code search, or multi-file reasoning. +- You can narrow the scope with paths or globs (src/**, app/**) to limit token footprint. +- You want structured or concise output (text/markdown/json) without dumping large source into the chat. +- You are planning a task that can be expressed as steps and constraints (set up CI, refactor plan, etc.). +- You need quick edits guided by a specific instruction (propose patch/diff or apply changes if supported). + +Avoid or minimize using cursor-agent when: +- You need to inspect a single small file and the content fits naturally inline in the conversation. +- You need model-specific features in Claude that don’t map well to the CLI (rare). +- A quick direct answer is faster than spawning a CLI process (e.g., trivial Q&A with no code context). + + +## Tool Overview and Decision Guide + +All tools accept COMMON fields: output_format ("text"|"markdown"|"json", default "text"), extra_args?: string[], cwd?: string, executable?: string, model?: string, force?: boolean, echo_prompt?: boolean. + +- cursor_agent_chat + - Use for general Q&A with a concise prompt. + - Prefer this over free-form chats when the question doesn’t need repo traversal. + +- cursor_agent_edit_file + - Use for targeted edits/suggestions to a known file. + - Provide: { file, instruction, dry_run?: boolean, apply?: boolean, prompt?: string } + - Good for diffs or guided changes with minimal tokens. + +- cursor_agent_analyze_files + - Use for scoped analysis on specific directories/files. + - Provide: { paths: string|string[], prompt?: string } + - Add a brief prompt for focus, e.g., “architecture overview,” “find race conditions.” + +- cursor_agent_search_repo + - Use for code/search queries with include/exclude globs. + - Provide: { query, include?: string|string[], exclude?: string|string[] } + - Ensures you only scan relevant parts of the repo. + +- cursor_agent_plan_task + - Use for plans/checklists with constraints. + - Provide: { goal, constraints?: string[] } + - Output as numbered steps; shallow context, lower cost. + +- cursor_agent_raw + - Escape hatch for advanced usage: provide argv and choose whether to inject print mode. + - Provide: { argv: string[], print?: boolean } + - Use sparingly; prefer verbs above for clear intent and cheaper prompts. + +- cursor_agent_run (legacy) + - Single-shot chat; preserved for backward compatibility. + - Prefer cursor_agent_chat in new flows. + + +## Cost-first Patterns (Do/Don’t) + +Do: +- Provide narrow scopes (paths/globs) before asking for repo work. +- Set output_format to "text" or "markdown" unless you need machine-readable "json". +- Ask for top-N results and short, bullet summaries. Example: “At most 10 matches. Include path and line.” +- Use echo_prompt only during debugging (it adds text to the result). + +Don’t: +- Request full-file dumps or whole-repo scans without filters. +- Ask for verbose narratives when a bullet list suffices. +- Keep DEBUG on in production; use it locally. + +Tip: The MCP supports echoing the prompt into the tool output when CURSOR_AGENT_ECHO_PROMPT=1 or echo_prompt=true is passed. Use this during setup then disable for cost savings. + + +## Example Invocations + +- Search for a symbol inside TypeScript only: + - Tool: cursor_agent_search_repo + - Arguments: + { + "query": "createServer(", + "include": ["src/**/*.ts", "server/**/*.ts"], + "exclude": ["node_modules/**", "dist/**"], + "output_format": "markdown" + } + +- Analyze architecture of src and scripts: + - Tool: cursor_agent_analyze_files + - Arguments: + { + "paths": ["src", "scripts"], + "prompt": "Summarize the architecture and main responsibilities of each module. Keep it under 250 words.", + "output_format": "text" + } + +- Edit a single file with a targeted instruction (dry run): + - Tool: cursor_agent_edit_file + - Arguments: + { + "file": "src/app.ts", + "instruction": "Extract the HTTP client into a separate module and add a retry wrapper. Propose a patch.", + "dry_run": true, + "output_format": "markdown" + } + +- Plan a task under constraints: + - Tool: cursor_agent_plan_task + - Arguments: + { + "goal": "Set up CI to lint and test this repo", + "constraints": ["GitHub Actions", "Node 18", "Cache npm deps"], + "output_format": "markdown" + } + +- General chat (lowest overhead): + - Tool: cursor_agent_chat + - Arguments: + { "prompt": "Explain SIMD in one paragraph", "output_format": "markdown" } + + +## Prompts that Save Tokens + +- Prefer short, targeted objectives: + - “List 10 references of X in src/**/*.ts with file and line.” + - “Summarize the structure of src/, skip node_modules and dist.” + - “Propose a minimal diff to fix the bug in src/app.ts (no code dumps).” +- Avoid long narratives and raw code dumps unless essential. +- Add constraints up front: length limits, sections to include/exclude, outline structure. + +Examples of short templates: +- “Provide at most N bullet points with file:line and a one-sentence context.” +- “Return a numbered plan of 5–7 steps to achieve X under constraints Y.” +- “Summarize module boundaries across these paths: A, B, C. 200 words.” + + +## Failure Handling and Retries + +- Timeouts: + - The CLI runs under a hard timeout; if it hits the ceiling, ask again with narrower scopes or increase timeout via env (see README). +- Idle kill: + - Disabled by default. Don’t rely on idle-kill for normal work; use hard timeouts and focused prompts. +- Unknown CLI errors: + - Suggest using cursor_agent_raw with argv ["--version"] to validate CLI availability. +- Credential/Model issues: + - Report a concise diagnostic and request user to set CURSOR_AGENT_MODEL and provider credentials. + +In case of failure: +1) Reduce scope (paths/globs/top-N); 2) tighten prompt; 3) increase CURSOR_AGENT_TIMEOUT_MS only if needed. + + +## Environment Defaults for Claude Code + +Host config example is in [mcp-cursor-agent/README.md](mcp-cursor-agent/README.md), but for the agent: + +- Prefer these env defaults for stability/cost: + - CURSOR_AGENT_IDLE_EXIT_MS="0" + - CURSOR_AGENT_TIMEOUT_MS="60000" + - CURSOR_AGENT_MODEL set to a cost-effective default + - CURSOR_AGENT_ECHO_PROMPT="0" normally (set to "1" only when debugging) +- Optional diagnostics: + - DEBUG_CURSOR_MCP="1" for local development (stderr logs, may not appear in Claude UI) +- Executable path: + - CURSOR_AGENT_PATH="/abs/path/to/cursor-agent" if not on PATH + + +## Claude-specific Guidance (How to choose a tool) + +Decision Tree: +- Do I need a plan/checklist? → use cursor_agent_plan_task. +- Do I need to search code across files? → use cursor_agent_search_repo with include/exclude. +- Do I need to analyze a subset of the repo? → use cursor_agent_analyze_files with paths. +- Do I need to change or propose edits to a file? → use cursor_agent_edit_file with instruction. +- Do I need a quick answer (no repo traversal)? → use cursor_agent_chat. +- Do I need a special CLI invocation? → use cursor_agent_raw, with print=false by default. + +Post-processing: +- Always summarize tool outputs back to the user in a concise form (bullets or short markdown). +- When output_format="json", keep the JSON body intact and also provide a short natural-language summary to reduce context pressure. + +Constraints: +- Avoid reprinting large code blocks unless explicitly requested. +- Use “top-N” or “limit to 10 results” patterns to cap output length. +- Prefer “text” or “markdown” outputs for summaries. + + +## Security and Safety + +- Assume the repository may contain secrets. Do not echo or log secrets. +- Don’t instruct the CLI to fetch or transmit external data unless explicitly requested. +- For edits, prefer dry_run and propose diffs unless the user instructs to apply. +- Use shell: false in spawns (enforced by the server) and avoid passing shell metacharacters via extra_args. + + +## References (clickable) + +- Tool definitions start at [JavaScript.server.tool()](mcp-cursor-agent/server.js:273) +- Executor: [JavaScript.invokeCursorAgent()](mcp-cursor-agent/server.js:38) +- Legacy runner: [JavaScript.runCursorAgent()](mcp-cursor-agent/server.js:153) +- Full README: [mcp-cursor-agent/README.md](mcp-cursor-agent/README.md) +- Smoke client: [mcp-cursor-agent/test_client.mjs](mcp-cursor-agent/test_client.mjs) \ No newline at end of file diff --git a/cursor-agent-mcp/misc/claude-project-instructions.md b/cursor-agent-mcp/misc/claude-project-instructions.md new file mode 100644 index 0000000..5493c2f --- /dev/null +++ b/cursor-agent-mcp/misc/claude-project-instructions.md @@ -0,0 +1,110 @@ +# Claude Code – Project Instructions (Use cursor-agent MCP to reduce tokens/cost) + +Audience: Claude Code (the assistant running inside the editor) + +Intent +- Primary goal is to keep token usage and cost low. +- Delegate repo-aware work (reading, searching, analyzing, planning, editing) to the cursor-agent MCP tools instead of loading large context into chat. +- Keep your own conversation context small; request concise, scoped outputs from tools. + +Background +- The project includes a custom MCP server that wraps the `cursor-agent` CLI and exposes focused tools: chat, edit_file, analyze_files, search_repo, plan_task, raw, run. +- Server entry: [mcp-cursor-agent/server.js](mcp-cursor-agent/server.js) + +Core Policy (always follow) +1) Prefer MCP tools over inline reading when the task can be scoped (paths/globs/top-N): + - Large or multi-file review → use cursor_agent_analyze_files + - Codebase search → use cursor_agent_search_repo + - Task/roadmap creation → use cursor_agent_plan_task + - Targeted file change or patch suggestion → use cursor_agent_edit_file + - General Q&A without repo traversal → use cursor_agent_chat + - Special CLI call → use cursor_agent_raw (advanced; use sparingly) + +2) Minimize scope explicitly + - Always pass specific paths/globs (include/exclude) for repo tasks. + - Ask for “top N” results and short bullet summaries. + +3) Keep outputs compact + - Default output_format="text" or "markdown". + - Use "json" only if the user asks for machine-readable output. + +4) Don’t paste large code blocks into chat unless the user explicitly asks. + - Prefer path + line references and tiny snippets (1–3 lines) as context. + +5) Summarize tool results for the user + - Provide a concise bullet summary in chat and, if applicable, a short next-step recommendation. + +6) Confirm before long/expensive operations + - If the task might traverse many files or run for a long time, ask the user to confirm scope and limits (paths, top N, time). + +Tool Selection – Decision Flow +- Need quick answer, no code traversal → cursor_agent_chat +- Need to search across files/folders → cursor_agent_search_repo with include/exclude +- Need to review or summarize multiple files/areas → cursor_agent_analyze_files with paths +- Need a plan/checklist → cursor_agent_plan_task with constraints +- Need a targeted edit/patch → cursor_agent_edit_file with file + instruction (default to dry-run) +- Need special CLI invocation → cursor_agent_raw (confirm with user; default print=false) +- Legacy/compat → cursor_agent_run (prefer chat in new flows) + +Argument Patterns (examples) + +A) Analyze a subset of the repo (scoped) +- Tool: cursor_agent_analyze_files +- Arguments: + { + "paths": ["src", "scripts"], + "prompt": "Architecture overview + module boundaries, under 200 words.", + "output_format": "text" + } + +B) Repository search (scoped) +- Tool: cursor_agent_search_repo +- Arguments: + { + "query": "createServer(", + "include": ["src/**/*.ts", "server/**/*.ts"], + "exclude": ["node_modules/**", "dist/**"], + "output_format": "markdown" + } + +C) Targeted file edit (dry run) +- Tool: cursor_agent_edit_file +- Arguments: + { + "file": "src/app.ts", + "instruction": "Extract the HTTP client into a separate module; add exponential backoff retries. Propose a unified diff.", + "dry_run": true, + "output_format": "markdown" + } + +D) Plan with constraints +- Tool: cursor_agent_plan_task +- Arguments: + { + "goal": "Set up CI to lint and test this repo", + "constraints": ["GitHub Actions", "Node 18", "Cache npm deps"], + "output_format": "markdown" + } + +Cost-First Prompt Templates +- “Return at most 10 results as bullets: file:line — 1 sentence context.” +- “Summarize modules across paths A,B,C in ≤200 words; avoid code dumps.” +- “Propose a minimal unified diff; no full file listings.” + +Anti-Patterns (avoid) +- Whole-repo scans without include/exclude scopes +- Large code blocks pasted into chat +- Verbose narrative when bullets or a short summary suffice + +Debug/Visibility +- If user wants to see the exact tool prompt in the UI, include "echo_prompt": true in arguments. +- Do not enable excessive debugging by default. Only use broader streaming (cursor_agent_raw) when necessary and agreed. + +Failure Handling +- If a tool times out or returns too large an output, reduce scope (paths/globs/top-N) and retry. +- Ask the user to confirm broader scopes before re-running. + +Summary Rule +- Use MCP tools to do the heavy lifting and keep chat context small. +- Explicitly scope tasks; request concise outputs. +- Confirm with the user before long scans or broad changes. \ No newline at end of file diff --git a/cursor-agent-mcp/misc/cursor-agent-instructions.md b/cursor-agent-mcp/misc/cursor-agent-instructions.md new file mode 100644 index 0000000..189490e --- /dev/null +++ b/cursor-agent-mcp/misc/cursor-agent-instructions.md @@ -0,0 +1,123 @@ +# Cursor‑Agent MCP – Operating Instructions (for use inside Claude Code) + +Audience: The cursor‑agent MCP (invoked by Claude Code via MCP) + +Intent +- You are invoked by Claude to reduce Claude’s token usage and cost. +- Always produce the smallest useful output that satisfies the user’s request. +- Prefer scoped analysis over broad scans. Respect limits, paths, and globs. +- Return structured, actionable answers (bullet points, diffs, short summaries). + + +## Behavioral Rules + +1) Be concise by default +- Default to short bullet lists or a compact paragraph. +- Include only what is necessary to address the user’s intent. +- If asked to “explain,” keep it under a few paragraphs unless the user requests detail. + +2) Scope first, then answer +- If arguments include paths, include/exclude, or top‑N limits, honor them strictly. +- Do not scan or summarize beyond the provided scope. +- If scope is missing and the task is potentially expensive (e.g., “review the codebase”), request a smaller scope in your output before proceeding. + +3) Avoid large code dumps +- Provide file:line references and tiny context snippets (1–3 lines) instead of full files. +- For edits, propose minimal unified diffs or concise patch blocks. Do not print entire files. + +4) Prefer focused formats +- Use the specified output_format (text|markdown|json). If unspecified, assume “text” and keep it short. +- Use “json” only when explicitly requested or when a tool contract requires it. + +5) Summaries before details +- Start with an at‑a‑glance summary, then add short, numbered or bulleted items for details. +- For search results, show at most the requested top‑N. If not specified, default to 10 or fewer. + +6) Defer broad/long operations +- If the task is inherently long (wide search, full repo analysis), note the cost and suggest narrower subsets or specific globs, and wait for confirmation in your output. + +7) Respect user instructions and constraints +- If the user provides constraints (frameworks, file types, folder paths), follow them precisely. +- If the request conflicts with constraints, say so and propose a scoped alternative. + +8) Privacy & safety +- Never output secrets or credentials if encountered in files. +- Do not fetch external data unless explicitly requested by the user/instruction arguments. + +9) Edits and patches +- Default to dry‑run suggestions (diffs) unless “apply” is specified and supported. +- Keep patches minimal and explain the intent briefly. + +10) Failure handling +- If an operation times out or is too large, return a concise note with suggested smaller scopes (paths/globs/top‑N). +- Provide a next step that Claude can take (e.g., “rerun with include: [‘src/**/*.ts’] and limit to top 10 results”). + +11) Echoed prompts (if configured) +- When CURSOR_AGENT_ECHO_PROMPT=1 or echo_prompt=true is present, prepend a short “Prompt used:” section. Keep the echo minimal and do not repeat it later. + + +## Mapping to Tools (What to output) + +- cursor_agent_chat + - Small, direct answers. Prefer bullets for lists. Avoid long exposition. + +- cursor_agent_edit_file + - Provide a minimal unified diff or patch suggestion with a 1–2 sentence rationale. + - Do not include full files. + +- cursor_agent_analyze_files + - Provide a compact overview of the requested paths (e.g., architecture summary under ~200–300 words). + - Bullet key modules and responsibilities; add file path anchors. + +- cursor_agent_search_repo + - Return top‑N matches as bullets: file:line — one‑sentence context. + - Respect include/exclude globs and limits. + +- cursor_agent_plan_task + - Return a numbered plan (5–7 steps unless otherwise requested). Be actionable and short. + +- cursor_agent_raw + - Use only for specialized invocations. Do not stream verbose transcripts unless asked. + - If asked to dump help/version, return only the essential lines. + + +## Where should these instructions live? + +Options (may use more than one): +1) Project/Global doc (recommended) + - Keep this file (misc/cursor-agent-instructions.md) in the repo. Claude can reference or paste its key parts into a preamble when calling tools. + - Best for transparent, host‑controlled governance. + +2) Host instructions (Claude Code “Project Instructions”) + - The host instructs itself to prefer MCP tools and to pass compact prompts and limits. See: [misc/claude-project-instructions.md](misc/claude-project-instructions.md) + +3) MCP server “instructions” field (lightweight hint) + - The server includes a short string for discovery, not a full policy. For robust behavior, prefer (1) and (2). If desired later, expand server hints in code at [JavaScript.new McpServer(...)](mcp-cursor-agent/server.js:197). + +Recommendation +- Keep this document as the agent policy. +- Keep [misc/claude-project-instructions.md](misc/claude-project-instructions.md) as the host policy. +- Optionally keep a short “use these tools and be concise” hint in the server’s instructions. This balances runtime control and code simplicity. + + +## Prompt & Output Recipes (Copy‑ready) + +- Repo search (concise): + - “Find at most 10 matches of ‘X’ inside include globs A,B; exclude C. Return bullets: file:line — one‑sentence context.” + +- Scoped analysis: + - “Analyze src/ and scripts/ for architecture overview: ~200 words; list key modules and responsibilities. Avoid code dumps.” + +- Targeted edit: + - “Propose a minimal unified diff to implement Y in file Z. Explain in one sentence. No full file listing.” + +- Planning: + - “Create 5–7 numbered steps to achieve GOAL under constraints A,B. Each step ≤ 1 sentence.” + + +## References (clickable) + +- Tool registrations start at [JavaScript.server.tool()](mcp-cursor-agent/server.js:273) +- Executor (spawn, timeouts, idle): [JavaScript.invokeCursorAgent()](mcp-cursor-agent/server.js:38) +- Legacy single‑shot runner: [JavaScript.runCursorAgent()](mcp-cursor-agent/server.js:153) +- Main README: [mcp-cursor-agent/README.md](mcp-cursor-agent/README.md) \ No newline at end of file diff --git a/cursor-agent-mcp/misc/readme.md b/cursor-agent-mcp/misc/readme.md new file mode 100644 index 0000000..970ef5e --- /dev/null +++ b/cursor-agent-mcp/misc/readme.md @@ -0,0 +1,30 @@ +# misc/ — Instructions for Claude Code + cursor-agent MCP + +Purpose +- This folder holds human- and agent-readable instruction files that guide how Claude Code should use the cursor-agent MCP to reduce tokens and cost. + +What’s inside +- Project policy (paste into Claude’s Project Instructions): [claude-project-instructions.md](claude-project-instructions.md) +- Agent policy (behavior for the MCP/tool side): [cursor-agent-instructions.md](cursor-agent-instructions.md) +- Extended guide (optional, longer form): [claude-agent-instructions.md](claude-agent-instructions.md) + +Why separate docs? +- Keeps operational guidance out of code, easy to copy/paste into Claude. +- Establishes clear host (Claude) vs agent (cursor-agent) responsibilities for cost‑aware workflows. + +See also +- Server entry: [mcp-cursor-agent/server.js](../server.js) +- Main docs: [mcp-cursor-agent/README.md](../README.md) +## Multi-MCP note: Gemini CLI and cursor-agent + +Claude Code can use multiple MCP servers simultaneously. Besides this repo’s cursor-agent MCP, you can also integrate a Gemini CLI MCP and route tasks to either tool based on cost/scope. + +- Example Gemini CLI MCP usage and instructions: + - https://github.com/sailay1996/random-stuff/blob/main/AI/claudecodeXgeminicli/Claude_instrctuons1.md + +Suggested pattern: +- Prefer cursor-agent MCP for repo-scoped tasks (search/analyze/plan/edit) when you want tight control of scope and concise outputs. +- Prefer Gemini CLI MCP when you need very large context windows or Gemini-specific capabilities. +- Keep Claude’s Project Instructions neutral: “Use the most cost‑effective MCP (cursor-agent or Gemini CLI) for the task; always scope paths/globs and return concise results.” +Note: You can get a Gemini CLI MCP implementation here: +- https://github.com/jamubc/gemini-mcp-tool diff --git a/cursor-agent-mcp/orchestrator-mcp/server.js b/cursor-agent-mcp/orchestrator-mcp/server.js new file mode 100644 index 0000000..fface5f --- /dev/null +++ b/cursor-agent-mcp/orchestrator-mcp/server.js @@ -0,0 +1,257 @@ +/** + * Orchestrator MCP Server — Claude Code's native interface to cursor-agent. + * + * Tools: + * cursor_agent_spawn — spawn a background cursor-agent process + * cursor_agent_check — check for pending question from cursor-agent + * cursor_agent_reply — reply to a pending question + * cursor_agent_status — get agent status (working/waiting/completed) + * cursor_agent_result — get final agent output + * cursor_agent_kill — terminate the agent + * + * Env: + * BRIDGE_SESSION_DIR — shared IPC dir (default: /tmp/cursor-bridge-session) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { spawn } from 'node:child_process'; +import { + existsSync, readFileSync, writeFileSync, readdirSync, + mkdirSync, unlinkSync, createWriteStream, +} from 'node:fs'; +import { join } from 'node:path'; + +const SESSION_DIR = process.env.BRIDGE_SESSION_DIR || '/tmp/cursor-bridge-session'; +const STATE_FILE = join(SESSION_DIR, '.orchestrator-state.json'); +mkdirSync(SESSION_DIR, { recursive: true }); + +// ── State helpers ──────────────────────────────────────────────────────────── + +function loadState() { + try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } + catch { return { pid: null, answeredCount: 0, startedAt: null }; } +} + +function saveState(state) { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8'); +} + +function isRunning(pid) { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function findPendingQuestion() { + const files = readdirSync(SESSION_DIR) + .filter(f => f.startsWith('question_') && f.endsWith('.json')) + .sort(); + for (const file of files) { + const turnNum = parseInt(file.match(/question_(\d+)\.json/)?.[1], 10); + if (!turnNum) continue; + if (existsSync(join(SESSION_DIR, `answer_${turnNum}.json`))) continue; + try { + const data = JSON.parse(readFileSync(join(SESSION_DIR, file), 'utf8')); + return { turn: turnNum, message: data.message }; + } catch { continue; } + } + return null; +} + +function countAnswered() { + return readdirSync(SESSION_DIR).filter(f => f.startsWith('answer_') && f.endsWith('.json')).length; +} + +// ── MCP Server ─────────────────────────────────────────────────────────────── + +const BRIDGE_PREAMBLE = [ + 'You have access to "report_to_orchestrator" from "orchestrator-bridge" MCP.', + 'RULES:', + '1. Use report_to_orchestrator for ALL communication with me.', + '2. NEVER finish or exit on your own. After completing each unit of work, ALWAYS call report_to_orchestrator to report what you did and ask what to do next.', + '3. If you need clarification or have questions, ask via report_to_orchestrator.', + '4. Your workflow: receive instruction → do work → report_to_orchestrator → wait for reply → repeat.', + '5. Only stop when I explicitly say "stop", "done", or "you can stop now".', + '', +].join('\n'); + +const server = new McpServer( + { name: 'cursor-agent-orchestrator', version: '1.0.0' }, + { + instructions: [ + 'Tools for spawning and communicating with a background cursor-agent subagent.', + 'Workflow: spawn → (check → reply) loop → result.', + 'The agent communicates back via bridge MCP. One process per task.', + ].join(' '), + } +); + +// ── spawn ──────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_spawn', + 'Spawn a background cursor-agent process. Returns immediately. The agent will call report_to_orchestrator to communicate — use cursor_agent_check to see its messages.', + { + task: z.string().min(1, 'task description is required'), + model: z.string().default('composer-1'), + cwd: z.string().optional(), + output_file: z.string().optional().describe('File path for agent to write detailed results to'), + }, + async ({ task, model, cwd, output_file }) => { + // Clean old session files + for (const f of readdirSync(SESSION_DIR)) { + if (f.startsWith('question_') || f.startsWith('answer_')) { + try { unlinkSync(join(SESSION_DIR, f)); } catch {} + } + } + + let prompt = BRIDGE_PREAMBLE + '\nTASK: ' + task; + if (output_file) { + prompt += `\n\nWrite detailed results to: ${output_file}`; + } + + const child = spawn('cursor-agent', [ + '--print', '--yolo', '--model', model, '--output-format', 'text', prompt, + ], { + cwd: cwd || process.cwd(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true, + }); + + child.stdin.end(); + child.unref(); + + const outStream = createWriteStream(join(SESSION_DIR, '.agent-stdout.txt')); + const errStream = createWriteStream(join(SESSION_DIR, '.agent-stderr.txt')); + child.stdout.pipe(outStream); + child.stderr.pipe(errStream); + + const state = { pid: child.pid, answeredCount: 0, startedAt: Date.now() }; + saveState(state); + + return { + content: [{ type: 'text', text: `Agent spawned (PID ${child.pid}, model: ${model}). Use cursor_agent_check to see its messages.` }], + }; + } +); + +// ── check ──────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_check', + 'Check if cursor-agent has a pending question/message. Returns the message or "no pending question".', + {}, + async () => { + const q = findPendingQuestion(); + if (q) { + return { + content: [{ type: 'text', text: `[Turn ${q.turn}] Agent says: ${q.message}` }], + }; + } + const state = loadState(); + const running = state.pid ? isRunning(state.pid) : false; + return { + content: [{ type: 'text', text: running ? 'No pending question. Agent is still working.' : 'No pending question. Agent has finished.' }], + }; + } +); + +// ── reply ──────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_reply', + 'Reply to cursor-agent\'s pending question. The agent will receive this and continue working.', + { + message: z.string().min(1, 'reply message is required'), + }, + async ({ message }) => { + const q = findPendingQuestion(); + if (!q) { + return { content: [{ type: 'text', text: 'No pending question to reply to.' }], isError: true }; + } + const answerFile = join(SESSION_DIR, `answer_${q.turn}.json`); + const replyWithReminder = message + '\n\n[When done, call report_to_orchestrator.]'; + writeFileSync(answerFile, JSON.stringify({ reply: replyWithReminder, timestamp: Date.now() }), 'utf8'); + + const state = loadState(); + state.answeredCount = countAnswered(); + saveState(state); + + return { + content: [{ type: 'text', text: `Reply sent (turn ${q.turn}, total: ${state.answeredCount}).` }], + }; + } +); + +// ── status ─────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_status', + 'Get current agent status: working, waiting_for_reply, or completed.', + {}, + async () => { + const state = loadState(); + const running = state.pid ? isRunning(state.pid) : false; + const q = findPendingQuestion(); + const answered = countAnswered(); + const elapsed = state.startedAt ? Math.round((Date.now() - state.startedAt) / 1000) : 0; + + let status; + if (running && q) status = 'waiting_for_reply'; + else if (running) status = 'working'; + else status = 'completed'; + + return { + content: [{ + type: 'text', + text: `Status: ${status} | Answered: ${answered} | Elapsed: ${elapsed}s` + + (q ? `\nPending: ${q.message}` : ''), + }], + }; + } +); + +// ── result ─────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_result', + 'Get the agent\'s final stdout output after it completes.', + {}, + async () => { + const outFile = join(SESSION_DIR, '.agent-stdout.txt'); + if (!existsSync(outFile)) { + return { content: [{ type: 'text', text: 'No output yet — agent may not have been spawned.' }] }; + } + const content = readFileSync(outFile, 'utf8').trim(); + return { content: [{ type: 'text', text: content || '(empty output)' }] }; + } +); + +// ── kill ───────────────────────────────────────────────────────────────────── + +server.tool( + 'cursor_agent_kill', + 'Force-terminate the running cursor-agent process.', + {}, + async () => { + const state = loadState(); + if (!state.pid) { + return { content: [{ type: 'text', text: 'No agent to kill.' }] }; + } + try { + process.kill(state.pid, 'SIGTERM'); + return { content: [{ type: 'text', text: `Killed agent (PID ${state.pid}).` }] }; + } catch { + return { content: [{ type: 'text', text: `Agent (PID ${state.pid}) already stopped.` }] }; + } + } +); + +// ── Connect ────────────────────────────────────────────────────────────────── + +const transport = new StdioServerTransport(); +server.connect(transport).catch((e) => { + console.error('Orchestrator MCP failed to start:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/orchestrator.js b/cursor-agent-mcp/orchestrator.js new file mode 100644 index 0000000..e59a905 --- /dev/null +++ b/cursor-agent-mcp/orchestrator.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * Orchestrator CLI — clean abstraction for bridge MCP communication. + * + * Usage: + * node orchestrator.js spawn "prompt" [--model M] [--cwd DIR] + * node orchestrator.js check + * node orchestrator.js reply "answer text" + * node orchestrator.js status + * node orchestrator.js result + * node orchestrator.js kill + * node orchestrator.js auto-reply "template" # auto-answer with template + */ + +import { spawn as spawnProc } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, unlinkSync, createWriteStream } from 'node:fs'; +import { join } from 'node:path'; + +const SESSION_DIR = process.env.BRIDGE_SESSION_DIR || '/tmp/cursor-bridge-session'; +const STATE_FILE = join(SESSION_DIR, '.orchestrator-state.json'); + +mkdirSync(SESSION_DIR, { recursive: true }); + +// ── State persistence ──────────────────────────────────────────────────────── + +function loadState() { + try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } + catch { return { pid: null, taskId: null, answeredCount: 0, startedAt: null }; } +} + +function saveState(state) { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8'); +} + +// ── File IPC helpers ───────────────────────────────────────────────────────── + +function findPendingQuestion() { + const files = readdirSync(SESSION_DIR) + .filter(f => f.startsWith('question_') && f.endsWith('.json')) + .sort(); + + for (const file of files) { + const turnNum = parseInt(file.match(/question_(\d+)\.json/)?.[1], 10); + if (!turnNum) continue; + + const answerFile = join(SESSION_DIR, `answer_${turnNum}.json`); + if (existsSync(answerFile)) continue; + + try { + const data = JSON.parse(readFileSync(join(SESSION_DIR, file), 'utf8')); + return { turn: turnNum, message: data.message, file }; + } catch { continue; } + } + return null; +} + +function writeAnswer(turnNum, reply) { + const answerFile = join(SESSION_DIR, `answer_${turnNum}.json`); + writeFileSync(answerFile, JSON.stringify({ reply, timestamp: Date.now() }), 'utf8'); +} + +function countAnswered() { + return readdirSync(SESSION_DIR) + .filter(f => f.startsWith('answer_') && f.endsWith('.json')) + .length; +} + +function isProcessRunning(pid) { + try { process.kill(pid, 0); return true; } + catch { return false; } +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +const cmd = process.argv[2]; + +if (cmd === 'spawn') { + const prompt = process.argv[3]; + if (!prompt) { console.error('Usage: spawn "prompt" [--model M] [--cwd DIR]'); process.exit(1); } + + const modelIdx = process.argv.indexOf('--model'); + const model = modelIdx >= 0 ? process.argv[modelIdx + 1] : 'composer-1'; + const cwdIdx = process.argv.indexOf('--cwd'); + const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd(); + + // Clean old session files + for (const f of readdirSync(SESSION_DIR)) { + if (f.startsWith('question_') || f.startsWith('answer_')) { + try { unlinkSync(join(SESSION_DIR, f)); } catch {} + } + } + + const child = spawnProc('cursor-agent', [ + '--print', '--yolo', '--model', model, '--output-format', 'text', prompt, + ], { + cwd, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true, + }); + + child.stdin.end(); + child.unref(); + + // Save stdout/stderr to files for later retrieval + const outFile = join(SESSION_DIR, '.agent-stdout.txt'); + const errFile = join(SESSION_DIR, '.agent-stderr.txt'); + const outStream = createWriteStream(outFile); + const errStream = createWriteStream(errFile); + child.stdout.pipe(outStream); + child.stderr.pipe(errStream); + + const state = { pid: child.pid, answeredCount: 0, startedAt: Date.now() }; + saveState(state); + + console.log(JSON.stringify({ status: 'spawned', pid: child.pid, model, session_dir: SESSION_DIR })); + +} else if (cmd === 'check') { + const q = findPendingQuestion(); + if (q) { + console.log(JSON.stringify({ pending: true, turn: q.turn, message: q.message })); + } else { + console.log(JSON.stringify({ pending: false })); + } + +} else if (cmd === 'reply') { + const reply = process.argv[3]; + if (!reply) { console.error('Usage: reply "answer text"'); process.exit(1); } + + const q = findPendingQuestion(); + if (!q) { + console.log(JSON.stringify({ error: 'no pending question' })); + process.exit(1); + } + + writeAnswer(q.turn, reply); + const state = loadState(); + state.answeredCount = countAnswered(); + saveState(state); + + console.log(JSON.stringify({ answered: q.turn, total_answered: state.answeredCount })); + +} else if (cmd === 'status') { + const state = loadState(); + const running = state.pid ? isProcessRunning(state.pid) : false; + const q = findPendingQuestion(); + const answered = countAnswered(); + const elapsed = state.startedAt ? Math.round((Date.now() - state.startedAt) / 1000) : 0; + + let agentStatus; + if (running && q) agentStatus = 'waiting_for_reply'; + else if (running) agentStatus = 'working'; + else agentStatus = 'completed'; + + console.log(JSON.stringify({ + agent: agentStatus, + pid: state.pid, + running, + questions_answered: answered, + pending_question: q ? q.message : null, + elapsed_seconds: elapsed, + })); + +} else if (cmd === 'result') { + const outFile = join(SESSION_DIR, '.agent-stdout.txt'); + if (existsSync(outFile)) { + const content = readFileSync(outFile, 'utf8').trim(); + console.log(content || '(no output yet)'); + } else { + console.log('(no output file — agent may not have been spawned)'); + } + +} else if (cmd === 'kill') { + const state = loadState(); + if (state.pid) { + try { process.kill(state.pid, 'SIGTERM'); console.log(JSON.stringify({ killed: state.pid })); } + catch { console.log(JSON.stringify({ error: 'process not found', pid: state.pid })); } + } else { + console.log(JSON.stringify({ error: 'no agent running' })); + } + +} else { + console.log(`Usage: node orchestrator.js `); + process.exit(1); +} diff --git a/cursor-agent-mcp/package-lock.json b/cursor-agent-mcp/package-lock.json new file mode 100644 index 0000000..920181e --- /dev/null +++ b/cursor-agent-mcp/package-lock.json @@ -0,0 +1,1051 @@ +{ + "name": "mcp-cursor-agent", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-cursor-agent", + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.3", + "zod": "^3.23.8" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz", + "integrity": "sha512-JPwUKWSsbzx+DLFznf/QZ32Qa+ptfbUlHhRLrBQBAFu9iI1iYvizM4p+zhhRDceSsPutXp4z+R/HPVphlIiclg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/cursor-agent-mcp/package.json b/cursor-agent-mcp/package.json new file mode 100644 index 0000000..774ba3d --- /dev/null +++ b/cursor-agent-mcp/package.json @@ -0,0 +1,16 @@ +{ + "name": "mcp-cursor-agent", + "version": "1.2.0", + "type": "module", + "private": true, + "description": "MCP wrapper server for cursor-agent CLI. Multi-tool: chat/edit/analyze/search/plan/raw + interactive sessions with bidirectional communication.", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.3", + "zod": "^3.23.8" + }, + "scripts": { + "start": "node ./server.js", + "test": "node ./test_client.mjs" + } +} \ No newline at end of file diff --git a/cursor-agent-mcp/server.js b/cursor-agent-mcp/server.js new file mode 100644 index 0000000..902c1b2 --- /dev/null +++ b/cursor-agent-mcp/server.js @@ -0,0 +1,791 @@ +// MCP wrapper server for cursor-agent CLI +// Exposes multiple tools (chat/edit/analyze/search/plan/raw + legacy run) for better discoverability. +// Start via MCP config (stdio). Requires Node 18+. + +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { spawn } from 'node:child_process'; +import process from 'node:process'; +import { randomUUID } from 'node:crypto'; +import { writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Tool input schema +const RUN_SCHEMA = z.object({ + prompt: z.string().min(1, 'prompt is required'), + output_format: z.enum(['text', 'json', 'markdown']).default('text'), + extra_args: z.array(z.string()).optional(), + cwd: z.string().optional(), + // Optional override for the executable path if not on PATH + executable: z.string().optional(), + // Optional model and force for parity with other tools/env overrides + model: z.string().optional(), + force: z.boolean().optional(), +}); + +// Resolve the executable path for cursor-agent +function resolveExecutable(explicit) { + if (explicit && explicit.trim()) return explicit.trim(); + if (process.env.CURSOR_AGENT_PATH && process.env.CURSOR_AGENT_PATH.trim()) { + return process.env.CURSOR_AGENT_PATH.trim(); + } + // default assumes "cursor-agent" is on PATH + return 'cursor-agent'; +} + +/** +* Internal executor that spawns cursor-agent with provided argv and common options. +* Adds --print and --output-format, handles env/model/force, timeouts and idle kill. +*/ +async function invokeCursorAgent({ argv, output_format = 'text', cwd, executable, model, force, print = true }) { + const cmd = resolveExecutable(executable); + + // Compute model/force from args/env + const userArgs = [...(argv ?? [])]; + const hasModelFlag = userArgs.some((a) => a === '-m' || a === '--model' || /^(?:-m=|--model=)/.test(String(a))); + const envModel = process.env.CURSOR_AGENT_MODEL && process.env.CURSOR_AGENT_MODEL.trim(); + const effectiveModel = model?.trim?.() || envModel; + + const hasForceFlag = userArgs.some((a) => a === '-f' || a === '--force'); + const envForce = (() => { + const v = (process.env.CURSOR_AGENT_FORCE || '').toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; + })(); + const effectiveForce = typeof force === 'boolean' ? force : envForce; + + const finalArgv = [ + ...(print ? ['--print', '--output-format', output_format] : []), + ...userArgs, + ...(hasForceFlag || !effectiveForce ? [] : ['-f']), + ...(hasModelFlag || !effectiveModel ? [] : ['--model', effectiveModel]), + ]; + + return new Promise((resolve) => { + let settled = false; + let out = ''; + let err = ''; + let idleTimer = null; + let killedByIdle = false; + + const cleanup = () => { + if (mainTimer) clearTimeout(mainTimer); + if (idleTimer) clearTimeout(idleTimer); + }; + + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { + console.error('[cursor-mcp] spawn:', cmd, ...finalArgv); + } catch {} + } + + const child = spawn(cmd, finalArgv, { + shell: false, // safer across platforms; rely on PATH/PATHEXT + cwd: cwd || process.cwd(), + env: process.env, + }); + try { child.stdin?.end(); } catch {} + + const idleMs = Number.parseInt(process.env.CURSOR_AGENT_IDLE_EXIT_MS || '0', 10); + const scheduleIdleKill = () => { + if (!Number.isFinite(idleMs) || idleMs <= 0) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + killedByIdle = true; + try { child.kill('SIGKILL'); } catch {} + }, idleMs); + }; + + child.stdout.on('data', (d) => { + out += d.toString(); + scheduleIdleKill(); + }); + + child.stderr.on('data', (d) => { + err += d.toString(); + }); + + child.on('error', (e) => { + if (settled) return; + settled = true; + cleanup(); + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { console.error('[cursor-mcp] error:', e); } catch {} + } + const msg = + `Failed to start "${cmd}": ${e?.message || e}\n` + + `Args: ${JSON.stringify(finalArgv)}\n` + + (process.env.CURSOR_AGENT_PATH ? `CURSOR_AGENT_PATH=${process.env.CURSOR_AGENT_PATH}\n` : ''); + resolve({ content: [{ type: 'text', text: msg }], isError: true }); + }); + + const defaultTimeout = 30000; + const timeoutMs = Number.parseInt(process.env.CURSOR_AGENT_TIMEOUT_MS || String(defaultTimeout), 10); + const mainTimer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + if (settled) return; + settled = true; + cleanup(); + resolve({ + content: [{ type: 'text', text: `cursor-agent timed out after ${Number.isFinite(timeoutMs) ? timeoutMs : defaultTimeout}ms` }], + isError: true, + }); + }, Number.isFinite(timeoutMs) ? timeoutMs : defaultTimeout); + + child.on('close', (code) => { + if (settled) return; + settled = true; + cleanup(); + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { console.error('[cursor-mcp] exit:', code, 'stdout bytes=', out.length, 'stderr bytes=', err.length); } catch {} + } + if (code === 0 || (killedByIdle && out)) { + resolve({ content: [{ type: 'text', text: out || '(no output)' }] }); + } else { + resolve({ + content: [{ type: 'text', text: `cursor-agent exited with code ${code}\n${err || out || '(no output)'}` }], + isError: true, + }); + } + }); + }); +} + +// Back-compat: single-shot run by prompt as positional argument. +// Accepts either a flat args object or an object with an "arguments" field (some hosts). +async function runCursorAgent(input) { + const source = (input && typeof input === 'object' && input.arguments && typeof input.prompt === 'undefined') + ? input.arguments + : input; + + const { + prompt, + output_format = 'text', + extra_args, + cwd, + executable, + model, + force, + } = source || {}; + + const argv = [...(extra_args ?? []), String(prompt)]; + const usedPrompt = argv.length ? String(argv[argv.length - 1]) : ''; + + // Optional prompt echo and debug diagnostics + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { + const preview = usedPrompt.slice(0, 400).replace(/\n/g, '\\n'); + console.error('[cursor-mcp] prompt:', preview); + if (extra_args?.length) console.error('[cursor-mcp] extra_args:', JSON.stringify(extra_args)); + if (model) console.error('[cursor-mcp] model:', model); + if (typeof force === 'boolean') console.error('[cursor-mcp] force:', String(force)); + } catch {} + } + + const result = await invokeCursorAgent({ argv, output_format, cwd, executable, model, force }); + + // Echo prompt either when env is set or when caller provided echo_prompt: true (if host forwards unknown args it's fine) + const echoEnabled = process.env.CURSOR_AGENT_ECHO_PROMPT === '1' || source?.echo_prompt === true; + if (echoEnabled) { + const text = `Prompt used:\n${usedPrompt}`; + const content = Array.isArray(result?.content) ? result.content : []; + return { ...result, content: [{ type: 'text', text }, ...content] }; + } + + return result; +} + +/** +* Create MCP server and register a suite of cursor-agent tools. +* We expose multiple verbs for better discoverability in hosts (chat/edit/analyze/search/plan), +* plus the legacy cursor_agent_run for back-compat and a raw escape hatch. +*/ +const server = new McpServer( + { + name: 'cursor-agent', + version: '1.2.0', + description: 'MCP wrapper for cursor-agent CLI (multi-tool: chat/edit/analyze/search/plan/raw + interactive sessions)', + }, + { + instructions: + [ + 'Tools:', + '- cursor_agent_chat: chat with a prompt; optional model/force/format.', + '- cursor_agent_edit_file: prompt-based file edit wrapper; you provide file and instruction.', + '- cursor_agent_analyze_files: prompt-based analysis of one or more paths.', + '- cursor_agent_search_repo: prompt-based code search with include/exclude globs.', + '- cursor_agent_plan_task: prompt-based planning given a goal and optional constraints.', + '- cursor_agent_raw: pass raw argv directly to cursor-agent; set print=false to avoid implicit --print.', + '- cursor_agent_run: legacy single-shot chat (prompt as positional).', + '- cursor_agent_session_start: start a multi-round interactive session; cursor-agent can ask questions back.', + '- cursor_agent_session_reply: answer a pending question from cursor-agent within a session.', + '- cursor_agent_session_status: check the status of an active session.', + '- cursor_agent_session_end: terminate a session and clean up.', + ].join(' '), + }, +); + +// Common shape used by multiple schemas +const COMMON = { + output_format: z.enum(['text', 'json', 'markdown']).default('text'), + extra_args: z.array(z.string()).optional(), + cwd: z.string().optional(), + executable: z.string().optional(), + model: z.string().optional(), + force: z.boolean().optional(), + // When true, the server will prepend the effective prompt to the tool output (useful for Claude debugging) + echo_prompt: z.boolean().optional(), +}; + +// --------------------------------------------------------------------------- +// Session management for multi-round bidirectional communication +// --------------------------------------------------------------------------- + +const sessions = new Map(); + +const SESSION_PROTOCOL_PREAMBLE = [ + '=== INTERACTIVE SESSION PROTOCOL ===', + 'You are in a multi-turn interactive session managed by an orchestration layer.', + 'Follow these rules for EVERY response:', + '', + 'RULE 1 - QUESTION: If you need information, clarification, or a decision from', + 'the user before you can proceed, wrap your question in these exact markers:', + '[CURSOR_QUESTION]', + 'Your question here. Be specific about what you need.', + '[/CURSOR_QUESTION]', + '', + 'RULE 2 - FINAL RESULT: When you have completed the task and have a final answer', + 'or result, wrap it in these exact markers:', + '[CURSOR_RESULT]', + 'Your complete final result here.', + '[/CURSOR_RESULT]', + '', + 'RULE 3: Use EXACTLY ONE marker pair per response. Never both. If uncertain', + 'whether you can proceed, ask a question rather than guessing.', + '', + 'RULE 4: Put ALL meaningful content inside the markers. Text outside markers', + 'is treated as debug output and may not be shown to the user.', + '=== END PROTOCOL ===', +].join('\n'); + +const MAX_HISTORY_ENTRY_CHARS = 8000; + +function getSessionDir() { + const custom = process.env.CURSOR_SESSION_DIR; + const dir = custom || join(tmpdir(), 'cursor-agent-mcp-sessions'); + try { mkdirSync(dir, { recursive: true }); } catch {} + return dir; +} + +function writeSessionFile(session) { + try { + const dir = getSessionDir(); + const filePath = join(dir, `${session.session_id}.json`); + writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf8'); + } catch (e) { + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { console.error('[cursor-mcp] writeSessionFile error:', e); } catch {} + } + } +} + +function readSessionFile(sessionId) { + try { + const dir = getSessionDir(); + const filePath = join(dir, `${sessionId}.json`); + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function resolveSession(sessionId) { + let session = sessions.get(sessionId); + if (!session) { + session = readSessionFile(sessionId); + if (session) sessions.set(sessionId, session); + } + return session || null; +} + +function cleanupExpiredSessions() { + const ttl = parseInt(process.env.CURSOR_SESSION_TTL_MS || '1800000', 10); + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.updated_at > ttl) sessions.delete(id); + } + try { + const dir = getSessionDir(); + for (const file of readdirSync(dir)) { + if (!file.endsWith('.json')) continue; + const filePath = join(dir, file); + try { + const stat = statSync(filePath); + if (now - stat.mtimeMs > ttl) unlinkSync(filePath); + } catch {} + } + } catch {} +} + +function truncate(str, max) { + if (!str || str.length <= max) return str; + return str.slice(0, max) + `\n... (truncated at ${max} chars)`; +} + +function buildSessionPrompt(session) { + let prompt = SESSION_PROTOCOL_PREAMBLE + '\n\n'; + if (session.history.length > 1) { + prompt += '=== CONVERSATION HISTORY ===\n'; + for (const entry of session.history.slice(0, -1)) { + const label = entry.role === 'user' ? 'User' : 'Assistant'; + prompt += `[${label}]: ${truncate(entry.content, MAX_HISTORY_ENTRY_CHARS)}\n\n`; + } + prompt += '=== END HISTORY ===\n\n'; + } + const latest = session.history[session.history.length - 1]; + prompt += `Current request:\n${latest.content}`; + return prompt; +} + +function parseSessionOutput(rawOutput) { + const questionMatch = rawOutput.match( + /\[CURSOR_QUESTION\]([\s\S]*?)\[\/CURSOR_QUESTION\]/ + ); + if (questionMatch) { + return { type: 'question', content: questionMatch[1].trim() }; + } + const resultMatch = rawOutput.match( + /\[CURSOR_RESULT\]([\s\S]*?)\[\/CURSOR_RESULT\]/ + ); + if (resultMatch) { + return { type: 'result', content: resultMatch[1].trim() }; + } + // Graceful degradation: no markers found, treat entire output as result + return { type: 'result', content: rawOutput.trim() }; +} + +function formatSessionResult(session) { + let text = ''; + text += `[SESSION_STATUS]\n`; + text += `session_id: ${session.session_id}\n`; + text += `status: ${session.status}\n`; + text += `round: ${session.round} / ${session.max_rounds}\n`; + if (session.model) text += `model: ${session.model}\n`; + text += '\n'; + + if (session.status === 'waiting_for_answer') { + text += `[QUESTION_FROM_CURSOR_AGENT]\n${session.pending_question}\n\n`; + text += `[ACTION_REQUIRED]\n`; + text += `Call cursor_agent_session_reply with:\n`; + text += ` session_id: "${session.session_id}"\n`; + text += ` reply: ""\n`; + } else if (session.status === 'completed') { + text += `[RESULT_FROM_CURSOR_AGENT]\n${session.result}\n`; + } else if (session.status === 'error') { + text += `[ERROR]\n${session.error}\n`; + if (session.raw_outputs.length) { + const lastOutput = session.raw_outputs[session.raw_outputs.length - 1]; + text += `\n[LAST_OUTPUT]\n${truncate(lastOutput, 2000)}\n`; + } + } + + return { content: [{ type: 'text', text }] }; +} + +async function invokeSessionRound(session) { + session.round += 1; + session.status = 'active'; + session.updated_at = Date.now(); + + if (session.round > session.max_rounds) { + session.status = 'error'; + session.error = `Max rounds (${session.max_rounds}) exceeded.`; + writeSessionFile(session); + return formatSessionResult(session); + } + + const sessionPrompt = buildSessionPrompt(session); + const extraArgs = session.extra_args ?? []; + + const result = await invokeCursorAgent({ + argv: [...extraArgs, sessionPrompt], + output_format: session.output_format || 'text', + cwd: session.cwd, + executable: session.executable, + model: session.model, + force: session.force, + print: true, + }); + + if (result.isError) { + session.status = 'error'; + session.error = result.content[0]?.text || 'Unknown invocation error'; + writeSessionFile(session); + sessions.set(session.session_id, session); + return formatSessionResult(session); + } + + const rawOutput = result.content[0]?.text || ''; + session.raw_outputs.push(rawOutput); + + const parsed = parseSessionOutput(rawOutput); + + if (parsed.type === 'question') { + session.status = 'waiting_for_answer'; + session.pending_question = parsed.content; + session.history.push({ role: 'assistant', content: parsed.content }); + } else { + session.status = 'completed'; + session.result = parsed.content; + session.pending_question = null; + session.history.push({ role: 'assistant', content: parsed.content }); + } + + session.updated_at = Date.now(); + writeSessionFile(session); + sessions.set(session.session_id, session); + + return formatSessionResult(session); +} + +// Session schemas +const SESSION_START_SCHEMA = z.object({ + prompt: z.string().min(1, 'prompt is required'), + model: z.string().optional(), + cwd: z.string().optional(), + executable: z.string().optional(), + force: z.boolean().optional(), + output_format: z.enum(['text', 'json', 'markdown']).default('text'), + extra_args: z.array(z.string()).optional(), + max_rounds: z.number().int().min(1).max(20).default(10), +}); + +const SESSION_REPLY_SCHEMA = z.object({ + session_id: z.string().min(1, 'session_id is required'), + reply: z.string().min(1, 'reply is required'), +}); + +const SESSION_STATUS_SCHEMA = z.object({ + session_id: z.string().min(1, 'session_id is required'), +}); + +const SESSION_END_SCHEMA = z.object({ + session_id: z.string().min(1, 'session_id is required'), + reason: z.string().optional(), +}); + +// Schemas +const CHAT_SCHEMA = z.object({ + prompt: z.string().min(1, 'prompt is required'), + ...COMMON, +}); + +const EDIT_FILE_SCHEMA = z.object({ + file: z.string().min(1, 'file is required'), + instruction: z.string().min(1, 'instruction is required'), + apply: z.boolean().optional(), + dry_run: z.boolean().optional(), + // optional free-form prompt to pass if the CLI supports one + prompt: z.string().optional(), + ...COMMON, +}); + +const ANALYZE_FILES_SCHEMA = z.object({ + paths: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), + prompt: z.string().optional(), + ...COMMON, +}); + +const SEARCH_REPO_SCHEMA = z.object({ + query: z.string().min(1, 'query is required'), + include: z.union([z.string(), z.array(z.string())]).optional(), + exclude: z.union([z.string(), z.array(z.string())]).optional(), + ...COMMON, +}); + +const PLAN_TASK_SCHEMA = z.object({ + goal: z.string().min(1, 'goal is required'), + constraints: z.array(z.string()).optional(), + ...COMMON, +}); + +const RAW_SCHEMA = z.object({ + // raw argv to pass after common flags; e.g., ["--help"] or ["subcmd","--flag"] + argv: z.array(z.string()).min(1, 'argv must contain at least one element'), + print: z.boolean().optional(), + ...COMMON, +}); + +// Tools +server.tool( + 'cursor_agent_chat', + 'Chat with cursor-agent using a prompt and optional model/force/output_format.', + CHAT_SCHEMA.shape, + async (args) => { + try { + // Normalize prompt in case the host nests under "arguments" + const prompt = + (args && typeof args === 'object' && 'prompt' in args ? args.prompt : undefined) ?? + (args && typeof args === 'object' && args.arguments && typeof args.arguments === 'object' ? args.arguments.prompt : undefined); + + const flat = { + ...(args && typeof args === 'object' && args.arguments && typeof args.arguments === 'object' ? args.arguments : args), + prompt, + }; + + return await runCursorAgent(flat); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_edit_file', + 'Edit a file with an instruction. Prompt-based wrapper; no CLI subcommand required.', + EDIT_FILE_SCHEMA.shape, + async (args) => { + try { + const { file, instruction, apply, dry_run, prompt, output_format, cwd, executable, model, force, extra_args } = args; + const composedPrompt = + `Edit the repository file:\n` + + `- File: ${String(file)}\n` + + `- Instruction: ${String(instruction)}\n` + + (apply ? `- Apply changes if safe.\n` : `- Propose a patch/diff without applying.\n`) + + (dry_run ? `- Treat as dry-run; do not write to disk.\n` : ``) + + (prompt ? `- Additional context: ${String(prompt)}\n` : ``); + return await runCursorAgent({ prompt: composedPrompt, output_format, extra_args, cwd, executable, model, force }); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_analyze_files', + 'Analyze one or more paths; optional prompt. Prompt-based wrapper.', + ANALYZE_FILES_SCHEMA.shape, + async (args) => { + try { + const { paths, prompt, output_format, cwd, executable, model, force, extra_args } = args; + const list = Array.isArray(paths) ? paths : [paths]; + const composedPrompt = + `Analyze the following paths in the repository:\n` + + list.map((p) => `- ${String(p)}`).join('\n') + '\n' + + (prompt ? `Additional prompt: ${String(prompt)}\n` : ''); + return await runCursorAgent({ prompt: composedPrompt, output_format, extra_args, cwd, executable, model, force }); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_search_repo', + 'Search repository code with include/exclude patterns. Prompt-based wrapper.', + SEARCH_REPO_SCHEMA.shape, + async (args) => { + try { + const { query, include, exclude, output_format, cwd, executable, model, force, extra_args } = args; + const inc = include == null ? [] : (Array.isArray(include) ? include : [include]); + const exc = exclude == null ? [] : (Array.isArray(exclude) ? exclude : [exclude]); + const composedPrompt = + `Search the repository for occurrences relevant to:\n` + + `- Query: ${String(query)}\n` + + (inc.length ? `- Include globs:\n${inc.map((p)=>` - ${String(p)}`).join('\n')}\n` : '') + + (exc.length ? `- Exclude globs:\n${exc.map((p)=>` - ${String(p)}`).join('\n')}\n` : '') + + `Return concise findings with file paths and line references.`; + return await runCursorAgent({ prompt: composedPrompt, output_format, extra_args, cwd, executable, model, force }); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_plan_task', + 'Generate a plan for a goal with optional constraints. Prompt-based wrapper.', + PLAN_TASK_SCHEMA.shape, + async (args) => { + try { + const { goal, constraints, output_format, cwd, executable, model, force, extra_args } = args; + const cons = constraints ?? []; + const composedPrompt = + `Create a step-by-step plan to accomplish the following goal:\n` + + `- Goal: ${String(goal)}\n` + + (cons.length ? `- Constraints:\n${cons.map((c)=>` - ${String(c)}`).join('\n')}\n` : '') + + `Provide a numbered list of actions.`; + return await runCursorAgent({ prompt: composedPrompt, output_format, extra_args, cwd, executable, model, force }); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +// Raw escape hatch for power-users and forward compatibility +server.tool( + 'cursor_agent_raw', + 'Advanced: provide raw argv array to pass after common flags (e.g., ["search","--query","foo"]).', + RAW_SCHEMA.shape, + async (args) => { + try { + const { argv, output_format, cwd, executable, model, force } = args; + // For raw calls we disable implicit --print to allow commands like "--help" + return await invokeCursorAgent({ argv, output_format, cwd, executable, model, force, print: false }); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +// Legacy single-shot prompt tool retained for compatibility +server.tool( + 'cursor_agent_run', + 'Run cursor-agent with a prompt and desired output format (legacy single-shot).', + RUN_SCHEMA.shape, + async (args) => { + try { + return await runCursorAgent(args); + } catch (e) { + return { content: [{ type: 'text', text: `Invalid params: ${e?.message || e}` }], isError: true }; + } + }, +); + +// --------------------------------------------------------------------------- +// Session tools: multi-round bidirectional communication with cursor-agent +// --------------------------------------------------------------------------- + +server.tool( + 'cursor_agent_session_start', + 'Start an interactive multi-round session with cursor-agent. Returns a question (needs session_reply) or a final result. Use for complex tasks where cursor-agent may need clarification.', + SESSION_START_SCHEMA.shape, + async (args) => { + try { + cleanupExpiredSessions(); + const sessionId = randomUUID(); + const now = Date.now(); + const defaultMaxRounds = parseInt(process.env.CURSOR_SESSION_MAX_ROUNDS || '10', 10); + const session = { + session_id: sessionId, + status: 'active', + model: args.model || undefined, + cwd: args.cwd || undefined, + executable: args.executable || undefined, + force: args.force ?? undefined, + output_format: args.output_format || 'text', + extra_args: args.extra_args || [], + max_rounds: args.max_rounds ?? defaultMaxRounds, + history: [{ role: 'user', content: args.prompt }], + round: 0, + pending_question: null, + result: null, + raw_outputs: [], + error: null, + created_at: now, + updated_at: now, + }; + sessions.set(sessionId, session); + + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { console.error('[cursor-mcp] session_start:', sessionId, 'prompt:', args.prompt.slice(0, 200)); } catch {} + } + + return await invokeSessionRound(session); + } catch (e) { + return { content: [{ type: 'text', text: `Session start failed: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_session_reply', + 'Send a reply to cursor-agent within a session. Works when agent is waiting for an answer OR when you want to give feedback on a completed result (re-opens the session for another round).', + SESSION_REPLY_SCHEMA.shape, + async (args) => { + try { + const session = resolveSession(args.session_id); + if (!session) { + return { + content: [{ type: 'text', text: `Session "${args.session_id}" not found. It may have expired (TTL: ${process.env.CURSOR_SESSION_TTL_MS || '1800000'}ms). Start a new session with cursor_agent_session_start.` }], + isError: true, + }; + } + if (session.status !== 'waiting_for_answer' && session.status !== 'completed') { + return { + content: [{ type: 'text', text: `Session "${args.session_id}" cannot accept a reply. Current status: ${session.status}` }], + isError: true, + }; + } + + // Re-open a completed session for feedback/follow-up + if (session.status === 'completed') { + session.status = 'active'; + session.result = null; + } + + session.history.push({ role: 'user', content: args.reply }); + session.pending_question = null; + + if (process.env.DEBUG_CURSOR_MCP === '1') { + try { console.error('[cursor-mcp] session_reply:', args.session_id, 'reply:', args.reply.slice(0, 200)); } catch {} + } + + return await invokeSessionRound(session); + } catch (e) { + return { content: [{ type: 'text', text: `Session reply failed: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_session_status', + 'Check the current status of an active session. Read-only.', + SESSION_STATUS_SCHEMA.shape, + async (args) => { + try { + const session = resolveSession(args.session_id); + if (!session) { + return { content: [{ type: 'text', text: `Session "${args.session_id}" not found.` }], isError: true }; + } + return formatSessionResult(session); + } catch (e) { + return { content: [{ type: 'text', text: `Session status check failed: ${e?.message || e}` }], isError: true }; + } + }, +); + +server.tool( + 'cursor_agent_session_end', + 'Explicitly terminate a session and clean up state.', + SESSION_END_SCHEMA.shape, + async (args) => { + try { + const session = resolveSession(args.session_id); + if (!session) { + return { content: [{ type: 'text', text: `Session "${args.session_id}" not found.` }], isError: true }; + } + session.status = args.reason ? 'error' : 'completed'; + if (args.reason) session.error = args.reason; + session.updated_at = Date.now(); + writeSessionFile(session); + sessions.delete(args.session_id); + + const text = `Session "${args.session_id}" terminated. Status: ${session.status}` + + (args.reason ? `. Reason: ${args.reason}` : '') + + `. Completed ${session.round} round(s).`; + return { content: [{ type: 'text', text }] }; + } catch (e) { + return { content: [{ type: 'text', text: `Session end failed: ${e?.message || e}` }], isError: true }; + } + }, +); + +// Connect using stdio transport +const transport = new StdioServerTransport(); + +server.connect(transport).catch((e) => { + console.error('MCP server failed to start:', e); + process.exit(1); +}); \ No newline at end of file diff --git a/cursor-agent-mcp/test_3turn.mjs b/cursor-agent-mcp/test_3turn.mjs new file mode 100644 index 0000000..e85a345 --- /dev/null +++ b/cursor-agent-mcp/test_3turn.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Single-execution 3-turn bidirectional test with composer-1. + * + * Turn 1: Assign task → agent responds (question or draft) + * Turn 2: Answer/requirements → agent delivers result + * Turn 3: Give feedback → agent delivers revised result + * + * The session system supports re-opening completed sessions, + * so turn 3 always works even if the agent completed on turn 2. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const log = (tag, msg) => console.log(`[${new Date().toISOString().slice(11,23)}] [${tag}] ${msg}`); +const txt = (r) => (r?.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('\n'); +const st = (t) => t.match(/status:\s*(\S+)/)?.[1] || '?'; +const rnd = (t) => t.match(/round:\s*(\d+)/)?.[1] || '?'; +const id = (t) => t.match(/session_id:\s*(\S+)/)?.[1]; +const question = (t) => t.match(/\[QUESTION_FROM_CURSOR_AGENT\]\n([\s\S]*?)\n\n\[/)?.[1]?.trim(); +const result = (t) => t.match(/\[RESULT_FROM_CURSOR_AGENT\]\n([\s\S]*)/)?.[1]?.trim(); + +async function main() { + const transport = new StdioClientTransport({ + command: 'node', args: ['./server.js'], + cwd: new URL('.', import.meta.url).pathname, + env: { + ...process.env, + CURSOR_AGENT_TIMEOUT_MS: '120000', + CURSOR_AGENT_FORCE: '1', + CURSOR_AGENT_MODEL: 'composer-1', + }, + }); + + const client = new Client({ name: '3turn-test', version: '1.0.0' }); + await client.connect(transport); + log('INIT', 'MCP connected → composer-1 via --yolo\n'); + + // ═══ TURN 1: Give task, agent should ask what it should do ════════════════ + log('TURN1→', 'Assigning task: "write a bash script, ask me what it should do first"'); + const r1 = txt(await client.callTool({ + name: 'cursor_agent_session_start', + arguments: { + prompt: [ + 'I need a bash script. Before writing anything, ask me ONE question about what', + 'it should do. Use [CURSOR_QUESTION]...[/CURSOR_QUESTION] markers.', + 'Do NOT write code yet.', + ].join(' '), + max_rounds: 6, + }, + })); + + const sessionId = id(r1); + log('TURN1←', `Status: ${st(r1)} | ${question(r1) ? 'Question: ' + question(r1) : 'Response received'}`); + console.log(''); + + // ═══ TURN 2: Give requirements → agent writes draft ═══════════════════════ + log('TURN2→', 'Answering: "list 5 largest files in a directory, human-readable sizes"'); + const r2 = txt(await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { + session_id: sessionId, + reply: [ + 'Write a bash script that takes a directory path and prints the 5 largest files', + 'with human-readable sizes. Sort largest first. Include a header showing the dir.', + ].join(' '), + }, + })); + + log('TURN2←', `Status: ${st(r2)} | Round: ${rnd(r2)}`); + const draft = result(r2) || question(r2) || '(no parsed content)'; + console.log(` Preview: ${draft.slice(0, 150).replace(/\n/g, '\\n')}...`); + console.log(''); + + // ═══ TURN 3: Give feedback → agent revises ════════════════════════════════ + log('TURN3→', 'Feedback: "add color output and error handling for missing dirs"'); + const r3 = txt(await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { + session_id: sessionId, + reply: [ + 'Two changes needed:', + '1) Add color output (green for header, yellow for file sizes)', + '2) Add error handling if the directory does not exist', + 'Return the final script using [CURSOR_RESULT]...[/CURSOR_RESULT] markers.', + ].join(' '), + }, + })); + + const finalCode = result(r3); + log('TURN3←', `Status: ${st(r3)} | Round: ${rnd(r3)}`); + + // ═══ Cleanup ══════════════════════════════════════════════════════════════ + await client.callTool({ name: 'cursor_agent_session_end', arguments: { session_id: sessionId } }); + + // ═══ Summary ══════════════════════════════════════════════════════════════ + console.log('\n' + '═'.repeat(60)); + console.log('FINAL RESULT:'); + console.log('═'.repeat(60)); + console.log(finalCode || r3); + console.log('═'.repeat(60)); + console.log(`\n3-turn test: ${st(r3) === 'completed' ? '✓ PASS' : '⚠ ' + st(r3)} | Rounds: ${rnd(r3)}`); + + await client.close(); + process.exit(st(r3) === 'completed' ? 0 : 1); +} + +main().catch(e => { console.error('Test failed:', e); process.exit(1); }); diff --git a/cursor-agent-mcp/test_5turn_bridge.mjs b/cursor-agent-mcp/test_5turn_bridge.mjs new file mode 100644 index 0000000..9794d62 --- /dev/null +++ b/cursor-agent-mcp/test_5turn_bridge.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * 5-turn test: ONE cursor-agent process, bidirectional via bridge MCP. + * + * Prerequisites: + * - orchestrator-bridge MCP enabled: `cursor-agent mcp enable orchestrator-bridge` + * - BRIDGE_SESSION_DIR set to /tmp/cursor-bridge-session in mcp.json + * + * This script does NOT modify ~/.cursor/mcp.json. + * It only reads/writes files in the fixed session dir. + */ + +import { spawn } from 'node:child_process'; +import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +// ── Config ─────────────────────────────────────────────────────────────────── + +const SESSION_DIR = '/tmp/cursor-bridge-session'; +const MODEL = process.env.CURSOR_AGENT_MODEL || 'composer-1'; + +// Clean session dir +mkdirSync(SESSION_DIR, { recursive: true }); +try { + for (const f of readdirSync(SESSION_DIR)) unlinkSync(join(SESSION_DIR, f)); +} catch {} + +const log = (tag, msg) => console.log(`[${new Date().toISOString().slice(11, 23)}] [${tag}] ${msg}`); + +// ── Pre-scripted orchestrator replies ──────────────────────────────────────── + +const REPLIES = [ + 'Write a JavaScript class called `TaskQueue` that manages async tasks with a concurrency limit. Ask me about the API design.', + 'API: constructor(concurrency:number), add(asyncFn):Promise, waitAll():Promise. Show me a draft and ask for feedback.', + 'Good. Add: 1) onTaskComplete callback option in constructor, 2) pause() and resume() methods. Show updated code and ask if I want tests.', + 'Yes, write 3 tests: concurrency limiting, pause/resume, onTaskComplete callback. Show them and ask if anything else is needed.', + 'Everything looks great. No more changes needed. You are done, stop working.', +]; + +// ── Orchestrator: poll for questions, provide answers ───────────────────────── + +let turnCount = 0; +const answeredTurns = new Set(); + +function checkForQuestions() { + try { + const files = readdirSync(SESSION_DIR) + .filter((f) => f.startsWith('question_') && f.endsWith('.json')) + .sort(); + + for (const file of files) { + const turnNum = parseInt(file.match(/question_(\d+)\.json/)?.[1], 10); + if (!turnNum || answeredTurns.has(turnNum)) continue; + + const answerFile = join(SESSION_DIR, `answer_${turnNum}.json`); + if (existsSync(answerFile)) continue; + + try { + const question = JSON.parse(readFileSync(join(SESSION_DIR, file), 'utf8')); + turnCount++; + answeredTurns.add(turnNum); + + const preview = question.message.replace(/\n/g, '\\n').slice(0, 150); + log(`TURN${turnCount}←`, preview); + + const reply = REPLIES[turnCount - 1] || 'Done. Stop working.'; + log(`TURN${turnCount}→`, reply.slice(0, 120)); + + writeFileSync(answerFile, JSON.stringify({ reply, timestamp: Date.now() }), 'utf8'); + } catch {} + } + } catch {} +} + +const pollInterval = setInterval(checkForQuestions, 300); + +// ── Spawn ONE cursor-agent ─────────────────────────────────────────────────── + +const TASK_PROMPT = [ + 'You have access to a tool called "report_to_orchestrator" from the "orchestrator-bridge" MCP server.', + '', + 'RULES:', + '1. Use report_to_orchestrator for ALL communication with me.', + '2. Call it, read my reply, do work, call it again. Loop until I say stop.', + '3. Do NOT produce final output without going through the tool first.', + '', + 'Start NOW: call report_to_orchestrator to ask me what I want you to build.', +].join('\n'); + +log('INIT', `Model: ${MODEL} | Session dir: ${SESSION_DIR}`); +log('INIT', 'Spawning ONE cursor-agent process...\n'); + +const child = spawn('cursor-agent', [ + '--print', '--yolo', '--model', MODEL, '--output-format', 'text', + TASK_PROMPT, +], { + cwd: process.cwd(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], +}); + +try { child.stdin.end(); } catch {} + +let stdout = ''; +let stderr = ''; +child.stdout.on('data', (d) => { stdout += d.toString(); }); +child.stderr.on('data', (d) => { stderr += d.toString(); }); + +const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code)); + setTimeout(() => { + log('TIMEOUT', '5 min timeout, killing'); + try { child.kill('SIGKILL'); } catch {} + resolve(-1); + }, 300_000); +}); + +clearInterval(pollInterval); + +// ── Summary ────────────────────────────────────────────────────────────────── + +console.log('\n' + '═'.repeat(60)); +log('DONE', `Exit code: ${exitCode} | Turns: ${turnCount}`); + +if (stdout.trim()) { + console.log('\n── Agent output ──'); + console.log(stdout.trim().slice(0, 2000)); + if (stdout.trim().length > 2000) console.log(' ...(truncated)'); +} + +console.log('\n' + '═'.repeat(60)); +const pass = turnCount >= 3; +console.log(pass + ? `✓ PASS — ${turnCount} turns in 1 cursor-agent process via MCP` + : `✗ FAIL — only ${turnCount} turns (agent may not have called the tool)`); + +process.exit(pass ? 0 : 1); diff --git a/cursor-agent-mcp/test_5turn_single.mjs b/cursor-agent-mcp/test_5turn_single.mjs new file mode 100644 index 0000000..a4fe582 --- /dev/null +++ b/cursor-agent-mcp/test_5turn_single.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * 5-turn conversation in a SINGLE cursor-agent process. + * + * Uses interactive mode (no --print) with --output-format stream-json + * to detect response boundaries via {"type":"result",...} events. + * + * One process. Five rounds. Real bidirectional communication. + */ + +import { spawn } from 'node:child_process'; + +// ── Config ─────────────────────────────────────────────────────────────────── + +const MODEL = process.env.CURSOR_AGENT_MODEL || 'composer-1'; +const EXECUTABLE = process.env.CURSOR_AGENT_PATH || 'cursor-agent'; + +const TURNS = [ + // Turn 1: Assign task + 'I need a JavaScript utility function. Before writing it, ask me what it should do. Just ask the question, nothing else.', + + // Turn 2: Answer the question + 'Write a function called `deepMerge` that deep-merges two objects. Show me the code.', + + // Turn 3: Request a change + 'Good, but add support for merging arrays by concatenation instead of overwriting. Show updated code.', + + // Turn 4: Ask for tests + 'Now write 3 unit test cases for deepMerge covering: nested objects, array merging, and null handling.', + + // Turn 5: Final feedback + 'Perfect. Now add JSDoc comments to the deepMerge function with @param and @returns tags. Show the final version.', +]; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const log = (tag, msg) => { + const ts = new Date().toISOString().slice(11, 23); + console.log(`[${ts}] [${tag}] ${msg}`); +}; + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + log('INIT', `Spawning single cursor-agent process (model: ${MODEL})...`); + + const child = spawn(EXECUTABLE, [ + '--yolo', + '--model', MODEL, + '--output-format', 'stream-json', + ], { + cwd: process.cwd(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderrBuf = ''; + child.stderr.on('data', (d) => { stderrBuf += d.toString(); }); + + // Stream stdout line-by-line + let lineBuf = ''; + const lineListeners = []; + + function onLine(line) { + for (const listener of lineListeners) { + listener(line); + } + } + + child.stdout.on('data', (chunk) => { + lineBuf += chunk.toString(); + const lines = lineBuf.split('\n'); + lineBuf = lines.pop(); // keep incomplete line + for (const line of lines) { + if (line.trim()) onLine(line.trim()); + } + }); + + // Wait for a turn to complete: returns the full assistant response text + function waitForResult() { + return new Promise((resolve, reject) => { + let assistantText = ''; + const timeout = setTimeout(() => { + reject(new Error('Timeout waiting for result (120s)')); + }, 120_000); + + const handler = (line) => { + try { + const evt = JSON.parse(line); + + if (evt.type === 'assistant' && evt.message?.content) { + for (const part of evt.message.content) { + if (part.type === 'text') assistantText += part.text; + } + } + + if (evt.type === 'result') { + clearTimeout(timeout); + // Remove this handler + const idx = lineListeners.indexOf(handler); + if (idx >= 0) lineListeners.splice(idx, 1); + resolve({ + text: assistantText || evt.result || '(no text)', + duration_ms: evt.duration_ms, + is_error: evt.is_error, + }); + } + } catch { + // Not JSON, ignore + } + }; + + lineListeners.push(handler); + }); + } + + // Wait for the init event + await new Promise((resolve) => { + const handler = (line) => { + try { + const evt = JSON.parse(line); + if (evt.type === 'system' && evt.subtype === 'init') { + const idx = lineListeners.indexOf(handler); + if (idx >= 0) lineListeners.splice(idx, 1); + log('INIT', `Session: ${evt.session_id} | Model: ${evt.model}`); + resolve(); + } + } catch {} + }; + lineListeners.push(handler); + }); + + log('INIT', `Running ${TURNS.length} turns in single process...\n`); + + // ── Run all turns ────────────────────────────────────────────────────────── + + const results = []; + + for (let i = 0; i < TURNS.length; i++) { + const turnNum = i + 1; + const prompt = TURNS[i]; + + log(`TURN${turnNum}→`, prompt.slice(0, 90) + (prompt.length > 90 ? '...' : '')); + + // Write the message to stdin (newline submits it) + child.stdin.write(prompt + '\n'); + + // Wait for the full response + const response = await waitForResult(); + results.push(response); + + const preview = response.text.trim().replace(/\n/g, '\\n').slice(0, 120); + log(`TURN${turnNum}←`, `(${response.duration_ms}ms) ${preview}...`); + console.log(''); + } + + // Close stdin to end the process + child.stdin.end(); + await new Promise((resolve) => child.on('close', resolve)); + + // ── Summary ──────────────────────────────────────────────────────────────── + + console.log('═'.repeat(60)); + console.log('5-TURN SINGLE-PROCESS TEST RESULTS'); + console.log('═'.repeat(60)); + + for (let i = 0; i < results.length; i++) { + console.log(`\n── Turn ${i + 1} (${results[i].duration_ms}ms) ──`); + // Show first 300 chars of each response + console.log(results[i].text.trim().slice(0, 300)); + if (results[i].text.trim().length > 300) console.log(' ...(truncated)'); + } + + console.log('\n' + '═'.repeat(60)); + const totalMs = results.reduce((s, r) => s + (r.duration_ms || 0), 0); + const errors = results.filter(r => r.is_error).length; + log('DONE', `✓ ${TURNS.length} turns completed in 1 process | Total API time: ${(totalMs/1000).toFixed(1)}s | Errors: ${errors}`); + + process.exit(errors > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error('5-turn test failed:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/test_client.mjs b/cursor-agent-mcp/test_client.mjs new file mode 100644 index 0000000..9ab9ce8 --- /dev/null +++ b/cursor-agent-mcp/test_client.mjs @@ -0,0 +1,152 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +function parseListEnv(name) { + const v = process.env[name]; + if (!v) return undefined; + try { + // Allow JSON array in env + const parsed = JSON.parse(v); + if (Array.isArray(parsed)) return parsed; + } catch {} + // Fallback: comma-separated list + return v.split(',').map(s => s.trim()).filter(Boolean); +} + +async function main() { + const transport = new StdioClientTransport({ + command: 'node', + args: ['./server.js'], + cwd: new URL('.', import.meta.url).pathname.replace(/test_client\.mjs$/, ''), + // inherit PATH so cursor-agent can be found; override via env if needed + env: { ...process.env, CURSOR_AGENT_TIMEOUT_MS: process.env.CURSOR_AGENT_TIMEOUT_MS ?? '8000' }, + }); + + const client = new Client({ + name: 'cursor-agent-e2e-test', + version: '0.0.1', + }); + + await client.connect(transport); + + const tools = await client.listTools({}); + const names = tools.tools.map(t => t.name); + console.log('Tools:', names.join(', ')); + + // Default user prompt and extra CLI passthrough args + const [promptDefault = 'hello from MCP smoke test', ...extraArgs] = process.argv.slice(2); + + const preferred = process.env.TEST_TOOL; + const toolName = preferred && names.includes(preferred) + ? preferred + : (names.includes('cursor_agent_chat') ? 'cursor_agent_chat' : 'cursor_agent_run'); + + console.log('Using tool:', toolName); + + let args; + switch (toolName) { + case 'cursor_agent_chat': + args = { + prompt: process.env.TEST_PROMPT || promptDefault, + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + + case 'cursor_agent_run': + args = { + prompt: process.env.TEST_PROMPT || promptDefault, + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + + case 'cursor_agent_edit_file': + args = { + file: process.env.TEST_FILE || 'README.md', + instruction: process.env.TEST_INSTRUCTION || 'Summarize the file and suggest one improvement.', + apply: process.env.TEST_APPLY === '1', + dry_run: process.env.TEST_DRY_RUN === '1', + prompt: process.env.TEST_PROMPT, + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + + case 'cursor_agent_analyze_files': { + const paths = parseListEnv('TEST_PATHS') || ['.']; + args = { + paths, + prompt: process.env.TEST_PROMPT || 'Provide a brief analysis of these paths.', + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + } + + case 'cursor_agent_search_repo': { + args = { + query: process.env.TEST_QUERY || 'TODO', + include: parseListEnv('TEST_INCLUDE'), + exclude: parseListEnv('TEST_EXCLUDE'), + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + } + + case 'cursor_agent_plan_task': + args = { + goal: process.env.TEST_GOAL || 'Set up CI to lint and test this repo.', + constraints: parseListEnv('TEST_CONSTRAINTS'), + output_format: process.env.TEST_FORMAT || 'text', + extra_args: extraArgs, + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + + case 'cursor_agent_raw': { + const rawArgv = process.env.TEST_ARGV ? JSON.parse(process.env.TEST_ARGV) : ['--help']; + const print = process.env.TEST_PRINT === '1'; + args = { + argv: rawArgv, + print, + output_format: process.env.TEST_FORMAT || 'text', + ...(process.env.TEST_CWD ? { cwd: process.env.TEST_CWD } : {}), + }; + break; + } + + default: + throw new Error(`Unknown test tool ${toolName}`); + } + + const call = client.callTool({ + name: toolName, + arguments: args, + }); + + const callTimeout = Number.parseInt(process.env.TEST_TIMEOUT_MS || '90000', 10); + const result = await Promise.race([ + call, + new Promise((_, rej) => setTimeout(() => rej(new Error(`tool call timeout after ${callTimeout}ms`)), callTimeout)), + ]); + + if (result && result.content) { + const text = result.content.filter(c => c.type === 'text').map(c => c.text).join('\n'); + console.log('Tool call output (first 500 chars):'); + console.log(text.slice(0, 500)); + } + + await client.close(); +} + +main().catch((e) => { + console.error('E2E test failed:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/test_retryWithBackoff.mjs b/cursor-agent-mcp/test_retryWithBackoff.mjs new file mode 100644 index 0000000..c5bd219 --- /dev/null +++ b/cursor-agent-mcp/test_retryWithBackoff.mjs @@ -0,0 +1,52 @@ +import { retryWithBackoff } from './retryWithBackoff.js'; + +// Test 1: Success on first attempt +console.log('Test 1: Success on first attempt'); +try { + const result = await retryWithBackoff(async () => { + return 'success'; + }); + console.log('✓ Passed:', result === 'success'); +} catch (error) { + console.log('✗ Failed:', error.message); +} + +// Test 2: Success after retries +console.log('\nTest 2: Success after retries'); +let attemptCount = 0; +try { + const result = await retryWithBackoff(async () => { + attemptCount++; + if (attemptCount < 3) { + throw new Error(`Attempt ${attemptCount} failed`); + } + return 'success after retries'; + }, { maxRetries: 3, initialDelay: 100 }); + console.log('✓ Passed:', result === 'success after retries', `(attempts: ${attemptCount})`); +} catch (error) { + console.log('✗ Failed:', error.message); +} + +// Test 3: All retries fail +console.log('\nTest 3: All retries fail'); +let failCount = 0; +try { + await retryWithBackoff(async () => { + failCount++; + throw new Error(`Attempt ${failCount} failed`); + }, { maxRetries: 2, initialDelay: 50 }); + console.log('✗ Failed: Should have thrown an error'); +} catch (error) { + console.log('✓ Passed:', error.message === 'Attempt 3 failed', `(attempts: ${failCount})`); +} + +// Test 4: Input validation +console.log('\nTest 4: Input validation'); +try { + await retryWithBackoff('not a function'); + console.log('✗ Failed: Should have thrown TypeError'); +} catch (error) { + console.log('✓ Passed:', error instanceof TypeError); +} + +console.log('\nAll tests completed'); diff --git a/cursor-agent-mcp/test_session_e2e.mjs b/cursor-agent-mcp/test_session_e2e.mjs new file mode 100644 index 0000000..4b56853 --- /dev/null +++ b/cursor-agent-mcp/test_session_e2e.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * End-to-end test for bidirectional session tools via MCP. + * + * Spins up the cursor-agent-mcp server as a child process, connects as an + * MCP client, and runs a multi-round session conversation with cursor-agent. + * + * Usage: + * node test_session_e2e.mjs + * + * Env overrides: + * CURSOR_AGENT_MODEL – model to use (default: composer-1) + * CURSOR_AGENT_TIMEOUT_MS – per-round timeout (default: 120000) + * CURSOR_AGENT_FORCE – set to 1 to pass --yolo/-f (default: 1) + * DEBUG_CURSOR_MCP – set to 1 for server debug logs + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function log(tag, msg) { + const ts = new Date().toISOString().slice(11, 23); + console.log(`[${ts}] [${tag}] ${msg}`); +} + +function extractText(result) { + if (!result?.content) return ''; + return result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + +function parseStatus(text) { + const status = text.match(/status:\s*(\S+)/)?.[1] || 'unknown'; + const round = text.match(/round:\s*(\d+)/)?.[1] || '?'; + const sessionId = text.match(/session_id:\s*(\S+)/)?.[1] || null; + return { status, round, sessionId }; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const serverDir = new URL('.', import.meta.url).pathname; + + log('INIT', 'Starting cursor-agent-mcp server...'); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['./server.js'], + cwd: serverDir, + env: { + ...process.env, + CURSOR_AGENT_TIMEOUT_MS: process.env.CURSOR_AGENT_TIMEOUT_MS ?? '120000', + CURSOR_AGENT_FORCE: process.env.CURSOR_AGENT_FORCE ?? '1', + CURSOR_AGENT_MODEL: process.env.CURSOR_AGENT_MODEL ?? 'composer-1', + DEBUG_CURSOR_MCP: process.env.DEBUG_CURSOR_MCP ?? '0', + }, + }); + + const client = new Client({ + name: 'session-e2e-test', + version: '1.0.0', + }); + + await client.connect(transport); + log('INIT', 'Connected to MCP server.'); + + // List tools to verify session tools exist + const { tools } = await client.listTools({}); + const toolNames = tools.map((t) => t.name); + const sessionTools = toolNames.filter((n) => n.includes('session')); + log('INIT', `Found ${tools.length} tools. Session tools: ${sessionTools.join(', ')}`); + + if (!sessionTools.includes('cursor_agent_session_start')) { + throw new Error('cursor_agent_session_start tool not found!'); + } + + // ── Round 1: Start session with a task that should trigger a question ────── + + const task = [ + 'I need you to write a short JavaScript function.', + 'Before writing it, you MUST ask me exactly ONE question:', + 'what should the function do? Use the [CURSOR_QUESTION] markers.', + 'Do NOT write any code yet until I answer your question.', + ].join(' '); + + log('SESSION', `Starting session with task...`); + log('SESSION', `Task: "${task.slice(0, 100)}..."`); + + const startResult = await client.callTool({ + name: 'cursor_agent_session_start', + arguments: { + prompt: task, + output_format: 'text', + max_rounds: 5, + }, + }); + + const startText = extractText(startResult); + const s1 = parseStatus(startText); + log('ROUND1', `Status: ${s1.status} | Round: ${s1.round}`); + console.log('─'.repeat(60)); + console.log(startText); + console.log('─'.repeat(60)); + + if (!s1.sessionId) { + throw new Error('No session_id returned from session_start'); + } + + // ── Handle: if it asked a question, reply. If it completed, that's ok too ── + + let sessionId = s1.sessionId; + let currentStatus = s1.status; + let roundNum = 1; + + if (currentStatus === 'waiting_for_answer') { + // ── Round 2: Reply with what the function should do ──────────────────── + + roundNum++; + const reply1 = 'The function should take an array of numbers and return the sum of all even numbers. Name it sumEvens.'; + log(`ROUND${roundNum}`, `Replying: "${reply1.slice(0, 80)}..."`); + + const replyResult = await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { + session_id: sessionId, + reply: reply1, + }, + }); + + const replyText = extractText(replyResult); + const s2 = parseStatus(replyText); + log(`ROUND${roundNum}`, `Status: ${s2.status} | Round: ${s2.round}`); + console.log('─'.repeat(60)); + console.log(replyText); + console.log('─'.repeat(60)); + currentStatus = s2.status; + + // ── Round 3: If it asks another question, give feedback ────────────── + + if (currentStatus === 'waiting_for_answer') { + roundNum++; + const reply2 = 'Looks good! Please finalize the code and return it as the final result using [CURSOR_RESULT] markers.'; + log(`ROUND${roundNum}`, `Replying with feedback: "${reply2.slice(0, 80)}..."`); + + const feedbackResult = await client.callTool({ + name: 'cursor_agent_session_reply', + arguments: { + session_id: sessionId, + reply: reply2, + }, + }); + + const fbText = extractText(feedbackResult); + const s3 = parseStatus(fbText); + log(`ROUND${roundNum}`, `Status: ${s3.status} | Round: ${s3.round}`); + console.log('─'.repeat(60)); + console.log(fbText); + console.log('─'.repeat(60)); + currentStatus = s3.status; + } + } + + // ── Check final session status ───────────────────────────────────────────── + + log('CHECK', 'Querying final session status...'); + const statusResult = await client.callTool({ + name: 'cursor_agent_session_status', + arguments: { session_id: sessionId }, + }); + const statusText = extractText(statusResult); + const sFinal = parseStatus(statusText); + log('FINAL', `Status: ${sFinal.status} | Total rounds: ${sFinal.round}`); + console.log('═'.repeat(60)); + console.log(statusText); + console.log('═'.repeat(60)); + + // ── End session ──────────────────────────────────────────────────────────── + + log('CLEANUP', 'Ending session...'); + const endResult = await client.callTool({ + name: 'cursor_agent_session_end', + arguments: { session_id: sessionId }, + }); + log('CLEANUP', extractText(endResult)); + + // ── Summary ──────────────────────────────────────────────────────────────── + + console.log('\n' + '═'.repeat(60)); + log('SUMMARY', `Test complete!`); + log('SUMMARY', `Final status: ${sFinal.status}`); + log('SUMMARY', `Total rounds: ${sFinal.round}`); + log('SUMMARY', `Session ID: ${sessionId}`); + + if (sFinal.status === 'completed') { + log('SUMMARY', '✓ Session completed successfully — bidirectional communication worked!'); + } else if (sFinal.status === 'waiting_for_answer') { + log('SUMMARY', '⚠ Session still waiting (model may not have used CURSOR_RESULT markers)'); + } else { + log('SUMMARY', `⚠ Unexpected final status: ${sFinal.status}`); + } + + await client.close(); + process.exit(sFinal.status === 'completed' ? 0 : 1); +} + +main().catch((e) => { + console.error('E2E session test failed:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/test_session_feedback.mjs b/cursor-agent-mcp/test_session_feedback.mjs new file mode 100644 index 0000000..97ae8dc --- /dev/null +++ b/cursor-agent-mcp/test_session_feedback.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * 3-round feedback test: start → question → reply → feedback → final result. + * + * Tests that the session can handle multiple back-and-forth exchanges, + * including giving feedback on an intermediate result. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +function log(tag, msg) { + const ts = new Date().toISOString().slice(11, 23); + console.log(`[${ts}] [${tag}] ${msg}`); +} + +function extractText(result) { + return (result?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n'); +} + +function parseStatus(text) { + return { + status: text.match(/status:\s*(\S+)/)?.[1] || 'unknown', + round: text.match(/round:\s*(\d+)/)?.[1] || '?', + sessionId: text.match(/session_id:\s*(\S+)/)?.[1] || null, + }; +} + +async function callTool(client, name, args) { + const result = await client.callTool({ name, arguments: args }); + const text = extractText(result); + const info = parseStatus(text); + return { text, ...info }; +} + +async function main() { + const serverDir = new URL('.', import.meta.url).pathname; + + log('INIT', 'Connecting to cursor-agent-mcp server...'); + const transport = new StdioClientTransport({ + command: 'node', + args: ['./server.js'], + cwd: serverDir, + env: { + ...process.env, + CURSOR_AGENT_TIMEOUT_MS: process.env.CURSOR_AGENT_TIMEOUT_MS ?? '120000', + CURSOR_AGENT_FORCE: process.env.CURSOR_AGENT_FORCE ?? '1', + CURSOR_AGENT_MODEL: process.env.CURSOR_AGENT_MODEL ?? 'composer-1', + }, + }); + + const client = new Client({ name: 'feedback-test', version: '1.0.0' }); + await client.connect(transport); + log('INIT', 'Connected.'); + + // ── Round 1: Start with a deliberately ambiguous task ────────────────────── + const task = [ + 'I want you to write a Python function, but first ask me TWO things:', + '1) What the function should do', + '2) What to name it', + 'Ask both questions in a single [CURSOR_QUESTION] block.', + 'Do NOT write any code until I answer both questions.', + ].join(' '); + + log('R1', 'Starting session...'); + const r1 = await callTool(client, 'cursor_agent_session_start', { + prompt: task, + max_rounds: 6, + }); + log('R1', `→ status=${r1.status} round=${r1.round}`); + console.log(r1.text); + console.log(''); + + if (r1.status !== 'waiting_for_answer') { + log('R1', '⚠ Expected a question, got: ' + r1.status); + await client.close(); + process.exit(1); + } + + // ── Round 2: Answer the questions ────────────────────────────────────────── + const answer = [ + '1) The function should check if a string is a palindrome (reads the same forwards and backwards).', + '2) Name it is_palindrome.', + 'Write the code now, but use [CURSOR_QUESTION] to ask if I want any edge case handling (like ignoring spaces/case).', + ].join(' '); + + log('R2', 'Sending answers...'); + const r2 = await callTool(client, 'cursor_agent_session_reply', { + session_id: r1.sessionId, + reply: answer, + }); + log('R2', `→ status=${r2.status} round=${r2.round}`); + console.log(r2.text); + console.log(''); + + // ── Round 3: Give feedback if waiting, otherwise accept ──────────────────── + let r3; + if (r2.status === 'waiting_for_answer') { + const feedback = [ + 'Yes, make it case-insensitive and ignore spaces.', + 'Also add a docstring explaining the function.', + 'Now give me the final code with [CURSOR_RESULT] markers.', + ].join(' '); + + log('R3', 'Sending feedback...'); + r3 = await callTool(client, 'cursor_agent_session_reply', { + session_id: r1.sessionId, + reply: feedback, + }); + log('R3', `→ status=${r3.status} round=${r3.round}`); + console.log(r3.text); + console.log(''); + } else { + r3 = r2; + log('R3', 'Skipped (model completed on round 2).'); + } + + // ── If still waiting after 3 rounds, do one more ────────────────────────── + let rFinal = r3; + if (r3.status === 'waiting_for_answer') { + log('R4', 'Still waiting, sending final nudge...'); + rFinal = await callTool(client, 'cursor_agent_session_reply', { + session_id: r1.sessionId, + reply: 'Looks perfect. Please return the final code now using [CURSOR_RESULT] markers.', + }); + log('R4', `→ status=${rFinal.status} round=${rFinal.round}`); + console.log(rFinal.text); + console.log(''); + } + + // ── Cleanup ──────────────────────────────────────────────────────────────── + const endResult = await callTool(client, 'cursor_agent_session_end', { + session_id: r1.sessionId, + }); + + console.log('═'.repeat(60)); + log('DONE', `Final status: ${rFinal.status} | Total rounds: ${rFinal.round}`); + if (rFinal.status === 'completed') { + log('DONE', '✓ Multi-round feedback loop worked!'); + } else { + log('DONE', '⚠ Did not reach completed status.'); + } + + await client.close(); + process.exit(rFinal.status === 'completed' ? 0 : 1); +} + +main().catch((e) => { + console.error('Feedback test failed:', e); + process.exit(1); +}); diff --git a/cursor-agent-mcp/test_taskqueue.mjs b/cursor-agent-mcp/test_taskqueue.mjs new file mode 100644 index 0000000..8dd19d3 --- /dev/null +++ b/cursor-agent-mcp/test_taskqueue.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Tests for TaskQueue class + */ + +import { TaskQueue } from './TaskQueue.js'; + +const log = (test, msg) => console.log(`[${test}] ${msg}`); +const assert = (condition, message) => { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +}; + +// Test 1: Concurrency limiting +async function testConcurrencyLimiting() { + log('TEST 1', 'Testing concurrency limiting...'); + + const concurrency = 2; + const queue = new TaskQueue(concurrency); + + const startTimes = []; + const endTimes = []; + + // Create 5 tasks that each take 100ms + const tasks = Array.from({ length: 5 }, (_, i) => + queue.add(async () => { + startTimes.push({ id: i, time: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 100)); + endTimes.push({ id: i, time: Date.now() }); + return i; + }) + ); + + await queue.waitAll(); + + // Check that at most 'concurrency' tasks ran simultaneously + // We'll verify by checking start times - first 2 should start close together, + // then next 2 should start after first 2 finish + const sortedStarts = startTimes.sort((a, b) => a.time - b.time); + const sortedEnds = endTimes.sort((a, b) => a.time - b.time); + + // First two should start almost simultaneously (within 50ms) + const firstTwoStartDiff = sortedStarts[1].time - sortedStarts[0].time; + assert(firstTwoStartDiff < 50, 'First two tasks should start almost simultaneously'); + + // Third task should start after first task finishes (within 50ms) + const thirdStartAfterFirstEnd = sortedStarts[2].time - sortedEnds[0].time; + assert(thirdStartAfterFirstEnd < 50, 'Third task should start after first task finishes'); + + // Verify all tasks completed + const results = await Promise.all(tasks); + assert(results.length === 5, 'All 5 tasks should complete'); + assert(results.every((r, i) => r === i), 'All tasks should return correct results'); + + log('TEST 1', '✓ PASS - Concurrency limiting works correctly'); +} + +// Test 2: Pause/Resume +async function testPauseResume() { + log('TEST 2', 'Testing pause/resume...'); + + const queue = new TaskQueue(2); + const executionOrder = []; + + // Add first task that completes quickly + const task1 = queue.add(async () => { + executionOrder.push('task1-start'); + await new Promise(resolve => setTimeout(resolve, 50)); + executionOrder.push('task1-end'); + return 1; + }); + + // Pause before adding more tasks + queue.pause(); + + // Add second task - should be queued but not executed + const task2 = queue.add(async () => { + executionOrder.push('task2-start'); + await new Promise(resolve => setTimeout(resolve, 50)); + executionOrder.push('task2-end'); + return 2; + }); + + // Wait a bit to ensure task2 doesn't start + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify task1 completed but task2 hasn't started + assert(executionOrder.includes('task1-start'), 'Task1 should start'); + assert(executionOrder.includes('task1-end'), 'Task1 should complete'); + assert(!executionOrder.includes('task2-start'), 'Task2 should not start while paused'); + + // Resume and wait for all tasks + queue.resume(); + await queue.waitAll(); + + // Verify task2 executed after resume + assert(executionOrder.includes('task2-start'), 'Task2 should start after resume'); + assert(executionOrder.includes('task2-end'), 'Task2 should complete'); + + // Verify results + const [result1, result2] = await Promise.all([task1, task2]); + assert(result1 === 1, 'Task1 should return correct result'); + assert(result2 === 2, 'Task2 should return correct result'); + + log('TEST 2', '✓ PASS - Pause/resume works correctly'); +} + +// Test 3: onTaskComplete callback +async function testOnTaskComplete() { + log('TEST 3', 'Testing onTaskComplete callback...'); + + const completedTasks = []; + const errors = []; + + const queue = new TaskQueue(2, { + onTaskComplete: (error, result) => { + if (error) { + errors.push(error); + } else { + completedTasks.push(result); + } + } + }); + + // Add successful tasks + await queue.add(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return 'success1'; + }); + + await queue.add(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return 'success2'; + }); + + // Add a failing task + await queue.add(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + throw new Error('task failed'); + }).catch(() => { + // Expected error + }); + + await queue.waitAll(); + + // Verify callback was called for all tasks + assert(completedTasks.length === 2, 'Callback should be called for 2 successful tasks'); + assert(completedTasks.includes('success1'), 'Callback should receive success1'); + assert(completedTasks.includes('success2'), 'Callback should receive success2'); + assert(errors.length === 1, 'Callback should be called for 1 failed task'); + assert(errors[0].message === 'task failed', 'Callback should receive correct error'); + + log('TEST 3', '✓ PASS - onTaskComplete callback works correctly'); +} + +// Run all tests +async function runTests() { + console.log('═'.repeat(60)); + console.log('TaskQueue Tests'); + console.log('═'.repeat(60)); + console.log(''); + + try { + await testConcurrencyLimiting(); + console.log(''); + await testPauseResume(); + console.log(''); + await testOnTaskComplete(); + console.log(''); + console.log('═'.repeat(60)); + console.log('All tests passed! ✓'); + console.log('═'.repeat(60)); + process.exit(0); + } catch (error) { + console.error(''); + console.error('═'.repeat(60)); + console.error('Test failed:', error.message); + console.error('═'.repeat(60)); + process.exit(1); + } +} + +runTests();