From 57a036ba5e77419f320701b6f25c05cc2c3aa280 Mon Sep 17 00:00:00 2001 From: Vsevolod Sauta Date: Mon, 21 Sep 2026 10:31:36 +0300 Subject: [PATCH] Initial segregate: proxy source, deploy manifests, ImagePolicy Flux CRs. Move workload out of infra with cursor-devbox-style GitRepository + run_number-sha image automation. --- .gitea/workflows/build-and-push.yaml | 36 +++ .gitignore | 3 + Dockerfile | 51 +++ README.md | 46 +++ cursor-bootstrap/cli-config.json | 26 ++ deploy/010-cursor-agent-api.yaml | 153 +++++++++ docs-bootstrap-goal.md | 26 ++ entrypoint.sh | 10 + flux/image-automation.yaml | 59 ++++ scripts/bootstrap-cursor.sh | 29 ++ scripts/e2e-docker.sh | 80 +++++ scripts/mcp-stdio-bridge.sh | 36 +++ src/acp-client.mjs | 164 ++++++++++ src/acp-client.test.mjs | 57 ++++ src/mcp-server.mjs | 222 +++++++++++++ src/message-converter.mjs | 225 +++++++++++++ src/message-converter.test.mjs | 171 ++++++++++ src/models.mjs | 51 +++ src/server.mjs | 405 +++++++++++++++++++++++ src/session-manager.mjs | 464 +++++++++++++++++++++++++++ src/session-manager.test.mjs | 26 ++ src/stream-translator.mjs | 104 ++++++ 22 files changed, 2444 insertions(+) create mode 100644 .gitea/workflows/build-and-push.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cursor-bootstrap/cli-config.json create mode 100644 deploy/010-cursor-agent-api.yaml create mode 100644 docs-bootstrap-goal.md create mode 100644 entrypoint.sh create mode 100644 flux/image-automation.yaml create mode 100755 scripts/bootstrap-cursor.sh create mode 100755 scripts/e2e-docker.sh create mode 100755 scripts/mcp-stdio-bridge.sh create mode 100644 src/acp-client.mjs create mode 100644 src/acp-client.test.mjs create mode 100644 src/mcp-server.mjs create mode 100644 src/message-converter.mjs create mode 100644 src/message-converter.test.mjs create mode 100644 src/models.mjs create mode 100644 src/server.mjs create mode 100644 src/session-manager.mjs create mode 100644 src/session-manager.test.mjs create mode 100644 src/stream-translator.mjs diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..6058ea9 --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,36 @@ +name: build-and-push + +on: + push: + branches: [master] + paths: + - 'Dockerfile' + - 'entrypoint.sh' + - 'src/**' + - 'scripts/**' + - 'cursor-bootstrap/**' + - '.gitea/workflows/build-and-push.yaml' + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: dcr.trinitysan.by + username: ${{ secrets.DCR_USERNAME }} + password: ${{ secrets.DCR_PASSWORD }} + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest + dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.sha }} + dcr.trinitysan.by/infra/cursor-agent-api-proxy:${{ github.run_number }}-${{ github.sha }} + cache-from: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache + cache-to: type=registry,ref=dcr.trinitysan.by/infra/cursor-agent-api-proxy:build-cache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb404b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.log +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..243b4c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# cursor-agent-api-proxy — OpenAI-compatible API in front of cursor-agent CLI (ACP). +# +# Every request uses cursor-agent ACP (`agent acp`): +# - session/new with in-process MCP server for client tools[] +# - session/prompt with text + image ContentBlocks +# - session/request_permission denies built-in tools, allows MCP client tools +# - Tool invocations park until HTTP client returns role:"tool" +# +# Requires CURSOR_API_KEY (Cursor subscription) at runtime. + +FROM ubuntu:noble + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Node 20 (LTS) — proxy and stdio MCP bridge both run on Node. +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* + +# cursor-agent CLI (subscription-backed). +RUN curl -fsS https://cursor.com/install | bash && \ + CURSOR_VERSION_DIR="$(echo /root/.local/share/cursor-agent/versions/*)" && \ + cp -r "${CURSOR_VERSION_DIR}" /opt/cursor-agent && \ + ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent && \ + ln -sf /opt/cursor-agent/cursor-agent /usr/local/bin/agent.real + +# Application code. +COPY src/ /app/src/ +COPY scripts/ /app/scripts/ +COPY cursor-bootstrap/ /opt/cursor-bootstrap/ +COPY entrypoint.sh /app/entrypoint.sh + +RUN mkdir -p /workspace /home/nobody/.cursor && \ + chmod +x /app/scripts/*.sh /app/entrypoint.sh && \ + chown -R nobody:nogroup /opt/cursor-agent /opt/cursor-bootstrap /app /workspace /home/nobody + +WORKDIR /workspace +ENV PORT=4646 +ENV HOME=/home/nobody +ENV NODE_ENV=production +USER nobody + +EXPOSE 4646 +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf6907e --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# cursor-agent-api-proxy + +OpenAI-compatible HTTP API wrapping `cursor-agent` via **ACP** (`agent acp`). Deployed on **node4-k3s**. + +- **Image**: `dcr.trinitysan.by/infra/cursor-agent-api-proxy` +- **Deploy**: `deploy/` (PVC, Deployment, Service, ServiceExport) +- **Flux image automation**: `flux/` (ImageRepository / ImagePolicy / ImageUpdateAutomation) + +Infra on node4 keeps thin Flux glue (Namespace, SecretAdapters, `GitRepository`, Kustomizations) in `infrastructure/node4-k3s/131-cursor-agent-api.yaml`. Public HTTPS + Authentik live on node2 (`245-authentik-cursor-agent-api`). + +## Access + +| Endpoint | Detail | +|----------|--------| +| Public | `https://cursor-api.trinitysan.by` (Authentik outpost on node2) | +| In-cluster / MCS | `cursor-agent-api.cursor-agent-api.svc.clusterset.local:4646` | +| Health | `GET /health` | + +## CI + +Gitea Actions (`.gitea/workflows/build-and-push.yaml`) on `master` and `workflow_dispatch` pushes: + +| Tag | Purpose | +|-----|---------| +| `:latest` | Convenience | +| `:` | Immutable full SHA | +| `:-` | Sortable; elected by Flux ImagePolicy | + +Org secrets: `DCR_USERNAME`, `DCR_PASSWORD` (infra org). + +## Local build / E2E + +```bash +docker build -t cursor-agent-api-proxy:latest . +# Requires CURSOR_API_KEY in env or .env +./scripts/e2e-docker.sh +``` + +## Flux update flow + +1. CI pushes `run_number-sha` to DCR +2. ImageRepository scans; ImagePolicy picks highest `run_number` +3. ImageUpdateAutomation commits the new tag into `deploy/010-cursor-agent-api.yaml` +4. Kustomization applies; Deployment rolls + +Requires a Gitea PAT with **read+write** on this repo (`GITEA_CURSOR_AGENT_API_GITOPS_TOKEN` on node4, see infra `060-external-secrets.yaml.template`). diff --git a/cursor-bootstrap/cli-config.json b/cursor-bootstrap/cli-config.json new file mode 100644 index 0000000..d23dbe0 --- /dev/null +++ b/cursor-bootstrap/cli-config.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "editor": { "vimMode": false }, + "permissions": { + "allow": ["Mcp(*)"], + "deny": ["Shell(*)", "Read(*)", "Write(*)", "Grep(*)", "Glob(*)", "WebFetch(*)"] + }, + "model": { + "modelId": "default", + "displayModelId": "auto", + "displayName": "Auto", + "displayNameShort": "Auto", + "aliases": ["auto"], + "maxMode": false + }, + "hasChangedDefaultModel": true, + "approvalMode": "allowlist", + "sandbox": { + "mode": "disabled", + "networkAccess": "user_config_with_defaults" + }, + "attribution": { + "attributeCommitsToAgent": false, + "attributePRsToAgent": false + } +} diff --git a/deploy/010-cursor-agent-api.yaml b/deploy/010-cursor-agent-api.yaml new file mode 100644 index 0000000..2841495 --- /dev/null +++ b/deploy/010-cursor-agent-api.yaml @@ -0,0 +1,153 @@ +# Cursor Agent API workload (node4). +# Namespace + SecretAdapters live in infra glue (131-cursor-agent-api.yaml). +# Image tag updated by Flux ImageUpdateAutomation (run_number-sha tags). + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cursor-agent-state + namespace: cursor-agent-api +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-storage + resources: + requests: + storage: 1Gi + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cursor-agent-api + namespace: cursor-agent-api + labels: + app: cursor-agent-api +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: cursor-agent-api + template: + metadata: + labels: + app: cursor-agent-api + spec: + imagePullSecrets: + - name: dcr-registry-auth-ro + initContainers: + - name: bootstrap-cursor + image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"} + imagePullPolicy: IfNotPresent + env: + - name: CURSOR_API_KEY + valueFrom: + secretKeyRef: + name: cursor-auth + key: CURSOR_API_KEY + command: + - /app/scripts/bootstrap-cursor.sh + volumeMounts: + - name: cursor-state + mountPath: /home/nobody/.cursor + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: proxy + image: dcr.trinitysan.by/infra/cursor-agent-api-proxy:latest # {"$imagepolicy": "flux-system:cursor-agent-api-proxy"} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 4646 + env: + - name: CURSOR_API_KEY + valueFrom: + secretKeyRef: + name: cursor-auth + key: CURSOR_API_KEY + - name: PORT + value: "4646" + - name: PROXY_SESSION_TTL_MS + value: "120000" + - name: PROXY_MAX_CONCURRENT_SESSIONS + value: "4" + - name: ACP_REQUEST_TIMEOUT_MS + value: "300000" + - name: PROXY_INTERNAL_TOKEN + valueFrom: + secretKeyRef: + name: cursor-proxy-internal-token + key: PROXY_INTERNAL_TOKEN + volumeMounts: + - name: cursor-state + mountPath: /home/nobody/.cursor + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + volumes: + - name: cursor-state + persistentVolumeClaim: + claimName: cursor-agent-state + +--- +apiVersion: v1 +kind: Service +metadata: + name: cursor-agent-api + namespace: cursor-agent-api + annotations: + service.kubernetes.io/topology-mode: Auto +spec: + selector: + app: cursor-agent-api + ports: + - name: http + port: 4646 + targetPort: http + +--- +apiVersion: multicluster.x-k8s.io/v1alpha1 +kind: ServiceExport +metadata: + name: cursor-agent-api + namespace: cursor-agent-api diff --git a/docs-bootstrap-goal.md b/docs-bootstrap-goal.md new file mode 100644 index 0000000..f6a8cd3 --- /dev/null +++ b/docs-bootstrap-goal.md @@ -0,0 +1,26 @@ +# cursor-agent bootstrap — Desired State + +## Purpose + +Prepare the **cursor-agent-api-proxy** container image with a working Cursor CLI login and persistent `~/.cursor` state before the proxy serves traffic. + +## Mechanism + +1. **Image** — Ubuntu + Node 20 + cursor-agent CLI (`agent.real`) + the OpenAI-compatible ACP proxy (`src/server.mjs`). +2. **Bootstrap** — `bootstrap-cursor.sh` runs in an init container (and entrypoint) with `CURSOR_API_KEY` from SecretAdapter `cursor-auth` to seed `/home/nobody/.cursor` on the PVC. +3. **Runtime** — Each `/v1/chat/completions` request spawns `agent acp` with in-process MCP for client tools and native image ContentBlocks. + +The legacy GLM/stream-json wrapper path is **not** used in this image. + +## Build-time + +- Install cursor-agent CLI and proxy sources in the Docker image (no secrets at build time). + +## Runtime + +- Init container + PVC: `bootstrap-cursor.sh` with `CURSOR_API_KEY` from `cursor-auth`. +- Proxy env: `PROXY_INTERNAL_TOKEN`, `PROXY_MAX_CONCURRENT_SESSIONS`, `PROXY_SESSION_TTL_MS`, `ACP_REQUEST_TIMEOUT_MS`. + +## Related + +- [131-cursor-agent-api-goal.md](../../infrastructure/node4-k3s/131-cursor-agent-api-goal.md) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..9266051 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Container entrypoint: run cursor-agent bootstrap, then start the proxy. +set -euo pipefail + +# Seed ~/.cursor/cli-config.json (default global permissions) and verify +# the cursor-agent binary responds. +/app/scripts/bootstrap-cursor.sh + +# The HTTP server. Sessions spawn cursor-agent ACP subprocesses per request. +exec node /app/src/server.mjs diff --git a/flux/image-automation.yaml b/flux/image-automation.yaml new file mode 100644 index 0000000..0a5ae9a --- /dev/null +++ b/flux/image-automation.yaml @@ -0,0 +1,59 @@ +# Flux image automation for cursor-agent-api-proxy (applied into flux-system on node4). +# GitRepository cursor-agent-api-proxy is defined in infra glue (131-cursor-agent-api.yaml). +# CI tags: latest, , - — policy elects the last form. + +--- +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImageRepository +metadata: + name: cursor-agent-api-proxy + namespace: flux-system +spec: + image: dcr.trinitysan.by/infra/cursor-agent-api-proxy + interval: 1m0s + secretRef: + name: dcr-registry-auth-ro + +--- +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImagePolicy +metadata: + name: cursor-agent-api-proxy + namespace: flux-system +spec: + imageRepositoryRef: + name: cursor-agent-api-proxy + filterTags: + pattern: '^(?P[0-9]+)-[0-9a-f]{40}$' + extract: '$n' + policy: + numerical: + order: desc + +--- +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImageUpdateAutomation +metadata: + name: cursor-agent-api-proxy + namespace: flux-system +spec: + interval: 5m0s + sourceRef: + kind: GitRepository + name: cursor-agent-api-proxy + git: + checkout: + ref: + branch: master + commit: + author: + email: fluxcdbot@users.noreply.gitea.trinitysan.by + name: fluxcdbot + messageTemplate: | + chore(deploy): update cursor-agent-api-proxy image + {{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}} + push: + branch: master + update: + path: ./deploy + strategy: Setters diff --git a/scripts/bootstrap-cursor.sh b/scripts/bootstrap-cursor.sh new file mode 100755 index 0000000..ee234bb --- /dev/null +++ b/scripts/bootstrap-cursor.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Bootstrap: seed ~/.cursor/cli-config.json and verify the cursor-agent +# binary works before the HTTP server starts serving traffic. +set -euo pipefail + +CURSOR_HOME="${HOME}/.cursor" +BOOTSTRAP="/opt/cursor-bootstrap" +export NO_OPEN_BROWSER=1 + +mkdir -p "${CURSOR_HOME}" + +if [[ -f "${BOOTSTRAP}/cli-config.json" ]] && [[ ! -f "${CURSOR_HOME}/cli-config.json" ]]; then + cp -f "${BOOTSTRAP}/cli-config.json" "${CURSOR_HOME}/cli-config.json" +fi + +# cursor-agent auth via env (no interactive login). +if [[ -z "${CURSOR_API_KEY:-}" ]]; then + echo "CURSOR_API_KEY is not set" >&2 + exit 1 +fi +export CURSOR_API_KEY + +# cursor-agent itself must respond. +if ! /usr/local/bin/agent.real --version >/dev/null 2>&1; then + echo "cursor-agent binary is not executable" >&2 + exit 1 +fi + +echo "cursor-agent bootstrap: auth ok, agent responds" diff --git a/scripts/e2e-docker.sh b/scripts/e2e-docker.sh new file mode 100755 index 0000000..e60ed6c --- /dev/null +++ b/scripts/e2e-docker.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Local E2E: build image, run container with .env secrets, smoke-test text + image. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +IMAGE="${E2E_IMAGE:-cursor-agent-api-proxy:latest}" +PORT="${E2E_PORT:-18766}" +CONTAINER="${E2E_CONTAINER:-cursor-agent-api-e2e}" +TIMEOUT="${E2E_TIMEOUT:-180}" + +ENV_FILE="${REPO_ROOT}/.env" +if [ ! -f "$ENV_FILE" ]; then + echo "Error: .env not found at $ENV_FILE" >&2 + exit 1 +fi + +cleanup() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Building $IMAGE" +docker build -t "$IMAGE" "$ROOT" + +cleanup +echo "==> Starting container (host network, PORT=$PORT)" +docker run -d --name "$CONTAINER" --network host --env-file "$ENV_FILE" -e "PORT=$PORT" "$IMAGE" >/dev/null + +echo "==> Waiting for /health" +for i in $(seq 1 60); do + if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then + break + fi + if [ "$i" -eq 60 ]; then + echo "Health check failed" >&2 + docker logs "$CONTAINER" 2>&1 | tail -30 + exit 1 + fi + sleep 1 +done + +echo "==> Plain text chat" +AUTH_ARGS=() +if [ -n "${PROXY_INTERNAL_TOKEN:-}" ]; then + AUTH_ARGS=(-H "Authorization: Bearer ${PROXY_INTERNAL_TOKEN}") +fi +TEXT_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \ + "${AUTH_ARGS[@]}" \ + -H "Content-Type: application/json" \ + -d '{"model":"auto","stream":false,"messages":[{"role":"user","content":"Reply with exactly: HELLO"}]}')" +echo "$TEXT_RESP" | grep -q '"content":"HELLO"' || { + echo "Plain chat failed:" >&2 + echo "$TEXT_RESP" >&2 + exit 1 +} +echo "OK: plain chat" + +PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +echo "==> Image chat (data: PNG)" +IMG_RESP="$(curl -sf --max-time "$TIMEOUT" "http://127.0.0.1:${PORT}/v1/chat/completions" \ + "${AUTH_ARGS[@]}" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"auto\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What color is this 1x1 pixel? Reply with one color word only.\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,${PNG_B64}\"}}]}]}")" +echo "$IMG_RESP" | grep -q '"finish_reason":"stop"' || { + echo "Image chat failed:" >&2 + echo "$IMG_RESP" >&2 + docker logs "$CONTAINER" 2>&1 | tail -40 + exit 1 +} +CONTENT="$(echo "$IMG_RESP" | sed -n 's/.*"content":"\([^"]*\)".*/\1/p' | head -1)" +if [ -z "$CONTENT" ]; then + echo "Image chat returned empty content:" >&2 + echo "$IMG_RESP" >&2 + exit 1 +fi +echo "OK: image chat → $CONTENT" + +echo "==> All E2E checks passed" diff --git a/scripts/mcp-stdio-bridge.sh b/scripts/mcp-stdio-bridge.sh new file mode 100755 index 0000000..e92dc58 --- /dev/null +++ b/scripts/mcp-stdio-bridge.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Bridge: cursor-agent spawns this as a stdio MCP server. We connect to a +# per-session unix socket provided as $1 and tunnel newline-delimited +# JSON-RPC frames between cursor-agent's stdin/stdout and the proxy-owned +# socket server (which is the real MCP server). +# +# Uses Node (always available in the image) rather than nc, to avoid +# depending on netcat variants that may not support -U for unix sockets. +socket="$1" +if [ -z "$socket" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +exec node -e ' +const sock = process.argv[1]; +if (!sock) { console.error("mcp-stdio-bridge: missing socket arg"); process.exit(1); } +const net = require("node:net"); +const conn = net.createConnection(sock); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + // Forward as-is; JSON-RPC frames are newline-delimited on both sides. + conn.write(chunk); +}); +process.stdin.on("end", () => conn.end()); +conn.setEncoding("utf8"); +conn.on("data", (chunk) => process.stdout.write(chunk)); +conn.on("end", () => process.exit(0)); +conn.on("close", () => process.exit(0)); +conn.on("error", (e) => { + console.error("[mcp-stdio-bridge] socket error: " + e.message); + process.exit(1); +}); +process.on("SIGPIPE", () => process.exit(0)); +' "$socket" diff --git a/src/acp-client.mjs b/src/acp-client.mjs new file mode 100644 index 0000000..1168e54 --- /dev/null +++ b/src/acp-client.mjs @@ -0,0 +1,164 @@ +/** + * Minimal ACP (Agent Client Protocol) JSON-RPC client over stdio. + * Newline-delimited JSON-RPC 2.0 per ACP transport spec. + */ + +export class AcpClient { + /** + * @param {object} opts + * @param {import("node:stream").Writable} opts.stdin + * @param {import("node:stream").Readable} opts.stdout + * @param {(msg: string) => void} [opts.log] + */ + constructor({ stdin, stdout, log, requestTimeoutMs }) { + this.stdin = stdin; + this.stdout = stdout; + this.log = log || (() => {}); + this.requestTimeoutMs = + requestTimeoutMs ?? parseInt(process.env.ACP_REQUEST_TIMEOUT_MS || "300000", 10); + this._buf = ""; + this._nextId = 1; + /** @type {Map} */ + this._pending = new Map(); + /** @type {(update: object) => void} */ + this.onSessionUpdate = null; + /** + * Handle incoming JSON-RPC requests from the agent (permissions, cursor extensions). + * @type {(method: string, params: object) => Promise} + */ + this.onIncomingRequest = null; + this._closed = false; + this.agentCapabilities = null; + + stdout.setEncoding("utf8"); + stdout.on("data", (chunk) => this._onData(chunk)); + } + + _send(obj) { + if (this._closed || this.stdin.destroyed) return; + this.stdin.write(`${JSON.stringify(obj)}\n`); + } + + _onData(chunk) { + this._buf += chunk; + let nl; + while ((nl = this._buf.indexOf("\n")) >= 0) { + const line = this._buf.slice(0, nl).trim(); + this._buf = this._buf.slice(nl + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch (e) { + this.log(`[acp] parse error: ${e.message}`); + continue; + } + this._dispatch(msg).catch((e) => this.log(`[acp] dispatch error: ${e.message}`)); + } + } + + async _dispatch(msg) { + if (msg.method === "session/update") { + this.onSessionUpdate?.(msg.params || {}); + return; + } + + if (msg.method && msg.id != null && !Object.prototype.hasOwnProperty.call(msg, "result") && !msg.error) { + const handler = this.onIncomingRequest; + if (!handler) { + this._send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32603, message: "no incoming request handler" }, + }); + return; + } + try { + const result = await handler(msg.method, msg.params || {}); + this._send({ jsonrpc: "2.0", id: msg.id, result: result ?? {} }); + } catch (e) { + this._send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32000, message: e.message || "handler failed" }, + }); + } + return; + } + + if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) { + const p = this._pending.get(msg.id); + if (!p) return; + this._pending.delete(msg.id); + if (msg.error) { + const err = new Error(msg.error.message || "ACP error"); + err.code = msg.error.code; + err.data = msg.error.data; + p.reject(err); + } else { + p.resolve(msg.result); + } + } + } + + /** + * @param {string} method + * @param {object} [params] + * @returns {Promise} + */ + request(method, params = {}) { + const id = this._nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this._pending.has(id)) return; + this._pending.delete(id); + reject(new Error(`ACP request timeout after ${this.requestTimeoutMs}ms: ${method}`)); + }, this.requestTimeoutMs); + + this._pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, + }); + this._send({ jsonrpc: "2.0", id, method, params }); + }); + } + + async initialize() { + const result = await this.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "cursor-agent-api-proxy", version: "1.6.0" }, + }); + this.agentCapabilities = result.agentCapabilities || null; + if (!result.agentCapabilities?.promptCapabilities?.image) { + throw new Error("cursor-agent ACP does not advertise image prompt capability"); + } + return result; + } + + async authenticateIfNeeded() { + try { + return await this.request("authenticate", { methodId: "cursor_login" }); + } catch (e) { + this.log(`[acp] authenticate skipped or failed: ${e.message}`); + return null; + } + } + + close() { + this._closed = true; + for (const [, p] of this._pending) { + p.reject(new Error("ACP client closed")); + } + this._pending.clear(); + } +} diff --git a/src/acp-client.test.mjs b/src/acp-client.test.mjs new file mode 100644 index 0000000..d2cc926 --- /dev/null +++ b/src/acp-client.test.mjs @@ -0,0 +1,57 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { AcpClient } from "./acp-client.mjs"; + +describe("AcpClient", () => { + it("resolves request/response pairs", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const client = new AcpClient({ stdin, stdout }); + + const p = client.request("ping", { ok: true }); + const written = await new Promise((r) => { + stdin.once("data", (c) => r(JSON.parse(c.toString()))); + }); + assert.equal(written.method, "ping"); + assert.equal(written.id, 1); + + stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", id: written.id, result: { pong: true } })}\n` + ); + const result = await p; + assert.deepEqual(result, { pong: true }); + client.close(); + }); + + it("dispatches session/update notifications", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const client = new AcpClient({ stdin, stdout }); + const updates = []; + client.onSessionUpdate = (u) => updates.push(u); + + stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate: "agent_message_chunk" } }, + })}\n` + ); + + await new Promise((r) => setTimeout(r, 50)); + assert.equal(updates.length, 1); + assert.equal(updates[0].sessionId, "s1"); + client.close(); + }); + + it("rejects requests that exceed timeout", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const client = new AcpClient({ stdin, stdout, requestTimeoutMs: 30 }); + + const p = client.request("slow", {}); + await assert.rejects(p, /timeout.*slow/i); + client.close(); + }); +}); diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs new file mode 100644 index 0000000..51d9476 --- /dev/null +++ b/src/mcp-server.mjs @@ -0,0 +1,222 @@ +/** + * In-process MCP server speaking JSON-RPC 2.0 (newline-delimited) over a + * unix socket. cursor-agent connects to this socket via a tiny stdio bridge + * script (scripts/mcp-stdio-bridge.sh) and treats it as a regular stdio MCP + * server. + * + * The server ADVERTISES the client's tools (so the LLM sees them and can + * decide to call them) but does NOT execute them. When `tools/call` arrives, + * it generates an OpenAI-style `tool_call_id`, parks the response as a + * pending Promise, and emits a `tool_call` event on the parent session. + * The proxy resolves the Promise later when the HTTP client sends back a + * `role: "tool"` message with the result. + */ + +import { createServer } from "node:net"; + +/** + * @typedef {Object} McpTool + * @property {string} name + * @property {string} [description] + * @property {object} [inputSchema] JSON Schema for parameters + */ + +/** + * @typedef {Object} PendingToolCall + * @property {string} toolCallId + * @property {string} name + * @property {object} arguments + * @property {(result: string) => void} resolve + * @property {(err: Error) => void} reject + */ + +export class ProxyMcpServer { + /** + * @param {object} opts + * @param {string} opts.socketPath + * @param {McpTool[]} opts.tools + * @param {(call: {toolCallId: string, name: string, arguments: object}) => void} opts.onToolCall + * @param {(msg: string) => void} [opts.log] + */ + constructor({ socketPath, tools, onToolCall, log }) { + this.socketPath = socketPath; + this.tools = tools; + this.onToolCall = onToolCall || (() => {}); + this.log = log || (() => {}); + /** @type {import("node:net").Server} */ + this.server = null; + /** Connection that cursor-agent is currently using. */ + this.conn = null; + /** @type {Map} key = toolCallId */ + this.pending = new Map(); + } + + /** Start listening on the unix socket. */ + async start() { + this.server = createServer((conn) => this._handleConn(conn)); + await new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(this.socketPath, resolve); + }); + } + + async stop() { + // Reject any pending tool calls so callers don't hang. + for (const [, p] of this.pending) { + p.reject(new Error("mcp server stopped")); + } + this.pending.clear(); + if (this.conn) { + try { + this.conn.end(); + } catch { + /* ignore */ + } + } + if (this.server) { + await new Promise((r) => this.server.close(r)); + } + } + + /** + * Resolve a parked tool call with a result string. Returns true if a + * matching pending call was found. + */ + resolveToolCall(toolCallId, result) { + const p = this.pending.get(toolCallId); + if (!p) return false; + this.pending.delete(toolCallId); + p.resolve(String(result ?? "")); + return true; + } + + /** Reject a parked tool call (e.g. on session teardown). */ + rejectToolCall(toolCallId, err) { + const p = this.pending.get(toolCallId); + if (!p) return false; + this.pending.delete(toolCallId); + p.reject(err); + return true; + } + + // -------------------------------------------------------------------------- + + _handleConn(conn) { + // cursor-agent spawns one stdio bridge per session; only one connection + // is expected. If a new one arrives, drop the old. + if (this.conn && this.conn.writable) { + this.log("mcp: new connection replacing existing"); + try { + this.conn.end(); + } catch { + /* ignore */ + } + } + this.conn = conn; + conn.setEncoding("utf8"); + let buf = ""; + conn.on("data", (chunk) => { + buf += chunk; + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + const trimmed = line.trim(); + if (!trimmed) continue; + let msg; + try { + msg = JSON.parse(trimmed); + } catch (e) { + this.log(`mcp: parse error: ${e.message}`); + continue; + } + this._handleMessage(msg, conn).catch((e) => this.log(`mcp: handler error: ${e.message}`)); + } + }); + conn.on("error", (e) => this.log(`mcp: conn error: ${e.message}`)); + conn.on("close", () => { + if (this.conn === conn) this.conn = null; + }); + } + + _send(conn, obj) { + if (conn.writableEnded) return; + conn.write(JSON.stringify(obj) + "\n"); + } + + async _handleMessage(msg, conn) { + const { id, method, params } = msg; + + if (method === "initialize") { + this._send(conn, { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "proxy-client-tools", version: "1.0.0" }, + }, + }); + return; + } + + if (method === "notifications/initialized") { + // No response required for notifications. + return; + } + + if (method === "tools/list") { + const tools = this.tools.map((t) => ({ + name: t.name, + description: t.description || `Client-side tool ${t.name}`, + inputSchema: t.inputSchema || { type: "object", properties: {} }, + })); + this._send(conn, { jsonrpc: "2.0", id, result: { tools } }); + return; + } + + if (method === "tools/call") { + this._handleToolsCall(id, params || {}, conn); + return; + } + + this._send(conn, { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `unknown method: ${method}` }, + }); + } + + _handleToolsCall(id, params, conn) { + const { name, arguments: args } = params; + const toolCallId = + params.toolCallId || + `call_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + + /** @type {PendingToolCall} */ + const pending = { + toolCallId, + name, + arguments: args || {}, + resolve: (result) => { + this._send(conn, { + jsonrpc: "2.0", + id, + result: { content: [{ type: "text", text: String(result ?? "") }] }, + }); + }, + reject: (err) => { + this._send(conn, { + jsonrpc: "2.0", + id, + error: { code: -32000, message: err.message || "tool call failed" }, + }); + }, + }; + this.pending.set(toolCallId, pending); + + // Notify session so it can stream the OpenAI tool_calls chunk to the + // HTTP client. The session stays alive while we hold this Promise. + this.onToolCall({ toolCallId, name, arguments: args || {} }); + } +} diff --git a/src/message-converter.mjs b/src/message-converter.mjs new file mode 100644 index 0000000..446883a --- /dev/null +++ b/src/message-converter.mjs @@ -0,0 +1,225 @@ +/** + * Convert OpenAI chat messages to ACP ContentBlock arrays. + */ + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export const MAX_IMAGES_PER_REQUEST = 5; +export const MAX_IMAGE_BYTES = 15 * 1024 * 1024; +const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + +const PRIVATE_IP_PATTERNS = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^169\.254\./, + /^0\./, + /^::1$/, + /^fc00:/i, + /^fd00:/i, + /^fe80:/i, +]; + +export function messageContentToText(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((p) => p.type === "text") + .map((p) => p.text || "") + .join(""); +} + +export function hasImageParts(messages) { + for (const m of messages || []) { + if (!Array.isArray(m.content)) continue; + if (m.content.some((p) => p.type === "image_url")) return true; + } + return false; +} + +function countImages(messages) { + let n = 0; + for (const m of messages || []) { + if (!Array.isArray(m.content)) continue; + n += m.content.filter((p) => p.type === "image_url").length; + } + return n; +} + +function parseDataUrl(url) { + const m = url.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i); + if (!m) throw new Error("invalid data:image URL (expected base64 image)"); + const mimeType = m[1].toLowerCase(); + if (!ALLOWED_MIMES.has(mimeType)) { + throw new Error(`unsupported image MIME type: ${mimeType}`); + } + const data = m[2]; + const buf = Buffer.from(data, "base64"); + if (buf.length > MAX_IMAGE_BYTES) { + throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`); + } + return { mimeType, data: buf.toString("base64") }; +} + +/** Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) to dotted IPv4 for range checks. */ +export function normalizeIpAddress(ip) { + const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (mapped) return mapped[1]; + return ip; +} + +export function isPrivateIp(ip) { + const normalized = normalizeIpAddress(ip); + if (isIP(normalized) === 4) { + if (normalized.startsWith("172.")) { + const second = parseInt(normalized.split(".")[1], 10); + if (second >= 16 && second <= 31) return true; + } + return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized)); + } + if (isIP(normalized) === 6) { + return PRIVATE_IP_PATTERNS.some((re) => re.test(normalized)); + } + return false; +} + +async function assertPublicHost(hostname) { + if (hostname === "localhost" || hostname.endsWith(".local")) { + throw new Error("image URL host not allowed"); + } + const records = await lookup(hostname, { all: true }); + for (const r of records) { + if (isPrivateIp(r.address)) { + throw new Error("image URL resolves to private IP address"); + } + } +} + +async function fetchHttpsImage(url) { + let current = url; + for (let redirects = 0; redirects <= 3; redirects++) { + const parsed = new URL(current); + if (parsed.protocol !== "https:") { + throw new Error("image URL must use https"); + } + await assertPublicHost(parsed.hostname); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10000); + let res; + try { + res = await fetch(current, { signal: controller.signal, redirect: "manual" }); + } finally { + clearTimeout(timer); + } + + if (res.status >= 300 && res.status < 400) { + const loc = res.headers.get("location"); + if (!loc) throw new Error("image URL redirect missing Location header"); + current = new URL(loc, current).toString(); + continue; + } + + if (!res.ok) { + throw new Error(`failed to fetch image URL: HTTP ${res.status}`); + } + + const contentType = (res.headers.get("content-type") || "image/jpeg").split(";")[0].trim().toLowerCase(); + if (!ALLOWED_MIMES.has(contentType)) { + throw new Error(`unsupported image MIME type from URL: ${contentType}`); + } + + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > MAX_IMAGE_BYTES) { + throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`); + } + return { mimeType: contentType, data: buf.toString("base64") }; + } + throw new Error("too many redirects fetching image URL"); +} + +/** + * @param {string} url + * @returns {Promise<{mimeType: string, data: string}>} + */ +export async function parseImageUrl(url) { + if (typeof url !== "string" || !url) { + throw new Error("image_url.url is required"); + } + if (url.startsWith("data:")) { + return parseDataUrl(url); + } + if (url.startsWith("https://")) { + return fetchHttpsImage(url); + } + throw new Error("image_url must be a data: URL or https:// URL"); +} + +function roleLabel(role) { + if (role === "system") return "System"; + if (role === "user") return "User"; + if (role === "assistant") return "Assistant"; + return role; +} + +function formatMessageText(m) { + const text = messageContentToText(m.content); + let body = text; + if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { + const calls = m.tool_calls + .map((c) => ` - ${c.function?.name}(${c.function?.arguments || "{}"}) [id=${c.id}]`) + .join("\n"); + body = `${text}\n[Tool calls issued:]\n${calls}`; + } + return body; +} + +/** + * @param {Array} messages OpenAI messages + * @returns {Promise>} ACP ContentBlock[] + */ +export async function openAiMessagesToAcpBlocks(messages) { + const imageCount = countImages(messages); + if (imageCount > MAX_IMAGES_PER_REQUEST) { + throw new Error(`too many images (max ${MAX_IMAGES_PER_REQUEST})`); + } + + const filtered = (messages || []).filter((m) => m.role !== "tool"); + const nonEmpty = filtered.filter((m) => { + if (typeof m.content === "string") return m.content.length > 0; + if (Array.isArray(m.content)) { + return m.content.some((p) => p.type === "text" && p.text) || m.content.some((p) => p.type === "image_url"); + } + if (m.tool_calls) return true; + return false; + }); + + /** @type {Array} */ + const blocks = []; + + for (const m of nonEmpty) { + const label = roleLabel(m.role); + const body = formatMessageText(m); + if (body) { + blocks.push({ type: "text", text: `[${label}]\n${body}` }); + } else if (Array.isArray(m.content) && m.content.some((p) => p.type === "image_url")) { + blocks.push({ type: "text", text: `[${label}]` }); + } + + if (Array.isArray(m.content)) { + for (const part of m.content) { + if (part.type !== "image_url") continue; + const url = part.image_url?.url; + const { mimeType, data } = await parseImageUrl(url); + blocks.push({ type: "image", mimeType, data }); + } + } + } + + if (blocks.length === 0) { + throw new Error("no content blocks produced from messages"); + } + + return blocks; +} diff --git a/src/message-converter.test.mjs b/src/message-converter.test.mjs new file mode 100644 index 0000000..4b36e2f --- /dev/null +++ b/src/message-converter.test.mjs @@ -0,0 +1,171 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + messageContentToText, + hasImageParts, + parseImageUrl, + openAiMessagesToAcpBlocks, + MAX_IMAGES_PER_REQUEST, +} from "./message-converter.mjs"; +import { pickModelValueId, resolveModelId } from "./session-manager.mjs"; + +const PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +describe("messageContentToText", () => { + it("joins text parts", () => { + assert.equal( + messageContentToText([{ type: "text", text: "a" }, { type: "text", text: "b" }]), + "ab" + ); + }); + + it("ignores image_url parts", () => { + assert.equal( + messageContentToText([ + { type: "text", text: "hi" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ]), + "hi" + ); + }); +}); + +describe("hasImageParts", () => { + it("detects image_url", () => { + assert.equal( + hasImageParts([{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }]), + true + ); + assert.equal(hasImageParts([{ role: "user", content: "text only" }]), false); + }); +}); + +describe("parseImageUrl", () => { + it("parses data URL", async () => { + const r = await parseImageUrl(`data:image/png;base64,${PNG_B64}`); + assert.equal(r.mimeType, "image/png"); + assert.equal(r.data, PNG_B64); + }); + + it("rejects http URL", async () => { + await assert.rejects(() => parseImageUrl("http://example.com/x.png"), /https/); + }); + + it("rejects private IP URL", async () => { + await assert.rejects( + () => parseImageUrl("https://127.0.0.1/x.png"), + /private|not allowed/i + ); + }); +}); + +describe("isPrivateIp", () => { + it("treats IPv4-mapped loopback as private", async () => { + const { isPrivateIp } = await import("./message-converter.mjs"); + assert.equal(isPrivateIp("::ffff:127.0.0.1"), true); + assert.equal(isPrivateIp("::ffff:10.0.0.1"), true); + assert.equal(isPrivateIp("8.8.8.8"), false); + }); +}); + +describe("openAiMessagesToAcpBlocks", () => { + it("produces labeled text blocks", async () => { + const blocks = await openAiMessagesToAcpBlocks([{ role: "user", content: "Hello" }]); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].type, "text"); + assert.match(blocks[0].text, /\[User\]/); + assert.match(blocks[0].text, /Hello/); + }); + + it("adds image block after message text", async () => { + const blocks = await openAiMessagesToAcpBlocks([ + { + role: "user", + content: [ + { type: "text", text: "What color?" }, + { type: "image_url", image_url: { url: `data:image/png;base64,${PNG_B64}` } }, + ], + }, + ]); + assert.equal(blocks.length, 2); + assert.equal(blocks[0].type, "text"); + assert.equal(blocks[1].type, "image"); + assert.equal(blocks[1].mimeType, "image/png"); + }); + + it("rejects too many images", async () => { + const parts = Array.from({ length: MAX_IMAGES_PER_REQUEST + 1 }, () => ({ + type: "image_url", + image_url: { url: `data:image/png;base64,${PNG_B64}` }, + })); + await assert.rejects( + () => openAiMessagesToAcpBlocks([{ role: "user", content: parts }]), + /too many images/ + ); + }); +}); + +describe("pickModelValueId", () => { + const modelOption = { + id: "model", + currentValue: "default[]", + options: [ + { value: "default[]", name: "Auto" }, + { value: "gpt-5.2", name: "GPT-5.2" }, + { value: "composer-2.5[fast=true]", name: "composer-2.5" }, + ], + }; + + it("maps auto to default[]", () => { + assert.equal(pickModelValueId(modelOption, "auto"), "default[]"); + }); + + it("maps exact model id", () => { + assert.equal(pickModelValueId(modelOption, "gpt-5.2"), "gpt-5.2"); + }); + + it("maps by prefix bracket", () => { + assert.equal(pickModelValueId(modelOption, "composer-2.5"), "composer-2.5[fast=true]"); + }); +}); + +describe("resolveModelId", () => { + const sessionWithModels = { + models: { + currentModelId: "default[]", + availableModels: [ + { modelId: "default[]", name: "Auto" }, + { modelId: "gpt-5.2", name: "GPT-5.2" }, + { modelId: "composer-2.5[fast=true]", name: "composer-2.5" }, + ], + }, + configOptions: [], + }; + + it("maps auto via availableModels", () => { + assert.equal(resolveModelId(sessionWithModels, "auto"), "default[]"); + }); + + it("maps exact modelId in availableModels", () => { + assert.equal(resolveModelId(sessionWithModels, "gpt-5.2"), "gpt-5.2"); + }); + + it("maps by name in availableModels", () => { + assert.equal(resolveModelId(sessionWithModels, "composer-2.5"), "composer-2.5[fast=true]"); + }); + + it("falls back to configOptions when availableModels empty", () => { + const sessionResult = { + models: { currentModelId: null, availableModels: [] }, + configOptions: [ + { + id: "model", + currentValue: "default[]", + options: [{ value: "default[]", name: "Auto" }, { value: "gpt-5.2", name: "GPT-5.2" }], + }, + ], + }; + assert.equal(resolveModelId(sessionResult, "gpt-5.2"), "gpt-5.2"); + }); +}); diff --git a/src/models.mjs b/src/models.mjs new file mode 100644 index 0000000..e579e17 --- /dev/null +++ b/src/models.mjs @@ -0,0 +1,51 @@ +/** + * Model registry. The proxy exposes all Cursor-subscription models, served + * via the cursor-agent CLI. There is no separate provider path. + * + * The list is served on GET /v1/models. + */ + +export const CURSOR_MODELS = [ + "auto", + "composer-1.5", + "composer-1", + "opus-4.6-thinking", + "opus-4.6", + "opus-4.5-thinking", + "opus-4.5", + "sonnet-4.5-thinking", + "sonnet-4.5", + "gpt-5.3-codex", + "gpt-5.3-codex-fast", + "gpt-5.3-codex-low", + "gpt-5.3-codex-low-fast", + "gpt-5.3-codex-high", + "gpt-5.3-codex-high-fast", + "gpt-5.3-codex-xhigh", + "gpt-5.3-codex-xhigh-fast", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.2-codex-low", + "gpt-5.2-codex-low-fast", + "gpt-5.1-codex-max", + "gemini-3-pro", + "gemini-3-flash", + "grok", +]; + +export function allModelIds() { + return [...CURSOR_MODELS]; +} + +/** Normalize a model id from the request to a known value or "auto". */ +export function normalizeModel(input) { + if (!input) return "auto"; + const m = String(input); + if (m.startsWith("cursor/")) return m.slice("cursor/".length) || "auto"; + if (m.startsWith("cursor-")) { + const r = m.slice("cursor-".length); + if (r && CURSOR_MODELS.includes(r)) return r; + } + if (CURSOR_MODELS.includes(m)) return m; + return "auto"; +} diff --git a/src/server.mjs b/src/server.mjs new file mode 100644 index 0000000..62a1819 --- /dev/null +++ b/src/server.mjs @@ -0,0 +1,405 @@ +/** + * OpenAI-compatible proxy in front of cursor-agent CLI (ACP transport). + * + * Endpoints: + * GET /v1/models List supported models + * POST /v1/chat/completions Chat completion (streaming or not) + * GET /health Liveness probe + * + * Every request uses cursor-agent ACP (`agent acp`): + * - session/new with in-process MCP server for client tools[] + * - session/prompt with text + image ContentBlocks + * - session/request_permission denies built-in tools, allows MCP client tools + * - Tool invocations park until HTTP client returns role:"tool" + * - In-cluster callers authenticate with PROXY_INTERNAL_TOKEN; Authentik outpost via X-Authentik-Username + * + * No external dependencies. Uses only Node built-ins. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { allModelIds, normalizeModel } from "./models.mjs"; +import { + createSession, + findSessionForFollowUp, + destroyAllSessions, +} from "./session-manager.mjs"; +import { + messageContentToText, + hasImageParts, + openAiMessagesToAcpBlocks, +} from "./message-converter.mjs"; +import { textChunk, toolCallChunk, finishChunk, nonStreamResponse } from "./stream-translator.mjs"; + +const PORT = parseInt(process.env.PORT || "4646", 10); +const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "10000", 10); +const MAX_BODY_BYTES = parseInt(process.env.PROXY_MAX_BODY_BYTES || String(24 * 1024 * 1024), 10); +const INTERNAL_TOKEN = process.env.PROXY_INTERNAL_TOKEN || ""; + +/** @param {import("node:http").ServerResponse} res */ +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Length", Buffer.byteLength(body)); + res.end(body); +} + +function readJsonBody(req, maxBytes = MAX_BODY_BYTES) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > maxBytes) { + reject(new Error(`request body exceeds ${maxBytes} bytes`)); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + const buf = Buffer.concat(chunks); + if (buf.length === 0) { + resolve({}); + return; + } + try { + resolve(JSON.parse(buf.toString("utf8"))); + } catch (e) { + reject(new Error(`invalid JSON body: ${e.message}`)); + } + }); + req.on("error", reject); + }); +} + +function extractBearerToken(req) { + const auth = req.headers.authorization || ""; + if (auth.startsWith("Bearer ")) { + return auth.slice(7).trim(); + } + return ""; +} + +function isPublicPath(pathname) { + return pathname === "/health" || pathname === "/"; +} + +/** + * When PROXY_INTERNAL_TOKEN is set, require it for API routes unless the + * request was already authenticated by an Authentik proxy outpost. + */ +function isAuthorized(req) { + if (!INTERNAL_TOKEN) return true; + + const url = new URL(req.url, "http://internal"); + if (isPublicPath(url.pathname)) return true; + + if (req.headers["x-authentik-username"]) return true; + + const bearer = extractBearerToken(req); + if (bearer === INTERNAL_TOKEN) return true; + + const headerToken = req.headers["x-proxy-internal-token"]; + if (typeof headerToken === "string" && headerToken === INTERNAL_TOKEN) return true; + + return false; +} + +function sendUnauthorized(res) { + sendJson(res, 401, { + error: { + message: "unauthorized", + type: "authentication_error", + code: "invalid_internal_token", + }, + }); +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +async function handleModels(req, res) { + if (!isAuthorized(req)) { + sendUnauthorized(res); + return; + } + const now = Math.floor(Date.now() / 1000); + sendJson(res, 200, { + object: "list", + data: allModelIds().map((id) => ({ id, object: "model", owned_by: "cursor", created: now })), + }); +} + +function handleHealth(_req, res) { + sendJson(res, 200, { status: "ok", timestamp: new Date().toISOString() }); +} + +async function handleChatCompletions(req, res) { + if (!isAuthorized(req)) { + sendUnauthorized(res); + return; + } + + const requestId = randomUUID().replace(/-/g, "").slice(0, 24); + let body; + try { + body = await readJsonBody(req); + } catch (e) { + sendJson(res, 400, { + error: { message: e.message, type: "invalid_request_error", code: "invalid_body" }, + }); + return; + } + + if (!Array.isArray(body.messages) || body.messages.length === 0) { + sendJson(res, 400, { + error: { + message: "messages is required and must be a non-empty array", + type: "invalid_request_error", + code: "invalid_messages", + }, + }); + return; + } + + const stream = body.stream === true; + const model = normalizeModel(body.model); + const toolsCount = Array.isArray(body.tools) ? body.tools.length : 0; + const imageCount = hasImageParts(body.messages) ? body.messages.reduce((n, m) => { + if (!Array.isArray(m.content)) return n; + return n + m.content.filter((p) => p.type === "image_url").length; + }, 0) : 0; + + const followUp = findSessionForFollowUp(body); + if (followUp) { + const { session, toolResults } = followUp; + console.error( + `[chat] id=${requestId} transport=acp model=${model} FOLLOWUP session=${session.sessionId} pending=${toolResults.length} stream=${stream}` + ); + for (const tr of toolResults) { + const ok = session.resolveToolCall(tr.tool_call_id, messageContentToText(tr.content)); + if (!ok) { + console.error( + `[chat] id=${requestId} tool_call_id=${tr.tool_call_id} not pending in session ${session.sessionId}` + ); + } + } + await streamSessionTurn({ res, session, requestId, stream, blocks: null }); + return; + } + + console.error( + `[chat] id=${requestId} transport=acp model=${body.model} -> ${model} tools=${toolsCount} images=${imageCount} stream=${stream}` + ); + + let blocks; + try { + blocks = await openAiMessagesToAcpBlocks(body.messages); + } catch (e) { + sendJson(res, 400, { + error: { message: e.message, type: "invalid_request_error", code: "invalid_messages" }, + }); + return; + } + + let session; + try { + session = await createSession({ + tools: body.tools || [], + model, + log: (...args) => console.error(`[chat:${requestId}]`, ...args), + }); + } catch (e) { + console.error(`[chat] id=${requestId} createSession error: ${e.message}`); + if (e.code === "too_many_sessions") { + sendJson(res, 429, { + error: { + message: e.message, + type: "rate_limit_error", + code: "too_many_sessions", + }, + }); + return; + } + if (!res.headersSent) { + sendJson(res, 500, { error: { message: e.message, type: "server_error", code: null } }); + } else { + try { + res.end(); + } catch { + /* ignore */ + } + } + return; + } + + await streamSessionTurn({ res, session, requestId, stream, blocks }); +} + +async function streamSessionTurn({ res, session, requestId, stream, blocks }) { + if (!stream) { + const result = await collectSessionTurn(session, blocks); + if (!res.headersSent) { + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify( + nonStreamResponse({ + id: requestId, + model: session.detectedModel || "auto", + text: result.text, + toolCalls: result.calls, + finishReason: result.finishReason, + usage: result.usage, + }) + ) + ); + } + if (result.finishReason !== "tool_calls") { + await session.destroy(); + } + return; + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Request-Id", requestId); + res.flushHeaders(); + res.write(":ok\n\n"); + + let isFirst = true; + let finished = false; + + const handlers = { + onAssistantDelta: (delta) => { + if (res.writableEnded || finished) return; + const chunk = textChunk({ + id: requestId, + model: session.detectedModel || "auto", + delta, + isFirst, + }); + isFirst = false; + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + }, + onToolCall: (call) => { + if (res.writableEnded || finished) return; + const chunk = toolCallChunk({ + id: requestId, + model: session.detectedModel || "auto", + call, + isFirst, + }); + isFirst = false; + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + }, + onFinish: async (reason, _usage) => { + if (finished) return; + finished = true; + if (!res.writableEnded) { + const chunk = finishChunk({ + id: requestId, + model: session.detectedModel || "auto", + finishReason: reason, + }); + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); + } + if (reason === "stop" || reason === "error") { + await session.destroy(); + } + }, + }; + + session.runTurn(blocks, handlers); +} + +function collectSessionTurn(session, blocks) { + return new Promise((resolve) => { + let text = ""; + const calls = []; + let finishReason = "stop"; + let usage = null; + session.runTurn(blocks, { + onAssistantDelta: (d) => { + text += d; + }, + onToolCall: (c) => { + calls.push(c); + }, + onFinish: (reason, u) => { + finishReason = reason; + usage = u; + resolve({ text, calls, finishReason, usage }); + }, + }); + }); +} + +// --------------------------------------------------------------------------- +// Server bootstrap & graceful shutdown +// --------------------------------------------------------------------------- + +const server = createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.end(); + return; + } + + const url = new URL(req.url, "http://internal"); + try { + if (req.method === "GET" && url.pathname === "/v1/models") { + await handleModels(req, res); + } else if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + await handleChatCompletions(req, res); + } else if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) { + handleHealth(req, res); + } else { + sendJson(res, 404, { error: { message: `not found: ${req.method} ${url.pathname}` } }); + } + } catch (e) { + console.error(`[server] unhandled error: ${e.stack || e.message}`); + if (!res.headersSent) { + sendJson(res, 500, { error: { message: e.message, type: "server_error" } }); + } else { + try { + res.end(); + } catch { + /* ignore */ + } + } + } +}); + +server.listen(PORT, () => { + console.error(`[server] cursor-agent-api-proxy (ACP) listening on :${PORT}`); +}); + +let shuttingDown = false; +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.error(`[server] ${signal} received, shutting down...`); + server.close(); + const force = setTimeout(() => { + console.error("[server] shutdown timeout, forcing exit"); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + try { + await destroyAllSessions(); + } catch (e) { + console.error(`[server] error during shutdown: ${e.message}`); + } + clearTimeout(force); + process.exit(0); +} +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/src/session-manager.mjs b/src/session-manager.mjs new file mode 100644 index 0000000..34d42fa --- /dev/null +++ b/src/session-manager.mjs @@ -0,0 +1,464 @@ +/** + * Cursor-agent session manager (ACP transport). + * + * Each HTTP chat request creates an ACP session (`agent acp`): + * initialize → session/new (MCP stdio bridge) → session/prompt + * + * Client tools are advertised via in-process ProxyMcpServer. MCP tools/call + * parks until the HTTP client returns role:"tool" with the result. + * + * Built-in cursor-agent tools are denied via session/request_permission. + * Images are sent as native ACP image ContentBlocks (not via Read). + */ + +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { AcpClient } from "./acp-client.mjs"; +import { ProxyMcpServer } from "./mcp-server.mjs"; + +export const SESSION_TTL_MS = parseInt(process.env.PROXY_SESSION_TTL_MS || "120000", 10); +export const MAX_CONCURRENT_SESSIONS = parseInt(process.env.PROXY_MAX_CONCURRENT_SESSIONS || "4", 10); +export const TOOL_CALL_BATCH_MS = parseInt(process.env.PROXY_TOOL_CALL_BATCH_MS || "50", 10); +const REAL_AGENT = process.env.CURSOR_AGENT_BIN || "/usr/local/bin/agent.real"; +const MCP_BRIDGE = process.env.MCP_BRIDGE_BIN || "/app/scripts/mcp-stdio-bridge.sh"; + +/** @type {Map} */ +const SESSIONS = new Map(); + +/** + * Resolve ACP model id from session/new response and requested OpenAI model id. + * @param {object} sessionResult session/new result + * @param {string} requested + */ +export function resolveModelId(sessionResult, requested) { + const available = sessionResult.models?.availableModels || []; + if (available.length > 0) { + if (!requested || requested === "auto") { + return sessionResult.models.currentModelId || available[0].modelId; + } + const exact = available.find((m) => m.modelId === requested || m.name === requested); + if (exact) return exact.modelId; + const byName = available.find((m) => m.name?.toLowerCase() === requested.toLowerCase()); + if (byName) return byName.modelId; + const prefix = available.find( + (m) => m.modelId.startsWith(`${requested}[`) || m.modelId.startsWith(requested) + ); + if (prefix) return prefix.modelId; + const contains = available.find((m) => m.modelId.includes(requested)); + if (contains) return contains.modelId; + return sessionResult.models.currentModelId || available[0].modelId; + } + + const modelOption = (sessionResult.configOptions || []).find( + (o) => o.id === "model" || o.category === "model" + ); + return pickModelValueId(modelOption, requested); +} + +/** + * Pick an ACP model config value for the requested OpenAI model id. + * @param {object} modelOption session/new config option with id "model" + * @param {string} requested e.g. "auto", "gpt-5.2" + */ +export function pickModelValueId(modelOption, requested) { + const options = modelOption?.options || []; + if (!options.length) return null; + + if (!requested || requested === "auto") { + const auto = options.find((o) => o.value === "default[]" || o.name === "Auto"); + return auto?.value || modelOption.currentValue || options[0].value; + } + + const exact = options.find((o) => o.value === requested); + if (exact) return exact.value; + + const byName = options.find((o) => o.name?.toLowerCase() === requested.toLowerCase()); + if (byName) return byName.value; + + const prefix = options.find( + (o) => o.value.startsWith(`${requested}[`) || o.value.startsWith(requested) + ); + if (prefix) return prefix.value; + + const contains = options.find((o) => o.value.includes(requested)); + if (contains) return contains.value; + + return null; +} + +function pickPermissionOption(options, allow) { + if (!Array.isArray(options) || options.length === 0) return null; + const kindMatch = allow + ? (o) => o.kind === "allow_once" || o.kind === "allow_always" + : (o) => o.kind === "reject_once" || o.kind === "reject_always"; + return options.find(kindMatch) || (allow ? options[0] : options[options.length - 1]); +} + +export function extractToolNameFromPermission(params) { + const tool = params?.toolCall; + if (!tool) return ""; + + if (tool.rawInput && typeof tool.rawInput === "object" && typeof tool.rawInput.name === "string") { + let name = tool.rawInput.name; + if (name.startsWith("mcp_client_")) { + name = name.slice("mcp_client_".length); + } + return name; + } + + if (typeof tool.title === "string") { + const clientMcp = tool.title.match(/^client-([^:]+):\s*(\S+)/); + if (clientMcp) { + return clientMcp[2]; + } + const m = tool.title.match(/^(\S+)/); + if (m) return m[1].replace(/:$/, ""); + } + + return ""; +} + +function mapStopReason(stopReason) { + if (stopReason === "end_turn") return "stop"; + if (stopReason === "cancelled") return "error"; + return "stop"; +} + +export class CursorSession { + constructor({ sessionId, tools, model, log }) { + this.sessionId = sessionId; + this.tools = tools || []; + this.clientToolNames = new Set(this.tools.map((t) => t.function?.name).filter(Boolean)); + this.model = model || "auto"; + this.log = log || console.error; + this.detectedModel = model || "auto"; + this.createdAt = Date.now(); + this.lastActivityAt = Date.now(); + this.workDir = null; + this.cursorProc = null; + this.acp = null; + this.mcp = null; + this.acpSessionId = null; + this.timeoutHandle = null; + this._finishToolCallsTimer = null; + this.terminated = false; + this._handlers = null; + this._hasToolCallInTurn = false; + this._promptInFlight = false; + } + + async start() { + this.workDir = mkdtempSync(join(tmpdir(), `cursor-${this.sessionId}-`)); + + const mcpTools = this.tools.map((t) => ({ + name: t.function.name, + description: t.function.description, + inputSchema: t.function.parameters, + })); + + const socketPath = join(this.workDir, "mcp.sock"); + this.mcp = new ProxyMcpServer({ + socketPath, + tools: mcpTools, + onToolCall: ({ toolCallId, name, arguments: args }) => { + if (!this._handlers) return; + this._hasToolCallInTurn = true; + this._handlers.onToolCall({ + id: toolCallId, + name, + arguments: JSON.stringify(args || {}), + }); + this._scheduleFinishToolCallsTurn(); + }, + log: (...args) => this.log(`[mcp:${this.sessionId}]`, ...args), + }); + await this.mcp.start(); + + const args = ["acp"]; + const env = { ...process.env }; + if (process.env.CURSOR_API_KEY) env.CURSOR_API_KEY = process.env.CURSOR_API_KEY; + + this.cursorProc = spawn(REAL_AGENT, args, { + cwd: this.workDir, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.acp = new AcpClient({ + stdin: this.cursorProc.stdin, + stdout: this.cursorProc.stdout, + log: (...a) => this.log(`[acp:${this.sessionId}]`, ...a), + }); + + this.acp.onSessionUpdate = (params) => this._onSessionUpdate(params); + this.acp.onIncomingRequest = (method, params) => this._onIncomingRequest(method, params); + + this.cursorProc.stderr.on("data", (d) => + this.log(`[cursor:${this.sessionId} stderr] ${d.toString().trim()}`) + ); + this.cursorProc.on("close", (code) => { + this.log(`[cursor:${this.sessionId}] process exited code=${code}`); + if (this._handlers) this._finishTurn("error", null); + }); + this.cursorProc.on("error", (e) => { + this.log(`[cursor:${this.sessionId}] spawn error: ${e.message}`); + if (this._handlers) this._finishTurn("error", null); + }); + + await this.acp.initialize(); + await this.acp.authenticateIfNeeded(); + + const mcpServers = [ + { + name: "client", + command: MCP_BRIDGE, + args: [socketPath], + env: [], + }, + ]; + + let newSessionResult; + try { + newSessionResult = await this.acp.request("session/new", { + cwd: this.workDir, + mcpServers, + }); + } catch (e) { + if (e.code === -32000) { + await this.acp.request("authenticate", { methodId: "cursor_login" }); + newSessionResult = await this.acp.request("session/new", { + cwd: this.workDir, + mcpServers, + }); + } else { + throw e; + } + } + + this.acpSessionId = newSessionResult.sessionId; + const modelId = resolveModelId(newSessionResult, this.model); + const currentModelId = newSessionResult.models?.currentModelId; + if (modelId && modelId !== currentModelId) { + await this.acp.request("session/set_config_option", { + sessionId: this.acpSessionId, + configId: "model", + value: modelId, + }); + } + this.detectedModel = modelId || currentModelId || this.model; + + this._resetTimer(); + return this; + } + + async _onIncomingRequest(method, params) { + if (method === "session/request_permission") { + const toolName = extractToolNameFromPermission(params); + const allow = this.clientToolNames.has(toolName); + const option = pickPermissionOption(params.options, allow); + if (!option) { + return { outcome: { outcome: "cancelled" } }; + } + const optionId = option.optionId || option.id; + return { outcome: { outcome: "selected", optionId } }; + } + + if (method === "cursor/ask_question") { + const first = params?.questions?.[0]?.options?.[0]?.id; + return { answers: first ? { [params.questions[0].id]: first } : {} }; + } + + if (method === "cursor/create_plan") { + return { approved: true }; + } + + if ( + method === "cursor/update_todos" || + method === "cursor/task" || + method === "cursor/generate_image" + ) { + return {}; + } + + this.log(`[acp] unhandled incoming method: ${method}`); + return {}; + } + + _onSessionUpdate(params) { + if (!this._handlers || params.sessionId !== this.acpSessionId) return; + const update = params.update; + if (!update) return; + + if (update.sessionUpdate === "agent_message_chunk") { + const content = update.content; + if (content?.type === "text" && content.text) { + this._handlers.onAssistantDelta(content.text); + } + return; + } + + // agent_thought_chunk and other updates are ignored for OpenAI output. + } + + /** + * @param {Array|null} blocks ACP ContentBlocks, or null for follow-up only + */ + runTurn(blocks, handlers) { + this._handlers = handlers; + this.lastActivityAt = Date.now(); + this._resetTimer(); + + if (blocks === null) { + // Follow-up after tool_calls: same session/prompt is still in flight. + this._hasToolCallInTurn = false; + return; + } + + this._hasToolCallInTurn = false; + this._promptInFlight = true; + this.acp + .request("session/prompt", { + sessionId: this.acpSessionId, + prompt: blocks, + }) + .then((result) => { + this._promptInFlight = false; + if (!this._handlers) return; + if (this.mcp.pending.size > 0 || this._hasToolCallInTurn) { + this._scheduleFinishToolCallsTurn(); + return; + } + this._finishTurn(mapStopReason(result.stopReason), null); + }) + .catch((e) => { + this._promptInFlight = false; + this.log(`[acp] session/prompt error: ${e.message}`); + if (this._handlers) this._finishTurn("error", null); + }); + } + + resolveToolCall(toolCallId, result) { + const ok = this.mcp.resolveToolCall(toolCallId, result); + if (ok) { + this.lastActivityAt = Date.now(); + this._resetTimer(); + } + return ok; + } + + _scheduleFinishToolCallsTurn() { + if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer); + this._finishToolCallsTimer = setTimeout(() => { + this._finishToolCallsTimer = null; + if (!this._handlers || !this._hasToolCallInTurn) return; + if (this.mcp.pending.size === 0) return; + this._finishTurn("tool_calls", null); + }, TOOL_CALL_BATCH_MS); + } + + async destroy() { + if (this.terminated) return; + this.terminated = true; + if (this._finishToolCallsTimer) clearTimeout(this._finishToolCallsTimer); + if (this.timeoutHandle) clearTimeout(this.timeoutHandle); + try { + this.acp?.close(); + if (this.cursorProc && this.cursorProc.exitCode === null) { + this.cursorProc.kill("SIGTERM"); + await new Promise((r) => { + const kill = setTimeout(() => { + try { + this.cursorProc?.kill("SIGKILL"); + } catch { + /* ignore */ + } + r(); + }, 5000); + this.cursorProc?.once("close", () => { + clearTimeout(kill); + r(); + }); + }); + } + } catch { + /* ignore */ + } + try { + await this.mcp?.stop(); + } catch { + /* ignore */ + } + try { + if (this.workDir) rmSync(this.workDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + SESSIONS.delete(this.sessionId); + } + + _resetTimer() { + if (this.timeoutHandle) clearTimeout(this.timeoutHandle); + this.timeoutHandle = setTimeout(async () => { + this.log(`[cursor:${this.sessionId}] TTL expired (idle ${SESSION_TTL_MS}ms)`); + await this.destroy(); + }, SESSION_TTL_MS); + } + + _finishTurn(reason, usage) { + if (!this._handlers) return; + const handlers = this._handlers; + this._handlers = null; + void Promise.resolve(handlers.onFinish(reason, usage)); + } +} + +export function getActiveSessionCount() { + return SESSIONS.size; +} + +export async function createSession({ tools, model, log }) { + if (SESSIONS.size >= MAX_CONCURRENT_SESSIONS) { + const err = new Error(`too many concurrent sessions (max ${MAX_CONCURRENT_SESSIONS})`); + err.code = "too_many_sessions"; + throw err; + } + + const sessionId = `s_${randomUUID().replace(/-/g, "").slice(0, 16)}`; + const session = new CursorSession({ sessionId, tools, model, log }); + SESSIONS.set(sessionId, session); + try { + await session.start(); + return session; + } catch (e) { + await session.destroy(); + SESSIONS.delete(sessionId); + throw e; + } +} + +export function findSessionForFollowUp(body) { + const toolResults = (body.messages || []).filter((m) => m.role === "tool"); + if (toolResults.length === 0) return null; + for (const session of SESSIONS.values()) { + if (!session.mcp) continue; + for (const tr of toolResults) { + if (session.mcp.pending.has(tr.tool_call_id)) { + return { session, toolResults }; + } + } + } + return null; +} + +export function getSession(sessionId) { + return SESSIONS.get(sessionId); +} + +export async function destroyAllSessions() { + const all = [...SESSIONS.values()]; + SESSIONS.clear(); + await Promise.all(all.map((s) => s.destroy())); +} diff --git a/src/session-manager.test.mjs b/src/session-manager.test.mjs new file mode 100644 index 0000000..96dc758 --- /dev/null +++ b/src/session-manager.test.mjs @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { extractToolNameFromPermission } from "./session-manager.mjs"; + +describe("extractToolNameFromPermission", () => { + it("parses client MCP title format", () => { + const name = extractToolNameFromPermission({ + toolCall: { title: "client-get_weather: get_weather" }, + }); + assert.equal(name, "get_weather"); + }); + + it("strips mcp_client_ prefix from rawInput name", () => { + const name = extractToolNameFromPermission({ + toolCall: { rawInput: { name: "mcp_client_get_weather" } }, + }); + assert.equal(name, "get_weather"); + }); + + it("uses first token for built-in tools", () => { + const name = extractToolNameFromPermission({ + toolCall: { title: "Shell: rm -rf /" }, + }); + assert.equal(name, "Shell"); + }); +}); diff --git a/src/stream-translator.mjs b/src/stream-translator.mjs new file mode 100644 index 0000000..5afb928 --- /dev/null +++ b/src/stream-translator.mjs @@ -0,0 +1,104 @@ +/** + * Helpers to build OpenAI-compatible chat.completion.chunk objects. + * + * Used both by the plain-chat path (text deltas only) and the tool-aware + * session path (text deltas + tool_calls deltas). + */ + +/** + * Build a streaming chunk carrying an assistant text delta. + */ +export function textChunk({ id, model, delta, isFirst }) { + return { + id: `chatcmpl-${id}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { ...(isFirst ? { role: "assistant" } : {}), content: delta }, + finish_reason: null, + }, + ], + }; +} + +/** + * Build a streaming chunk carrying an OpenAI tool_calls delta. The client + * receives this when cursor-agent decides to call an MCP tool. + */ +export function toolCallChunk({ id, model, call, isFirst, index = 0 }) { + return { + id: `chatcmpl-${id}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + ...(isFirst ? { role: "assistant" } : {}), + tool_calls: [ + { + index, + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + }, + ], + }, + finish_reason: null, + }, + ], + }; +} + +/** + * Build the terminal chunk for a streaming response. finish_reason is one of + * "stop" | "tool_calls" | "length" | "error". + */ +export function finishChunk({ id, model, finishReason }) { + return { + id: `chatcmpl-${id}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + }; +} + +/** + * Build an error event (sent as a chunk before [DONE]). + */ +export function errorChunk({ id, model, message }) { + return { + error: { message, type: "server_error", code: null }, + }; +} + +/** + * Build a non-streaming chat.completion response. Used when stream=false. + */ +export function nonStreamResponse({ id, model, text, toolCalls, finishReason, usage }) { + const message = + toolCalls && toolCalls.length > 0 + ? { + role: "assistant", + content: text || null, + tool_calls: toolCalls.map((c) => ({ + id: c.id, + type: "function", + function: { name: c.name, arguments: c.arguments }, + })), + } + : { role: "assistant", content: text || "" }; + return { + id: `chatcmpl-${id}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; +}