diff --git a/.github/wiki/Guide-Agents-MCP.md b/.github/wiki/Guide-Agents-MCP.md index 5bd75ccdd..fda042888 100644 --- a/.github/wiki/Guide-Agents-MCP.md +++ b/.github/wiki/Guide-Agents-MCP.md @@ -52,3 +52,24 @@ nself ci ``` `nself ci --check` runs the gates only. `nself ci` also posts the `nself-ci` commit status. See [[Guide-CI-CD]] and the plugins CI [Getting Started](https://github.com/nself-org/plugins/blob/main/.github/wiki/guides/ci/Getting-Started.md) page. + +## End-to-end proof + +The agent E2E harness proves an agent can run a whole project through MCP alone. It lives in `test/e2e/agent/` (build tag `e2eagent`) and `scripts/e2e/agent/`. + +What it covers: + +- A replay client speaks MCP to `nself mcp` and nothing else. It runs `init`, `build` (plan, then confirm), `start`, `db migrate up`, `add ci`, an async CI run (detach, wait, evidence), `deploy qa`, then `deploy promote qa prod` as a dry run and as a confirmed run. +- Two legs: failure recovery (the harness stops one service, the agent reads `status` and recovers with `start`) and the destructive gate (`reset` without a confirmation returns the confirmation request and no volume changes). +- `qa` and `prod` are two loopback sim hosts. The server runs in a sandbox: a temp `HOME` holding only the sim key and ssh config, no SSH agent, and an allowlist of `127.0.0.1` for every host it may name. +- Every request, result and harness action goes to a JSONL transcript, and a verifier checks it. + +How to run it (shown as text; it needs Docker and a cli checkout): + +```text +bash scripts/e2e/agent/run.sh --self-test +bash scripts/e2e/agent/run.sh --replay --transcript /tmp/agent-e2e/replay.jsonl +bash scripts/e2e/agent/run.sh --verify-transcript /tmp/agent-e2e/replay.jsonl +``` + +The `Agent E2E` workflow runs the replay on GitHub-hosted ubuntu and uploads the transcript as `agent-e2e-transcript`. Details and known gaps: `test/e2e/agent/README.md`. diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml new file mode 100644 index 000000000..34cb868ef --- /dev/null +++ b/.github/workflows/agent-e2e.yml @@ -0,0 +1,88 @@ +name: Agent E2E + +# MCP-only agent E2E proof (P7-SHIP-02, SHIP Epic D7): replays +# test/e2e/agent/testdata/replay.jsonl over a sandboxed `nself mcp` against two +# loopback sim hosts and uploads the transcript. GitHub-hosted ubuntu only; the +# live Claude session runs on the owner machine (P7-SHIP-13). + +on: + workflow_dispatch: + pull_request: + paths: + - test/e2e/agent/** + - scripts/e2e/agent/** + - .github/workflows/agent-e2e.yml + +permissions: + contents: read + +concurrency: + group: agent-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + replay: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + NSELF_V15: "1" + GITLEAKS_SHA256: "5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba" # gitleaks 8.21.2 linux_x64 + TRIVY_SHA256: "e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7" # trivy 0.76.0 Linux-64bit + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Checkout plugins main (ci plugin source) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: nself-org/plugins + ref: main + path: plugins-src + persist-credentials: false + # The replay's CI run uses the default pipeline (gitleaks and trivy jobs); + # a job whose tool is missing cannot be placed and fails the run. The + # sandboxed server sees only the system PATH, so both go to /usr/local/bin. + - name: Install gitleaks and trivy + run: | + set -euo pipefail + curl --fail --silent --show-error --location --proto '=https' --output "$RUNNER_TEMP/gitleaks.tar.gz" \ + https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz + echo "$GITLEAKS_SHA256 $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict - + tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks + sudo install -m 0755 "$RUNNER_TEMP/gitleaks" /usr/local/bin/gitleaks + curl --fail --silent --show-error --location --proto '=https' --output "$RUNNER_TEMP/trivy.tar.gz" \ + https://github.com/aquasecurity/trivy/releases/download/v0.76.0/trivy_0.76.0_Linux-64bit.tar.gz + echo "$TRIVY_SHA256 $RUNNER_TEMP/trivy.tar.gz" | sha256sum --check --strict - + tar -xzf "$RUNNER_TEMP/trivy.tar.gz" -C "$RUNNER_TEMP" trivy + sudo install -m 0755 "$RUNNER_TEMP/trivy" /usr/local/bin/trivy + # Pull the digest-pinned sim host image (sdk/go/simharness OpenSSHRef) + # before the replay, so a registry throttle on the shared runner IP is + # absorbed here with a bounded backoff instead of failing the fleet start. + - name: Pre-pull the sim host image + env: + SIM_IMAGE: lscr.io/linuxserver/openssh-server@sha256:46f115de7c251558297e7e87566fc3fc08544b63e55502b5cf294454db5d29d1 + run: | + for i in 1 2 3 4; do + docker pull --quiet "$SIM_IMAGE" && exit 0 + sleep $((i * 30)) + done + exit 1 + - name: Self-test (verifier and sandbox refusals) + run: bash scripts/e2e/agent/run.sh --self-test + - name: Replay over MCP + # The ci plugin comes from plugins main until a served ci release + # carries the machine surface (P7-CI-62). + run: bash scripts/e2e/agent/run.sh --replay --transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl" --ci-plugin-src plugins-src/free/ci + - name: Verify the transcript + run: bash scripts/e2e/agent/run.sh --verify-transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl" + - name: Upload transcript + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: agent-e2e-transcript + path: ${{ runner.temp }}/agent-e2e/replay.jsonl + if-no-files-found: warn + retention-days: 14 diff --git a/scripts/e2e/agent/mcp-sandbox.sh b/scripts/e2e/agent/mcp-sandbox.sh new file mode 100755 index 000000000..5fad72e58 --- /dev/null +++ b/scripts/e2e/agent/mcp-sandbox.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# mcp-sandbox.sh — start an MCP server (normally `nself mcp`) for the agent E2E +# harness in a sealed environment (P7-SHIP-02, SHIP Epic D7). +# +# Usage: +# mcp-sandbox.sh --home DIR --project DIR --operator-home DIR +# [--inventory FILE] [--allow NAME]... [--env K=V]... +# -- BIN [ARGS...] +# +# What it does: refuses to start (exit 1, naming the entry) when +# - SSH_AUTH_SOCK is set (an agent socket could reach real keys), +# - the sandbox HOME is, contains, links to or names the operator's ~/.ssh, +# - HOME, the inventory or the ssh config names a host outside the +# allowlist (127.0.0.1, localhost and each --allow name), +# - an --env entry is outside the fixed list below; +# otherwise it cds into --project and execs BIN under `env -i` with only +# PATH, HOME, TMPDIR, NSELF_V15 and the allowed --env entries. +# +# Allowed --env names: NSELF_V15, NSELF_E2E_SIM_KEY (a path inside HOME), +# DOCKER_HOST (unix:// sockets only). +# +# Exit codes: 0 never returns on success (exec), 1 refused, 2 usage. +# Bash 3.2 compatible. + +set -u + +die_usage() { printf 'mcp-sandbox: usage: %s\n' "$1" >&2; exit 2; } +refuse() { printf 'mcp-sandbox: refused: %s\n' "$1" >&2; exit 1; } + +HOME_DIR="" PROJECT="" OPHOME="" INVENTORY="" +ALLOW="127.0.0.1 localhost" +ENVS=() +while [ $# -gt 0 ]; do + case "$1" in + --home) HOME_DIR="${2:-}"; shift 2 ;; + --project) PROJECT="${2:-}"; shift 2 ;; + --operator-home) OPHOME="${2:-}"; shift 2 ;; + --inventory) INVENTORY="${2:-}"; shift 2 ;; + --allow) ALLOW="$ALLOW ${2:-}"; shift 2 ;; + --env) ENVS+=("${2:-}"); shift 2 ;; + --) shift; break ;; + *) die_usage "unknown argument $1" ;; + esac +done +[ -n "$HOME_DIR" ] && [ -d "$HOME_DIR" ] || die_usage "--home must be an existing directory" +[ -n "$PROJECT" ] && [ -d "$PROJECT" ] || die_usage "--project must be an existing directory" +[ -n "$OPHOME" ] || die_usage "--operator-home is required" +[ $# -gt 0 ] || die_usage "missing -- BIN" + +resolve() { readlink -f "$1" 2>/dev/null || printf '%s' "$1"; } + +# 1. No SSH agent. +[ -z "${SSH_AUTH_SOCK:-}" ] || refuse "SSH_AUTH_SOCK is set; unset it so no agent key is reachable" + +# 2. HOME must not reach the operator's ~/.ssh. +H=$(resolve "$HOME_DIR") +OP=$(resolve "$OPHOME") +OPSSH="$OP/.ssh" +[ "$H" != "$OP" ] || refuse "HOME $HOME_DIR is the operator's home" +case "$H/" in "$OPSSH"/*) refuse "HOME $HOME_DIR is inside the operator's ~/.ssh" ;; esac +while IFS= read -r link; do + [ -n "$link" ] || continue + target=$(resolve "$link") + case "$target/" in + "$OPSSH"/*|"$OP"/) refuse "$link reaches the operator's ~/.ssh ($target)" ;; + esac + case "$target/" in "$H"/*) ;; *) refuse "$link points outside HOME ($target)" ;; esac +done </dev/null) +EOF + +# 3. Files to scan: every regular file in HOME, plus the inventory. +FILES=$(find "$HOME_DIR" -type f 2>/dev/null) +if [ -n "$INVENTORY" ]; then + [ -f "$INVENTORY" ] || die_usage "--inventory $INVENTORY does not exist" + FILES="$FILES +$INVENTORY" +fi + +allowed() { + for a in $ALLOW; do [ "$1" = "$a" ] && return 0; done + return 1 +} + +check_host() { # file host + h="$2" + h="${h#*@}" # user@host + case "$h" in \[*\]*) h="${h#\[}"; h="${h%%\]*}" ;; *) h="${h%%:*}" ;; esac + h=$(printf '%s' "$h" | tr -d '"'"'"' ') + [ -n "$h" ] || return 0 + allowed "$h" || refuse "$1 names host $h outside the allowlist ($ALLOW)" +} + +while IFS= read -r f; do + [ -n "$f" ] || continue + if grep -qF "$OPSSH" "$f" 2>/dev/null; then + refuse "$f names the operator's ~/.ssh" + fi + # IPv4 literals anywhere. + for ip in $(grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$f" 2>/dev/null); do + check_host "$f" "$ip" + done + # ssh config Host / HostName / ProxyJump lines; Include, Match and + # ProxyCommand are refused outright (they can reach anything). + for h in $(awk 'tolower($1)=="host"||tolower($1)=="hostname"||tolower($1)=="proxyjump"{for(i=2;i<=NF;i++)print $i}' "$f" 2>/dev/null); do + check_host "$f" "$h" + done + kw=$(awk 'tolower($1)=="include"||tolower($1)=="match"||tolower($1)=="proxycommand"{print $1; exit}' "$f" 2>/dev/null) + [ -z "$kw" ] || refuse "$f uses $kw, which can reach hosts or config outside the sandbox" + # Inventory host fields (YAML `host: ...`). + for h in $(sed -n 's/^[[:space:]-]*host:[[:space:]]*//p' "$f" 2>/dev/null); do + check_host "$f" "$h" + done +done <&2; exit 2; } +fail() { printf 'agent-e2e: %s\n' "$1" >&2; exit 1; } + +# Scratch lives under $HOME by default: a Colima or Docker Desktop VM shares +# only the home directory, so bind mounts from a system TMPDIR fail there. +E2E_TMP="${AGENT_E2E_TMP:-$HOME/.cache/nself-agent-e2e}" +scratch() { mkdir -p "$E2E_TMP" && mktemp -d "$E2E_TMP/run.XXXXXX"; } + +need_docker() { + command -v docker >/dev/null 2>&1 || fail "Docker is required (no docker CLI)" + v=$(docker info --format '{{.ServerVersion}}' 2>/dev/null) + [ -n "$v" ] || fail "Docker is required (the daemon is not reachable)" +} + +build_bin() { # out + (cd "$ROOT" && CGO_ENABLED=0 go build -mod=vendor -o "$1" ./cmd/nself) || fail "go build failed" +} + +abspath() { (cd "$(dirname "$1")" && printf '%s/%s' "$(pwd)" "$(basename "$1")"); } + +mode_replay() { + out="${AGENT_E2E_OUT:-$E2E_TMP/out}" + transcript="$out/replay.jsonl" cisrc="" + while [ $# -gt 0 ]; do + case "$1" in + --transcript) transcript="${2:-}"; shift 2 ;; + --ci-plugin-src) cisrc="${2:-}"; shift 2 ;; + *) usage ;; + esac + done + if [ -n "$cisrc" ]; then + [ -f "$cisrc/plugin.json" ] || fail "--ci-plugin-src $cisrc has no plugin.json" + cisrc=$(cd "$cisrc" && pwd) + fi + need_docker + mkdir -p "$(dirname "$transcript")" || fail "cannot create $(dirname "$transcript")" + transcript=$(abspath "$transcript") + tmp=$(scratch); bin="$tmp/nself" + build_bin "$bin" + # GOTMPDIR becomes the test's TMPDIR: keep the fixture project under TMPDIR + # (a Colima VM shares only $HOME, so bind mounts elsewhere fail). + (cd "$ROOT" && TMPDIR="$tmp" GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + AGENT_E2E_CI_PLUGIN_SRC="$cisrc" $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") + rc=$? + rm -rf "$tmp" + [ $rc -eq 0 ] || fail "replay failed; transcript: $transcript" + printf 'agent-e2e: replay passed; transcript: %s\n' "$transcript" +} + +prod_probe() { # dir label + [ -n "${NSELF_PROD_PROBE_HOST:-}" ] || fail "--prod-probe needs NSELF_PROD_PROBE_HOST (never committed)" + ssh -o BatchMode=yes "$NSELF_PROD_PROBE_HOST" \ + 'docker ps -q | xargs -r docker inspect --format "{{.Name}} {{.Id}} {{.State.StartedAt}} {{.RestartCount}}" | sort' \ + > "$1/$2.txt" || fail "prod probe ($2) failed" +} + +mode_live() { + transcript="" probe="" + while [ $# -gt 0 ]; do + case "$1" in + --transcript) transcript="${2:-}"; shift 2 ;; + --prod-probe) probe="${2:-}"; shift 2 ;; + *) usage ;; + esac + done + [ -n "$transcript" ] || usage + need_docker + command -v claude >/dev/null 2>&1 || fail "claude is not on PATH" + mkdir -p "$(dirname "$transcript")" || fail "cannot create $(dirname "$transcript")" + transcript=$(abspath "$transcript") + if [ -n "$probe" ]; then mkdir -p "$probe" && prod_probe "$probe" before; fi + tmp=$(scratch); bin="$tmp/nself" + build_bin "$bin" + (cd "$ROOT" && TMPDIR="$tmp" GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + $GOTEST -timeout 90m -v -run '^TestAgentLive$' "$PKG") + rc=$? + rm -rf "$tmp" + if [ -n "$probe" ]; then + prod_probe "$probe" after + diff "$probe/before.txt" "$probe/after.txt" > "$probe/diff.txt" || fail "production changed during the live run: $probe/diff.txt" + fi + [ $rc -eq 0 ] || fail "live run failed; transcript: $transcript" +} + +mode_verify() { + [ $# -eq 1 ] || usage + [ -f "$1" ] || fail "no transcript at $1" + f=$(abspath "$1") + (cd "$ROOT" && AGENT_E2E_TRANSCRIPT="$f" $GOTEST -run '^TestVerifyTranscriptFile$' "$PKG") || fail "transcript $1 does not verify" +} + +expect_refusal() { # name want-substring sandbox-args... + name="$1"; want="$2"; shift 2 + err=$("$@" 2>&1 >/dev/null); rc=$? + [ $rc -eq 1 ] || fail "self-test $name: want exit 1, got $rc ($err)" + case "$err" in *"$want"*) ;; *) fail "self-test $name: refusal does not name $want: $err" ;; esac + printf 'agent-e2e: self-test %s: refused as expected\n' "$name" +} + +mode_self_test() { + (cd "$ROOT" && $GOTEST -run '^(TestVerifyFixtures|TestReplayFile)$' "$PKG") || fail "unit tests failed" + tmp=$(scratch) + op="$tmp/operator"; mkdir -p "$op/.ssh" "$tmp/project" + mk_home() { d="$tmp/$1"; mkdir -p "$d/.ssh"; printf 'Host 127.0.0.1\n IdentitiesOnly yes\n' > "$d/.ssh/config"; printf '%s' "$d"; } + base="--project $tmp/project --operator-home $op" + h=$(mk_home clean) + # shellcheck disable=SC2086 + env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true || fail "self-test clean: a clean sandbox was refused" + printf 'agent-e2e: self-test clean: accepted\n' + h=$(mk_home foreign); printf 'Host sim\n HostName 192.0.2.10\n' >> "$h/.ssh/config" + # shellcheck disable=SC2086 + expect_refusal foreign-host 192.0.2.10 env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true + h=$(mk_home inventory); printf 'environments:\n prod:\n servers:\n - host: deploy@db.example.com:22\n' > "$tmp/inv.yaml" + # shellcheck disable=SC2086 + expect_refusal foreign-inventory db.example.com env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base --inventory "$tmp/inv.yaml" -- true + h=$(mk_home agent) + # shellcheck disable=SC2086 + expect_refusal ssh-auth-sock SSH_AUTH_SOCK env SSH_AUTH_SOCK="$tmp/agent.sock" bash "$SANDBOX" --home "$h" $base -- true + h=$(mk_home link); ln -s "$op/.ssh" "$h/operator-ssh" + # shellcheck disable=SC2086 + expect_refusal operator-ssh "$h/operator-ssh" env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true + rm -rf "$tmp" + printf 'agent-e2e: self-test passed\n' +} + +mode_tools_list() { + [ $# -ge 1 ] || usage + bin=$(abspath "$1"); shift + envs="" + while [ $# -gt 0 ]; do + case "$1" in + --env) envs="${envs:+$envs,}${2:-}"; shift 2 ;; + *) usage ;; + esac + done + [ -x "$bin" ] || fail "$bin is not an executable" + tmp=$(scratch) + (cd "$ROOT" && AGENT_E2E_BIN="$bin" AGENT_E2E_TOOLS_OUT="$tmp/tools.json" AGENT_E2E_ENV="$envs" \ + $GOTEST -run '^TestToolsList$' "$PKG" >&2) || { rm -rf "$tmp"; fail "tools/list failed"; } + cat "$tmp/tools.json" + rm -rf "$tmp" +} + +[ $# -ge 1 ] || usage +mode="$1"; shift +case "$mode" in + --replay) mode_replay "$@" ;; + --live) mode_live "$@" ;; + --verify-transcript) mode_verify "$@" ;; + --self-test) mode_self_test "$@" ;; + --tools-list) mode_tools_list "$@" ;; + -h|--help) usage ;; + *) usage ;; +esac diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md new file mode 100644 index 000000000..a4b5b69ad --- /dev/null +++ b/test/e2e/agent/README.md @@ -0,0 +1,52 @@ +# Agent E2E harness + +The MCP-only agent end-to-end proof (P7-SHIP-02, SHIP Epic D7). An agent runs a whole nSelf project through the `nself` MCP server and nothing else. The harness checks every step from a transcript. + +## Layout + +| File | Role | +|---|---| +| `agent_test.go` | Tests: verifier fixtures, replay file, transcript check, tools/list, the replay | +| `live_test.go` | Live mode: a headless `claude -p` session with only the nself MCP server | +| `flow.go` | Replay client (mcp-go `client`): one `tools/call` per step, confirmation round trips | +| `harness.go` | Sim hosts and harness actions (fixture, git commit, service stop, probes) | +| `sandbox.go` | Sandbox HOME, inventory v2 file, the `mcp-sandbox.sh` argv | +| `transcript.go` | Transcript JSONL writer and reader | +| `verify.go`, `verify_legs.go` | Transcript rules | +| `testdata/replay.jsonl` | The replayed agent session | +| `testdata/live-prompt.md` | The live-session prompt | +| `testdata/good.jsonl`, `testdata/bad/*.jsonl` | Verifier fixtures | +| `testdata/sim/*.sh` | Recording `docker` and `nself` stubs for the sim hosts | + +Scripts: `scripts/e2e/agent/run.sh` (all modes) and `scripts/e2e/agent/mcp-sandbox.sh` (sealed server start). Workflow: `.github/workflows/agent-e2e.yml`. + +## Run it + +```text +bash scripts/e2e/agent/run.sh --self-test +bash scripts/e2e/agent/run.sh --replay --transcript /tmp/agent-e2e/replay.jsonl [--ci-plugin-src ../plugins/free/ci] +bash scripts/e2e/agent/run.sh --verify-transcript /tmp/agent-e2e/replay.jsonl +bash scripts/e2e/agent/run.sh --tools-list ./nself --env NSELF_V15=1 +bash scripts/e2e/agent/run.sh --live --transcript /tmp/agent-e2e/live.jsonl [--prod-probe DIR] +``` + +`--replay` and `--live` need Docker. Linux runs are the evidence; macOS runs only the live session. + +## Sandbox + +`mcp-sandbox.sh` starts the server with `env -i` (PATH, HOME, TMPDIR, NSELF_V15 and a fixed list of extras). HOME is a temp dir with only the sim key (generated per run by `sdk/go/simharness`, never committed), an ssh config, known hosts and, when the compose plugin is outside the system plugin dirs, a docker config naming its directory (no auths). The script refuses to start when SSH_AUTH_SOCK is set, when HOME links to or names the operator's `~/.ssh`, or when HOME, the inventory or the ssh config names a host other than `127.0.0.1` or `localhost`. + +## Transcript + +One JSON object per line: `{seq, time, role, kind, tool, arguments, envelope, is_error, note}`. Role is `agent`, `server` or `harness`. Kind is `tools/list`, `tools/call`, `result`, `action` or `probe`. Confirmation tokens and secret arguments are written as `[REDACTED]`. + +The verifier fails a transcript that has a non-MCP agent step, a missing D7 step, steps out of order, a promote apply before its dry run, CI evidence before the run wait, a `tools/call` result that is not a v1 envelope, or a failed recovery or gate leg. + +## Notes + +- The server starts in the empty fixture project and lists `nself_init` plus the read-only tools; after `init` it lists every tool, and after `add ci` it sends `notifications/tools/list_changed` and the ci tools appear. +- The replay and live runs start `nself mcp --timeout 30m`: a cold `start` pulls every image and outlasts the 5m default. +- The served ci release (1.2.13) predates the ci machine surface (P7-CI-62). `run.sh --replay --ci-plugin-src /free/ci` builds ci from that source; after the agent's `nself_add ci` the harness swaps the build in (a `role: harness` record naming the revision) and an agent `nself_status` call lets the server refresh its tool list. The workflow uses plugins main. Without the flag the served plugin is kept, and `discover-ci` fails until a release carries the surface. The live mode has no such swap, so it needs that release. +- The prod sim env sets `require_verified_release: false` (an inventory field, DEPL D20). The proof covers the promote and its digest compare; a verified release needs a signed release-class ci pipeline, which DEPL-19's own tests cover. The fixture repo has no origin, so nothing committed overrides the field. +- run.sh keeps its scratch (and the test's TMPDIR and GOTMPDIR) under `$HOME/.cache/nself-agent-e2e` unless `AGENT_E2E_TMP` says otherwise: a Colima or Docker Desktop VM shares only HOME. +- The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do. The image-digest compare after the promote reads the stubs' record of `compose up`. diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go new file mode 100644 index 000000000..dbc830331 --- /dev/null +++ b/test/e2e/agent/agent_test.go @@ -0,0 +1,256 @@ +//go:build e2eagent + +package agent + +// Agent E2E harness tests (P7-SHIP-02, SHIP Epic D7). Run through +// scripts/e2e/agent/run.sh; each test reads its inputs from AGENT_E2E_* env. +// +// TestVerifyFixtures, TestReplayFile unit tests, always run +// TestVerifyTranscriptFile AGENT_E2E_TRANSCRIPT= +// TestToolsList AGENT_E2E_BIN, AGENT_E2E_TOOLS_OUT[, AGENT_E2E_ENV] +// TestAgentReplay AGENT_E2E_BIN, INTEGRATION=1, Docker[, AGENT_E2E_TRANSCRIPT_OUT] + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/nself-org/cli/internal/controlplane" + "github.com/nself-org/cli/sdk/go/v2/remote" +) + +// replayTimeout bounds one tool call in the replay and live runs: a cold +// `start` pulls every image, which outlasts the server's 5m default. +const replayTimeout = "30m" + +func TestVerifyFixtures(t *testing.T) { + good, err := ReadTranscript(filepath.Join("testdata", "good.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := Verify(good); err != nil { + t.Fatalf("good fixture: %v", err) + } + for name, want := range map[string]string{ + "non-mcp": "non-MCP tool use", + "missing-step": `missing step "db migrate up"`, + "promote-order": "promote apply before its dry-run", + "bad-envelope": "fails the v1 schema", + "evidence-before-wait": "ci evidence before ci runs wait", + } { + recs, err := ReadTranscript(filepath.Join("testdata", "bad", name+".jsonl")) + if err != nil { + t.Fatal(err) + } + if err := Verify(recs); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("bad/%s: want an error containing %q, got %v", name, want, err) + } + } +} + +func TestReplayFile(t *testing.T) { + steps, err := LoadReplay(filepath.Join("testdata", "replay.jsonl")) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, s := range steps { + if seen[s.ID] { + t.Errorf("duplicate step id %q", s.ID) + } + seen[s.ID] = true + if s.Role == RoleAgent && s.Kind != KindToolsList && !strings.HasPrefix(s.Tool, "nself_") { + t.Errorf("step %s: agent tool %q is not an nself MCP tool", s.ID, s.Tool) + } + if s.Role == RoleHarness && s.Action == "" { + t.Errorf("step %s: harness step without an action", s.ID) + } + } +} + +func TestVerifyTranscriptFile(t *testing.T) { + path := os.Getenv("AGENT_E2E_TRANSCRIPT") + if path == "" { + t.Skip("AGENT_E2E_TRANSCRIPT is not set") + } + recs, err := ReadTranscript(path) + if err != nil { + t.Fatal(err) + } + if err := Verify(recs); err != nil { + t.Fatalf("%s:\n%v", path, err) + } +} + +// startClient starts and initialises an MCP client over the sandboxed server. +func startClient(t *testing.T, box *Sandbox, bin, operatorHome string, env ...string) *client.Client { + t.Helper() + t.Setenv("SSH_AUTH_SOCK", "") + prog, args := box.ServerCommand(bin, operatorHome, env...) + c, err := client.NewStdioMCPClient(prog, nil, args...) + if err != nil { + t.Fatalf("start nself mcp in the sandbox: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + req := mcp.InitializeRequest{} + req.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + req.Params.ClientInfo = mcp.Implementation{Name: "nself-agent-e2e", Version: "1"} + if _, err := c.Initialize(context.Background(), req); err != nil { + t.Fatalf("initialize: %v", err) + } + return c +} + +func TestToolsList(t *testing.T) { + bin, out := os.Getenv("AGENT_E2E_BIN"), os.Getenv("AGENT_E2E_TOOLS_OUT") + if bin == "" || out == "" { + t.Skip("AGENT_E2E_BIN and AGENT_E2E_TOOLS_OUT are not set") + } + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // Outside a project the server lists only init and the read-only tools; + // a project marker makes it list the whole surface (the parity input). + if err := os.WriteFile(filepath.Join(box.Project, ".env"), nil, 0o600); err != nil { + t.Fatal(err) + } + var env []string + if e := os.Getenv("AGENT_E2E_ENV"); e != "" { + env = strings.Split(e, ",") + } + c := startClient(t, box, bin, os.Getenv("HOME"), env...) + res, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) + if err != nil { + t.Fatal(err) + } + type row struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` + } + rows := make([]row, 0, len(res.Tools)) + for _, tool := range res.Tools { + b, err := json.Marshal(tool) + if err != nil { + t.Fatal(err) + } + var r row + if err := json.Unmarshal(b, &r); err != nil { + t.Fatal(err) + } + rows = append(rows, r) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(out, append(b, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAgentReplay(t *testing.T) { + bin := os.Getenv("AGENT_E2E_BIN") + if bin == "" { + t.Skip("AGENT_E2E_BIN is not set") + } + operatorHome := os.Getenv("HOME") + if out, err := exec.Command("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output(); err == nil { + t.Setenv("DOCKER_HOST", strings.TrimSpace(string(out))) + } + fleet, hosts := StartSim(t) + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + box.Timeout = replayTimeout + if err := box.WriteDockerConfig(composePluginPath(t)); err != nil { + t.Fatal(err) + } + for _, step := range []func() error{ + func() error { return box.InstallKey(fleet.KeyPath()) }, + box.WriteSSHConfig, + func() error { return box.WriteInventory(hosts...) }, + } { + if err := step(); err != nil { + t.Fatal(err) + } + } + ciPlugin := BuildCIPlugin(t) + t.Setenv("HOME", box.Home) + trustHosts(t, hosts) + tpath := os.Getenv("AGENT_E2E_TRANSCRIPT_OUT") + if tpath == "" { + tpath = filepath.Join(t.TempDir(), "replay.jsonl") + } + w, err := NewWriter(tpath) + if err != nil { + t.Fatal(err) + } + defer w.Close() + h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts, CI: ciPlugin} + t.Cleanup(h.Teardown) + ctx := context.Background() + if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { + t.Fatal(err) + } + steps, err := LoadReplay(filepath.Join("testdata", "replay.jsonl")) + if err != nil { + t.Fatal(err) + } + c := startClient(t, box, bin, operatorHome) + r := &Runner{C: c, H: h, W: w} + if err := r.Run(ctx, steps); err != nil { + t.Fatalf("replay stopped (transcript %s): %v", tpath, err) + } + if err := Verify(w.Records); err != nil { + t.Fatalf("transcript %s does not verify:\n%v", tpath, err) + } +} + +// composePluginPath is the docker compose CLI plugin path `docker info` +// reports (run with the operator's HOME, before the sandbox HOME is set). +func composePluginPath(t *testing.T) string { + t.Helper() + out, err := exec.Command("docker", "info", "--format", + `{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}`).Output() + if err != nil { + t.Fatalf("find the docker compose plugin: %v", err) + } + return strings.TrimSpace(string(out)) +} + +// trustHosts records each sim host's ed25519 key in the sandbox HOME (the +// HOME must already be the sandbox's), as the deploy-sim tests do. +func trustHosts(t *testing.T, hosts []SimHost) { + t.Helper() + for _, h := range hosts { + scan, err := exec.Command("ssh-keyscan", "-T", "5", "-t", "ed25519", "-p", strconv.Itoa(h.Port), "--", LoopbackHost).Output() + if err != nil { + t.Fatalf("ssh-keyscan %s: %v", h.Server, err) + } + fp := "" + for _, line := range strings.Split(string(scan), "\n") { + if got, err := remote.Fingerprint(line); err == nil { + fp = got + break + } + } + if fp == "" { + t.Fatalf("no ed25519 key from %s", h.Server) + } + if err := controlplane.TrustHostKey(context.Background(), h.Host(), fp); err != nil { + t.Fatalf("trust %s: %v", h.Server, err) + } + } +} diff --git a/test/e2e/agent/ci_plugin.go b/test/e2e/agent/ci_plugin.go new file mode 100644 index 000000000..c46a45348 --- /dev/null +++ b/test/e2e/agent/ci_plugin.go @@ -0,0 +1,77 @@ +//go:build e2eagent + +package agent + +// Purpose: run the replay's CI leg against a ci plugin built from plugins +// source (run.sh --ci-plugin-src) until a served ci release carries the +// machine surface (P7-CI-62). The agent's `nself_add ci` stays a real MCP +// step; the harness then replaces the installed manifest and binary with the +// source build, as a `role: harness` record naming the source revision. +// Inputs: AGENT_E2E_CI_PLUGIN_SRC (the free/ci directory of a plugins checkout). +// Outputs: HOME/.nself/plugins/ci/plugin.json and HOME/.nself/plugins/bin/nself-ci +// in the sandbox (the P7-CI-47 layout). +// Constraints: the build runs before HOME points at the sandbox, so the Go +// caches stay the operator's; without a source dir the served plugin is kept. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// CIPluginSrcEnv names the plugins free/ci source directory. +const CIPluginSrcEnv = "AGENT_E2E_CI_PLUGIN_SRC" + +// CIPlugin is a ci plugin built from source. +type CIPlugin struct { + Manifest, Binary, Revision string +} + +// BuildCIPlugin builds the ci plugin from $AGENT_E2E_CI_PLUGIN_SRC, or +// returns nil when it is not set. +func BuildCIPlugin(t *testing.T) *CIPlugin { + t.Helper() + src := os.Getenv(CIPluginSrcEnv) + if src == "" { + return nil + } + bin := filepath.Join(t.TempDir(), "nself-ci") + ctx := context.Background() + if _, err := run(ctx, src, "go", "build", "-mod=vendor", "-trimpath", "-o", bin, "./cmd"); err != nil { + t.Fatalf("build the ci plugin from %s: %v", src, err) + } + rev, err := run(ctx, src, "git", "rev-parse", "HEAD") + if err != nil { + rev = "unknown" + } + return &CIPlugin{Manifest: filepath.Join(src, "plugin.json"), Binary: bin, Revision: strings.TrimSpace(rev)} +} + +// installCIPlugin replaces the installed ci plugin with the source build. +func (h *SimHarness) installCIPlugin() (string, error) { + if h.CI == nil { + return "served ci plugin kept (no " + CIPluginSrcEnv + ")", nil + } + root := filepath.Join(h.Box.Home, ".nself", "plugins") + for src, dst := range map[string]string{ + h.CI.Manifest: filepath.Join(root, "ci", "plugin.json"), + h.CI.Binary: filepath.Join(root, "bin", "nself-ci"), + } { + b, err := os.ReadFile(src) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(dst+".tmp", b, 0o755); err != nil { + return "", err + } + if err := os.Rename(dst+".tmp", dst); err != nil { + return "", err + } + } + return "ci plugin from plugins source " + h.CI.Revision, nil +} diff --git a/test/e2e/agent/flow.go b/test/e2e/agent/flow.go new file mode 100644 index 000000000..1c7ffbef1 --- /dev/null +++ b/test/e2e/agent/flow.go @@ -0,0 +1,279 @@ +//go:build e2eagent + +package agent + +// Purpose: the replay client: follow testdata/replay.jsonl against `nself mcp` +// with the mcp-go client, one tools/call per agent step, confirmation tokens +// taken from the previous result, every request, result and harness action +// written to the transcript. +// Inputs: the replay steps, a started *client.Client, a Harness for the +// harness-role steps, a transcript Writer. +// Outputs: nil when every step met its expectation; else the first failure. +// Constraints: a failed step stops the run (never retried silently); the only +// repeat is a step's declared `until` loop over a server-side wait; no sleeps; +// the harness never forges a confirmation token. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +// Step is one line of testdata/replay.jsonl. +type Step struct { + ID string `json:"id"` + Role string `json:"role"` // agent | harness + // Kind is tools/call (default) or tools/list for agent steps. + Kind string `json:"kind,omitempty"` + Tool string `json:"tool,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + // Confirm: the first result must be a confirmation; the call is repeated + // once with its confirm token. + Confirm bool `json:"confirm,omitempty"` + // Expect: ok (default) | confirmation | unhealthy | state-ok | evidence-pass. + Expect string `json:"expect,omitempty"` + // Capture maps a name to a data field of the final result; later steps + // use it as ${name} in their arguments. + Capture map[string]string `json:"capture,omitempty"` + // Until "terminal" repeats the call while data.state is not terminal + // (the server waits; MaxRepeats bounds it). + Until string `json:"until,omitempty"` + MaxRepeats int `json:"max_repeats,omitempty"` + // Require lists tool names a tools/list step must see. + Require []string `json:"require,omitempty"` + // Action names a harness action (harness.go). + Action string `json:"action,omitempty"` + Note string `json:"note,omitempty"` +} + +// LoadReplay parses a replay JSONL file. +func LoadReplay(path string) ([]Step, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var steps []Step + sc := bufio.NewScanner(f) + for n := 1; sc.Scan(); n++ { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + var s Step + dec := json.NewDecoder(strings.NewReader(line)) + dec.DisallowUnknownFields() + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("replay line %d: %w", n, err) + } + if s.Role != RoleAgent && s.Role != RoleHarness { + return nil, fmt.Errorf("replay line %d: role %q", n, s.Role) + } + steps = append(steps, s) + } + return steps, sc.Err() +} + +// Harness runs the harness-role steps (sim fleet, docker, git). +type Harness interface { + Do(ctx context.Context, s Step, w *Writer) error +} + +// Runner drives one replay. +type Runner struct { + C *client.Client + H Harness + W *Writer + captured map[string]string +} + +// Run executes steps in order and stops at the first failure. +func (r *Runner) Run(ctx context.Context, steps []Step) error { + r.captured = map[string]string{} + for _, s := range steps { + var err error + switch { + case s.Role == RoleHarness: + err = r.H.Do(ctx, s, r.W) + case s.Kind == KindToolsList: + err = r.list(ctx, s) + default: + err = r.call(ctx, s) + } + if err != nil { + return fmt.Errorf("step %s: %w", s.ID, err) + } + } + return nil +} + +func (r *Runner) list(ctx context.Context, s Step) error { + if _, err := r.W.Write(Record{Role: RoleAgent, Kind: KindToolsList, Note: s.ID}); err != nil { + return err + } + res, err := r.C.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + return err + } + names := make([]string, 0, len(res.Tools)) + for _, t := range res.Tools { + names = append(names, t.Name) + } + sort.Strings(names) + body, _ := json.Marshal(map[string]any{"tools": names}) + if _, err := r.W.Write(Record{Role: RoleServer, Kind: KindResult, Envelope: body, Note: s.ID}); err != nil { + return err + } + if !hasAll(names, s.Require...) { + return fmt.Errorf("tools/list lacks one of %v (P7-SURF-03 / P7-CI-62 gap)", s.Require) + } + return nil +} + +// outcome is one tools/call result as the runner sees it. +type outcome struct { + isError bool + env json.RawMessage + data map[string]any +} + +func (r *Runner) invoke(ctx context.Context, tool string, args map[string]any, id string) (outcome, error) { + if _, err := r.W.Write(Record{Role: RoleAgent, Kind: KindToolsCall, Tool: tool, Arguments: args, Note: id}); err != nil { + return outcome{}, err + } + wire := map[string]any{} + for k, v := range args { + if k != "_secret" { + wire[k] = v + } + } + req := mcp.CallToolRequest{} + req.Params.Name, req.Params.Arguments = tool, wire + res, err := r.C.CallTool(ctx, req) + if err != nil { + return outcome{}, fmt.Errorf("tools/call %s: %w", tool, err) + } + o := outcome{isError: res.IsError} + for _, c := range res.Content { + if tc, ok := c.(mcp.TextContent); ok { + o.env = json.RawMessage(tc.Text) + break + } + } + var doc struct { + Data map[string]any `json:"data"` + } + _ = json.Unmarshal(o.env, &doc) + o.data = doc.Data + _, err = r.W.Write(Record{Role: RoleServer, Kind: KindResult, Tool: tool, Envelope: compact(o.env), IsError: o.isError, Note: id}) + return o, err +} + +// compact re-encodes b on one line; text that is not JSON becomes a JSON +// string (the verifier then reports it as a failed envelope). +func compact(b json.RawMessage) json.RawMessage { + if len(b) == 0 { + return nil + } + var v any + if json.Unmarshal(b, &v) != nil { + s, _ := json.Marshal(string(b)) + return s + } + out, _ := json.Marshal(v) + return out +} + +func (r *Runner) call(ctx context.Context, s Step) error { + args := r.expand(s.Arguments) + o, err := r.invoke(ctx, s.Tool, args, s.ID) + if err != nil { + return err + } + if s.Confirm { + if o.isError || o.data["confirmation_required"] != true { + return fmt.Errorf("%s: wanted a confirmation request, got %s", s.Tool, o.env) + } + token, _ := o.data["confirm"].(string) + args = withConfirm(args, token) + if o, err = r.invoke(ctx, s.Tool, args, s.ID+" (confirmed)"); err != nil { + return err + } + } + for n := 1; s.Until == "terminal" && !o.isError && !terminal(o.data); n++ { + if n >= max(s.MaxRepeats, 1) { + return fmt.Errorf("%s: still not terminal after %d calls", s.Tool, n) + } + if o, err = r.invoke(ctx, s.Tool, args, fmt.Sprintf("%s (repeat %d)", s.ID, n)); err != nil { + return err + } + } + if err := expect(s, o); err != nil { + return err + } + for name, field := range s.Capture { + v, ok := o.data[field].(string) + if !ok || v == "" { + return fmt.Errorf("%s: result has no data.%s to capture", s.Tool, field) + } + r.captured[name] = v + } + return nil +} + +func withConfirm(args map[string]any, token string) map[string]any { + out := map[string]any{} + for k, v := range args { + out[k] = v + } + out["confirm"] = token + return out +} + +func expect(s Step, o outcome) error { + state, _ := o.data["state"].(string) + switch s.Expect { + case "", "ok": + if o.isError { + return fmt.Errorf("%s returned an error: %s", s.Tool, o.env) + } + case "confirmation": + if o.isError || o.data["confirmation_required"] != true { + return fmt.Errorf("%s: wanted a confirmation request, got %s", s.Tool, o.env) + } + case "unhealthy": + if !o.isError && state == "ok" { + return fmt.Errorf("%s: wanted an unhealthy report, got %s", s.Tool, o.env) + } + case "state-ok": + if o.isError || state != "ok" { + return fmt.Errorf("%s: wanted data.state ok, got %s", s.Tool, o.env) + } + case "evidence-pass": + if o.isError || o.data["result"] != "pass" { + return fmt.Errorf("%s: wanted evidence result pass, got %s", s.Tool, o.env) + } + default: + return fmt.Errorf("unknown expect %q", s.Expect) + } + return nil +} + +// expand replaces ${name} in string values with captured values. +func (r *Runner) expand(v map[string]any) map[string]any { + b, _ := json.Marshal(v) + s := string(b) + for k, val := range r.captured { + s = strings.ReplaceAll(s, "${"+k+"}", val) + } + out := map[string]any{} + _ = json.Unmarshal([]byte(s), &out) + return out +} diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go new file mode 100644 index 000000000..8a579e54e --- /dev/null +++ b/test/e2e/agent/harness.go @@ -0,0 +1,295 @@ +//go:build e2eagent + +package agent + +// Purpose: the harness side of the replay: the two loopback sim hosts (qa tier +// local-servers, prod tier prod) and every harness action (git commit, service stop, docker volume and image-digest probes). Each action is +// a `role: harness` transcript record; none of them is an agent step. +// Inputs: a *testing.T, the Sandbox, the replay's harness Steps. +// Outputs: transcript records; an error when an action or a probe fails. +// Constraints: docker and git run from argv slices (no host shell); the sim +// hosts get recording docker/nself stubs, as deploy-sim.yml's tests do; the +// harness never calls an nself command on the agent's behalf. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/nself-org/cli/internal/controlplane" + "github.com/nself-org/cli/sdk/go/v2/simharness" +) + +// SimRemotePath is the project path on each sim host. +const SimRemotePath = "/tmp/nself-agent-e2e/app" + +// SimHarness owns the fleet and the sandbox for one replay. +type SimHarness struct { + T *testing.T + Fleet *simharness.Fleet + Box *Sandbox + Hosts []SimHost + // CI, when set, replaces the ci plugin after the agent's add (ci_plugin.go). + CI *CIPlugin + // before holds the image sets of the sim hosts captured by digest-before. + before map[string]string +} + +// StartSim starts qa-1 and prod-1, installs the stubs and returns the hosts. +func StartSim(t *testing.T) (*simharness.Fleet, []SimHost) { + t.Helper() + fleet := simharness.Start(t, simharness.Config{Nodes: []simharness.NodeSpec{{Name: "qa-1"}, {Name: "prod-1"}}}) + hosts := []SimHost{ + {Env: "qa", Server: "qa-1", Tier: controlplane.TierLocalServers}, + {Env: "prod", Server: "prod-1", Tier: controlplane.TierProd}, + } + for i := range hosts { + n := fleet.Node(t, hosts[i].Server) + hosts[i].User, hosts[i].Port, hosts[i].RemotePath = n.User, n.Port, SimRemotePath + fleet.Exec(t, n.Name, "sh", "-c", "command -v rsync >/dev/null || apk add --no-cache rsync >/dev/null") + for _, name := range []string{"docker", "nself"} { + fleet.CopyTo(t, n.Name, filepath.Join("testdata", "sim", name+".sh"), "/usr/local/bin/"+name) + fleet.Exec(t, n.Name, "chmod", "755", "/usr/local/bin/"+name) + } + fleet.Exec(t, n.Name, "sh", "-c", "mkdir -p "+SimRemotePath+" /tmp/agent-e2e && chown -R "+n.User+" /tmp/nself-agent-e2e /tmp/agent-e2e && chmod 1777 /tmp") + } + t.Cleanup(func() { + if !t.Failed() { + return + } + for _, h := range hosts { + t.Logf("sim host %s calls:\n%s", h.Server, fleet.Exec(t, h.Server, "sh", "-c", + "tail -n 60 /tmp/agent-e2e/docker.log /tmp/agent-e2e/nself.log 2>/dev/null; cat /tmp/agent-e2e/running 2>/dev/null; true")) + } + }) + return fleet, hosts +} + +// Do runs one harness step and writes its record. +func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { + note := s.Action + if s.Note != "" { + note += ": " + s.Note + } + switch s.Action { + case "git-commit": + if _, err := run(ctx, h.Box.Project, "git", "add", "-A"); err != nil { + return err + } + if _, err := run(ctx, h.Box.Project, "git", "-c", "user.name=agent-e2e", "-c", "user.email=agent-e2e@localhost", + "commit", "-q", "--no-gpg-sign", "-m", "agent e2e: "+s.ID); err != nil { + return err + } + case NoteStopService: + name, err := h.serviceContainer(ctx) + if err != nil { + return err + } + if _, err := run(ctx, "", "docker", "stop", name); err != nil { + return err + } + note = NoteStopService + ": " + name + case "volumes-before", "volumes-after": + out, err := run(ctx, "", "docker", "volume", "ls", "--format", "{{.Name}}", + "--filter", "label=com.docker.compose.project="+h.Box.ComposeProject()) + if err != nil { + return err + } + vols := strings.Fields(out) + sort.Strings(vols) + body, _ := json.Marshal(map[string]any{"volumes": vols}) + probe := map[string]string{"volumes-before": NoteVolumesBefore, "volumes-after": NoteVolumesAfter}[s.Action] + _, err = w.Write(Record{Role: RoleHarness, Kind: KindProbe, Envelope: body, Note: probe}) + return err + case "digest-before", "digest-compare": + return h.digests(s.Action, w) + case "seed-target-plugins": + names, err := h.seedTargetPlugins() + if err != nil { + return err + } + note = s.Action + ": " + strings.Join(names, ", ") + case "seed-image-digests": + n, err := h.seedImageDigests(ctx) + if err != nil { + return err + } + note = fmt.Sprintf("%s: %d images", s.Action, n) + case "ci-plugin-from-source": + msg, err := h.installCIPlugin() + if err != nil { + return err + } + note = s.Action + ": " + msg + default: + return fmt.Errorf("unknown harness action %q", s.Action) + } + _, err := w.Write(Record{Role: RoleHarness, Kind: KindAction, Note: note}) + return err +} + +// digests reads the image set each sim host runs (the docker stub's record of +// `compose up`) and, on compare, wants prod == qa as captured before the +// promote and qa unchanged (DEPL D16 compare). +func (h *SimHarness) digests(action string, w *Writer) error { + now := map[string]string{} + for _, host := range h.Hosts { + now[host.Env] = h.Fleet.Exec(h.T, host.Server, "sh", "-c", "cat /tmp/agent-e2e/running 2>/dev/null || true") + } + body, _ := json.Marshal(now) + if _, err := w.Write(Record{Role: RoleHarness, Kind: KindProbe, Envelope: body, Note: action}); err != nil { + return err + } + if action == "digest-before" { + h.before = now + return nil + } + switch { + case strings.TrimSpace(h.before["qa"]) == "": + return fmt.Errorf("qa ran no images before the promote") + case now["qa"] != h.before["qa"]: + return fmt.Errorf("the promote changed the qa host") + case now["prod"] != h.before["qa"]: + return fmt.Errorf("prod does not run the qa images by digest:\nqa: %s\nprod: %s", h.before["qa"], now["prod"]) + } + return nil +} + +// Teardown removes the fixture project's containers, volumes and networks +// (by its unique compose project label) after the run, pass or fail. It is a +// harness cleanup, never an agent step, and touches nothing else. +func (h *SimHarness) Teardown() { + ctx := context.Background() + label := "label=com.docker.compose.project=" + h.Box.ComposeProject() + for _, kind := range [][]string{{"container", "rm", "-f", "-v"}, {"volume", "rm", "-f"}, {"network", "rm"}} { + lsArgs := []string{kind[0], "ls", "-q", "--filter", label} + if kind[0] == "container" { + lsArgs = []string{"ps", "-aq", "--filter", label} + } + out, err := run(ctx, "", "docker", lsArgs...) + if err != nil { + h.T.Logf("teardown: %v", err) + continue + } + if ids := strings.Fields(out); len(ids) > 0 { + if _, err := run(ctx, "", "docker", append(kind, ids...)...); err != nil { + h.T.Logf("teardown: %v", err) + } + } + } +} + +// seedTargetPlugins copies the manifest of every plugin installed in the +// sandbox HOME to each sim host's ~/.nself/plugins, so the hosts stand in for +// servers already provisioned with the project's plugins (deploy refuses a +// target whose plugin set differs, E485). +func (h *SimHarness) seedTargetPlugins() ([]string, error) { + root := filepath.Join(h.Box.Home, ".nself", "plugins") + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "plugin.json")) { + names = append(names, e.Name()) + } + } + for _, host := range h.Hosts { + home := strings.TrimSpace(h.Fleet.Exec(h.T, host.Server, "sh", "-c", "getent passwd "+host.User+" | cut -d: -f6")) + for _, name := range names { + dir := home + "/.nself/plugins/" + name + h.Fleet.Exec(h.T, host.Server, "mkdir", "-p", dir) + h.Fleet.CopyTo(h.T, host.Server, filepath.Join(root, name, "plugin.json"), dir+"/plugin.json") + } + h.Fleet.Exec(h.T, host.Server, "chown", "-R", host.User, home+"/.nself") + } + return names, nil +} + +// seedImageDigests writes " @sha256:" for every image the +// local project runs (as the operator's Docker pulled it) to each sim host's +// /tmp/agent-e2e/digests, which the docker stub's `image inspect` reports. +func (h *SimHarness) seedImageDigests(ctx context.Context) (int, error) { + // Resolve through each container's image id: an image pulled by digest has + // no local tag, so inspecting the reference by name can fail. + out, err := run(ctx, "", "docker", "ps", "--format", "{{.Image}} {{.ID}}", + "--filter", "label=com.docker.compose.project="+h.Box.ComposeProject()) + if err != nil { + return 0, err + } + seen := map[string]bool{} + var lines []string + for _, row := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Fields(row) + if len(f) != 2 || seen[f[0]] { + continue + } + seen[f[0]] = true + id, err := run(ctx, "", "docker", "inspect", "--format", "{{.Image}}", f[1]) + if err != nil { + return 0, err + } + rd, err := run(ctx, "", "docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", strings.TrimSpace(id)) + if err != nil { + return 0, err + } + lines = append(lines, f[0]+" "+strings.TrimSpace(rd)) + } + if len(lines) == 0 { + return 0, fmt.Errorf("the project runs no images to seed") + } + sort.Strings(lines) + f := filepath.Join(h.T.TempDir(), "digests") + if err := os.WriteFile(f, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + return 0, err + } + for _, host := range h.Hosts { + h.Fleet.CopyTo(h.T, host.Server, f, "/tmp/agent-e2e/digests") + h.Fleet.Exec(h.T, host.Server, "chmod", "644", "/tmp/agent-e2e/digests") + } + return len(lines), nil +} + +func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } + +// serviceContainer picks one running service container of the project +// (hasura when present, else the first by name). +func (h *SimHarness) serviceContainer(ctx context.Context) (string, error) { + dir, err := filepath.EvalSymlinks(h.Box.Project) + if err != nil { + return "", err + } + out, err := run(ctx, "", "docker", "ps", "--format", "{{.Names}}", + "--filter", "label=com.docker.compose.project.working_dir="+dir) + if err != nil { + return "", err + } + names := strings.Fields(out) + sort.Strings(names) + if len(names) == 0 { + return "", fmt.Errorf("no running container of the project %s", dir) + } + for _, n := range names { + if strings.Contains(n, "hasura") { + return n, nil + } + } + return names[0], nil +} + +func run(ctx context.Context, dir, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out) + } + return string(out), nil +} diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go new file mode 100644 index 000000000..9f77af55d --- /dev/null +++ b/test/e2e/agent/live_test.go @@ -0,0 +1,172 @@ +//go:build e2eagent + +package agent + +// Live mode (run.sh --live; the session itself is P7-SHIP-13): a headless +// Claude Code session whose only tools are the sandboxed nself MCP server. +// The stream-json output is converted to the transcript format and verified +// like a replay. Needs AGENT_E2E_LIVE=1, AGENT_E2E_BIN, INTEGRATION=1, Docker +// and a `claude` login on the operator machine (not a production credential). + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// builtinTools are disallowed so the MCP server is the agent's only surface. +var builtinTools = []string{"Bash", "Edit", "MultiEdit", "Write", "Read", "Glob", "Grep", "LS", + "WebFetch", "WebSearch", "NotebookEdit", "Task", "TodoWrite"} + +func TestAgentLive(t *testing.T) { + bin := os.Getenv("AGENT_E2E_BIN") + if os.Getenv("AGENT_E2E_LIVE") != "1" || bin == "" { + t.Skip("AGENT_E2E_LIVE=1 and AGENT_E2E_BIN are not set") + } + operatorHome := os.Getenv("HOME") + claude, err := exec.LookPath("claude") + if err != nil { + t.Fatal("claude is not on PATH") + } + fleet, hosts := StartSim(t) + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + box.Timeout = replayTimeout + if err := box.WriteDockerConfig(composePluginPath(t)); err != nil { + t.Fatal(err) + } + if err := box.InstallKey(fleet.KeyPath()); err != nil { + t.Fatal(err) + } + if err := box.WriteSSHConfig(); err != nil { + t.Fatal(err) + } + if err := box.WriteInventory(hosts...); err != nil { + t.Fatal(err) + } + tpath := os.Getenv("AGENT_E2E_TRANSCRIPT_OUT") + if tpath == "" { + tpath = filepath.Join(t.TempDir(), "live.jsonl") + } + w, err := NewWriter(tpath) + if err != nil { + t.Fatal(err) + } + defer w.Close() + t.Cleanup((&SimHarness{T: t, Box: box}).Teardown) + ctx := context.Background() + if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { + t.Fatal(err) + } + // The trust step needs the sandbox HOME; claude keeps the operator's. + t.Setenv("HOME", box.Home) + trustHosts(t, hosts) + t.Setenv("HOME", operatorHome) + t.Setenv("SSH_AUTH_SOCK", "") + + prog, args := box.ServerCommand(bin, operatorHome) + cfg, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"nself": map[string]any{"command": prog, "args": args}}}) + cfgPath := filepath.Join(t.TempDir(), "mcp.json") + if err := os.WriteFile(cfgPath, cfg, 0o600); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join("testdata", "live-prompt.md")) + if err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(ctx, claude, "-p", string(prompt), "--output-format", "stream-json", "--verbose", + "--strict-mcp-config", "--mcp-config", cfgPath, + "--disallowedTools", strings.Join(builtinTools, ","), "--allowedTools", "mcp__nself") + cmd.Dir = t.TempDir() + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if err := convertStream(out, w); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("claude: %v", err) + } + if err := Verify(w.Records); err != nil { + t.Fatalf("live transcript %s does not verify:\n%v", tpath, err) + } +} + +// streamLine is the part of a stream-json line the converter reads. +type streamLine struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } `json:"content"` + } `json:"message"` +} + +// convertStream writes one agent record per tool_use (an MCP tool of the +// nself server keeps its bare name; anything else keeps its own name, which +// the verifier rejects) and one server record per tool_result. +func convertStream(r interface{ Read([]byte) (int, error) }, w *Writer) error { + tools := map[string]string{} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for sc.Scan() { + var l streamLine + if json.Unmarshal(sc.Bytes(), &l) != nil { + continue + } + for _, c := range l.Message.Content { + switch c.Type { + case "tool_use": + name := strings.TrimPrefix(c.Name, "mcp__nself__") + tools[c.ID] = name + if _, err := w.Write(Record{Role: RoleAgent, Kind: KindToolsCall, Tool: name, Arguments: c.Input}); err != nil { + return err + } + case "tool_result": + if _, err := w.Write(Record{Role: RoleServer, Kind: KindResult, Tool: tools[c.ToolUseID], + Envelope: resultText(c.Content), IsError: c.IsError}); err != nil { + return err + } + } + } + } + return sc.Err() +} + +// resultText returns the envelope JSON of a tool_result content value. +func resultText(raw json.RawMessage) json.RawMessage { + var s string + if json.Unmarshal(raw, &s) == nil { + return compact(json.RawMessage(s)) + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &parts) == nil { + for _, p := range parts { + if p.Type == "text" { + return compact(json.RawMessage(p.Text)) + } + } + } + return raw +} diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go new file mode 100644 index 000000000..4becf3459 --- /dev/null +++ b/test/e2e/agent/sandbox.go @@ -0,0 +1,186 @@ +//go:build e2eagent + +package agent + +// Purpose: the agent sandbox (SHIP Epic D7): a temp HOME holding only the sim +// hosts' ssh config and key, a fixture project with the inventory v2 file, and +// the argv that starts `nself mcp` through scripts/e2e/agent/mcp-sandbox.sh. +// Inputs: a temp root, the sim fleet's generated key and host addresses. +// Outputs: a Sandbox with Home, Project and the server command. +// Constraints: no credential is ever written into the repo: the only key is +// the one simharness generates per fleet at run time, copied into the temp +// HOME. Hosts are 127.0.0.1 only (published sim ports); mcp-sandbox.sh +// refuses anything else, an SSH agent socket, and a HOME that reaches the +// operator's ~/.ssh. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/nself-org/cli/internal/controlplane" +) + +// SimKeyEnv names the variable whose value is the sim key path; the inventory +// refers to it through ssh_key_ref (secrets are never stored inline). +const SimKeyEnv = "NSELF_E2E_SIM_KEY" + +// LoopbackHost is the only host the sandbox may name. +const LoopbackHost = "127.0.0.1" + +// SimHost is one sim host as the inventory sees it. +type SimHost struct { + Env, Server, User string + Port int + Tier controlplane.Tier + RemotePath string +} + +// Host returns the inventory host spec user@127.0.0.1:port. +func (h SimHost) Host() string { return fmt.Sprintf("%s@%s:%d", h.User, LoopbackHost, h.Port) } + +// Sandbox is the agent's whole world: HOME and the project directory. +type Sandbox struct { + Root, Home, Project string + // KeyPath is the copy of the fleet key inside Home (empty until InstallKey). + KeyPath string + // Timeout, when set, is passed as `nself mcp --timeout` (a cold start + // that pulls every image outlasts the 5m default). + Timeout string +} + +// NewSandbox makes root/home (with an empty .ssh) and root/ae2e, the +// project. The random name becomes the compose project name, so container, +// volume and network names never collide with another run on the same host. +func NewSandbox(root string) (*Sandbox, error) { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return nil, err + } + s := &Sandbox{Root: root, Home: filepath.Join(root, "home"), Project: filepath.Join(root, "ae2e"+hex.EncodeToString(b[:]))} + for _, d := range []string{filepath.Join(s.Home, ".ssh"), s.Project} { + if err := os.MkdirAll(d, 0o700); err != nil { + return nil, err + } + } + return s, nil +} + +// InstallKey copies the fleet's generated private key into HOME/.ssh (0600). +func (s *Sandbox) InstallKey(fleetKey string) error { + b, err := os.ReadFile(fleetKey) + if err != nil { + return err + } + s.KeyPath = filepath.Join(s.Home, ".ssh", "id_sim") + return os.WriteFile(s.KeyPath, b, 0o600) +} + +// WriteSSHConfig points every loopback connection at the sim key and the +// sandbox known_hosts file, and nothing else. +func (s *Sandbox) WriteSSHConfig() error { + if s.KeyPath == "" { + return fmt.Errorf("sandbox: InstallKey first") + } + known := filepath.Join(s.Home, ".ssh", "known_hosts") + cfg := strings.Join([]string{ + "Host " + LoopbackHost, + " IdentityFile " + s.KeyPath, + " IdentitiesOnly yes", + " IdentityAgent none", + " UserKnownHostsFile " + known, + "", + }, "\n") + return os.WriteFile(filepath.Join(s.Home, ".ssh", "config"), []byte(cfg), 0o600) +} + +// systemPluginDirs are where the docker CLI finds plugins without a config. +var systemPluginDirs = map[string]bool{ + "/usr/local/lib/docker/cli-plugins": true, "/usr/local/libexec/docker/cli-plugins": true, + "/usr/lib/docker/cli-plugins": true, "/usr/libexec/docker/cli-plugins": true, +} + +// WriteDockerConfig lets the docker CLI in the sandbox find the compose +// plugin (at composePlugin, the path `docker info` reports) when it lives +// outside the system plugin dirs (Homebrew, Docker Desktop): +// HOME/.docker/config.json with only cliPluginsExtraDirs, no auths. +func (s *Sandbox) WriteDockerConfig(composePlugin string) error { + if composePlugin == "" { + return fmt.Errorf("sandbox: docker has no compose plugin") + } + dir := filepath.Dir(composePlugin) + if systemPluginDirs[dir] { + return nil + } + b, _ := json.Marshal(map[string]any{"cliPluginsExtraDirs": []string{dir}}) + if err := os.MkdirAll(filepath.Join(s.Home, ".docker"), 0o700); err != nil { + return err + } + return os.WriteFile(filepath.Join(s.Home, ".docker", "config.json"), b, 0o600) +} + +// WriteInventory writes the inventory v2 file: local, plus one remote env per +// sim host (qa tier local-servers, prod tier prod). +func (s *Sandbox) WriteInventory(hosts ...SimHost) error { + inv := &controlplane.Inventory{SchemaVersion: 2, Environments: map[string]controlplane.Environment{ + "local": {Name: "local", Kind: "local", Tier: controlplane.TierLocal}, + }} + // The prod sim env declares no verified release: D7 proves the promote and + // its digest compare; release verification (DEPL D20) has its own proof and + // needs a signed release-class pipeline the fixture project does not have. + noRelease := false + for _, h := range hosts { + env := controlplane.Environment{Name: h.Env, Kind: "remote", Tier: h.Tier, + Servers: []controlplane.Server{{Name: h.Server, Role: controlplane.RoleApp, Host: h.Host(), + RemotePath: h.RemotePath, Primary: true, SSHKeyRef: SimKeyEnv}}} + if h.Tier == controlplane.TierProd { + env.RequireVerifiedRelease = &noRelease + } + inv.Environments[h.Env] = env + } + return controlplane.Write(s.Project, inv) +} + +// ComposeProject is the compose project name of the fixture project. +func (s *Sandbox) ComposeProject() string { return strings.ToLower(filepath.Base(s.Project)) } + +// InventoryPath is where controlplane.Write puts the inventory. +func (s *Sandbox) InventoryPath() string { + return filepath.Join(s.Project, ".nself", "control-plane.yaml") +} + +// SandboxScript returns the absolute path of scripts/e2e/agent/mcp-sandbox.sh. +func SandboxScript() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "e2e", "agent", "mcp-sandbox.sh") +} + +// ServerCommand returns the program and argv that start bin's `nself mcp` in +// the sandbox. operatorHome is the real HOME the script must keep out of +// reach; extraEnv entries are K=V and pass the script's own allowlist. +func (s *Sandbox) ServerCommand(bin, operatorHome string, extraEnv ...string) (string, []string) { + args := []string{SandboxScript(), "--home", s.Home, "--project", s.Project, + "--operator-home", operatorHome} + if _, err := os.Stat(s.InventoryPath()); err == nil { + args = append(args, "--inventory", s.InventoryPath()) + } + if s.KeyPath != "" { + args = append(args, "--env", SimKeyEnv+"="+s.KeyPath) + } + if dh := os.Getenv("DOCKER_HOST"); strings.HasPrefix(dh, "unix://") { + args = append(args, "--env", "DOCKER_HOST="+dh) + } + for _, e := range extraEnv { + args = append(args, "--env", e) + } + args = append(args, "--", bin, "mcp") + if s.Timeout != "" { + args = append(args, "--timeout", s.Timeout) + } + return "bash", args +} diff --git a/test/e2e/agent/testdata/bad/bad-envelope.jsonl b/test/e2e/agent/testdata/bad/bad-envelope.jsonl new file mode 100644 index 000000000..9e5341c34 --- /dev/null +++ b/test/e2e/agent/testdata/bad/bad-envelope.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl b/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl new file mode 100644 index 000000000..828700fac --- /dev/null +++ b/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/missing-step.jsonl b/test/e2e/agent/testdata/bad/missing-step.jsonl new file mode 100644 index 000000000..77727cc59 --- /dev/null +++ b/test/e2e/agent/testdata/bad/missing-step.jsonl @@ -0,0 +1,50 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/non-mcp.jsonl b/test/e2e/agent/testdata/bad/non-mcp.jsonl new file mode 100644 index 000000000..cbea9eb81 --- /dev/null +++ b/test/e2e/agent/testdata/bad/non-mcp.jsonl @@ -0,0 +1,53 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"Bash","arguments":{"command":"nself db migrate up"},"is_error":false,"note":"shell"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":53,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/promote-order.jsonl b/test/e2e/agent/testdata/bad/promote-order.jsonl new file mode 100644 index 000000000..ed0943e91 --- /dev/null +++ b/test/e2e/agent/testdata/bad/promote-order.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/good.jsonl b/test/e2e/agent/testdata/good.jsonl new file mode 100644 index 000000000..b771e6f9b --- /dev/null +++ b/test/e2e/agent/testdata/good.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/live-prompt.md b/test/e2e/agent/testdata/live-prompt.md new file mode 100644 index 000000000..0bd23a202 --- /dev/null +++ b/test/e2e/agent/testdata/live-prompt.md @@ -0,0 +1,30 @@ +# Agent E2E live session (P7-SHIP-13) + +You operate an nSelf project only through the `nself` MCP server. You have no +shell, file or web tools. The project directory and its inventory are already +prepared: environment `qa` (tier local-servers) and `prod` (tier prod) are two +loopback sim hosts. + +Do these steps, in order, using MCP tools only: + +1. Initialise the project (`nself_init`). +2. Build it: ask for the plan first, read it, then confirm it with the plan id + the server returned. +3. Start the stack and check `nself_status` until `data.state` is `ok`. +4. If `nself_status` reports a service unhealthy, recover it with + `nself_start` and check status again. +5. Call `nself_reset` once without a confirmation, read the confirmation + request it returns, and do NOT confirm it. +6. Create a first migration (`nself_db_migrate_create` named + `agent_e2e_first`) and apply pending migrations (`nself_db_migrate_up`). +7. Install the ci plugin (`nself_add` with `ci`), list the tools again, then + run CI the async way: `nself_ci` with `detach: true`, then + `nself_ci_runs_wait` with the returned run id until it is terminal, then + `nself_ci_evidence`. The evidence result must be `pass`. +8. Deploy to `qa`. +9. Promote `qa` to `prod` with `dry-run` first, read the plan, then run the + confirmed promote. + +Rules: a remote or destructive call returns a confirmation request; repeat the +identical call with its `confirm` value only when the step above says so. +Never invent a confirmation value. Stop and report if a step fails. diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl new file mode 100644 index 000000000..9568db649 --- /dev/null +++ b/test/e2e/agent/testdata/replay.jsonl @@ -0,0 +1,35 @@ +// P7-SHIP-02 replay (SHIP Epic D7). One step per line; `//` lines are comments. +// Agent steps are MCP tools/call (or tools/list) to the sandboxed `nself mcp` +// with v1.5 tool names. Harness steps are `role: harness` records, never agent +// actions. A step with "confirm": true expects a confirmation request first and +// repeats the identical call with the server's token (plan id or nonce). +{"id":"init","role":"agent","tool":"nself_init","arguments":{"flags":{"non-interactive":true}}} +{"id":"commit-after-init","role":"harness","action":"git-commit"} +{"id":"build","role":"agent","tool":"nself_build","confirm":true} +{"id":"start","role":"agent","tool":"nself_start"} +{"id":"status-healthy","role":"agent","tool":"nself_status","expect":"state-ok"} +// Leg: failure recovery. +{"id":"stop-one-service","role":"harness","action":"stop-service"} +{"id":"status-unhealthy","role":"agent","tool":"nself_status","expect":"unhealthy"} +{"id":"recover","role":"agent","tool":"nself_start"} +{"id":"status-recovered","role":"agent","tool":"nself_status","expect":"state-ok"} +// Leg: destructive gate (reset is never confirmed). +{"id":"gate-volumes-before","role":"harness","action":"volumes-before"} +{"id":"reset-unconfirmed","role":"agent","tool":"nself_reset","expect":"confirmation"} +{"id":"gate-volumes-after","role":"harness","action":"volumes-after"} +{"id":"migrate-create","role":"agent","tool":"nself_db_migrate_create","arguments":{"args":["agent_e2e_first"]}} +{"id":"migrate","role":"agent","tool":"nself_db_migrate_up"} +{"id":"add-ci","role":"agent","tool":"nself_add","arguments":{"args":["ci"]},"confirm":true} +{"id":"ci-plugin-from-source","role":"harness","action":"ci-plugin-from-source","note":"until a served ci release carries the machine surface (P7-CI-62)"} +{"id":"status-after-add","role":"agent","tool":"nself_status"} +{"id":"discover-ci","role":"agent","kind":"tools/list","require":["nself_ci","nself_ci_runs_wait","nself_ci_evidence"]} +{"id":"ci-detach","role":"agent","tool":"nself_ci","arguments":{"flags":{"detach":true}},"confirm":true,"capture":{"run_id":"run_id"}} +{"id":"ci-wait","role":"agent","tool":"nself_ci_runs_wait","arguments":{"args":["${run_id}"]},"until":"terminal","max_repeats":30} +{"id":"ci-evidence","role":"agent","tool":"nself_ci_evidence","arguments":{"args":["${run_id}"]},"expect":"evidence-pass"} +{"id":"seed-target-plugins","role":"harness","action":"seed-target-plugins","note":"the sim hosts stand in for servers provisioned with the project's plugins"} +{"id":"seed-image-digests","role":"harness","action":"seed-image-digests","note":"the stubbed hosts report the registry digests of the images the project pulled"} +{"id":"deploy-qa","role":"agent","tool":"nself_deploy","arguments":{"args":["qa"]},"confirm":true} +{"id":"digest-before","role":"harness","action":"digest-before"} +{"id":"promote-dry-run","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"confirm":true} +{"id":"promote","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"confirm":true} +{"id":"digest-compare","role":"harness","action":"digest-compare"} diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh new file mode 100755 index 000000000..44c2156cc --- /dev/null +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Recording docker stub for the agent E2E sim hosts (P7-SHIP-02), modelled on +# the stubs of internal/controlplane/sim. The sim hosts are sshd containers +# without a Docker engine, so: +# - `compose config --format json` is a model of every service (image, +# restart) of the compose file named by -f, as deploy-sim's fixture is; +# - `compose ... up -d --dry-run` lists every service of the compose file named +# by -f as "Recreate", and `up -d [--no-deps] [svc...]` records " +# " lines in /tmp/agent-e2e/running and bumps a start counter; +# - `compose images`, `compose ps`, `inspect -f` answer from that record; +# - `image inspect` reports the image's real registry digest from +# /tmp/agent-e2e/digests (" @sha256:" lines the harness +# writes from the operator's pulled images) and the host's platform, so +# promote's digest compare sees what a real host would report. +S=/tmp/agent-e2e +mkdir -p "$S" +echo "$*" >> "$S/docker.log" +F=docker-compose.yml +prev='' +for a; do [ "$prev" = "-f" ] && F=$a; prev=$a; done +services() { # " " for every service of $F, at any indentation + awk ' + function ind(l) { match(l, /^ */); return RLENGTH } + /^services:/ { s=1; si=-1; next } + s && /^[^ #]/ { s=0 } + !s || /^ *(#|$)/ { next } + si < 0 && /^ +[A-Za-z0-9_.-]+: *$/ { si=ind($0) } + ind($0) == si && /^ +[A-Za-z0-9_.-]+: *$/ { svc=$1; sub(":", "", svc); next } + svc != "" && ind($0) > si && $1 == "image:" { img=$2; gsub(/["\047]/, "", img); print svc " " img } + ' "$F" 2>/dev/null | sort -u +} +idof() { printf 'sha256:%s' "$(printf '%s' "$1" | sha256sum | cut -c1-64)"; } +platform() { case "$(uname -m)" in aarch64|arm64) echo linux/arm64 ;; *) echo linux/amd64 ;; esac; } +up() { # svc...: record those services (all when none) as running + services > "$S/compose.now" + [ $# -gt 0 ] || set -- $(awk '{print $1}' "$S/compose.now") + for svc; do + line=$(awk -v s="$svc" '$1==s' "$S/compose.now") + [ -n "$line" ] || continue + { grep -v "^$svc " "$S/running" 2>/dev/null; echo "$line"; } | sort > "$S/running.new" + mv "$S/running.new" "$S/running" + n=$(cat "$S/up-$svc" 2>/dev/null || echo 0); echo $((n+1)) > "$S/up-$svc" + done +} +case "$*" in + *"config --services"*) services | awk '{print $1}' ;; + *"config --format json"*) + printf '{"name":"app","services":{'; sep='' + services | while read -r svc ref; do printf '%s"%s":{"image":"%s","restart":"always"}' "$sep" "$svc" "$ref"; sep=','; done + printf '}}' ;; + *"up -d --dry-run"*) services | while read -r svc ref; do printf ' Container app-%s-1 Recreate\n' "$svc"; done ;; + *" up "*|*" up") + known=$(services | awk '{print $1}') + list='' + for a; do for k in $known; do [ "$a" = "$k" ] && list="$list $a"; done; done + # shellcheck disable=SC2086 + up $list ;; + *" images --format json"*) + printf '['; sep='' + while read -r svc ref; do + [ -n "$ref" ] || continue + base=${ref%@*}; repo=${base%:*}; tag=${base##*:} + [ "$repo" = "$base" ] && tag=latest + printf '%s{"Service":"%s","ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$svc" "$svc" "$repo" "$tag" "$(idof "$ref")"; sep=',' + done < "$S/running" 2>/dev/null + printf ']' ;; + *"ps -a --format json"*) + for last; do :; done + printf '[{"ID":"id-%s","Name":"app-%s-1","Service":"%s","State":"running","Health":"healthy"}]' "$last" "$last" "$last" ;; + *"ps --all --format json"*) + while read -r svc ref; do + [ -n "$ref" ] && printf '{"Name":"app-%s-1","Service":"%s","State":"running","Health":"healthy"}\n' "$svc" "$svc" + done < "$S/running" 2>/dev/null ;; + "inspect -f"*) + shift 3 + for i; do n=$(cat "$S/up-${i#id-}" 2>/dev/null || echo 0); printf '2026-10-30T00:00:%02dZ\n' "$n"; done ;; + *"image inspect --format"*) + for last; do :; done + while read -r svc ref; do + [ "$(idof "$ref")" = "$last" ] || continue + case "$ref" in + *@sha256:*) base=${ref%@*}; rd="${base%:*}@${ref#*@}" ;; + *) rd=$(awk -v r="$ref" '$1==r{print $2; exit}' "$S/digests" 2>/dev/null) ;; + esac + printf '["%s"]|%s' "$rd" "$(platform)" + exit 0 + done < "$S/running" 2>/dev/null + exit 1 ;; + "network inspect"*) exit 1 ;; +esac +exit 0 diff --git a/test/e2e/agent/testdata/sim/nself.sh b/test/e2e/agent/testdata/sim/nself.sh new file mode 100755 index 000000000..518424293 --- /dev/null +++ b/test/e2e/agent/testdata/sim/nself.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Recording nself stub for the agent E2E sim hosts (P7-SHIP-02); the same +# shape as the promote stub of internal/controlplane/sim: health passes, +# migrations and backups are recorded under /tmp/agent-e2e. +S=/tmp/agent-e2e +mkdir -p "$S" +echo "$*" >> "$S/nself.log" +case "$*" in + "doctor"*) exit 0 ;; + "db migrate up"*) printf migrated > "$S/db-fp" ;; + "backup create --tag "*) cp "$S/db-fp" "$S/db-backup" 2>/dev/null; echo "$4" > "$S/tag" ;; + "backup list"*) t=$(cat "$S/tag" 2>/dev/null); printf '[{"id":"sim_%s","tag":"%s","date":"2026-10-29T12:00:00Z"}]' "$t" "$t" ;; + "backup verify "*) exit 0 ;; + "backup restore "*) cp "$S/db-backup" "$S/db-fp" 2>/dev/null ;; +esac +exit 0 diff --git a/test/e2e/agent/transcript.go b/test/e2e/agent/transcript.go new file mode 100644 index 000000000..27f610216 --- /dev/null +++ b/test/e2e/agent/transcript.go @@ -0,0 +1,186 @@ +//go:build e2eagent + +package agent + +// Purpose: the agent E2E transcript: one JSON object per line recording every +// agent MCP request, every server result and every harness action. +// Inputs: Records from the replay client (flow.go) or a live session. +// Outputs: a JSONL file; ReadTranscript parses one back for verify.go. +// Constraints: the field set is the ticket's transcript format +// {seq, time, role, kind, tool, arguments, envelope, is_error, note}; secret +// argument values and confirmation tokens are never written (they become +// Redacted), so a transcript is safe to upload as a CI artifact. + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +// Roles and kinds of a transcript record. +const ( + RoleAgent = "agent" + RoleHarness = "harness" + RoleServer = "server" + + KindToolsList = "tools/list" + KindToolsCall = "tools/call" + KindResult = "result" + KindAction = "action" + KindProbe = "probe" +) + +// Redacted replaces secret values and confirmation tokens in a transcript. +const Redacted = "[REDACTED]" + +// Record is one transcript line. +type Record struct { + Seq int `json:"seq"` + Time string `json:"time"` + Role string `json:"role"` + Kind string `json:"kind"` + Tool string `json:"tool,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + Envelope json.RawMessage `json:"envelope,omitempty"` + IsError bool `json:"is_error"` + Note string `json:"note,omitempty"` +} + +// Writer appends records to a transcript file; safe for concurrent use. +type Writer struct { + mu sync.Mutex + f *os.File + seq int + // Records keeps every written record so the test can verify in process. + Records []Record +} + +// NewWriter creates (truncates) path with mode 0600. +func NewWriter(path string) (*Writer, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + return &Writer{f: f}, nil +} + +// Write stamps seq and time, redacts r and appends it as one line. +func (w *Writer) Write(r Record) (Record, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.seq++ + r.Seq = w.seq + r.Time = time.Now().UTC().Format(time.RFC3339Nano) + r.Arguments = redactArgs(r.Arguments) + r.Envelope = redactEnvelope(r.Envelope) + b, err := json.Marshal(r) + if err != nil { + return r, err + } + if _, err := w.f.Write(append(b, '\n')); err != nil { + return r, err + } + w.Records = append(w.Records, r) + return r, nil +} + +// Close closes the file. +func (w *Writer) Close() error { return w.f.Close() } + +// redactArgs copies args with the confirm token and every flag or arg named +// in the private "_secret" list replaced by Redacted. The list itself is a +// replay-file annotation and is dropped. +func redactArgs(args map[string]any) map[string]any { + if args == nil { + return nil + } + out := map[string]any{} + secret := map[string]bool{} + if list, ok := args["_secret"].([]any); ok { + for _, s := range list { + if name, ok := s.(string); ok { + secret[name] = true + } + } + } + for k, v := range args { + switch { + case k == "_secret": + case k == "confirm": + out[k] = Redacted + case k == "flags": + out[k] = redactFlags(v, secret) + default: + out[k] = v + } + } + return out +} + +func redactFlags(v any, secret map[string]bool) any { + flags, ok := v.(map[string]any) + if !ok || len(secret) == 0 { + return v + } + out := map[string]any{} + for k, fv := range flags { + if secret[k] { + out[k] = Redacted + } else { + out[k] = fv + } + } + return out +} + +// redactEnvelope replaces data.confirm (a nonce or plan id) with Redacted. +func redactEnvelope(env json.RawMessage) json.RawMessage { + if len(env) == 0 { + return env + } + var doc map[string]json.RawMessage + if json.Unmarshal(env, &doc) != nil { + return env + } + var data map[string]any + if json.Unmarshal(doc["data"], &data) != nil || data["confirm"] == nil { + return env + } + data["confirm"] = Redacted + b, err := json.Marshal(data) + if err != nil { + return env + } + doc["data"] = b + out, err := json.Marshal(doc) + if err != nil { + return env + } + return out +} + +// ReadTranscript parses a JSONL transcript; a malformed line is an error. +func ReadTranscript(path string) ([]Record, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var out []Record + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for n := 1; sc.Scan(); n++ { + if len(sc.Bytes()) == 0 { + continue + } + var r Record + if err := json.Unmarshal(sc.Bytes(), &r); err != nil { + return nil, fmt.Errorf("transcript line %d: %w", n, err) + } + out = append(out, r) + } + return out, sc.Err() +} diff --git a/test/e2e/agent/verify.go b/test/e2e/agent/verify.go new file mode 100644 index 000000000..03c5730a6 --- /dev/null +++ b/test/e2e/agent/verify.go @@ -0,0 +1,267 @@ +//go:build e2eagent + +package agent + +// Purpose: verify an agent E2E transcript against SHIP Epic D7: every agent +// record is an MCP request to the nself server, every tools/call result is a +// v1 envelope, the D7 steps all happen in order, and the two legs hold. +// Inputs: transcript Records (ReadTranscript or Writer.Records). +// Outputs: nil, or an error listing every violation found. +// Constraints: pure function of the records; no retries, no network. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/nself-org/cli/schemas" +) + +// call is an agent tools/call paired with the server result that follows it. +type call struct { + seq int + tool string + args map[string]any + result *Record +} + +func (c call) flags() map[string]any { m, _ := c.args["flags"].(map[string]any); return m } + +func (c call) arg0() string { + if list, ok := c.args["args"].([]any); ok && len(list) > 0 { + s, _ := list[0].(string) + return s + } + return "" +} + +func (c call) confirmed() bool { _, ok := c.args["confirm"]; return ok } + +func (c call) data() map[string]any { + if c.result == nil { + return nil + } + var doc struct { + Data map[string]any `json:"data"` + } + _ = json.Unmarshal(c.result.Envelope, &doc) + return doc.Data +} + +func (c call) ok() bool { return c.result != nil && !c.result.IsError } + +func (c call) confirmation() bool { + d := c.data() + return c.ok() && d != nil && d["confirmation_required"] == true +} + +// milestone is one D7 step; first is the seq of its first matching call. +type milestone struct { + name string + match func(call) bool +} + +func toolIs(name string) func(call) bool { return func(c call) bool { return c.tool == name } } + +// Milestones are the D7 flow steps in their required order. +var Milestones = []milestone{ + {"init", func(c call) bool { return toolIs("nself_init")(c) && c.ok() }}, + {"build plan", func(c call) bool { return toolIs("nself_build")(c) && !c.confirmed() && c.confirmation() }}, + {"build confirm", func(c call) bool { return toolIs("nself_build")(c) && c.confirmed() && c.ok() }}, + {"start", func(c call) bool { return toolIs("nself_start")(c) && c.ok() }}, + {"db migrate up", func(c call) bool { return toolIs("nself_db_migrate_up")(c) && c.ok() }}, + {"add ci", func(c call) bool { return toolIs("nself_add")(c) && c.arg0() == "ci" && c.confirmed() && c.ok() }}, + {"ci detach", func(c call) bool { + return toolIs("nself_ci")(c) && c.flags()["detach"] == true && c.confirmed() && c.ok() + }}, + {"ci runs wait", func(c call) bool { return toolIs("nself_ci_runs_wait")(c) && c.ok() && terminal(c.data()) }}, + {"ci evidence", func(c call) bool { return toolIs("nself_ci_evidence")(c) && c.ok() && c.data()["result"] == "pass" }}, + {"deploy qa", func(c call) bool { return toolIs("nself_deploy")(c) && c.arg0() == "qa" && c.confirmed() && c.ok() }}, + {"promote dry-run", func(c call) bool { return isPromote(c) && c.flags()["dry-run"] == true && c.ok() }}, + {"promote", func(c call) bool { return isPromote(c) && c.flags()["dry-run"] != true && c.confirmed() && c.ok() }}, +} + +func isPromote(c call) bool { return c.tool == "nself_deploy_promote" } + +func terminal(d map[string]any) bool { + s, _ := d["state"].(string) + return s == "passed" || s == "failed" || s == "cancelled" +} + +// Verify returns every D7 violation in recs, joined; nil when the transcript holds. +func Verify(recs []Record) error { + var errs []error + calls := pair(recs) + errs = append(errs, checkMCPOnly(recs)...) + errs = append(errs, checkEnvelopes(recs)...) + first := map[string]int{} + for _, m := range Milestones { + for _, c := range calls { + if m.match(c) { + first[m.name] = c.seq + break + } + } + } + prev, prevName := 0, "" + for _, m := range Milestones { + seq, ok := first[m.name] + if !ok { + errs = append(errs, fmt.Errorf("missing step %q", m.name)) + continue + } + if seq < prev { + errs = append(errs, fmt.Errorf("step %q (seq %d) runs before %q (seq %d)", m.name, seq, prevName, prev)) + } + prev, prevName = seq, m.name + } + errs = append(errs, checkPromoteOrder(calls)...) + errs = append(errs, checkEvidenceOrder(calls)...) + errs = append(errs, checkCIDiscovery(recs, first)...) + errs = append(errs, checkRecoveryLeg(recs, calls)...) + errs = append(errs, checkGateLeg(recs, calls)...) + return errors.Join(errs...) +} + +// pair links each agent tools/call with the next server result for its tool. +func pair(recs []Record) []call { + var out []call + for i, r := range recs { + if r.Role != RoleAgent || r.Kind != KindToolsCall { + continue + } + c := call{seq: r.Seq, tool: r.Tool, args: r.Arguments} + for j := i + 1; j < len(recs); j++ { + if recs[j].Role == RoleServer && recs[j].Kind == KindResult && recs[j].Tool == r.Tool { + c.result = &recs[j] + break + } + } + out = append(out, c) + } + return out +} + +func checkMCPOnly(recs []Record) []error { + var errs []error + for _, r := range recs { + if r.Role != RoleAgent { + continue + } + if r.Kind != KindToolsCall && r.Kind != KindToolsList { + errs = append(errs, fmt.Errorf("seq %d: non-MCP tool use: agent record kind %q", r.Seq, r.Kind)) + } else if r.Kind == KindToolsCall && !strings.HasPrefix(r.Tool, "nself_") { + errs = append(errs, fmt.Errorf("seq %d: non-MCP tool use: %q is not a tool of the nself server", r.Seq, r.Tool)) + } + } + return errs +} + +var envelopeSchema = func() *jsonschema.Schema { + c := jsonschema.NewCompiler() + for _, name := range schemas.Names() { + b, _ := schemas.Lookup(name) + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + continue + } + if m, ok := doc.(map[string]any); ok { + if id, ok := m["$id"].(string); ok { + _ = c.AddResource(id, doc) + } + } + } + return c.MustCompile("urn:nself:cli:schema:envelope:v1") +}() + +func checkEnvelopes(recs []Record) []error { + var errs []error + for i, r := range recs { + if r.Role != RoleServer || r.Kind != KindResult || i == 0 || !callBefore(recs, i) { + continue + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(r.Envelope)) + if err == nil { + err = envelopeSchema.Validate(doc) + } + if err != nil { + errs = append(errs, fmt.Errorf("seq %d: %s envelope fails the v1 schema: %v", r.Seq, r.Tool, err)) + } + } + return errs +} + +// callBefore reports whether the result at i answers a tools/call (tools/list +// results carry a tool-name list, not an envelope). +func callBefore(recs []Record, i int) bool { + for j := i - 1; j >= 0; j-- { + if recs[j].Role == RoleAgent { + return recs[j].Kind == KindToolsCall + } + } + return false +} + +func checkPromoteOrder(calls []call) []error { + for _, c := range calls { + if !isPromote(c) { + continue + } + if c.flags()["dry-run"] == true { + return nil + } + if c.confirmed() { + return []error{fmt.Errorf("seq %d: promote apply before its dry-run", c.seq)} + } + } + return nil +} + +func checkEvidenceOrder(calls []call) []error { + for _, c := range calls { + switch c.tool { + case "nself_ci_runs_wait": + return nil + case "nself_ci_evidence": + return []error{fmt.Errorf("seq %d: ci evidence before ci runs wait", c.seq)} + } + } + return nil +} + +// checkCIDiscovery wants a tools/list result after `add ci` naming the three ci tools. +func checkCIDiscovery(recs []Record, first map[string]int) []error { + after, ok := first["add ci"] + if !ok { + return nil + } + for _, r := range recs { + if r.Seq <= after || r.Role != RoleServer || r.Kind != KindResult || r.Tool != "" { + continue + } + var list struct { + Tools []string `json:"tools"` + } + if json.Unmarshal(r.Envelope, &list) == nil && hasAll(list.Tools, "nself_ci", "nself_ci_runs_wait", "nself_ci_evidence") { + return nil + } + } + return []error{errors.New("no tools/list after add ci shows nself_ci, nself_ci_runs_wait and nself_ci_evidence")} +} + +func hasAll(have []string, want ...string) bool { + set := map[string]bool{} + for _, h := range have { + set[h] = true + } + for _, w := range want { + if !set[w] { + return false + } + } + return true +} diff --git a/test/e2e/agent/verify_legs.go b/test/e2e/agent/verify_legs.go new file mode 100644 index 000000000..717915b8d --- /dev/null +++ b/test/e2e/agent/verify_legs.go @@ -0,0 +1,103 @@ +//go:build e2eagent + +package agent + +// Purpose: transcript checks for the two D7 legs: failure recovery and the +// destructive gate. +// Inputs: the transcript records and their paired calls (verify.go). +// Outputs: one error per violated rule. +// Constraints: harness records are matched by their note prefix, which +// flow.go writes from the constants below. + +import ( + "bytes" + "fmt" +) + +// Harness notes the leg checks look for. +const ( + NoteStopService = "stop-service" + NoteVolumesBefore = "volumes before" + NoteVolumesAfter = "volumes after" +) + +func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p } + +func stateOK(c call) bool { d := c.data(); return c.ok() && d != nil && d["state"] == "ok" } + +// checkRecoveryLeg wants, after the harness stops a service: a status call +// that reports the unhealthy state, then a successful start, then a status +// call whose data.state is ok. +func checkRecoveryLeg(recs []Record, calls []call) []error { + stopSeq := 0 + for _, r := range recs { + if r.Role == RoleHarness && hasPrefix(r.Note, NoteStopService) { + stopSeq = r.Seq + break + } + } + if stopSeq == 0 { + return []error{fmt.Errorf("missing step %q: no harness %s record", "failure recovery", NoteStopService)} + } + phase := 0 + for _, c := range calls { + if c.seq < stopSeq { + continue + } + switch { + case phase == 0 && c.tool == "nself_status": + if stateOK(c) { + return []error{fmt.Errorf("seq %d: status after the stopped service reports ok", c.seq)} + } + phase = 1 + case phase == 1 && c.tool == "nself_start" && c.ok(): + phase = 2 + case phase == 2 && c.tool == "nself_status" && stateOK(c): + return nil + } + } + return []error{fmt.Errorf("missing step %q: status, start, status ok after %s (reached phase %d)", "failure recovery", NoteStopService, phase)} +} + +// checkGateLeg wants a reset without confirm answered by a confirmation (not +// an error), never confirmed later, between two equal volume probes. +func checkGateLeg(recs []Record, calls []call) []error { + var resetSeq int + for _, c := range calls { + if c.tool != "nself_reset" { + continue + } + if c.confirmed() { + return []error{fmt.Errorf("seq %d: the destructive-gate leg confirmed reset", c.seq)} + } + if !c.confirmation() { + return []error{fmt.Errorf("seq %d: reset without confirm did not return a confirmation request", c.seq)} + } + if resetSeq == 0 { + resetSeq = c.seq + } + } + if resetSeq == 0 { + return []error{fmt.Errorf("missing step %q", "destructive gate")} + } + var before, after *Record + for i := range recs { + r := &recs[i] + if r.Role != RoleHarness || r.Kind != KindProbe { + continue + } + if r.Note == NoteVolumesBefore && r.Seq < resetSeq { + before = r + } + if r.Note == NoteVolumesAfter && r.Seq > resetSeq && after == nil { + after = r + } + } + if before == nil || after == nil { + return []error{fmt.Errorf("missing step %q: volume probes around reset", "destructive gate")} + } + if !bytes.Equal(before.Envelope, after.Envelope) { + return []error{fmt.Errorf("seq %d: docker volumes changed across the unconfirmed reset", after.Seq)} + } + return nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/client.go b/vendor/github.com/mark3labs/mcp-go/client/client.go new file mode 100644 index 000000000..0a415ac89 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/client.go @@ -0,0 +1,814 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "sync" + "sync/atomic" + + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/tracing" +) + +// Client implements the MCP client. +type Client struct { + transport transport.Interface + + initialized atomic.Bool + notifications []func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + requestID atomic.Int64 + clientCapabilities mcp.ClientCapabilities + serverCapabilities mcp.ServerCapabilities + protocolVersion string + samplingHandler SamplingHandler + rootsHandler RootsHandler + elicitationHandler ElicitationHandler + tracer tracing.Tracer + propagator tracing.Propagator + metaPropagator tracing.MetaPropagator +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithClientCapabilities sets the client capabilities for the client. +func WithClientCapabilities(capabilities mcp.ClientCapabilities) ClientOption { + return func(c *Client) { + c.clientCapabilities = capabilities + } +} + +// WithSamplingHandler sets the sampling handler for the client. +// When set, the client will declare sampling capability during initialization. +func WithSamplingHandler(handler SamplingHandler) ClientOption { + return func(c *Client) { + c.samplingHandler = handler + } +} + +// WithRootsHandler sets the roots handler for the client. +// WithRootsHandler returns a ClientOption that sets the client's RootsHandler. +// When provided, the client will declare the roots capability (ListChanged) during initialization. +func WithRootsHandler(handler RootsHandler) ClientOption { + return func(c *Client) { + c.rootsHandler = handler + } +} + +// WithElicitationHandler sets the elicitation handler for the client. +// When set, the client will declare elicitation capability during initialization. +func WithElicitationHandler(handler ElicitationHandler) ClientOption { + return func(c *Client) { + c.elicitationHandler = handler + } +} + +// WithSession assumes a MCP Session has already been initialized +func WithSession() ClientOption { + return func(c *Client) { + c.initialized.Store(true) + } +} + +// NewClient creates a new MCP client with the given transport. +// Usage: +// +// stdio := transport.NewStdio("./mcp_server", nil, "xxx") +// client, err := NewClient(stdio) +// if err != nil { +// log.Fatalf("Failed to create client: %v", err) +// } +func NewClient(transport transport.Interface, options ...ClientOption) *Client { + client := &Client{ + transport: transport, + tracer: tracing.NoopTracer(), + propagator: tracing.NoopPropagator(), + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// Start initiates the connection to the server. +// Must be called before using the client. +func (c *Client) Start(ctx context.Context) error { + if c.transport == nil { + return fmt.Errorf("transport is nil") + } + + // Start is idempotent - transports handle being called multiple times + err := c.transport.Start(ctx) + if err != nil { + return err + } + + c.transport.SetNotificationHandler(func(notification mcp.JSONRPCNotification) { + c.notifyMu.RLock() + defer c.notifyMu.RUnlock() + for _, handler := range c.notifications { + handler(notification) + } + }) + + // Set up request handler for bidirectional communication (e.g., sampling) + if bidirectional, ok := c.transport.(transport.BidirectionalInterface); ok { + bidirectional.SetRequestHandler(c.handleIncomingRequest) + } + + return nil +} + +// Close shuts down the client and closes the transport. +func (c *Client) Close() error { + return c.transport.Close() +} + +// OnNotification registers a handler function to be called when notifications are received. +// Multiple handlers can be registered and will be called in the order they were added. +func (c *Client) OnNotification( + handler func(notification mcp.JSONRPCNotification), +) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.notifications = append(c.notifications, handler) +} + +// OnConnectionLost registers a handler function to be called when the connection is lost. +// This is useful for handling HTTP2 idle timeout disconnections that should not be treated as errors. +func (c *Client) OnConnectionLost(handler func(error)) { + type connectionLostSetter interface { + SetConnectionLostHandler(func(error)) + } + if setter, ok := c.transport.(connectionLostSetter); ok { + setter.SetConnectionLostHandler(handler) + } +} + +// sendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *Client) sendRequest( + ctx context.Context, + method string, + params any, + header http.Header, +) (*json.RawMessage, error) { + if !c.initialized.Load() && method != "initialize" { + return nil, fmt.Errorf("client not initialized") + } + + id := c.requestID.Add(1) + + ctx, header, span := c.startSendSpan(ctx, method, header) + + request := transport.JSONRPCRequest{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: mcp.NewRequestId(id), + Method: method, + Params: params, + Header: header, + } + + response, err := c.transport.SendRequest(ctx, request) + if err != nil { + err = transport.NewError(err) + endSendSpan(span, err) + return nil, err + } + + if response.Error != nil { + err := response.Error.AsError() + endSendSpan(span, err) + return nil, err + } + + endSendSpan(span, nil) + return &response.Result, nil +} + +func outboundHeader(header http.Header, requestMethod string) http.Header { + // A typed request with Method set was decoded from an inbound JSON-RPC + // message. Its Header contains received HTTP metadata, not opt-in outbound + // headers for a new client call. + if requestMethod != "" { + return nil + } + return header +} + +// Initialize negotiates with the server. +// Must be called after Start, and before any request methods. +func (c *Client) Initialize( + ctx context.Context, + request mcp.InitializeRequest, +) (*mcp.InitializeResult, error) { + // Merge client capabilities with sampling capability if handler is configured + capabilities := request.Params.Capabilities + if c.samplingHandler != nil { + capabilities.Sampling = &mcp.SamplingCapability{} + } + if c.rootsHandler != nil { + capabilities.Roots = &struct { + ListChanged bool `json:"listChanged,omitempty"` + }{ + ListChanged: true, + } + } + // Add elicitation capability if handler is configured + if c.elicitationHandler != nil { + capabilities.Elicitation = &mcp.ElicitationCapability{} + } + + // Ensure we send a params object with all required fields + params := struct { + ProtocolVersion string `json:"protocolVersion"` + ClientInfo mcp.Implementation `json:"clientInfo"` + Capabilities mcp.ClientCapabilities `json:"capabilities"` + }{ + ProtocolVersion: request.Params.ProtocolVersion, + ClientInfo: request.Params.ClientInfo, + Capabilities: capabilities, + } + + // By default, use client supported latest protocol version if version not specified + if params.ProtocolVersion == "" { + params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + } + + response, err := c.sendRequest(ctx, "initialize", params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + var result mcp.InitializeResult + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Validate protocol version + if !slices.Contains(mcp.ValidProtocolVersions, result.ProtocolVersion) { + return nil, mcp.UnsupportedProtocolVersionError{Version: result.ProtocolVersion} + } + + // Store serverCapabilities and protocol version + c.serverCapabilities = result.Capabilities + c.protocolVersion = result.ProtocolVersion + + // Set protocol version on HTTP transports + if httpConn, ok := c.transport.(transport.HTTPConnection); ok { + httpConn.SetProtocolVersion(result.ProtocolVersion) + } + + // Send initialized notification + notification := mcp.JSONRPCNotification{ + JSONRPC: mcp.JSONRPC_VERSION, + Notification: mcp.Notification{ + Method: string(mcp.MethodNotificationInitialized), + }, + } + + err = c.transport.SendNotification(ctx, notification) + if err != nil { + return nil, fmt.Errorf( + "failed to send initialized notification: %w", + err, + ) + } + + c.initialized.Store(true) + return &result, nil +} + +// Ping sends a ping request to verify the server is responsive. +func (c *Client) Ping(ctx context.Context) error { + _, err := c.sendRequest(ctx, string(mcp.MethodPing), nil, nil) + return err +} + +// ListResourcesByPage manually list resources by page. +func (c *Client) ListResourcesByPage( + ctx context.Context, + request mcp.ListResourcesRequest, +) (*mcp.ListResourcesResult, error) { + result, err := listByPage[mcp.ListResourcesResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodResourcesList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListResources lists all resources by following paginated responses. +func (c *Client) ListResources( + ctx context.Context, + request mcp.ListResourcesRequest, +) (*mcp.ListResourcesResult, error) { + result, err := c.ListResourcesByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListResourcesByPage(ctx, request) + if err != nil { + return nil, err + } + result.Resources = append(result.Resources, newPageRes.Resources...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// ListResourceTemplatesByPage manually lists resource templates by page. +func (c *Client) ListResourceTemplatesByPage( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) (*mcp.ListResourceTemplatesResult, error) { + result, err := listByPage[mcp.ListResourceTemplatesResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodResourcesTemplatesList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListResourceTemplates lists all resource templates by following paginated responses. +func (c *Client) ListResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) (*mcp.ListResourceTemplatesResult, error) { + result, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + return nil, err + } + result.ResourceTemplates = append(result.ResourceTemplates, newPageRes.ResourceTemplates...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// ReadResource reads the contents of a resource from the server. +func (c *Client) ReadResource( + ctx context.Context, + request mcp.ReadResourceRequest, +) (*mcp.ReadResourceResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodResourcesRead), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseReadResourceResult(response) +} + +// Subscribe subscribes to updates for a resource. +func (c *Client) Subscribe( + ctx context.Context, + request mcp.SubscribeRequest, +) error { + _, err := c.sendRequest(ctx, string(mcp.MethodResourcesSubscribe), request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// Unsubscribe removes a resource update subscription. +func (c *Client) Unsubscribe( + ctx context.Context, + request mcp.UnsubscribeRequest, +) error { + _, err := c.sendRequest(ctx, string(mcp.MethodResourcesUnsubscribe), request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// ListPromptsByPage manually lists prompts by page. +func (c *Client) ListPromptsByPage( + ctx context.Context, + request mcp.ListPromptsRequest, +) (*mcp.ListPromptsResult, error) { + result, err := listByPage[mcp.ListPromptsResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodPromptsList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListPrompts lists all prompts by following paginated responses. +func (c *Client) ListPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, +) (*mcp.ListPromptsResult, error) { + result, err := c.ListPromptsByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListPromptsByPage(ctx, request) + if err != nil { + return nil, err + } + result.Prompts = append(result.Prompts, newPageRes.Prompts...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// GetPrompt gets a prompt and renders it with the provided arguments. +func (c *Client) GetPrompt( + ctx context.Context, + request mcp.GetPromptRequest, +) (*mcp.GetPromptResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodPromptsGet), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseGetPromptResult(response) +} + +// ListToolsByPage manually lists tools by page. +func (c *Client) ListToolsByPage( + ctx context.Context, + request mcp.ListToolsRequest, +) (*mcp.ListToolsResult, error) { + result, err := listByPage[mcp.ListToolsResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodToolsList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListTools lists all tools by following paginated responses. +func (c *Client) ListTools( + ctx context.Context, + request mcp.ListToolsRequest, +) (*mcp.ListToolsResult, error) { + result, err := c.ListToolsByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListToolsByPage(ctx, request) + if err != nil { + return nil, err + } + result.Tools = append(result.Tools, newPageRes.Tools...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// CallTool invokes a tool on the server. +func (c *Client) CallTool( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodToolsCall), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseCallToolResult(response) +} + +// SetLevel sets the server logging level. +func (c *Client) SetLevel( + ctx context.Context, + request mcp.SetLevelRequest, +) error { + _, err := c.sendRequest(ctx, "logging/setLevel", request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// Complete requests completion suggestions from the server. +func (c *Client) Complete( + ctx context.Context, + request mcp.CompleteRequest, +) (*mcp.CompleteResult, error) { + response, err := c.sendRequest(ctx, "completion/complete", request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + var result mcp.CompleteResult + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// RootListChanges sends a roots list-changed notification to the server. +func (c *Client) RootListChanges( + ctx context.Context, +) error { + // Send root list changes notification + notification := mcp.JSONRPCNotification{ + JSONRPC: mcp.JSONRPC_VERSION, + Notification: mcp.Notification{ + Method: mcp.MethodNotificationRootsListChanged, + }, + } + + err := c.transport.SendNotification(ctx, notification) + if err != nil { + return fmt.Errorf( + "failed to send root list change notification: %w", + err, + ) + } + return nil +} + +// handleIncomingRequest processes incoming requests from the server. +// This is the main entry point for server-to-client requests like sampling and elicitation. +func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + switch request.Method { + case string(mcp.MethodSamplingCreateMessage): + return c.handleSamplingRequestTransport(ctx, request) + case string(mcp.MethodElicitationCreate): + return c.handleElicitationRequestTransport(ctx, request) + case string(mcp.MethodPing): + return c.handlePingRequestTransport(ctx, request) + case string(mcp.MethodListRoots): + return c.handleListRootsRequestTransport(ctx, request) + default: + return nil, fmt.Errorf("unsupported request method: %s", request.Method) + } +} + +// handleSamplingRequestTransport handles sampling requests at the transport level. +func (c *Client) handleSamplingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.samplingHandler == nil { + return nil, fmt.Errorf("no sampling handler configured") + } + + // Parse the request parameters + var params mcp.CreateMessageParams + if request.Params != nil { + paramsBytes, err := json.Marshal(request.Params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + if err := json.Unmarshal(paramsBytes, ¶ms); err != nil { + return nil, fmt.Errorf("failed to unmarshal params: %w", err) + } + } + + // Fix content parsing - HTTP transport unmarshals TextContent as map[string]any + // Use the helper function to properly handle content from different transports + for i := range params.Messages { + if contentMap, ok := params.Messages[i].Content.(map[string]any); ok { + // Parse the content map into a proper Content type + content, err := mcp.ParseContent(contentMap) + if err != nil { + return nil, fmt.Errorf("failed to parse content for message %d: %w", i, err) + } + params.Messages[i].Content = content + } + } + + // Create the MCP request + mcpRequest := mcp.CreateMessageRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodSamplingCreateMessage), + }, + CreateMessageParams: params, + } + + // Call the sampling handler + result, err := c.samplingHandler.CreateMessage(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes)) + + return response, nil +} + +// handleListRootsRequestTransport handles list roots requests at the transport level. +func (c *Client) handleListRootsRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.rootsHandler == nil { + return nil, fmt.Errorf("no roots handler configured") + } + + // Create the MCP request + mcpRequest := mcp.ListRootsRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodListRoots), + }, + } + + // Call the list roots handler + result, err := c.rootsHandler.ListRoots(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes)) + + return response, nil +} + +// handleElicitationRequestTransport handles elicitation requests at the transport level. +func (c *Client) handleElicitationRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.elicitationHandler == nil { + return nil, fmt.Errorf("no elicitation handler configured") + } + + // Parse the request parameters + var params mcp.ElicitationParams + if request.Params != nil { + paramsBytes, err := json.Marshal(request.Params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + if err := json.Unmarshal(paramsBytes, ¶ms); err != nil { + return nil, fmt.Errorf("failed to unmarshal params: %w", err) + } + } + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("invalid elicitation params: %w", err) + } + + // Create the MCP request + mcpRequest := mcp.ElicitationRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodElicitationCreate), + }, + Params: params, + } + + // Call the elicitation handler + result, err := c.elicitationHandler.Elicit(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, resultBytes) + + return response, nil +} + +func (c *Client) handlePingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + b, _ := json.Marshal(&mcp.EmptyResult{}) + return transport.NewJSONRPCResultResponse(request.ID, b), nil +} + +func listByPage[T any]( + ctx context.Context, + client *Client, + request mcp.PaginatedRequest, + header http.Header, + method string, +) (*T, error) { + response, err := client.sendRequest(ctx, method, request.Params, outboundHeader(header, request.Method)) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + return &result, nil +} + +// Helper methods + +// GetTransport gives access to the underlying transport layer. +// Cast it to the specific transport type and obtain the other helper methods. +func (c *Client) GetTransport() transport.Interface { + return c.transport +} + +// GetServerCapabilities returns the server capabilities. +func (c *Client) GetServerCapabilities() mcp.ServerCapabilities { + return c.serverCapabilities +} + +// GetClientCapabilities returns the client capabilities. +func (c *Client) GetClientCapabilities() mcp.ClientCapabilities { + return c.clientCapabilities +} + +// GetSessionId returns the session ID of the transport. +// If the transport does not support sessions, it returns an empty string. +func (c *Client) GetSessionId() string { + if c.transport == nil { + return "" + } + return c.transport.GetSessionId() +} + +// IsInitialized returns true if the client has been initialized. +func (c *Client) IsInitialized() bool { + return c.initialized.Load() +} + +// CancelTask returns canceled task result +func (c *Client) CancelTask( + ctx context.Context, + request mcp.CancelTaskRequest, +) (*mcp.CancelTaskResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksCancel), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseCancelTaskResult(response) +} + +// ListTasks returns the list of tasks +func (c *Client) ListTasks( + ctx context.Context, + request mcp.ListTasksRequest, +) (*mcp.ListTasksResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksList), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseListTasksResult(response) +} + +// TaskResult returns finished task result +func (c *Client) TaskResult( + ctx context.Context, + request mcp.TaskResultRequest, +) (*mcp.TaskResultResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksResult), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseTaskResultResult(response) +} + +// GetTask returns task with current status +func (c *Client) GetTask( + ctx context.Context, + request mcp.GetTaskRequest, +) (*mcp.GetTaskResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksGet), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseGetTaskResult(response) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/elicitation.go b/vendor/github.com/mark3labs/mcp-go/client/elicitation.go new file mode 100644 index 000000000..514878728 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/elicitation.go @@ -0,0 +1,19 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ElicitationHandler defines the interface for handling elicitation requests from servers. +// Clients can implement this interface to request additional information from users. +type ElicitationHandler interface { + // Elicit handles an elicitation request from the server and returns the user's response. + // The implementation should: + // 1. Present the request message to the user (and URL if in URL mode) + // 2. Validate input against the requested schema (for form mode) + // 3. Allow the user to accept, decline, or cancel + // 4. Return the appropriate response + Elicit(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/http.go b/vendor/github.com/mark3labs/mcp-go/client/http.go new file mode 100644 index 000000000..d001a1e63 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/http.go @@ -0,0 +1,22 @@ +package client + +import ( + "fmt" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// NewStreamableHttpClient is a convenience method that creates a new streamable-http-based MCP client +// with the given base URL. Returns an error if the URL is invalid. +func NewStreamableHttpClient(baseURL string, options ...transport.StreamableHTTPCOption) (*Client, error) { + trans, err := transport.NewStreamableHTTP(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + clientOptions := make([]ClientOption, 0) + sessionID := trans.GetSessionId() + if sessionID != "" { + clientOptions = append(clientOptions, WithSession()) + } + return NewClient(trans, clientOptions...), nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/inprocess.go b/vendor/github.com/mark3labs/mcp-go/client/inprocess.go new file mode 100644 index 000000000..62d28794d --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/inprocess.go @@ -0,0 +1,38 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// NewInProcessClient connect directly to a mcp server object in the same process +func NewInProcessClient(server *server.MCPServer) (*Client, error) { + inProcessTransport := transport.NewInProcessTransport(server) + return NewClient(inProcessTransport), nil +} + +// NewInProcessClientWithSamplingHandler creates an in-process client with sampling support +func NewInProcessClientWithSamplingHandler(server *server.MCPServer, handler SamplingHandler) (*Client, error) { + // Create a wrapper that implements server.SamplingHandler + serverHandler := &inProcessSamplingHandlerWrapper{handler: handler} + + inProcessTransport := transport.NewInProcessTransportWithOptions(server, + transport.WithSamplingHandler(serverHandler)) + + client := NewClient(inProcessTransport) + client.samplingHandler = handler + + return client, nil +} + +// inProcessSamplingHandlerWrapper wraps client.SamplingHandler to implement server.SamplingHandler +type inProcessSamplingHandlerWrapper struct { + handler SamplingHandler +} + +func (w *inProcessSamplingHandlerWrapper) CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) { + return w.handler.CreateMessage(ctx, request) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/interface.go b/vendor/github.com/mark3labs/mcp-go/client/interface.go new file mode 100644 index 000000000..233ca495a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/interface.go @@ -0,0 +1,109 @@ +// Package client provides MCP (Model Context Protocol) client implementations. +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// MCPClient represents an MCP client interface +type MCPClient interface { + // Initialize sends the initial connection request to the server + Initialize( + ctx context.Context, + request mcp.InitializeRequest, + ) (*mcp.InitializeResult, error) + + // Ping checks if the server is alive + Ping(ctx context.Context) error + + // ListResourcesByPage manually list resources by page. + ListResourcesByPage( + ctx context.Context, + request mcp.ListResourcesRequest, + ) (*mcp.ListResourcesResult, error) + + // ListResources requests a list of available resources from the server + ListResources( + ctx context.Context, + request mcp.ListResourcesRequest, + ) (*mcp.ListResourcesResult, error) + + // ListResourceTemplatesByPage manually list resource templates by page. + ListResourceTemplatesByPage( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, + ) (*mcp.ListResourceTemplatesResult, + error) + + // ListResourceTemplates requests a list of available resource templates from the server + ListResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, + ) (*mcp.ListResourceTemplatesResult, + error) + + // ReadResource reads a specific resource from the server + ReadResource( + ctx context.Context, + request mcp.ReadResourceRequest, + ) (*mcp.ReadResourceResult, error) + + // Subscribe requests notifications for changes to a specific resource + Subscribe(ctx context.Context, request mcp.SubscribeRequest) error + + // Unsubscribe cancels notifications for a specific resource + Unsubscribe(ctx context.Context, request mcp.UnsubscribeRequest) error + + // ListPromptsByPage manually list prompts by page. + ListPromptsByPage( + ctx context.Context, + request mcp.ListPromptsRequest, + ) (*mcp.ListPromptsResult, error) + + // ListPrompts requests a list of available prompts from the server + ListPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, + ) (*mcp.ListPromptsResult, error) + + // GetPrompt retrieves a specific prompt from the server + GetPrompt( + ctx context.Context, + request mcp.GetPromptRequest, + ) (*mcp.GetPromptResult, error) + + // ListToolsByPage manually list tools by page. + ListToolsByPage( + ctx context.Context, + request mcp.ListToolsRequest, + ) (*mcp.ListToolsResult, error) + + // ListTools requests a list of available tools from the server + ListTools( + ctx context.Context, + request mcp.ListToolsRequest, + ) (*mcp.ListToolsResult, error) + + // CallTool invokes a specific tool on the server + CallTool( + ctx context.Context, + request mcp.CallToolRequest, + ) (*mcp.CallToolResult, error) + + // SetLevel sets the logging level for the server + SetLevel(ctx context.Context, request mcp.SetLevelRequest) error + + // Complete requests completion options for a given argument + Complete( + ctx context.Context, + request mcp.CompleteRequest, + ) (*mcp.CompleteResult, error) + + // Close client connection and cleanup resources + Close() error + + // OnNotification registers a handler for notifications + OnNotification(handler func(notification mcp.JSONRPCNotification)) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/iter.go b/vendor/github.com/mark3labs/mcp-go/client/iter.go new file mode 100644 index 000000000..f819fa076 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/iter.go @@ -0,0 +1,153 @@ +package client + +import ( + "context" + "iter" + + "github.com/mark3labs/mcp-go/mcp" +) + +// IterTools returns an iterator that lazily fetches pages of tools from the +// server. Pages are fetched on demand as the iterator is consumed; breaking +// out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +// +// Example: +// +// for tool, err := range client.IterTools(ctx, mcp.ListToolsRequest{}) { +// if err != nil { +// return err +// } +// fmt.Println(tool.Name) +// } +func (c *Client) IterTools( + ctx context.Context, + request mcp.ListToolsRequest, +) iter.Seq2[mcp.Tool, error] { + return func(yield func(mcp.Tool, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Tool{}, err) + return + } + result, err := c.ListToolsByPage(ctx, request) + if err != nil { + yield(mcp.Tool{}, err) + return + } + for _, tool := range result.Tools { + if !yield(tool, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterResources returns an iterator that lazily fetches pages of resources +// from the server. Pages are fetched on demand as the iterator is consumed; +// breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterResources( + ctx context.Context, + request mcp.ListResourcesRequest, +) iter.Seq2[mcp.Resource, error] { + return func(yield func(mcp.Resource, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Resource{}, err) + return + } + result, err := c.ListResourcesByPage(ctx, request) + if err != nil { + yield(mcp.Resource{}, err) + return + } + for _, resource := range result.Resources { + if !yield(resource, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterResourceTemplates returns an iterator that lazily fetches pages of +// resource templates from the server. Pages are fetched on demand as the +// iterator is consumed; breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) iter.Seq2[mcp.ResourceTemplate, error] { + return func(yield func(mcp.ResourceTemplate, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.ResourceTemplate{}, err) + return + } + result, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + yield(mcp.ResourceTemplate{}, err) + return + } + for _, template := range result.ResourceTemplates { + if !yield(template, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterPrompts returns an iterator that lazily fetches pages of prompts from +// the server. Pages are fetched on demand as the iterator is consumed; +// breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, +) iter.Seq2[mcp.Prompt, error] { + return func(yield func(mcp.Prompt, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Prompt{}, err) + return + } + result, err := c.ListPromptsByPage(ctx, request) + if err != nil { + yield(mcp.Prompt{}, err) + return + } + for _, prompt := range result.Prompts { + if !yield(prompt, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/oauth.go b/vendor/github.com/mark3labs/mcp-go/client/oauth.go new file mode 100644 index 000000000..1e07897ce --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/oauth.go @@ -0,0 +1,105 @@ +package client + +import ( + "errors" + "fmt" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// OAuthConfig is a convenience type that wraps transport.OAuthConfig +type OAuthConfig = transport.OAuthConfig + +// Token is a convenience type that wraps transport.Token +type Token = transport.Token + +// TokenStore is a convenience type that wraps transport.TokenStore +type TokenStore = transport.TokenStore + +// MemoryTokenStore is a convenience type that wraps transport.MemoryTokenStore +type MemoryTokenStore = transport.MemoryTokenStore + +// NewMemoryTokenStore is a convenience function that wraps transport.NewMemoryTokenStore +var NewMemoryTokenStore = transport.NewMemoryTokenStore + +// NewOAuthStreamableHttpClient creates a new streamable-http-based MCP client with OAuth support. +// Returns an error if the URL is invalid. +func NewOAuthStreamableHttpClient(baseURL string, oauthConfig OAuthConfig, options ...transport.StreamableHTTPCOption) (*Client, error) { + // Add OAuth option to the list of options + options = append(options, transport.WithHTTPOAuth(oauthConfig)) + + trans, err := transport.NewStreamableHTTP(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP transport: %w", err) + } + return NewClient(trans), nil +} + +// NewOAuthStreamableHttpClient creates a new streamable-http-based MCP client with OAuth support. +// Returns an error if the URL is invalid. +func NewOAuthSSEClient(baseURL string, oauthConfig OAuthConfig, options ...transport.ClientOption) (*Client, error) { + // Add OAuth option to the list of options + options = append(options, transport.WithOAuth(oauthConfig)) + + trans, err := transport.NewSSE(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + return NewClient(trans), nil +} + +// GenerateCodeVerifier generates a code verifier for PKCE +var GenerateCodeVerifier = transport.GenerateCodeVerifier + +// GenerateCodeChallenge generates a code challenge from a code verifier +var GenerateCodeChallenge = transport.GenerateCodeChallenge + +// GenerateState generates a state parameter for OAuth +var GenerateState = transport.GenerateState + +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received +type AuthorizationRequiredError = transport.AuthorizationRequiredError + +// OAuthAuthorizationRequiredError is returned when OAuth authorization is required +type OAuthAuthorizationRequiredError = transport.OAuthAuthorizationRequiredError + +// IsAuthorizationRequiredError checks if an error is an AuthorizationRequiredError +func IsAuthorizationRequiredError(err error) bool { + var target *AuthorizationRequiredError + return errors.As(err, &target) +} + +// IsOAuthAuthorizationRequiredError checks if an error is an OAuthAuthorizationRequiredError +func IsOAuthAuthorizationRequiredError(err error) bool { + var target *OAuthAuthorizationRequiredError + return errors.As(err, &target) +} + +// GetOAuthHandler extracts the OAuthHandler from an OAuthAuthorizationRequiredError +func GetOAuthHandler(err error) *transport.OAuthHandler { + var oauthErr *OAuthAuthorizationRequiredError + if errors.As(err, &oauthErr) { + return oauthErr.Handler + } + return nil +} + +// GetResourceMetadataURL extracts the protected resource metadata URL from an authorization error. +// This URL is extracted from the WWW-Authenticate header per RFC9728 Section 5.1. +// Works with both AuthorizationRequiredError and OAuthAuthorizationRequiredError. +// Returns empty string if no metadata URL was discovered. +func GetResourceMetadataURL(err error) string { + // Try OAuthAuthorizationRequiredError first (contains AuthorizationRequiredError) + var oauthErr *OAuthAuthorizationRequiredError + if errors.As(err, &oauthErr) { + return oauthErr.ResourceMetadataURL + } + + // Try base AuthorizationRequiredError + var authErr *AuthorizationRequiredError + if errors.As(err, &authErr) { + return authErr.ResourceMetadataURL + } + + return "" +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/roots.go b/vendor/github.com/mark3labs/mcp-go/client/roots.go new file mode 100644 index 000000000..0a17aaf7a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/roots.go @@ -0,0 +1,17 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// RootsHandler defines the interface for handling roots requests from servers. +// Clients can implement this interface to provide roots list to servers. +type RootsHandler interface { + // ListRoots handles a list root request from the server and returns the roots list. + // The implementation should: + // 1. Validate input against the requested schema + // 2. Return the appropriate response + ListRoots(ctx context.Context, request mcp.ListRootsRequest) (*mcp.ListRootsResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/sampling.go b/vendor/github.com/mark3labs/mcp-go/client/sampling.go new file mode 100644 index 000000000..245e2c1f7 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/sampling.go @@ -0,0 +1,20 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// SamplingHandler defines the interface for handling sampling requests from servers. +// Clients can implement this interface to provide LLM sampling capabilities to servers. +type SamplingHandler interface { + // CreateMessage handles a sampling request from the server and returns the generated message. + // The implementation should: + // 1. Validate the request parameters + // 2. Optionally prompt the user for approval (human-in-the-loop) + // 3. Select an appropriate model based on preferences + // 4. Generate the response using the selected model + // 5. Return the result with model information and stop reason + CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/sse.go b/vendor/github.com/mark3labs/mcp-go/client/sse.go new file mode 100644 index 000000000..390d90922 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/sse.go @@ -0,0 +1,61 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// WithHeaders sets static headers for the SSE client. +func WithHeaders(headers map[string]string) transport.ClientOption { + return transport.WithHeaders(headers) +} + +// WithHeaderFunc sets a function that returns headers for each SSE request. +func WithHeaderFunc(headerFunc transport.HTTPHeaderFunc) transport.ClientOption { + return transport.WithHeaderFunc(headerFunc) +} + +// WithHTTPClient sets a custom HTTP client for the SSE transport. +func WithHTTPClient(httpClient *http.Client) transport.ClientOption { + return transport.WithHTTPClient(httpClient) +} + +// WithHTTPHost sets a custom Host header for the SSE client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/sse" but sending Host: "api.example.com" +func WithHTTPHost(host string) transport.ClientOption { + return transport.WithHTTPHost(host) +} + +// NewSSEMCPClient creates a new SSE-based MCP client with the given base URL. +// Returns an error if the URL is invalid. +// +// Example: +// +// c, err := client.NewSSEMCPClient("http://localhost:8080/mcp/sse") +// if err != nil { +// log.Fatalf("Failed to create client: %v", err) +// } +// defer c.Close() +// +// Note: Use a variable name other than "client" (e.g., "c", "mcpClient") to avoid +// shadowing the package name. +func NewSSEMCPClient(baseURL string, options ...transport.ClientOption) (*Client, error) { + sseTransport, err := transport.NewSSE(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + return NewClient(sseTransport), nil +} + +// GetEndpoint returns the current endpoint URL for the SSE connection. +// +// Note: This method only works with SSE transport, or it will panic. +func GetEndpoint(c *Client) *url.URL { + t := c.GetTransport() + sse := t.(*transport.SSE) + return sse.GetEndpoint() +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/stdio.go b/vendor/github.com/mark3labs/mcp-go/client/stdio.go new file mode 100644 index 000000000..6a2d89bcf --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/stdio.go @@ -0,0 +1,62 @@ +package client + +import ( + "context" + "fmt" + "io" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// NewStdioMCPClient creates a new stdio-based MCP client that communicates with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +// +// NOTICE: NewStdioMCPClient will start the connection automatically. +// This is for backward compatibility. +func NewStdioMCPClient( + command string, + env []string, + args ...string, +) (*Client, error) { + return NewStdioMCPClientWithOptions(command, env, args) +} + +// NewStdioMCPClientWithOptions creates a new stdio-based MCP client that communicates with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Optional configuration functions can be provided to customize the transport before it starts, +// such as setting a custom command function. +// +// NOTICE: NewStdioMCPClientWithOptions automatically starts the underlying transport. +// This is for backward compatibility. +func NewStdioMCPClientWithOptions( + command string, + env []string, + args []string, + opts ...transport.StdioOption, +) (*Client, error) { + stdioTransport := transport.NewStdioWithOptions(command, env, args, opts...) + + if err := stdioTransport.Start(context.Background()); err != nil { + return nil, fmt.Errorf("failed to start stdio transport: %w", err) + } + + return NewClient(stdioTransport), nil +} + +// GetStderr returns a reader for the stderr output of the subprocess. +// This can be used to capture error messages or logs from the subprocess. +// +// It works with any transport that exposes a Stderr() io.Reader method, +// including *transport.Stdio and *transport.CommandTransport. +func GetStderr(c *Client) (io.Reader, bool) { + t := c.GetTransport() + + type stderrer interface { + Stderr() io.Reader + } + if s, ok := t.(stderrer); ok { + return s.Stderr(), true + } + return nil, false +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/tracing.go b/vendor/github.com/mark3labs/mcp-go/client/tracing.go new file mode 100644 index 000000000..c81329e27 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/tracing.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/tracing" +) + +const attrMethod = "mcp.method" + +// WithTracer installs a tracer on the client. The tracer starts a client-kind +// span around every outgoing JSON-RPC method ("mcp."). For end-to-end +// propagation install a Propagator with WithPropagator as well. A nil tracer +// is treated as a no-op. +func WithTracer(tracer tracing.Tracer) ClientOption { + if tracer == nil { + tracer = tracing.NoopTracer() + } + return func(c *Client) { + c.tracer = tracer + } +} + +// WithPropagator installs a propagator that injects trace context into +// outgoing request headers. A nil propagator is treated as a no-op. +func WithPropagator(p tracing.Propagator) ClientOption { + if p == nil { + p = tracing.NoopPropagator() + } + return func(c *Client) { + c.propagator = p + } +} + +// WithMetaPropagator installs a propagator that injects trace context into +// the MCP _meta property bag of outgoing requests (per SEP-414). It covers +// all transports including stdio, where HTTP headers are unavailable. +// A nil propagator is treated as a no-op. +func WithMetaPropagator(p tracing.MetaPropagator) ClientOption { + if p == nil { + p = tracing.NoopMetaPropagator() + } + return func(c *Client) { + c.metaPropagator = p + } +} + +func (c *Client) injectMeta(ctx context.Context, meta *mcp.Meta) *mcp.Meta { + p := c.metaPropagator + if p == nil { + return meta + } + return p.InjectMeta(ctx, meta) +} + +func (c *Client) startSendSpan( + ctx context.Context, + method string, + header http.Header, +) (context.Context, http.Header, tracing.Span) { + tracer := c.tracer + if tracer == nil { + tracer = tracing.NoopTracer() + } + propagator := c.propagator + if propagator == nil { + propagator = tracing.NoopPropagator() + } + + ctx, span := tracer.Start(ctx, "mcp."+method, tracing.SpanKindClient, + tracing.String(attrMethod, method), + ) + + if header == nil { + header = make(http.Header) + } + propagator.Inject(ctx, header) + + return ctx, header, span +} + +func endSendSpan(span tracing.Span, err error) { + if err != nil { + span.SetStatus(tracing.StatusError, err.Error()) + span.RecordError(err) + } + span.End() +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/command.go b/vendor/github.com/mark3labs/mcp-go/client/transport/command.go new file mode 100644 index 000000000..716941a83 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/command.go @@ -0,0 +1,44 @@ +package transport + +// CommandTransport spawns a subprocess and communicates with it over its +// stdin/stdout streams using JSON-RPC messages. +// +// CommandTransport is a thin, convenience wrapper around the Stdio transport +// that makes the subprocess use case explicit and self-documenting. The +// underlying behavior is identical to NewStdio: any options, methods, or +// guarantees provided by Stdio are available here via embedding. +// +// Use CommandTransport when you want to launch an MCP server as a child +// process. Use Stdio (e.g. via NewIO) when you want to attach to existing +// streams. +type CommandTransport struct { + *Stdio +} + +// NewCommand creates a transport that spawns the given command as a subprocess +// and communicates with it over stdin/stdout. The current process's +// environment is inherited by the child. +// +// This is equivalent to calling NewStdio(command, nil, args...) but expresses +// the subprocess intent more clearly. +func NewCommand(command string, args ...string) *CommandTransport { + return &CommandTransport{Stdio: NewStdio(command, nil, args...)} +} + +// NewCommandWithEnv creates a transport that spawns the given command as a +// subprocess with additional environment variables appended to the parent +// process's environment. Each entry in env must be of the form "KEY=VALUE". +// +// This is equivalent to calling NewStdio(command, env, args...). +func NewCommandWithEnv(command string, env []string, args ...string) *CommandTransport { + return &CommandTransport{Stdio: NewStdio(command, env, args...)} +} + +// NewCommandWithOptions creates a transport that spawns the given command as a +// subprocess and applies the provided StdioOptions (for example +// WithCommandFunc or WithCommandLogger) before the subprocess is started. +// +// This is equivalent to calling NewStdioWithOptions(command, env, args, opts...). +func NewCommandWithOptions(command string, env []string, args []string, opts ...StdioOption) *CommandTransport { + return &CommandTransport{Stdio: NewStdioWithOptions(command, env, args, opts...)} +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go b/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go new file mode 100644 index 000000000..2fb503084 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go @@ -0,0 +1,7 @@ +package transport + +// Common HTTP header constants used across transports +const ( + HeaderKeySessionID = "Mcp-Session-Id" + HeaderKeyProtocolVersion = "Mcp-Protocol-Version" +) diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/error.go b/vendor/github.com/mark3labs/mcp-go/client/transport/error.go new file mode 100644 index 000000000..1f029944a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/error.go @@ -0,0 +1,22 @@ +package transport + +import "fmt" + +// Error wraps a low-level transport error in a concrete type. +type Error struct { + Err error +} + +func (e *Error) Error() string { + return fmt.Sprintf("transport error: %v", e.Err) +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func NewError(err error) *Error { + return &Error{ + Err: err, + } +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go b/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go new file mode 100644 index 000000000..c20e20863 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go @@ -0,0 +1,199 @@ +package transport + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type InProcessTransport struct { + server *server.MCPServer + samplingHandler server.SamplingHandler + elicitationHandler server.ElicitationHandler + rootsHandler server.RootsHandler + session *server.InProcessSession + sessionID string + + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + started bool + closed bool + startedMu sync.Mutex + + done chan struct{} + closeOnce sync.Once +} + +type InProcessOption func(*InProcessTransport) + +func WithSamplingHandler(handler server.SamplingHandler) InProcessOption { + return func(t *InProcessTransport) { + t.samplingHandler = handler + } +} + +func WithElicitationHandler(handler server.ElicitationHandler) InProcessOption { + return func(t *InProcessTransport) { + t.elicitationHandler = handler + } +} + +func WithRootsHandler(handler server.RootsHandler) InProcessOption { + return func(t *InProcessTransport) { + t.rootsHandler = handler + } +} + +func NewInProcessTransport(server *server.MCPServer) *InProcessTransport { + return &InProcessTransport{ + server: server, + sessionID: server.GenerateInProcessSessionID(), + done: make(chan struct{}), + } +} + +func NewInProcessTransportWithOptions(server *server.MCPServer, opts ...InProcessOption) *InProcessTransport { + t := &InProcessTransport{ + server: server, + sessionID: server.GenerateInProcessSessionID(), + done: make(chan struct{}), + } + + for _, opt := range opts { + opt(t) + } + + return t +} + +func (c *InProcessTransport) Start(ctx context.Context) error { + c.startedMu.Lock() + if c.closed { + c.startedMu.Unlock() + return ErrTransportClosed + } + if c.started { + c.startedMu.Unlock() + return nil + } + + // Always create and register a session so server-to-client notifications + // (progress, list-changed, resource updates, etc.) have somewhere to land, + // in addition to any sampling/elicitation/roots handlers. + // + // Registration and the c.session/c.started assignments all happen under a + // single startedMu hold so that Start and Close are mutually exclusive: + // a concurrent Close either runs entirely before this section (observed + // via c.closed above, so Start bails out before registering anything) or + // entirely after (Close will see c.started and c.session set, and will + // unregister the session). There is no window where a session is + // registered but Close skips unregistering it. RegisterSession does a + // sync.Map store plus runs synchronous OnRegisterSession hooks; neither + // re-enters this transport's lock (safe from deadlock); slow user hooks + // only extend the lock hold. + session := server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler, c.rootsHandler) + if err := c.server.RegisterSession(ctx, session); err != nil { + c.startedMu.Unlock() + return fmt.Errorf("failed to register session: %w", err) + } + + c.session = session + c.started = true + c.startedMu.Unlock() + + go c.forwardNotifications() + + return nil +} + +// forwardNotifications drains the session's notification channel and +// forwards each notification to the registered client handler, mirroring +// the equivalent readResponses notification path in the stdio transport. +// Runs until Close() closes the done channel. +func (c *InProcessTransport) forwardNotifications() { + notifications := c.session.ClientNotifications() + for { + select { + case <-c.done: + return + case notification, ok := <-notifications: + if !ok { + return + } + c.notifyMu.RLock() + handler := c.onNotification + c.notifyMu.RUnlock() + if handler != nil { + handler(notification) + } + } + } +} + +func (c *InProcessTransport) SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + requestBytes = append(requestBytes, '\n') + + // Add session to context if available + if c.session != nil { + ctx = c.server.WithContext(ctx, c.session) + } + + respMessage := c.server.HandleMessage(ctx, requestBytes) + respByte, err := json.Marshal(respMessage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response message: %w", err) + } + var rpcResp JSONRPCResponse + err = json.Unmarshal(respByte, &rpcResp) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response message: %w", err) + } + + return &rpcResp, nil +} + +func (c *InProcessTransport) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + notificationBytes = append(notificationBytes, '\n') + c.server.HandleMessage(ctx, notificationBytes) + + return nil +} + +func (c *InProcessTransport) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +func (c *InProcessTransport) Close() error { + c.startedMu.Lock() + c.closed = true + session := c.session + sessionID := c.sessionID + c.startedMu.Unlock() + + c.closeOnce.Do(func() { + close(c.done) + }) + + if session != nil { + c.server.UnregisterSession(context.Background(), sessionID) + } + return nil +} + +func (c *InProcessTransport) GetSessionId() string { + return "" +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go b/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go new file mode 100644 index 000000000..e35a5f318 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go @@ -0,0 +1,74 @@ +package transport + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" +) + +// HTTPHeaderFunc is a function that extracts header entries from the given context +// and returns them as key-value pairs. This is typically used to add context values +// as HTTP headers in outgoing requests. +type HTTPHeaderFunc func(context.Context) map[string]string + +// Interface for the transport layer. +type Interface interface { + // Start the connection. Start should only be called once. + Start(ctx context.Context) error + + // SendRequest sends a json RPC request and returns the response synchronously. + SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) + + // SendNotification sends a json RPC Notification to the server. + SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error + + // SetNotificationHandler sets the handler for notifications. + // Any notification before the handler is set will be discarded. + SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) + + // Close the connection. + Close() error + + // GetSessionId returns the session ID of the transport. + GetSessionId() string +} + +// RequestHandler defines a function that handles incoming requests from the server. +type RequestHandler func(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) + +// BidirectionalInterface extends Interface to support incoming requests from the server. +// This is used for features like sampling where the server can send requests to the client. +type BidirectionalInterface interface { + Interface + + // SetRequestHandler sets the handler for incoming requests from the server. + // The handler should process the request and return a response. + SetRequestHandler(handler RequestHandler) +} + +// HTTPConnection is a Transport that runs over HTTP and supports +// protocol version headers. +type HTTPConnection interface { + Interface + SetProtocolVersion(version string) +} + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID mcp.RequestId `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` + Header http.Header `json:"-"` +} + +// JSONRPCResponse represents a JSON-RPC 2.0 response message. +// Use NewJSONRPCResultResponse to create a JSONRPCResponse with a result. +// Use NewJSONRPCErrorResponse to create a JSONRPCResponse with an error. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID mcp.RequestId `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *mcp.JSONRPCErrorDetails `json:"error,omitempty"` +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go b/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go new file mode 100644 index 000000000..080baf5ff --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go @@ -0,0 +1,304 @@ +package transport + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// LoggingOption configures a LoggingTransport. +type LoggingOption func(*loggingConfig) + +type loggingConfig struct { + level slog.Level + payloads bool +} + +// WithLoggingLevel sets the slog level used for the log records emitted by +// the LoggingTransport. The default is slog.LevelDebug. +func WithLoggingLevel(level slog.Level) LoggingOption { + return func(c *loggingConfig) { + c.level = level + } +} + +// WithLoggingPayloads controls whether full JSON-RPC payloads (params, +// result and JSON-RPC error code/message) are included in the structured +// log records. When disabled, only message metadata (direction, method, +// id, duration and the bare "← response error" / "→ response error" +// marker for failed calls) is logged. The default is true. +func WithLoggingPayloads(enabled bool) LoggingOption { + return func(c *loggingConfig) { + c.payloads = enabled + } +} + +// LoggingTransport wraps a transport.Interface and logs every JSON-RPC +// message that flows through it using a slog.Logger. It is a transparent +// decorator: NewLogging returns a wrapper whose interface set mirrors the +// inner transport, so type assertions against BidirectionalInterface or +// HTTPConnection continue to behave as expected. +type LoggingTransport struct { + inner Interface + logger *slog.Logger + level slog.Level + logRaw bool + + notifyMu sync.RWMutex + onNotification func(notification mcp.JSONRPCNotification) +} + +// NewLogging returns an Interface that logs all JSON-RPC traffic passing +// through inner using the provided logger. When logger is nil, slog.Default +// is used. If inner additionally implements BidirectionalInterface and/or +// HTTPConnection, the returned wrapper also implements those interfaces, so +// it can be used as a drop-in replacement. +// +// NewLogging installs its own notification handler on the inner transport +// so that incoming notifications can be logged. Because [Interface] does +// not expose a way to read the previously-registered handler, NewLogging +// must be applied before any handler is registered on the inner transport; +// otherwise the previously-registered handler will be displaced. Callers +// that need to receive notifications should register their handler on the +// returned wrapper, which will both log the notification and forward it. +func NewLogging(inner Interface, logger *slog.Logger, opts ...LoggingOption) Interface { + base := newLoggingTransport(inner, logger, opts...) + + bidir, isBidir := inner.(BidirectionalInterface) + httpConn, isHTTP := inner.(HTTPConnection) + + switch { + case isBidir && isHTTP: + return &loggingBidirHTTPTransport{ + loggingBidirTransport: loggingBidirTransport{LoggingTransport: base, bidir: bidir}, + http: httpConn, + } + case isBidir: + return &loggingBidirTransport{LoggingTransport: base, bidir: bidir} + case isHTTP: + return &loggingHTTPTransport{LoggingTransport: base, http: httpConn} + default: + return base + } +} + +func newLoggingTransport(inner Interface, logger *slog.Logger, opts ...LoggingOption) *LoggingTransport { + cfg := loggingConfig{level: slog.LevelDebug, payloads: true} + for _, opt := range opts { + opt(&cfg) + } + if logger == nil { + logger = slog.Default() + } + lt := &LoggingTransport{ + inner: inner, + logger: logger, + level: cfg.level, + logRaw: cfg.payloads, + } + // Pre-register a notification handler on the inner transport so that + // notifications are still logged even when the user never registers one + // on the wrapper. + inner.SetNotificationHandler(lt.handleNotification) + return lt +} + +// Start delegates to the wrapped transport. +func (l *LoggingTransport) Start(ctx context.Context) error { + return l.inner.Start(ctx) +} + +// SendRequest forwards the request to the wrapped transport while logging +// both the outgoing request and the incoming response (or error). +func (l *LoggingTransport) SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) { + attrs := []slog.Attr{ + slog.String("method", request.Method), + slog.Any("id", request.ID), + } + if l.logRaw && request.Params != nil { + attrs = append(attrs, slog.Any("params", request.Params)) + } + l.log(ctx, "→ request", attrs...) + + start := time.Now() + resp, err := l.inner.SendRequest(ctx, request) + duration := time.Since(start) + + respAttrs := []slog.Attr{ + slog.Any("id", request.ID), + slog.String("method", request.Method), + slog.Duration("duration", duration), + } + if err != nil { + respAttrs = append(respAttrs, slog.String("error", err.Error())) + l.log(ctx, "← response error", respAttrs...) + return nil, err + } + if resp != nil && resp.Error != nil { + if l.logRaw { + respAttrs = append(respAttrs, + slog.Int("code", resp.Error.Code), + slog.String("error", resp.Error.Message), + ) + } + l.log(ctx, "← response error", respAttrs...) + return resp, nil + } + if l.logRaw && resp != nil && len(resp.Result) > 0 { + respAttrs = append(respAttrs, slog.String("result", string(resp.Result))) + } + l.log(ctx, "← response", respAttrs...) + return resp, nil +} + +// SendNotification forwards a notification to the wrapped transport and logs +// the outgoing message. +func (l *LoggingTransport) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + attrs := []slog.Attr{slog.String("method", notification.Method)} + if l.logRaw { + if data, err := json.Marshal(notification.Params); err == nil && len(data) > 0 && string(data) != "null" { + attrs = append(attrs, slog.String("params", string(data))) + } + } + l.log(ctx, "→ notification", attrs...) + + if err := l.inner.SendNotification(ctx, notification); err != nil { + l.log(ctx, "→ notification error", + slog.String("method", notification.Method), + slog.String("error", err.Error()), + ) + return err + } + return nil +} + +// SetNotificationHandler registers handler to receive notifications from the +// server. The wrapper logs every incoming notification before forwarding it +// to handler. +func (l *LoggingTransport) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + l.notifyMu.Lock() + defer l.notifyMu.Unlock() + l.onNotification = handler +} + +// Close shuts down the wrapped transport. +func (l *LoggingTransport) Close() error { + return l.inner.Close() +} + +// GetSessionId returns the session id reported by the wrapped transport. +func (l *LoggingTransport) GetSessionId() string { + return l.inner.GetSessionId() +} + +func (l *LoggingTransport) handleNotification(notification mcp.JSONRPCNotification) { + attrs := []slog.Attr{slog.String("method", notification.Method)} + if l.logRaw { + if data, err := json.Marshal(notification.Params); err == nil && len(data) > 0 && string(data) != "null" { + attrs = append(attrs, slog.String("params", string(data))) + } + } + l.log(context.Background(), "← notification", attrs...) + + l.notifyMu.RLock() + h := l.onNotification + l.notifyMu.RUnlock() + if h != nil { + h(notification) + } +} + +func (l *LoggingTransport) log(ctx context.Context, msg string, attrs ...slog.Attr) { + if !l.logger.Enabled(ctx, l.level) { + return + } + l.logger.LogAttrs(ctx, l.level, msg, attrs...) +} + +// loggingBidirTransport adds BidirectionalInterface support when the inner +// transport implements it. +type loggingBidirTransport struct { + *LoggingTransport + bidir BidirectionalInterface +} + +// SetRequestHandler registers a handler for incoming server-to-client +// requests. The wrapper logs each incoming request and the corresponding +// outgoing response before delegating to handler. +func (l *loggingBidirTransport) SetRequestHandler(handler RequestHandler) { + if handler == nil { + l.bidir.SetRequestHandler(nil) + return + } + wrapped := func(ctx context.Context, req JSONRPCRequest) (*JSONRPCResponse, error) { + attrs := []slog.Attr{ + slog.String("method", req.Method), + slog.Any("id", req.ID), + } + if l.logRaw && req.Params != nil { + attrs = append(attrs, slog.Any("params", req.Params)) + } + l.log(ctx, "← request", attrs...) + + start := time.Now() + resp, err := handler(ctx, req) + duration := time.Since(start) + + respAttrs := []slog.Attr{ + slog.Any("id", req.ID), + slog.String("method", req.Method), + slog.Duration("duration", duration), + } + if err != nil { + respAttrs = append(respAttrs, slog.String("error", err.Error())) + l.log(ctx, "→ response error", respAttrs...) + return nil, err + } + if resp != nil && resp.Error != nil { + if l.logRaw { + respAttrs = append(respAttrs, + slog.Int("code", resp.Error.Code), + slog.String("error", resp.Error.Message), + ) + } + l.log(ctx, "→ response error", respAttrs...) + return resp, nil + } + if l.logRaw && resp != nil && len(resp.Result) > 0 { + respAttrs = append(respAttrs, slog.String("result", string(resp.Result))) + } + l.log(ctx, "→ response", respAttrs...) + return resp, nil + } + l.bidir.SetRequestHandler(wrapped) +} + +// loggingHTTPTransport adds HTTPConnection support when the inner transport +// implements it. +type loggingHTTPTransport struct { + *LoggingTransport + http HTTPConnection +} + +// SetProtocolVersion forwards the negotiated protocol version to the wrapped +// HTTP transport. +func (l *loggingHTTPTransport) SetProtocolVersion(version string) { + l.http.SetProtocolVersion(version) +} + +// loggingBidirHTTPTransport supports both BidirectionalInterface and +// HTTPConnection. +type loggingBidirHTTPTransport struct { + loggingBidirTransport + http HTTPConnection +} + +// SetProtocolVersion forwards the negotiated protocol version to the wrapped +// HTTP transport. +func (l *loggingBidirHTTPTransport) SetProtocolVersion(version string) { + l.http.SetProtocolVersion(version) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go new file mode 100644 index 000000000..7ba1725a4 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go @@ -0,0 +1,1202 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// maxMetadataBodyBytes caps the size of an OAuth metadata document we are +// willing to read. +const maxMetadataBodyBytes = 1 << 20 // 1 MiB + +// ErrNoToken is returned when no token is available in the token store +var ErrNoToken = errors.New("no token available") + +// OAuthConfig holds the OAuth configuration for the client +type OAuthConfig struct { + // ClientID is the OAuth client ID + ClientID string + // ClientURI is the URI of the client + ClientURI string + // ClientSecret is the OAuth client secret (for confidential clients) + ClientSecret string + // RedirectURI is the redirect URI for the OAuth flow + RedirectURI string + // Scopes is the list of OAuth scopes to request + Scopes []string + // TokenStore is the storage for OAuth tokens + TokenStore TokenStore + // AuthServerMetadataURL is the URL to the OAuth server metadata + // If empty, the client will attempt to discover it from the base URL + AuthServerMetadataURL string + // ProtectedResourceMetadataURL is the URL to the OAuth protected resource metadata + // per RFC9728. If set, this URL will be used to discover the authorization server. + // This is typically extracted from the WWW-Authenticate header's resource_metadata parameter. + ProtectedResourceMetadataURL string + // PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients) + PKCEEnabled bool + // HTTPClient is an optional HTTP client to use for requests. + // If nil, a default HTTP client with a 30 second timeout will be used. + HTTPClient *http.Client +} + +// TokenStore is an interface for storing and retrieving OAuth tokens. +// +// Implementations must: +// - Honor context cancellation and deadlines, returning context.Canceled +// or context.DeadlineExceeded as appropriate +// - Return ErrNoToken (or a sentinel error that wraps it) when no token +// is available, rather than conflating this with other operational errors +// - Properly propagate all other errors (database failures, I/O errors, etc.) +// - Check ctx.Done() before performing operations and return ctx.Err() if cancelled +type TokenStore interface { + // GetToken returns the current token. + // Returns ErrNoToken if no token is available. + // Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. + // Returns other errors for operational failures (I/O, database, etc.). + GetToken(ctx context.Context) (*Token, error) + + // SaveToken saves a token. + // Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. + // Returns other errors for operational failures (I/O, database, etc.). + SaveToken(ctx context.Context, token *Token) error +} + +// Token represents an OAuth token +type Token struct { + // AccessToken is the OAuth access token + AccessToken string `json:"access_token"` + // TokenType is the type of token (usually "Bearer") + TokenType string `json:"token_type"` + // RefreshToken is the OAuth refresh token + RefreshToken string `json:"refresh_token,omitempty"` + // ExpiresIn is the number of seconds until the token expires + ExpiresIn int64 `json:"expires_in,omitempty"` + // Scope is the scope of the token + Scope string `json:"scope,omitempty"` + // ExpiresAt is the time when the token expires + ExpiresAt time.Time `json:"expires_at,omitzero"` +} + +// IsExpired returns true if the token is expired +func (t *Token) IsExpired() bool { + if t.ExpiresAt.IsZero() { + return false + } + return time.Now().After(t.ExpiresAt) +} + +// MemoryTokenStore is a simple in-memory token store +type MemoryTokenStore struct { + token *Token + mu sync.RWMutex +} + +// NewMemoryTokenStore creates a new in-memory token store +func NewMemoryTokenStore() *MemoryTokenStore { + return &MemoryTokenStore{} +} + +// GetToken returns the current token. +// Returns ErrNoToken if no token is available. +// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. +func (s *MemoryTokenStore) GetToken(ctx context.Context) (*Token, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.token == nil { + return nil, ErrNoToken + } + return s.token, nil +} + +// SaveToken saves a token. +// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. +func (s *MemoryTokenStore) SaveToken(ctx context.Context, token *Token) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.token = token + return nil +} + +// AuthServerMetadata represents the OAuth 2.0 Authorization Server Metadata +// as defined in RFC 8414 (https://www.rfc-editor.org/rfc/rfc8414.html). +// +// URL-bearing fields are validated by validateAuthServerMetadataURLs to +// reject non-http(s) schemes (e.g. javascript:, data:, file:) that could +// otherwise be injected by a hostile authorization server. +// +// Signed metadata (the "signed_metadata" JWT field) is not supported. +type AuthServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri,omitempty"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + ServiceDocumentation string `json:"service_documentation,omitempty"` + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + OpPolicyURI string `json:"op_policy_uri,omitempty"` + OpTOSURI string `json:"op_tos_uri,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +// OAuthHandler handles OAuth authentication for HTTP requests +type OAuthHandler struct { + config OAuthConfig + httpClient *http.Client + serverMetadata *AuthServerMetadata + metadataFetchErr error + // metadataOnce gates the discovery RPCs so they run exactly once per + // OAuthHandler. It is a pointer (rather than an embedded value) so + // SetProtectedResourceMetadataURL can swap in a fresh sync.Once + // without racing against a concurrently running Do on the previous + // instance. The pointer itself is protected by metadataMu; callers + // snapshot the pointer under the lock and then invoke Do without + // holding the lock. + metadataOnce *sync.Once + baseURL string + // metadataMu protects baseURL, serverMetadata, metadataFetchErr, + // metadataOnce, config.ProtectedResourceMetadataURL, and resourceURL. + // It MUST NOT be held across HTTP I/O — see getServerMetadata for the + // snapshot/publish discipline that keeps network round trips out of + // the critical section (#871). + metadataMu sync.Mutex + resourceURL string // RFC 8707 resource indicator; set from protected resource metadata + + mu sync.RWMutex // Protects expectedState + expectedState string // Expected state value for CSRF protection +} + +// NewOAuthHandler creates a new OAuth handler +func NewOAuthHandler(config OAuthConfig) *OAuthHandler { + if config.TokenStore == nil { + config.TokenStore = NewMemoryTokenStore() + } + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } + + return &OAuthHandler{ + config: config, + httpClient: config.HTTPClient, + } +} + +// GetAuthorizationHeader returns the Authorization header value for a request +func (h *OAuthHandler) GetAuthorizationHeader(ctx context.Context) (string, error) { + token, err := h.getValidToken(ctx) + if err != nil { + return "", err + } + + // Per RFC 6749 §5.1, token_type is case-insensitive. + // Normalize to "Bearer" for strict implementations. + tokenType := token.TokenType + if strings.EqualFold(tokenType, "bearer") { + tokenType = "Bearer" + } + + return fmt.Sprintf("%s %s", tokenType, token.AccessToken), nil +} + +// getValidToken returns a valid token, refreshing if necessary +func (h *OAuthHandler) getValidToken(ctx context.Context) (*Token, error) { + token, err := h.config.TokenStore.GetToken(ctx) + if err != nil && !errors.Is(err, ErrNoToken) { + return nil, err + } + if err == nil && !token.IsExpired() && token.AccessToken != "" { + return token, nil + } + + // If we have a refresh token, try to use it + if err == nil && token.RefreshToken != "" { + newToken, err := h.refreshToken(ctx, token.RefreshToken) + if err == nil { + return newToken, nil + } + // If refresh fails, continue to authorization flow + } + + // We need to get a new token through the authorization flow + return nil, ErrOAuthAuthorizationRequired +} + +// refreshToken refreshes an OAuth token +func (h *OAuthHandler) refreshToken(ctx context.Context, refreshToken string) (*Token, error) { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get server metadata: %w", err) + } + + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", refreshToken) + data.Set("client_id", h.config.ClientID) + if h.config.ClientSecret != "" { + data.Set("client_secret", h.config.ClientSecret) + } + // RFC 8707: Include resource parameter on refresh requests + if resourceURL := h.getResourceURL(); resourceURL != "" { + data.Set("resource", resourceURL) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.TokenEndpoint, + strings.NewReader(data.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create refresh token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send refresh token request: %w", err) + } + defer resp.Body.Close() + + // Accept any 2xx status; some authorization servers (e.g. Supabase) return + // 201 Created for successful token responses. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, extractOAuthError(body, resp.StatusCode, "refresh token request failed") + } + + // Read the response body for parsing + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response body: %w", err) + } + + // GitHub returns HTTP 200 even for errors, with error details in the JSON body + // Check if the response contains an error field before parsing as Token + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return nil, fmt.Errorf("refresh token request failed: %w", oauthErr) + } + + var tokenResp Token + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w", err) + } + + // Set expiration time + if tokenResp.ExpiresIn > 0 { + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + + // If no new refresh token is provided, keep the old one + if tokenResp.RefreshToken == "" { + tokenResp.RefreshToken = refreshToken + } + + // Save the token + if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to save token: %w", err) + } + + return &tokenResp, nil +} + +// RefreshToken is a public wrapper for refreshToken +func (h *OAuthHandler) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) { + return h.refreshToken(ctx, refreshToken) +} + +// GetClientID returns the client ID +func (h *OAuthHandler) GetClientID() string { + return h.config.ClientID +} + +// extractOAuthError attempts to parse an OAuth error response from the response body +func extractOAuthError(body []byte, statusCode int, context string) error { + // Try to parse the error as an OAuth error response + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return fmt.Errorf("%s: %w", context, oauthErr) + } + + // If not a valid OAuth error, return the raw response + return fmt.Errorf("%s with status %d: %s", context, statusCode, body) +} + +// SetProtectedResourceMetadataURL updates the protected resource metadata URL +// and resets the cached server metadata so it will be re-discovered on the next call. +// +// This setter does not validate the URL; callers that pass values obtained +// out of band are trusted. For values parsed from a 401 WWW-Authenticate +// header, prefer HandleUnauthorizedResponse, which applies origin +// validation before storing. +func (h *OAuthHandler) SetProtectedResourceMetadataURL(u string) { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + h.config.ProtectedResourceMetadataURL = u + h.serverMetadata = nil + h.metadataFetchErr = nil + // Install a fresh sync.Once so the next getServerMetadata call + // triggers re-discovery. Replacing the pointer (rather than mutating + // the pointed-to value) keeps this safe against concurrent Do calls + // that captured the previous instance. + h.metadataOnce = &sync.Once{} + h.resourceURL = "" +} + +// HandleUnauthorizedResponse inspects a 401 response for RFC 9728 §5.1 +// WWW-Authenticate challenges and, when one carries a resource_metadata +// parameter whose URL shares the protected resource's origin, stores it +// so subsequent metadata discovery can use it. It iterates every +// WWW-Authenticate header line (a response can carry multiple challenges +// — Basic, Bearer, etc. — each on its own line) and every +// resource_metadata parameter within each line, takes the first +// candidate that validates, and silently ignores headers that are +// absent, malformed, or advertise an unrelated origin. +// +// Origin validation rejects URLs whose scheme or host differs from the +// OAuth handler's configured base URL. This prevents a compromised or +// misconfigured resource from redirecting clients to an attacker's +// metadata endpoint. +// +// It is safe to call with a nil response. +func (h *OAuthHandler) HandleUnauthorizedResponse(resp *http.Response) { + if resp == nil { + return + } + for _, header := range resp.Header.Values("WWW-Authenticate") { + for _, candidate := range extractResourceMetadataURLs(header) { + if err := h.validateAdvertisedPRMURL(candidate); err != nil { + continue + } + h.SetProtectedResourceMetadataURL(candidate) + return + } + } +} + +// validateAdvertisedPRMURL enforces that a PRM URL advertised by the +// resource (i.e. parsed from an untrusted WWW-Authenticate header) shares +// the configured base URL's scheme and host. Returns a non-nil error when +// the candidate is unparseable, carries a different scheme or host, or +// when no base URL has been configured to validate against. +// +// RFC 9728 §3.2 requires the protected resource to serve its own metadata; +// this check rejects attempts to redirect discovery to an unrelated +// origin, which would otherwise let the resource point the client at an +// attacker-controlled OAuth metadata endpoint. +func (h *OAuthHandler) validateAdvertisedPRMURL(candidate string) error { + h.metadataMu.Lock() + baseURL := h.baseURL + h.metadataMu.Unlock() + if baseURL == "" { + return errors.New("no base URL configured for origin validation") + } + base, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("invalid base URL %q: %w", baseURL, err) + } + // url.Parse accepts relative references (empty Scheme and Host) + // without error. Reject those explicitly so two empty values do not + // EqualFold-match each other and bypass origin validation. + if base.Scheme == "" || base.Host == "" { + return fmt.Errorf("base URL %q is not absolute (missing scheme or host)", baseURL) + } + parsed, err := url.Parse(candidate) + if err != nil { + return fmt.Errorf("invalid advertised PRM URL %q: %w", candidate, err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("advertised PRM URL %q is not absolute (missing scheme or host)", candidate) + } + if !strings.EqualFold(parsed.Scheme, base.Scheme) { + return fmt.Errorf("advertised PRM URL scheme %q does not match base %q", parsed.Scheme, base.Scheme) + } + if !strings.EqualFold(parsed.Host, base.Host) { + return fmt.Errorf("advertised PRM URL host %q does not match base %q", parsed.Host, base.Host) + } + return nil +} + +// getResourceURL returns the RFC 8707 resource indicator under metadataMu. +func (h *OAuthHandler) getResourceURL() string { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + return h.resourceURL +} + +// GetClientSecret returns the client secret +func (h *OAuthHandler) GetClientSecret() string { + return h.config.ClientSecret +} + +// SetBaseURL sets the base URL for the API server. +// Must be called before any calls to getServerMetadata (i.e., during initialization). +func (h *OAuthHandler) SetBaseURL(baseURL string) { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + h.baseURL = baseURL +} + +// GetExpectedState returns the expected state value (for testing purposes) +func (h *OAuthHandler) GetExpectedState() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.expectedState +} + +// SetExpectedState sets the expected state value. +// +// This can be useful if you cannot maintain an OAuthHandler +// instance throughout the authentication flow; for example, if +// the initialization and callback steps are handled in different +// requests. +// +// In such cases, this should be called with the state value generated +// during the initial authentication request (e.g. by GenerateState) +// and included in the authorization URL. +func (h *OAuthHandler) SetExpectedState(expectedState string) { + h.mu.Lock() + defer h.mu.Unlock() + h.expectedState = expectedState +} + +// OAuthError represents a standard OAuth 2.0 error response +type OAuthError struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} + +// Error implements the error interface +func (e OAuthError) Error() string { + if e.ErrorDescription != "" { + return fmt.Sprintf("OAuth error: %s - %s", e.ErrorCode, e.ErrorDescription) + } + return fmt.Sprintf("OAuth error: %s", e.ErrorCode) +} + +// OAuthProtectedResource represents the response from /.well-known/oauth-protected-resource +type OAuthProtectedResource struct { + AuthorizationServers []string `json:"authorization_servers"` + Resource string `json:"resource"` + ResourceName string `json:"resource_name,omitempty"` +} + +// metadataDiscoveryResult bundles the outputs of a single OAuth server +// metadata discovery attempt so that fetchServerMetadata can return them +// to getServerMetadata for publication under metadataMu, instead of +// mutating shared state directly while holding the lock across HTTP I/O. +type metadataDiscoveryResult struct { + metadata *AuthServerMetadata + resourceURL string +} + +// getServerMetadata fetches the OAuth server metadata. +// +// Discovery is gated by metadataOnce so the network round trips happen +// exactly once per OAuthHandler. metadataMu is taken only to snapshot +// configuration inputs before the fetch and to publish the result +// afterwards; it is intentionally NOT held while HTTP requests are in +// flight so that concurrent callers of other metadataMu-guarded methods +// (e.g. SetProtectedResourceMetadataURL, getResourceURL, +// validateAdvertisedPRMURL) are not blocked on network I/O. See #871. +func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetadata, error) { + // Snapshot (and lazily initialize) the current sync.Once under the + // lock. Using a pointer means SetProtectedResourceMetadataURL can + // install a fresh once without racing with a Do already in flight on + // the previous instance. + h.metadataMu.Lock() + if h.metadataOnce == nil { + h.metadataOnce = &sync.Once{} + } + once := h.metadataOnce + h.metadataMu.Unlock() + + once.Do(func() { + result, err := h.fetchServerMetadata(ctx) + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + // If SetProtectedResourceMetadataURL swapped in a fresh sync.Once + // while the fetch was in flight, the configuration that produced + // `result` is now stale. Discard the result instead of letting it + // clobber the newer state; the next getServerMetadata call will + // drive re-discovery through the new once. + if h.metadataOnce != once { + return + } + if err != nil { + h.metadataFetchErr = err + return + } + // Only overwrite serverMetadata on a positive result. Preserving the + // existing value on a no-op fetch (nil metadata, nil error) matches + // the prior behavior where fetchMetadataFromURL silently returned on + // non-200 responses without touching shared state. + if result.metadata != nil { + h.serverMetadata = result.metadata + h.metadataFetchErr = nil + } + if result.resourceURL != "" { + h.resourceURL = result.resourceURL + } + // Discovery completed without producing metadata or an error (e.g. a + // non-2xx response handled by fetchMetadataFromURL). Record an explicit + // error — including the discovery target — so callers receive a + // consistent cached failure instead of a nil *AuthServerMetadata, which + // they would dereference and panic on. Skip when serverMetadata is + // already populated: a no-op re-discovery preserves the prior value. + if h.serverMetadata == nil && h.metadataFetchErr == nil { + if h.config.AuthServerMetadataURL != "" { + h.metadataFetchErr = fmt.Errorf("authorization server metadata unavailable: discovery at %q returned no metadata", h.config.AuthServerMetadataURL) + } else { + h.metadataFetchErr = errors.New("authorization server metadata unavailable: auto-discovery from the server base URL returned no metadata") + } + } + }) + + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + if h.metadataFetchErr != nil { + return nil, h.metadataFetchErr + } + // Defensive backstop: a concurrent SetProtectedResourceMetadataURL may have + // reset state after our once ran. Never return (nil, nil) — every caller + // dereferences the returned metadata. + if h.serverMetadata == nil { + return nil, errors.New("authorization server metadata unavailable") + } + return h.serverMetadata, nil +} + +// fetchServerMetadata performs OAuth server metadata discovery without +// holding metadataMu, so concurrent callers reading other state guarded +// by the mutex are not blocked on network I/O. Configuration inputs are +// snapshotted under the lock before the first request is issued; the +// caller (getServerMetadata) is responsible for publishing the returned +// result back into the handler under metadataMu. +func (h *OAuthHandler) fetchServerMetadata(ctx context.Context) (metadataDiscoveryResult, error) { + // Snapshot config and base URL under the lock; release before any HTTP I/O. + h.metadataMu.Lock() + authServerMetadataURL := h.config.AuthServerMetadataURL + protectedResourceMetadataURL := h.config.ProtectedResourceMetadataURL + baseURL, baseURLErr := h.extractBaseURL() + h.metadataMu.Unlock() + + // If AuthServerMetadataURL is explicitly provided, use it directly + if authServerMetadataURL != "" { + metadata, err := h.fetchMetadataFromURL(ctx, authServerMetadataURL) + if err != nil { + return metadataDiscoveryResult{}, err + } + return metadataDiscoveryResult{metadata: metadata}, nil + } + + // Always extract base URL for fallback scenarios + if baseURLErr != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to extract base URL: %w", baseURLErr) + } + + // Determine the protected resource metadata URL with priority: + // 1. Explicit config (ProtectedResourceMetadataURL from RFC9728 WWW-Authenticate header) + // 2. Constructed from base URL + var protectedResourceURL string + explicitMetadataURL := protectedResourceMetadataURL != "" + if explicitMetadataURL { + protectedResourceURL = protectedResourceMetadataURL + } else { + var err error + protectedResourceURL, err = buildWellKnownURL(baseURL, "oauth-protected-resource") + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to build protected resource URL: %w", err) + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to create protected resource request: %w", err) + } + + req.Header.Set("Accept", "application/json") + // Advertise the latest protocol version this client supports. Metadata + // discovery happens before the MCP initialize handshake, so no negotiated + // version is available yet; servers that don't recognise it can fall back + // per the MCP spec. + req.Header.Set("MCP-Protocol-Version", mcp.LATEST_PROTOCOL_VERSION) + + resp, err := h.httpClient.Do(req) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to send protected resource request: %w", err) + } + defer resp.Body.Close() + + // If we can't get the protected resource metadata, try OAuth Authorization Server discovery. + // However, if the resource_metadata URL was explicitly provided (via RFC 9728), don't + // fall back to baseURL-derived discovery — the server specifically indicated where to + // find metadata, so falling back would mask the signal. + if resp.StatusCode != http.StatusOK { + if explicitMetadataURL { + return metadataDiscoveryResult{}, fmt.Errorf("protected resource metadata discovery failed for explicit URL %q: status %d", protectedResourceURL, resp.StatusCode) + } + for _, u := range authorizationServerMetadataURLs(baseURL) { + // Intermediate fetch errors are intentionally discarded so the caller + // can fall through to the next candidate URL, mirroring the prior + // behavior where metadataFetchErr was cleared on the first success. + if metadata, _ := h.fetchMetadataFromURL(ctx, u); metadata != nil { + return metadataDiscoveryResult{metadata: metadata}, nil + } + } + // If that also fails, fall back to default endpoints + metadata, err := h.getDefaultEndpoints(baseURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata}, nil + } + + // Parse the protected resource metadata + var protectedResource OAuthProtectedResource + if err := json.NewDecoder(resp.Body).Decode(&protectedResource); err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to decode protected resource response: %w", err) + } + + // RFC 9728 §3.3/§7.3: when metadata is fetched from a PRM URL the + // server advertised via WWW-Authenticate (an untrusted network + // input), the declared resource identifier MUST match the + // protected resource the client addressed — otherwise the + // response MUST NOT be used. An advertised PRM response that + // omits the resource field is also rejected: since the PRM + // endpoint may not share an origin with the protected resource, + // the response cannot be implicitly trusted without an explicit + // binding. + // + // The check is scoped to the advertised path because the + // well-known origin-constructed path is already bound to the + // protected resource by same-origin URL construction. + if explicitMetadataURL { + if protectedResource.Resource == "" { + return metadataDiscoveryResult{}, fmt.Errorf( + "advertised protected resource metadata from %q omits required resource field", + protectedResourceURL, + ) + } + if !resourceIdentifiersEqual(protectedResource.Resource, baseURL) { + return metadataDiscoveryResult{}, fmt.Errorf( + "advertised protected resource metadata declares resource %q which does not match base URL %q", + protectedResource.Resource, baseURL, + ) + } + } + + // RFC 8707: Capture the resource identifier for use in authorization requests. + // If not provided in metadata, fall back to base URL per RFC 8707 Section 2: + // "The client SHOULD use the base URI of the API as the resource parameter value + // unless specific knowledge of the resource dictates otherwise." + resourceURL := protectedResource.Resource + if resourceURL == "" { + resourceURL = baseURL + } + + // If no authorization servers are specified, fall back to default endpoints + if len(protectedResource.AuthorizationServers) == 0 { + metadata, err := h.getDefaultEndpoints(baseURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil + } + + // Use the first authorization server + authServerURL := protectedResource.AuthorizationServers[0] + + // Try the MCP-specified discovery URLs in order (RFC 8414 with path + // insertion, plus OpenID Connect Discovery variants). + for _, u := range authorizationServerMetadataURLs(authServerURL) { + // Intermediate fetch errors are intentionally discarded so the caller + // can fall through to the next candidate URL. + if metadata, _ := h.fetchMetadataFromURL(ctx, u); metadata != nil { + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil + } + } + + // If both discovery methods fail, use default endpoints based on the authorization server URL + metadata, err := h.getDefaultEndpoints(authServerURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil +} + +// buildWellKnownURL constructs a well-known discovery URL by inserting the +// given suffix between the authority and path of baseURL (RFC 8414 §3 / +// RFC 9728 path-insertion semantics). +func buildWellKnownURL(baseURL string, suffix string) (string, error) { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse base URL: %w", err) + } + + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return "", fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL) + } + + path := strings.TrimSuffix(parsedURL.EscapedPath(), "/") + root := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + if path == "" || path == "/" { + return root + "/.well-known/" + suffix, nil + } + + return root + "/.well-known/" + suffix + path, nil +} + +// resourceIdentifiersEqual reports whether two OAuth protected resource +// identifiers refer to the same resource for the purposes of RFC 9728 §3.3 +// equality checks. Scheme and host are compared case-insensitively per +// RFC 3986 §3.1 / §3.2.2, and a single trailing slash on either path is +// ignored because real-world OAuth deployments routinely emit the +// resource with or without it for the same URL; rejecting that variant +// would produce false positives on legitimate servers. Query, fragment, +// and userinfo components are significant. Unparseable inputs fall back +// to exact string equality. +func resourceIdentifiersEqual(a, b string) bool { + ua, errA := url.Parse(a) + ub, errB := url.Parse(b) + if errA != nil || errB != nil { + return a == b + } + if !strings.EqualFold(ua.Scheme, ub.Scheme) { + return false + } + if !strings.EqualFold(ua.Host, ub.Host) { + return false + } + // Use EscapedPath rather than Path so percent-encoded reserved + // characters stay distinct from their decoded forms (e.g. "a%2Fb" + // must not compare equal to "a/b"), preserving RFC 3986 segment + // semantics. + if strings.TrimSuffix(ua.EscapedPath(), "/") != strings.TrimSuffix(ub.EscapedPath(), "/") { + return false + } + if ua.RawQuery != ub.RawQuery { + return false + } + if ua.Fragment != ub.Fragment { + return false + } + return ua.User.String() == ub.User.String() +} + +// fetchMetadataFromURL fetches and parses OAuth server metadata from a URL. +// Returns (nil, nil) when the server responds with a non-200 status so the +// caller can fall through to the next candidate URL. Network, decode, and +// validation failures are returned to the caller. +// +// This function performs HTTP I/O and must not be called while holding +// metadataMu (see #871). +func (h *OAuthHandler) fetchMetadataFromURL(ctx context.Context, metadataURL string) (*AuthServerMetadata, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create metadata request: %w", err) + } + + req.Header.Set("Accept", "application/json") + // Advertise the latest protocol version this client supports. Metadata + // discovery happens before the MCP initialize handshake, so no negotiated + // version is available yet; servers that don't recognise it can fall back + // per the MCP spec. + req.Header.Set("MCP-Protocol-Version", mcp.LATEST_PROTOCOL_VERSION) + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send metadata request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil + } + + var metadata AuthServerMetadata + dec := json.NewDecoder(io.LimitReader(resp.Body, maxMetadataBodyBytes)) + if err := dec.Decode(&metadata); err != nil { + return nil, fmt.Errorf("failed to decode metadata response: %w", err) + } + + if err := validateAuthServerMetadataURLs(&metadata); err != nil { + return nil, fmt.Errorf("invalid authorization server metadata from %s: %w", metadataURL, err) + } + + return &metadata, nil +} + +// validateAuthServerMetadataURLs ensures every URL-bearing field in m uses an +// http or https scheme. This prevents a hostile authorization server from +// advertising values like "javascript:..." or "file:..." that could be +// reflected into a browser or the local file system by downstream consumers. +// Empty optional fields are permitted. +func validateAuthServerMetadataURLs(m *AuthServerMetadata) error { + fields := []struct { + name string + value string + }{ + {"issuer", m.Issuer}, + {"authorization_endpoint", m.AuthorizationEndpoint}, + {"token_endpoint", m.TokenEndpoint}, + {"jwks_uri", m.JwksURI}, + {"registration_endpoint", m.RegistrationEndpoint}, + {"service_documentation", m.ServiceDocumentation}, + {"op_policy_uri", m.OpPolicyURI}, + {"op_tos_uri", m.OpTOSURI}, + {"revocation_endpoint", m.RevocationEndpoint}, + {"introspection_endpoint", m.IntrospectionEndpoint}, + } + for _, f := range fields { + if f.value == "" { + continue + } + u, err := url.Parse(f.value) + if err != nil { + return fmt.Errorf("%s: %w", f.name, err) + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("%s has disallowed scheme %q", f.name, u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("%s is missing host", f.name) + } + } + return nil +} + +// authorizationServerMetadataURLs returns the ordered list of discovery URLs to +// try for a given issuer URL, following the MCP authorization spec. This +// implements RFC 8414 §3 path-insertion semantics (the well-known segment is +// inserted between the authority and the issuer path, not appended), plus +// OpenID Connect Discovery 1.0 fallbacks. +func authorizationServerMetadataURLs(issuerURL string) []string { + u, err := url.Parse(issuerURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil + } + u.RawQuery = "" + u.Fragment = "" + + originalPath := strings.Trim(u.Path, "/") + + var urls []string + if originalPath == "" { + u.Path = "/.well-known/oauth-authorization-server" + urls = append(urls, u.String()) + u.Path = "/.well-known/openid-configuration" + urls = append(urls, u.String()) + return urls + } + u.Path = "/.well-known/oauth-authorization-server/" + originalPath + urls = append(urls, u.String()) + u.Path = "/.well-known/openid-configuration/" + originalPath + urls = append(urls, u.String()) + u.Path = "/" + originalPath + "/.well-known/openid-configuration" + urls = append(urls, u.String()) + return urls +} + +// extractBaseURL extracts the base URL from the first request +func (h *OAuthHandler) extractBaseURL() (string, error) { + // If we have a base URL from a previous request, use it + if h.baseURL != "" { + return h.baseURL, nil + } + + // Otherwise, we need to infer it from the redirect URI + if h.config.RedirectURI == "" { + return "", fmt.Errorf("no base URL available and no redirect URI provided") + } + + // Parse the redirect URI to extract the authority + parsedURL, err := url.Parse(h.config.RedirectURI) + if err != nil { + return "", fmt.Errorf("failed to parse redirect URI: %w", err) + } + + // Use the scheme and host from the redirect URI + baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + return baseURL, nil +} + +// GetServerMetadata is a public wrapper for getServerMetadata +func (h *OAuthHandler) GetServerMetadata(ctx context.Context) (*AuthServerMetadata, error) { + return h.getServerMetadata(ctx) +} + +// getDefaultEndpoints returns default OAuth endpoints based on the base URL +func (h *OAuthHandler) getDefaultEndpoints(baseURL string) (*AuthServerMetadata, error) { + // Parse the base URL to extract the authority + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse base URL: %w", err) + } + + // Discard any path component to get the authorization base URL + parsedURL.Path = "" + authBaseURL := parsedURL.String() + + // Validate that the URL has a scheme and host + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL) + } + + return &AuthServerMetadata{ + Issuer: authBaseURL, + AuthorizationEndpoint: authBaseURL + "/authorize", + TokenEndpoint: authBaseURL + "/token", + RegistrationEndpoint: authBaseURL + "/register", + }, nil +} + +// RegisterClient performs dynamic client registration +func (h *OAuthHandler) RegisterClient(ctx context.Context, clientName string) error { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return fmt.Errorf("failed to get server metadata: %w", err) + } + + if metadata.RegistrationEndpoint == "" { + return errors.New("server does not support dynamic client registration") + } + + // Prepare registration request + regRequest := map[string]any{ + "client_name": clientName, + "redirect_uris": []string{h.config.RedirectURI}, + "token_endpoint_auth_method": "none", // For public clients + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": strings.Join(h.config.Scopes, " "), + } + + if h.config.ClientURI != "" { + regRequest["client_uri"] = h.config.ClientURI + } + + // Add client_secret if this is a confidential client + if h.config.ClientSecret != "" { + regRequest["token_endpoint_auth_method"] = "client_secret_post" + } + + // RFC 8707: Include resource parameter in client registration + if resourceURL := h.getResourceURL(); resourceURL != "" { + regRequest["resource"] = resourceURL + } + + reqBody, err := json.Marshal(regRequest) + if err != nil { + return fmt.Errorf("failed to marshal registration request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.RegistrationEndpoint, + bytes.NewReader(reqBody), + ) + if err != nil { + return fmt.Errorf("failed to create registration request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send registration request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return extractOAuthError(body, resp.StatusCode, "registration request failed") + } + + var regResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + } + + if err := json.NewDecoder(resp.Body).Decode(®Response); err != nil { + return fmt.Errorf("failed to decode registration response: %w", err) + } + + // Update the client configuration + h.config.ClientID = regResponse.ClientID + if regResponse.ClientSecret != "" { + h.config.ClientSecret = regResponse.ClientSecret + } + + return nil +} + +// ErrInvalidState is returned when the state parameter doesn't match the expected value +var ErrInvalidState = errors.New("invalid state parameter, possible CSRF attack") + +// ProcessAuthorizationResponse processes the authorization response and exchanges the code for a token +func (h *OAuthHandler) ProcessAuthorizationResponse(ctx context.Context, code, state, codeVerifier string) error { + // Validate the state parameter to prevent CSRF attacks + h.mu.Lock() + expectedState := h.expectedState + if expectedState == "" { + h.mu.Unlock() + return errors.New("no expected state found, authorization flow may not have been initiated properly") + } + + if state != expectedState { + h.mu.Unlock() + return ErrInvalidState + } + + // Clear the expected state after validation + h.expectedState = "" + h.mu.Unlock() + + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return fmt.Errorf("failed to get server metadata: %w", err) + } + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", h.config.ClientID) + data.Set("redirect_uri", h.config.RedirectURI) + + if h.config.ClientSecret != "" { + data.Set("client_secret", h.config.ClientSecret) + } + + if h.config.PKCEEnabled && codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + // RFC 8707: Include resource parameter in token exchange + if resourceURL := h.getResourceURL(); resourceURL != "" { + data.Set("resource", resourceURL) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.TokenEndpoint, + strings.NewReader(data.Encode()), + ) + if err != nil { + return fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send token request: %w", err) + } + defer resp.Body.Close() + + // Accept any 2xx status; some authorization servers (e.g. Supabase) return + // 201 Created for successful token responses. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return extractOAuthError(body, resp.StatusCode, "token request failed") + } + + // Read the response body for parsing + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read token response body: %w", err) + } + + // GitHub returns HTTP 200 even for errors, with error details in the JSON body + // Check if the response contains an error field before parsing as Token + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return fmt.Errorf("token request failed: %w", oauthErr) + } + + var tokenResp Token + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + // Set expiration time + if tokenResp.ExpiresIn > 0 { + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + + // Save the token + if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil { + return fmt.Errorf("failed to save token: %w", err) + } + + return nil +} + +// GetAuthorizationURL returns the URL for the authorization endpoint +func (h *OAuthHandler) GetAuthorizationURL(ctx context.Context, state, codeChallenge string) (string, error) { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return "", fmt.Errorf("failed to get server metadata: %w", err) + } + + // Store the state for later validation + h.SetExpectedState(state) + + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", h.config.ClientID) + params.Set("redirect_uri", h.config.RedirectURI) + params.Set("state", state) + + if len(h.config.Scopes) > 0 { + params.Set("scope", strings.Join(h.config.Scopes, " ")) + } + + if h.config.PKCEEnabled && codeChallenge != "" { + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + } + + // RFC 8707: Include resource parameter in authorization URL + if resourceURL := h.getResourceURL(); resourceURL != "" { + params.Set("resource", resourceURL) + } + + return metadata.AuthorizationEndpoint + "?" + params.Encode(), nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go new file mode 100644 index 000000000..d87525a65 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go @@ -0,0 +1,68 @@ +package transport + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/url" +) + +// GenerateRandomString generates a random string of the specified length +func GenerateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil +} + +// GenerateCodeVerifier generates a code verifier for PKCE +func GenerateCodeVerifier() (string, error) { + // According to RFC 7636, the code verifier should be between 43 and 128 characters + return GenerateRandomString(64) +} + +// GenerateCodeChallenge generates a code challenge from a code verifier +func GenerateCodeChallenge(codeVerifier string) string { + // SHA256 hash the code verifier + hash := sha256.Sum256([]byte(codeVerifier)) + // Base64url encode the hash + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// GenerateState generates a state parameter for OAuth +func GenerateState() (string, error) { + return GenerateRandomString(32) +} + +// ValidateRedirectURI validates that a redirect URI is secure +func ValidateRedirectURI(redirectURI string) error { + // According to the spec, redirect URIs must be either localhost URLs or HTTPS URLs + if redirectURI == "" { + return fmt.Errorf("redirect URI cannot be empty") + } + + // Parse the URL + parsedURL, err := url.Parse(redirectURI) + if err != nil { + return fmt.Errorf("invalid redirect URI: %w", err) + } + + // Check if it's a localhost URL + if parsedURL.Scheme == "http" { + hostname := parsedURL.Hostname() + // Check for various forms of localhost + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" || hostname == "[::1]" { + return nil + } + return fmt.Errorf("HTTP redirect URI must use localhost or 127.0.0.1") + } + + // Check if it's an HTTPS URL + if parsedURL.Scheme == "https" { + return nil + } + + return fmt.Errorf("redirect URI must use either HTTP with localhost or HTTPS") +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go b/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go new file mode 100644 index 000000000..ec30fe23e --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go @@ -0,0 +1,767 @@ +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// SSE implements the transport layer of the MCP protocol using Server-Sent Events (SSE). +// It maintains a persistent HTTP connection to receive server-pushed events +// while sending requests over regular HTTP POST calls. The client handles +// automatic reconnection and message routing between requests and responses. +type SSE struct { + baseURL *url.URL + endpoint *url.URL + httpClient *http.Client + responses map[string]chan *JSONRPCResponse + mu sync.RWMutex + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + endpointChan chan struct{} + headers map[string]string + headerFunc HTTPHeaderFunc + host string + logger *slog.Logger + + started atomic.Bool + closed atomic.Bool + cancelSSEStream context.CancelFunc + protocolVersion atomic.Value // string + onConnectionLost func(error) + connectionLostMu sync.RWMutex + + endpointTimeout time.Duration + responseTimeout time.Duration + + // OAuth support + oauthHandler *OAuthHandler +} + +// ClientOption configures an SSE transport client. +type ClientOption func(*SSE) + +// WithSSELogger sets a custom structured logger for the SSE client. +// A nil logger falls back to slog.Default(). +func WithSSELogger(logger *slog.Logger) ClientOption { + return func(sc *SSE) { + if logger == nil { + sc.logger = slog.Default() + return + } + sc.logger = logger + } +} + +// WithHeaders sets static headers for SSE HTTP requests. +func WithHeaders(headers map[string]string) ClientOption { + return func(sc *SSE) { + sc.headers = headers + } +} + +// WithHeaderFunc sets a function that returns headers for SSE HTTP requests. +func WithHeaderFunc(headerFunc HTTPHeaderFunc) ClientOption { + return func(sc *SSE) { + sc.headerFunc = headerFunc + } +} + +// WithHTTPClient sets a custom HTTP client for the SSE transport. +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(sc *SSE) { + sc.httpClient = httpClient + } +} + +// WithOAuth enables OAuth authentication for the SSE transport. +func WithOAuth(config OAuthConfig) ClientOption { + return func(sc *SSE) { + sc.oauthHandler = NewOAuthHandler(config) + } +} + +// WithEndpointTimeout sets the maximum time to wait for the SSE endpoint to be +// received during Start(). Defaults to 30 seconds. If the context has a shorter +// deadline, the shorter value is used. +func WithEndpointTimeout(d time.Duration) ClientOption { + return func(sc *SSE) { + if d > 0 { + sc.endpointTimeout = d + } + } +} + +// WithResponseTimeout sets the maximum time to wait for an SSE response after +// sending a request. Defaults to 60 seconds. If the context has a shorter +// deadline, the shorter value is used. +func WithResponseTimeout(d time.Duration) ClientOption { + return func(sc *SSE) { + if d > 0 { + sc.responseTimeout = d + } + } +} + +// WithHTTPHost sets a custom Host header for the SSE client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/sse" but sending Host: "api.example.com" +func WithHTTPHost(host string) ClientOption { + return func(sc *SSE) { + sc.host = host + } +} + +// NewSSE creates a new SSE-based MCP client with the given base URL. +// Returns an error if the URL is invalid. +func NewSSE(baseURL string, options ...ClientOption) (*SSE, error) { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + smc := &SSE{ + baseURL: parsedURL, + httpClient: &http.Client{}, + responses: make(map[string]chan *JSONRPCResponse), + endpointChan: make(chan struct{}), + headers: make(map[string]string), + logger: slog.Default(), + endpointTimeout: 30 * time.Second, + responseTimeout: 60 * time.Second, + } + + for _, opt := range options { + opt(smc) + } + + // If OAuth is configured, set the base URL for metadata discovery + if smc.oauthHandler != nil { + discoveryURL := *parsedURL + discoveryURL.RawQuery = "" + discoveryURL.Fragment = "" + baseURL := discoveryURL.String() + smc.oauthHandler.SetBaseURL(baseURL) + } + + return smc, nil +} + +// Start initiates the SSE connection to the server and waits for the endpoint information. +// Returns an error if the connection fails or times out waiting for the endpoint. +func (c *SSE) Start(ctx context.Context) error { + if c.started.Load() { + return nil + } + + ctx, cancel := context.WithCancel(ctx) + c.cancelSSEStream = cancel + + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Connection", "keep-alive") + + // set custom http headers + for k, v := range c.headers { + req.Header.Set(k, v) + } + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to connect to SSE stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + go c.readSSE(resp.Body) + + // Wait for the endpoint to be received + endpointTimeout := c.endpointTimeout + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + // If context deadline has already passed, return immediately + if remaining <= 0 { + cancel() + return ctx.Err() + } + // Use the shorter of remaining time or default timeout + if remaining < endpointTimeout { + endpointTimeout = remaining + } + } + + timer := time.NewTimer(endpointTimeout) + defer timer.Stop() + + select { + case <-c.endpointChan: + // Endpoint received, proceed + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for endpoint: %w", ctx.Err()) + case <-timer.C: + cancel() + return fmt.Errorf("timeout waiting for endpoint after %v", endpointTimeout) + } + + c.started.Store(true) + return nil +} + +// readSSE continuously reads the SSE stream and processes events. +// It runs until the connection is closed or an error occurs. +func (c *SSE) readSSE(reader io.ReadCloser) { + defer reader.Close() + + br := bufio.NewReader(reader) + var event, data string + + for { + // when close or start's ctx cancel, the reader will be closed + // and the for loop will break. + line, err := br.ReadString('\n') + if err != nil { + if err == io.EOF { + // Process any pending event before exit + if data != "" { + // If no event type is specified, use empty string (default event type) + if event == "" { + event = "message" + } + c.handleSSEEvent(event, data) + } + } + c.connectionLostMu.RLock() + handler := c.onConnectionLost + c.connectionLostMu.RUnlock() + if handler != nil { + // Notify that the connection will be closed due to an error + handler(err) + } else if err == io.EOF && !c.closed.Load() { + c.logger.Error("SSE stream error", "err", err) + } + return + } + + // Remove only newline markers + line = strings.TrimRight(line, "\r\n") + if line == "" { + // Empty line means end of event + if data != "" { + // If no event type is specified, use empty string (default event type) + if event == "" { + event = "message" + } + c.handleSSEEvent(event, data) + event = "" + data = "" + } + continue + } + + if after, ok := strings.CutPrefix(line, "event:"); ok { + event = strings.TrimSpace(after) + } else if after, ok := strings.CutPrefix(line, "data:"); ok { + data = appendSSEData(data, after) + } + } +} + +// appendSSEData joins the value of an SSE "data:" field onto any data already +// accumulated for the current event. Per the Server-Sent Events specification, +// a single event may carry multiple "data:" lines and they must be +// concatenated with a newline. The previous implementation overwrote the +// buffer on every line, silently truncating multi-line payloads to their final +// line and corrupting JSON-RPC messages that span more than one data line. +// +// Only a single optional leading space after "data:" is removed, as the spec +// requires; any other leading or trailing whitespace in the value is +// significant and preserved. +func appendSSEData(existing, line string) string { + value := strings.TrimPrefix(line, " ") + if existing == "" { + return value + } + return existing + "\n" + value +} + +// handleSSEEvent processes SSE events based on their type. +// Handles 'endpoint' events for connection setup and 'message' events for JSON-RPC communication. +func (c *SSE) handleSSEEvent(event, data string) { + switch event { + case "endpoint": + endpoint, err := c.baseURL.Parse(data) + if err != nil { + c.logger.Error("Error parsing endpoint URL", "err", err) + return + } + if endpoint.Host != c.baseURL.Host { + c.logger.Error("Endpoint origin does not match connection origin") + return + } + c.endpoint = endpoint + close(c.endpointChan) + + case "message": + var baseMessage JSONRPCResponse + if err := json.Unmarshal([]byte(data), &baseMessage); err != nil { + c.logger.Error("Error unmarshaling message", "err", err) + return + } + + // Handle notification + if baseMessage.ID.IsNil() { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(data), ¬ification); err != nil { + return + } + c.notifyMu.RLock() + if c.onNotification != nil { + c.onNotification(notification) + } + c.notifyMu.RUnlock() + return + } + + // Create string key for map lookup + idKey := baseMessage.ID.String() + + c.mu.RLock() + ch, exists := c.responses[idKey] + c.mu.RUnlock() + + if exists { + ch <- &baseMessage + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + } +} + +// SetNotificationHandler sets the handler for incoming JSON-RPC notifications. +func (c *SSE) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +// SetConnectionLostHandler sets the handler called when the SSE connection is lost. +func (c *SSE) SetConnectionLostHandler(handler func(error)) { + c.connectionLostMu.Lock() + defer c.connectionLostMu.Unlock() + c.onConnectionLost = handler +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *SSE) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + if !c.started.Load() { + return nil, fmt.Errorf("transport not started yet") + } + if c.closed.Load() { + return nil, fmt.Errorf("transport has been closed") + } + if c.endpoint == nil { + return nil, fmt.Errorf("endpoint not received") + } + + // Marshal request + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint.String(), bytes.NewReader(requestBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + + for k, v := range request.Header { + if _, ok := req.Header[k]; !ok { + req.Header[k] = v + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return nil, fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Create string key for map lookup + idKey := request.ID.String() + + // Register response channel + responseChan := make(chan *JSONRPCResponse, 1) + c.mu.Lock() + c.responses[idKey] = responseChan + c.mu.Unlock() + deleteResponseChan := func() { + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Drain any outstanding io + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Check if we got an error response + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + deleteResponseChan() + + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + // Calculate response timeout + responseTimeout := c.responseTimeout + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + // Check if context deadline has already passed + if remaining <= 0 { + deleteResponseChan() + return nil, ctx.Err() + } + // Use the shorter of remaining time or default timeout + if remaining < responseTimeout { + responseTimeout = remaining + } + } + + timer := time.NewTimer(responseTimeout) + defer timer.Stop() + + select { + case <-ctx.Done(): + deleteResponseChan() + return nil, ctx.Err() + case <-timer.C: + // Timeout handling + deleteResponseChan() + return nil, fmt.Errorf("timeout waiting for SSE response after %v", responseTimeout) + case response, ok := <-responseChan: + if ok { + return response, nil + } + return nil, fmt.Errorf("connection has been closed") + } +} + +// Close shuts down the SSE client connection and cleans up any pending responses. +// Returns an error if the shutdown process fails. +func (c *SSE) Close() error { + if !c.closed.CompareAndSwap(false, true) { + return nil // Already closed + } + + if c.cancelSSEStream != nil { + // It could stop the sse stream body, to quit the readSSE loop immediately + // Also, it could quit start() immediately if not receiving the endpoint + c.cancelSSEStream() + } + + // Clean up any pending responses + c.mu.Lock() + for _, ch := range c.responses { + close(ch) + } + c.responses = make(map[string]chan *JSONRPCResponse) + c.mu.Unlock() + + return nil +} + +// GetSessionId returns the session ID of the transport. +// Since SSE does not maintain a session ID, it returns an empty string. +func (c *SSE) GetSessionId() string { + return "" +} + +// SetProtocolVersion sets the negotiated protocol version for this connection. +func (c *SSE) SetProtocolVersion(version string) { + c.protocolVersion.Store(version) +} + +// SendNotification sends a JSON-RPC notification to the server without expecting a response. +func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + if c.endpoint == nil { + return fmt.Errorf("endpoint not received") + } + + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + "POST", + c.endpoint.String(), + bytes.NewReader(notificationBytes), + ) + if err != nil { + return fmt.Errorf("failed to create notification request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + // Set custom HTTP headers + for k, v := range c.headers { + req.Header.Set(k, v) + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + // Handle other error responses + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "notification failed with status %d: %s", + resp.StatusCode, + body, + ) + } + + return nil +} + +// GetEndpoint returns the current endpoint URL for the SSE connection. +func (c *SSE) GetEndpoint() *url.URL { + return c.endpoint +} + +// GetBaseURL returns the base URL set in the SSE constructor. +func (c *SSE) GetBaseURL() *url.URL { + return c.baseURL +} + +// GetOAuthHandler returns the OAuth handler if configured +func (c *SSE) GetOAuthHandler() *OAuthHandler { + return c.oauthHandler +} + +// IsOAuthEnabled returns true if OAuth is enabled +func (c *SSE) IsOAuthEnabled() bool { + return c.oauthHandler != nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go b/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go new file mode 100644 index 000000000..11268f83c --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go @@ -0,0 +1,592 @@ +package transport + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +var ( + // ErrTransportClosed is returned when attempting to send a request or notification + // to a transport that has already been closed. + ErrTransportClosed = errors.New("transport closed") + // ErrChildShutdownTimeout is returned when a stdio subprocess still has not exited + // after the forced shutdown path has been attempted. + ErrChildShutdownTimeout = errors.New("stdio child did not exit after forced shutdown") +) + +// Stdio implements the transport layer of the MCP protocol using stdio communication. +// It launches a subprocess and communicates with it via standard input/output streams +// using JSON-RPC messages. The client handles message routing between requests and +// responses, and supports asynchronous notifications. +type Stdio struct { + command string + args []string + env []string + + cmd *exec.Cmd + cmdFunc CommandFunc + stdin io.WriteCloser + stdinMu sync.Mutex + stdout *bufio.Reader + stderr io.ReadCloser + responses map[string]chan *JSONRPCResponse + mu sync.RWMutex + done chan struct{} + closeOnce sync.Once + closeCleanupOnce sync.Once + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + onRequest RequestHandler + requestMu sync.RWMutex + ctx context.Context + ctxMu sync.RWMutex + logger *slog.Logger + started bool + startedMu sync.Mutex +} + +const ( + gracefulShutdownTimeout = 2 * time.Second + forceKillTimeout = 3 * time.Second +) + +func waitForProcessExit(waitErrCh <-chan error, timeout time.Duration) (error, bool) { + select { + case err := <-waitErrCh: + return err, true + case <-time.After(timeout): + return nil, false + } +} + +// StdioOption defines a function that configures a Stdio transport instance. +// Options can be used to customize the behavior of the transport before it starts, +// such as setting a custom command function. +type StdioOption func(*Stdio) + +// CommandFunc is a factory function that returns a custom exec.Cmd used to launch the MCP subprocess. +// It can be used to apply sandboxing, custom environment control, working directories, etc. +type CommandFunc func(ctx context.Context, command string, env []string, args []string) (*exec.Cmd, error) + +// WithCommandFunc sets a custom command factory function for the stdio transport. +// The CommandFunc is responsible for constructing the exec.Cmd used to launch the subprocess, +// allowing control over attributes like environment, working directory, and system-level sandboxing. +func WithCommandFunc(f CommandFunc) StdioOption { + return func(s *Stdio) { + s.cmdFunc = f + } +} + +// WithCommandLogger sets a custom structured logger for the stdio transport. +// A nil logger falls back to slog.Default(). +func WithCommandLogger(logger *slog.Logger) StdioOption { + return func(s *Stdio) { + if logger == nil { + s.logger = slog.Default() + return + } + s.logger = logger + } +} + +// NewIO returns a new stdio-based transport using existing input, output, and +// logging streams instead of spawning a subprocess. +// This is useful for testing and simulating client behavior. +func NewIO(input io.Reader, output io.WriteCloser, logging io.ReadCloser) *Stdio { + return &Stdio{ + stdin: output, + stdout: bufio.NewReader(input), + stderr: logging, + + responses: make(map[string]chan *JSONRPCResponse), + done: make(chan struct{}), + ctx: context.Background(), + logger: slog.Default(), + } +} + +// NewStdio creates a new stdio transport to communicate with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +func NewStdio( + command string, + env []string, + args ...string, +) *Stdio { + return NewStdioWithOptions(command, env, args) +} + +// NewStdioWithOptions creates a new stdio transport to communicate with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +// Optional configuration functions can be provided to customize the transport before it starts, +// such as setting a custom command factory. +func NewStdioWithOptions( + command string, + env []string, + args []string, + opts ...StdioOption, +) *Stdio { + s := &Stdio{ + command: command, + args: args, + env: env, + + responses: make(map[string]chan *JSONRPCResponse), + done: make(chan struct{}), + ctx: context.Background(), + logger: slog.Default(), + } + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (c *Stdio) Start(ctx context.Context) error { + c.startedMu.Lock() + if c.started { + c.startedMu.Unlock() + return nil + } + c.started = true + c.startedMu.Unlock() + + // Store the context for use in request handling + c.ctxMu.Lock() + c.ctx = ctx + c.ctxMu.Unlock() + + if err := c.spawnCommand(ctx); err != nil { + c.startedMu.Lock() + c.started = false + c.startedMu.Unlock() + return err + } + + ready := make(chan struct{}) + go func() { + c.readResponses(ready) + }() + <-ready + + return nil +} + +// spawnCommand spawns a new process running the configured command, args, and env. +// If an (optional) cmdFunc custom command factory function was configured, it will be used to construct the subprocess; +// otherwise, the default behavior uses exec.CommandContext with the merged environment. +// Initializes stdin, stdout, and stderr pipes for JSON-RPC communication. +func (c *Stdio) spawnCommand(ctx context.Context) error { + if c.command == "" { + return nil + } + + var cmd *exec.Cmd + var err error + + // Standard behavior if no command func present. + if c.cmdFunc == nil { + cmd = exec.CommandContext(ctx, c.command, c.args...) + cmd.Env = append(os.Environ(), c.env...) + } else if cmd, err = c.cmdFunc(ctx, c.command, c.env, c.args); err != nil { + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + c.cmd = cmd + c.stdin = stdin + c.stderr = stderr + c.stdout = bufio.NewReader(stdout) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %w", err) + } + + return nil +} + +// closeDone safely closes the done channel exactly once, unblocking all +// in-flight SendRequest calls. Safe to call from multiple goroutines. +func (c *Stdio) closeDone() { + c.closeOnce.Do(func() { close(c.done) }) +} + +// Close shuts down the stdio client, closing the stdin pipe and waiting for the subprocess to exit. +// Returns an error if there are issues closing stdin or waiting for the subprocess to terminate. +// Safe to call multiple times and concurrently with readResponses calling closeDone(). +func (c *Stdio) Close() error { + // Signal all in-flight requests to unblock. + c.closeDone() + + // Perform resource cleanup exactly once, even if readResponses already + // called closeDone() (e.g. server died). Without this, the old early-return + // guard would skip stdin/stderr cleanup and cmd.Wait(), causing FD leaks + // and zombie processes. + var closeErr error + c.closeCleanupOnce.Do(func() { + if c.stdin != nil { + if err := c.stdin.Close(); err != nil { + closeErr = fmt.Errorf("failed to close stdin: %w", err) + } + } + if c.stderr != nil { + if err := c.stderr.Close(); err != nil && closeErr == nil { + closeErr = fmt.Errorf("failed to close stderr: %w", err) + } + } + if c.cmd != nil { + waitErrCh := make(chan error, 1) + go func() { + waitErrCh <- c.cmd.Wait() + }() + + if err, done := waitForProcessExit(waitErrCh, gracefulShutdownTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + return + } + + if c.cmd.Process != nil { + _ = c.cmd.Process.Signal(syscall.SIGTERM) + } + + if err, done := waitForProcessExit(waitErrCh, forceKillTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + return + } + + if c.cmd.Process != nil { + if err := c.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) && closeErr == nil { + closeErr = fmt.Errorf("failed to kill process: %w", err) + } + } + + if err, done := waitForProcessExit(waitErrCh, forceKillTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + } else if closeErr == nil { + closeErr = ErrChildShutdownTimeout + } + } + }) + return closeErr +} + +// GetSessionId returns the session ID of the transport. +// Since stdio does not maintain a session ID, it returns an empty string. +func (c *Stdio) GetSessionId() string { + return "" +} + +// SetNotificationHandler sets the handler function to be called when a notification is received. +// Only one handler can be set at a time; setting a new one replaces the previous handler. +func (c *Stdio) SetNotificationHandler( + handler func(notification mcp.JSONRPCNotification), +) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +// SetRequestHandler sets the handler function to be called when a request is received from the server. +// This enables bidirectional communication for features like sampling. +func (c *Stdio) SetRequestHandler(handler RequestHandler) { + c.requestMu.Lock() + defer c.requestMu.Unlock() + c.onRequest = handler +} + +// readResponses continuously reads and processes responses from the server's stdout. +// It handles both responses to requests and notifications, routing them appropriately. +// Runs until the done channel is closed or an error occurs reading from stdout. +// The ready channel, if non-nil, is closed once the read loop is entered, signaling +// to Start() that the transport is actively processing responses. +func (c *Stdio) readResponses(ready chan struct{}) { + for { + // Signal readiness on the first iteration, inside the loop, so that + // Start() only unblocks after the reader is actively processing. + if ready != nil { + close(ready) + ready = nil + } + select { + case <-c.done: + return + default: + line, err := c.stdout.ReadString('\n') + if err != nil { + if err != io.EOF && !errors.Is(err, context.Canceled) && !errors.Is(err, fs.ErrClosed) { + c.logger.Error("Error reading from stdout", "err", err) + } + // Signal done so in-flight SendRequest calls unblock + // instead of hanging forever when the server dies. + c.closeDone() + return + } + + line = strings.TrimRight(line, "\r\n") + // First try to parse as a generic message to check for ID field + var baseMessage struct { + JSONRPC string `json:"jsonrpc"` + ID *mcp.RequestId `json:"id,omitempty"` + Method string `json:"method,omitempty"` + } + if err := json.Unmarshal([]byte(line), &baseMessage); err != nil { + continue + } + + // If it has a method but no ID, it's a notification + if baseMessage.Method != "" && baseMessage.ID == nil { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(line), ¬ification); err != nil { + continue + } + c.notifyMu.RLock() + if c.onNotification != nil { + c.onNotification(notification) + } + c.notifyMu.RUnlock() + continue + } + + // If it has a method and an ID, it's an incoming request + if baseMessage.Method != "" && baseMessage.ID != nil { + var request JSONRPCRequest + if err := json.Unmarshal([]byte(line), &request); err == nil { + c.handleIncomingRequest(request) + continue + } + } + + // Otherwise, it's a response to our request + var response JSONRPCResponse + if err := json.Unmarshal([]byte(line), &response); err != nil { + continue + } + + // Create string key for map lookup + idKey := response.ID.String() + + c.mu.RLock() + ch, exists := c.responses[idKey] + c.mu.RUnlock() + + if exists { + ch <- &response + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + } + } +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// It creates a unique request ID, sends the request over stdin, and waits for +// the corresponding response or context cancellation. +// Returns the raw JSON response message or an error if the request fails. +func (c *Stdio) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + // Check if transport is closed or context is already canceled before doing any work + select { + case <-c.done: + return nil, ErrTransportClosed + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if c.stdin == nil { + return nil, fmt.Errorf("stdio client not started") + } + + // Marshal request + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + requestBytes = append(requestBytes, '\n') + + // Create string key for map lookup + idKey := request.ID.String() + + // Register response channel + responseChan := make(chan *JSONRPCResponse, 1) + c.mu.Lock() + c.responses[idKey] = responseChan + c.mu.Unlock() + deleteResponseChan := func() { + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + + // Send request. stdinMu serializes frame writes so concurrent + // SendRequest/SendNotification/sendResponse calls cannot interleave + // JSON-RPC lines on the subprocess's stdin. + c.stdinMu.Lock() + _, err = c.stdin.Write(requestBytes) + c.stdinMu.Unlock() + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to write request: %w", err) + } + + select { + case <-c.done: + // Drain responseChan first: a valid response may have been delivered + // just before readResponses closed the done channel on EOF. + select { + case response := <-responseChan: + return response, nil + default: + } + deleteResponseChan() + return nil, ErrTransportClosed + case <-ctx.Done(): + deleteResponseChan() + return nil, ctx.Err() + case response := <-responseChan: + return response, nil + } +} + +// SendNotification sends a json RPC Notification to the server. +func (c *Stdio) SendNotification( + ctx context.Context, + notification mcp.JSONRPCNotification, +) error { + select { + case <-c.done: + return ErrTransportClosed + case <-ctx.Done(): + return ctx.Err() + default: + } + + if c.stdin == nil { + return fmt.Errorf("stdio client not started") + } + + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + notificationBytes = append(notificationBytes, '\n') + + c.stdinMu.Lock() + _, err = c.stdin.Write(notificationBytes) + c.stdinMu.Unlock() + if err != nil { + return fmt.Errorf("failed to write notification: %w", err) + } + + return nil +} + +// handleIncomingRequest processes incoming requests from the server. +// It calls the registered request handler and sends the response back to the server. +func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) { + c.requestMu.RLock() + handler := c.onRequest + c.requestMu.RUnlock() + + if handler == nil { + // Send error response if no handler is configured + errorResponse := *NewJSONRPCErrorResponse( + request.ID, + mcp.METHOD_NOT_FOUND, + "No request handler configured", + nil, + ) + c.sendResponse(errorResponse) + return + } + + // Handle the request in a goroutine to avoid blocking + go func() { + c.ctxMu.RLock() + ctx := c.ctx + c.ctxMu.RUnlock() + + // Check if context is already cancelled before processing + select { + case <-ctx.Done(): + errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, ctx.Err().Error(), nil) + c.sendResponse(errorResponse) + return + default: + } + + response, err := handler(ctx, request) + if err != nil { + errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, err.Error(), nil) + c.sendResponse(errorResponse) + return + } + + if response != nil { + c.sendResponse(*response) + } + }() +} + +// sendResponse sends a response back to the server. +func (c *Stdio) sendResponse(response JSONRPCResponse) { + responseBytes, err := json.Marshal(response) + if err != nil { + c.logger.Error("Error marshaling response", "err", err) + return + } + responseBytes = append(responseBytes, '\n') + + c.stdinMu.Lock() + _, err = c.stdin.Write(responseBytes) + c.stdinMu.Unlock() + if err != nil { + c.logger.Error("Error writing response", "err", err) + } +} + +// Stderr returns a reader for the stderr output of the subprocess. +// This can be used to capture error messages or logs from the subprocess. +func (c *Stdio) Stderr() io.Reader { + return c.stderr +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go b/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go new file mode 100644 index 000000000..75ebf814f --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go @@ -0,0 +1,1154 @@ +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// StreamableHTTPCOption configures a StreamableHTTP transport client. +type StreamableHTTPCOption func(*StreamableHTTP) + +// WithContinuousListening enables receiving server-to-client notifications when no request is in flight. +// In particular, if you want to receive global notifications from the server (like ToolListChangedNotification), +// you should enable this option. +// +// It will establish a standalone long-live GET HTTP connection to the server. +// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server +// NOTICE: Even enabled, the server may not support this feature. +func WithContinuousListening() StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.getListeningEnabled = true + } +} + +// WithHTTPBasicClient sets a custom HTTP client on the StreamableHTTP transport. +func WithHTTPBasicClient(client *http.Client) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.httpClient = client + } +} + +// WithHTTPHeaders sets static headers for StreamableHTTP requests. +func WithHTTPHeaders(headers map[string]string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.headers = headers + } +} + +// WithHTTPHeaderFunc sets a function that returns headers for StreamableHTTP requests. +func WithHTTPHeaderFunc(headerFunc HTTPHeaderFunc) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.headerFunc = headerFunc + } +} + +// WithHTTPTimeout sets the timeout for a HTTP request and stream. +func WithHTTPTimeout(timeout time.Duration) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.httpClient.Timeout = timeout + } +} + +// WithHTTPOAuth enables OAuth authentication for the client. +func WithHTTPOAuth(config OAuthConfig) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.oauthHandler = NewOAuthHandler(config) + } +} + +// WithHTTPLogger sets a custom structured logger for the StreamableHTTP +// transport. A nil logger falls back to slog.Default(). +func WithHTTPLogger(logger *slog.Logger) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + if logger == nil { + sc.logger = slog.Default() + return + } + sc.logger = logger + } +} + +// WithLogger sets a custom structured logger for the StreamableHTTP transport. +// +// Deprecated: Use [WithHTTPLogger] instead. +func WithLogger(logger *slog.Logger) StreamableHTTPCOption { + return WithHTTPLogger(logger) +} + +// WithSession creates a client with a pre-configured session +func WithSession(sessionID string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.sessionID.Store(sessionID) + } +} + +// WithStreamableHTTPHost sets a custom Host header for the StreamableHTTP client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/mcp" but sending Host: "api.example.com" +func WithStreamableHTTPHost(host string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.host = host + } +} + +// StreamableHTTP implements Streamable HTTP transport. +// +// It transmits JSON-RPC messages over individual HTTP requests. One message per request. +// The HTTP response body can either be a single JSON-RPC response, +// or an upgraded SSE stream that concludes with a JSON-RPC response for the same request. +// +// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports +// +// The current implementation does not support the following features: +// - resuming stream +// (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#resumability-and-redelivery) +type StreamableHTTP struct { + serverURL *url.URL + httpClient *http.Client + headers map[string]string + headerFunc HTTPHeaderFunc + host string + logger *slog.Logger + getListeningEnabled bool + + sessionID atomic.Value // string + protocolVersion atomic.Value // string + + initialized chan struct{} + initializedOnce sync.Once + + notificationHandler func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + + // Request handler for incoming server-to-client requests (like sampling) + requestHandler RequestHandler + requestMu sync.RWMutex + + closed chan struct{} + closeOnce sync.Once + + // OAuth support + oauthHandler *OAuthHandler +} + +// NewStreamableHTTP creates a new Streamable HTTP transport with the given server URL. +// Returns an error if the URL is invalid. +func NewStreamableHTTP(serverURL string, options ...StreamableHTTPCOption) (*StreamableHTTP, error) { + parsedURL, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + smc := &StreamableHTTP{ + serverURL: parsedURL, + httpClient: &http.Client{}, + headers: make(map[string]string), + closed: make(chan struct{}), + logger: slog.Default(), + initialized: make(chan struct{}), + } + smc.sessionID.Store("") // set initial value to simplify later usage + + for _, opt := range options { + if opt != nil { + opt(smc) + } + } + + // If OAuth is configured, set the base URL for metadata discovery + if smc.oauthHandler != nil { + discoveryURL := *parsedURL + discoveryURL.RawQuery = "" + discoveryURL.Fragment = "" + baseURL := discoveryURL.String() + smc.oauthHandler.SetBaseURL(baseURL) + } + + return smc, nil +} + +// Start initiates the HTTP connection to the server. +func (c *StreamableHTTP) Start(ctx context.Context) error { + // Start is idempotent - check if already initialized + select { + case <-c.initialized: + return nil + default: + } + + // For Streamable HTTP, we don't need to establish a persistent connection by default + if c.getListeningEnabled { + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in listener goroutine", "panic", r) + } + }() + select { + case <-c.initialized: + ctx, cancel := c.contextAwareOfClientClose(ctx) + defer cancel() + c.listenForever(ctx) + case <-c.closed: + return + case <-ctx.Done(): + return + } + }() + } + + return nil +} + +// Close closes the all the HTTP connections to the server. +func (c *StreamableHTTP) Close() error { + c.closeOnce.Do(func() { + // Cancel all in-flight requests + close(c.closed) + + sessionId := c.sessionID.Load().(string) + if sessionId != "" { + c.sessionID.Store("") + // notify server session closed + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.serverURL.String(), nil) + if err != nil { + c.logger.Error("failed to create close request", "err", err) + return + } + req.Header.Set(HeaderKeySessionID, sessionId) + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + res, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("failed to send close request", "err", err) + return + } + res.Body.Close() + } + }) + return nil +} + +// SetProtocolVersion sets the negotiated protocol version for this connection. +func (c *StreamableHTTP) SetProtocolVersion(version string) { + c.protocolVersion.Store(version) +} + +// ErrOAuthAuthorizationRequired is a sentinel error for OAuth authorization required +var ErrOAuthAuthorizationRequired = errors.New("no valid token available, authorization required") + +// ErrAuthorizationRequired is a sentinel error for authorization required (401) +var ErrAuthorizationRequired = errors.New("authorization required") + +// parseAuthParams parses the auth-params from a WWW-Authenticate header value +// per RFC 7235. It skips the auth-scheme (first token) and returns a map of +// key=value pairs. Values may be tokens or quoted-strings (with backslash +// escaping per RFC 7230 §3.2.6). +func parseAuthParams(header string) map[string]string { + params := make(map[string]string) + + // Skip leading whitespace + header = strings.TrimSpace(header) + if header == "" { + return params + } + + // Skip the auth-scheme (first token before space) + _, rest, found := strings.Cut(header, " ") + if !found { + return params // auth-scheme only, no params + } + rest = strings.TrimSpace(rest) + + for rest != "" { + rest = strings.TrimSpace(rest) + if rest == "" { + break + } + + // Parse key + eqIdx := strings.IndexByte(rest, '=') + if eqIdx == -1 { + break + } + key := strings.TrimSpace(rest[:eqIdx]) + rest = strings.TrimLeft(rest[eqIdx+1:], " \t") + + // Parse value: quoted-string or token + var value string + if len(rest) > 0 && rest[0] == '"' { + value, rest = parseQuotedString(rest) + } else { + // Token value: ends at comma, space, or end of string + end := strings.IndexAny(rest, ", \t") + if end == -1 { + value = rest + rest = "" + } else { + value = rest[:end] + rest = rest[end:] + } + } + + params[key] = value + + // Skip comma separator + rest = strings.TrimSpace(rest) + if len(rest) > 0 && rest[0] == ',' { + rest = rest[1:] + } + } + + return params +} + +// parseQuotedString parses a quoted-string value per RFC 7230 §3.2.6. +// Input must start with a double-quote. Returns the unescaped value and +// the remaining unparsed input after the closing quote. +func parseQuotedString(s string) (value, rest string) { + if len(s) == 0 || s[0] != '"' { + return "", s + } + s = s[1:] // skip opening quote + + var b strings.Builder + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\': + if i+1 < len(s) { + b.WriteByte(s[i+1]) + i++ // skip escaped char + } + case '"': + return b.String(), s[i+1:] + default: + b.WriteByte(s[i]) + } + } + // No closing quote found; return what we have + return b.String(), "" +} + +// extractResourceMetadataURL extracts the resource_metadata parameter from WWW-Authenticate headers +// per RFC9728 Section 5.1. Scans all provided header values since a response may contain multiple +// WWW-Authenticate headers (RFC 9110). Returns empty string if not found. +// Example: Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource" +func extractResourceMetadataURL(wwwAuthHeaders []string) string { + for _, header := range wwwAuthHeaders { + for _, u := range extractResourceMetadataURLs(header) { + if u != "" { + return u + } + } + } + return "" +} + +// extractResourceMetadataURLs returns every resource_metadata parameter +// value from a single WWW-Authenticate header value per RFC 9728 §5.1, +// in the order they appear. Returns an empty slice when the header is +// empty or no such parameters are present. Parameter names are matched +// case-insensitively per RFC 9110 §11.2; both quoted-string and token +// value forms are accepted. Multiple occurrences are possible when a +// single header value contains several Bearer challenges each carrying +// their own resource_metadata — an attacker-controlled first candidate +// must not mask a legitimate later one. +func extractResourceMetadataURLs(header string) []string { + const target = "resource_metadata" + var out []string + i := 0 + for i < len(header) { + // Advance to the next token start. + for i < len(header) && !isAuthTokenChar(header[i]) { + i++ + } + nameStart := i + for i < len(header) && isAuthTokenChar(header[i]) { + i++ + } + name := header[nameStart:i] + // Skip optional whitespace between the name and '='. + for i < len(header) && (header[i] == ' ' || header[i] == '\t') { + i++ + } + if i >= len(header) || header[i] != '=' { + // Name was a scheme token (e.g. "Bearer"), not a parameter. + continue + } + // Skip '=' and optional whitespace. + i++ + for i < len(header) && (header[i] == ' ' || header[i] == '\t') { + i++ + } + value, next, ok := parseAuthParamValue(header, i) + i = next + if !ok { + continue + } + if value != "" && strings.EqualFold(name, target) { + out = append(out, value) + } + } + return out +} + +// parseAuthParamValue reads a single WWW-Authenticate parameter value +// starting at offset i: a quoted-string (with backslash escapes) when the +// first byte is '"', otherwise a bare token. It returns the decoded +// value, the index of the first byte after it, and whether the value +// was well-formed. Truncated quoted strings (no closing '"') and lone +// trailing backslashes yield ok=false so malformed input is rejected +// rather than producing a partial value. +func parseAuthParamValue(s string, i int) (string, int, bool) { + if i >= len(s) { + return "", i, false + } + if s[i] == '"' { + i++ + var b strings.Builder + for i < len(s) { + c := s[i] + if c == '\\' { + if i+1 >= len(s) { + // Lone trailing backslash — the quoted string was + // truncated mid-escape, so the value is malformed. + return "", i + 1, false + } + b.WriteByte(s[i+1]) + i += 2 + continue + } + if c == '"' { + return b.String(), i + 1, true + } + b.WriteByte(c) + i++ + } + // Reached end of input without a closing '"'. + return "", i, false + } + start := i + for i < len(s) && isAuthTokenChar(s[i]) { + i++ + } + return s[start:i], i, i > start +} + +// isAuthTokenChar reports whether c is a valid RFC 9110 §5.6.2 token +// character — the character class used for scheme and parameter names in +// WWW-Authenticate. +func isAuthTokenChar(c byte) bool { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + return true + } + return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0 +} + +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received. +// It contains the protected resource metadata URL from the WWW-Authenticate header if present. +type AuthorizationRequiredError struct { + ResourceMetadataURL string // Extracted from WWW-Authenticate header per RFC9728 +} + +func (e *AuthorizationRequiredError) Error() string { + return ErrAuthorizationRequired.Error() +} + +func (e *AuthorizationRequiredError) Unwrap() error { + return ErrAuthorizationRequired +} + +// OAuthAuthorizationRequiredError is returned when OAuth authorization is required +// and an OAuth handler is available. +type OAuthAuthorizationRequiredError struct { + Handler *OAuthHandler + AuthorizationRequiredError +} + +func (e *OAuthAuthorizationRequiredError) Error() string { + return ErrOAuthAuthorizationRequired.Error() +} + +func (e *OAuthAuthorizationRequiredError) Unwrap() error { + return ErrOAuthAuthorizationRequired +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *StreamableHTTP) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + // Marshal request + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream", request.Header) + if err != nil { + cancel() + if errors.Is(err, ErrSessionTerminated) && request.Method == string(mcp.MethodInitialize) { + // Per the MCP spec's backwards compatibility section: a 404 on an + // initialize POST means the server likely only supports legacy SSE. + return nil, ErrLegacySSEServer + } + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Only proceed if we have a valid response. + if resp == nil { + cancel() + return nil, fmt.Errorf("failed to send request: no response received") + } + // Cancel the context before closing the body. On HTTP/2, Close() blocks in a + // select on cs.donec (stream cleanup) or cs.ctx.Done() (context cancellation). + // If cc.wmu is contended, cs.donec may never close, making ctx.Done() the only + // exit path. Canceling first guarantees Close() returns promptly. + defer func() { cancel(); resp.Body.Close() }() + + // Check if we got an error response + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + // Per the MCP spec's backwards compatibility section: if an initialize + // POST receives an HTTP 4xx (e.g. 405 Method Not Allowed, 404 Not Found), + // the server likely only supports the legacy HTTP+SSE transport. + if request.Method == string(mcp.MethodInitialize) && resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, ErrLegacySSEServer + } + + // handle error response + var errResponse JSONRPCResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &errResponse); err == nil { + return &errResponse, nil + } + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + if request.Method == string(mcp.MethodInitialize) { + // saved the received session ID in the response + // empty session ID is allowed + if sessionID := resp.Header.Get(HeaderKeySessionID); sessionID != "" { + c.sessionID.Store(sessionID) + } + + c.initializedOnce.Do(func() { + close(c.initialized) + }) + } + + // Handle different response types + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + switch mediaType { + case "application/json": + // Single response + var response JSONRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // should not be a notification + if response.ID.IsNil() { + return nil, fmt.Errorf("response should contain RPC id: %v", response) + } + + return &response, nil + + case "text/event-stream": + // Server is using SSE for streaming responses + return c.handleSSEResponse(ctx, resp.Body, false) + + default: + return nil, fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type")) + } +} + +func (c *StreamableHTTP) sendHTTP( + ctx context.Context, + method string, + body io.Reader, + acceptType string, + header http.Header, +) (resp *http.Response, err error) { + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, method, c.serverURL.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // request headers + if header != nil { + req.Header = header + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", acceptType) + sessionID := c.sessionID.Load().(string) + if sessionID != "" { + req.Header.Set(HeaderKeySessionID, sessionID) + } + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return nil, fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Send request + resp, err = c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // universal handling for session terminated + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + c.sessionID.CompareAndSwap(sessionID, "") + return nil, ErrSessionTerminated + } + + return resp, nil +} + +// handleSSEResponse processes an SSE stream for a specific request. +// It returns the final result for the request once received, or an error. +// If ignoreResponse is true, it won't return when a response messge is received. This is for continuous listening. +func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCloser, ignoreResponse bool) (*JSONRPCResponse, error) { + // Create a channel for this specific request + responseChan := make(chan *JSONRPCResponse, 1) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Start a goroutine to process the SSE stream + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in SSE stream reader", "panic", r) + } + }() + // Ensure this goroutine respects the context + defer close(responseChan) + + c.readSSE(ctx, reader, func(event, data string) { + // Try to unmarshal as a response first + var message JSONRPCResponse + if err := json.Unmarshal([]byte(data), &message); err != nil { + c.logger.Info("failed to unmarshal message (non-fatal)", "err", err, "message_len", len(data)) + return + } + + // Handle notification + if message.ID.IsNil() { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(data), ¬ification); err != nil { + c.logger.Error("failed to unmarshal notification", "err", err) + return + } + c.notifyMu.RLock() + if c.notificationHandler != nil { + c.notificationHandler(notification) + } + c.notifyMu.RUnlock() + return + } + + // Check if this is actually a request from the server by looking for method field + var rawMessage map[string]json.RawMessage + if err := json.Unmarshal([]byte(data), &rawMessage); err == nil { + if _, hasMethod := rawMessage["method"]; hasMethod && !message.ID.IsNil() { + var request JSONRPCRequest + if err := json.Unmarshal([]byte(data), &request); err == nil { + // This is a request from the server + c.handleIncomingRequest(ctx, request) + return + } + } + } + + if !ignoreResponse { + responseChan <- &message + } + }) + }() + + // Wait for the response or context cancellation + select { + case response := <-responseChan: + if response == nil { + return nil, fmt.Errorf("unexpected nil response") + } + return response, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// readSSE reads the SSE stream(reader) and calls the handler for each event and data pair. +// It will end when the reader is closed (or the context is done). +// +// A background goroutine closes the reader when ctx is cancelled, which unblocks +// any in-progress ReadString call. This is necessary because ReadString is blocking +// I/O that does not respect context cancellation on its own. +func (c *StreamableHTTP) readSSE(ctx context.Context, reader io.ReadCloser, handler func(event, data string)) { + // Close the reader when context is cancelled to interrupt blocking reads. + // This ensures ReadString returns immediately with an error instead of + // blocking indefinitely when the SSE stream is open but idle. + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic closing SSE reader", "panic", r) + } + }() + <-ctx.Done() + reader.Close() + }() + + br := bufio.NewReader(reader) + var event, data string + + for { + line, err := br.ReadString('\n') + if err != nil { + // Context was cancelled — reader was closed by the goroutine above. + if ctx.Err() != nil { + return + } + if err == io.EOF { + // Process any pending event before exit + if data != "" { + if event == "" { + event = "message" + } + handler(event, data) + } + return + } + c.logger.Error("SSE stream error", "err", err) + return + } + + // Remove only newline markers + line = strings.TrimRight(line, "\r\n") + if line == "" { + // Empty line means end of event + if data != "" { + if event == "" { + event = "message" + } + handler(event, data) + event = "" + data = "" + } + continue + } + + if eventStr, ok := strings.CutPrefix(line, "event:"); ok { + event = strings.TrimSpace(eventStr) + } else if dataStr, ok := strings.CutPrefix(line, "data:"); ok { + data = appendSSEData(data, dataStr) + } + } +} + +// SendNotification sends a JSON-RPC notification to the server without expecting a response. +func (c *StreamableHTTP) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + // Marshal request + requestBody, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + // Create HTTP request + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream", nil) + if err != nil { + cancel() + return fmt.Errorf("failed to send request: %w", err) + } + defer func() { cancel(); resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusOK, http.StatusAccepted, http.StatusNoContent: + return nil + case http.StatusUnauthorized: + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + default: + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "notification failed with status %d: %s", + resp.StatusCode, + body, + ) + } +} + +// SetNotificationHandler sets the handler for incoming JSON-RPC notifications. +func (c *StreamableHTTP) SetNotificationHandler(handler func(mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.notificationHandler = handler +} + +// SetRequestHandler sets the handler for incoming requests from the server. +func (c *StreamableHTTP) SetRequestHandler(handler RequestHandler) { + c.requestMu.Lock() + defer c.requestMu.Unlock() + c.requestHandler = handler +} + +// GetSessionId returns the current StreamableHTTP session ID. +func (c *StreamableHTTP) GetSessionId() string { + return c.sessionID.Load().(string) +} + +// GetOAuthHandler returns the OAuth handler if configured +func (c *StreamableHTTP) GetOAuthHandler() *OAuthHandler { + return c.oauthHandler +} + +// IsOAuthEnabled returns true if OAuth is enabled +func (c *StreamableHTTP) IsOAuthEnabled() bool { + return c.oauthHandler != nil +} + +func (c *StreamableHTTP) listenForever(ctx context.Context) { + c.logger.Info("listening to server forever") + for { + // Use the original context for continuous listening - no per-iteration timeout + // The SSE connection itself will detect disconnections via the underlying HTTP transport, + // and the context cancellation will propagate from the parent to stop listening gracefully. + // We don't add an artificial timeout here because: + // 1. Persistent SSE connections are meant to stay open indefinitely + // 2. Network-level timeouts and keep-alives handle connection health + // 3. Context cancellation (user-initiated or system shutdown) provides clean shutdown + err := c.createGETConnectionToServer(ctx) + if errors.Is(err, ErrGetMethodNotAllowed) { + // server does not support listening + c.logger.Error("server does not support listening") + return + } + if errors.Is(err, ErrSessionTerminated) { + // Server returned 404: the session no longer exists (server restarted + // or session expired). Retrying is pointless because the server won't + // recognize this session. The caller must re-initialize. + c.logger.Error("session terminated, stopping listener", "err", err) + return + } + + select { + case <-ctx.Done(): + return + default: + } + + if err != nil { + c.logger.Error("failed to listen to server. retry in 1 second", "err", err) + } + + // Use context-aware sleep + select { + case <-time.After(retryInterval): + case <-ctx.Done(): + return + } + } +} + +var ( + // ErrSessionTerminated indicates the server no longer recognizes the current session. + ErrSessionTerminated = fmt.Errorf("session terminated (404). need to re-initialize") + ErrGetMethodNotAllowed = fmt.Errorf("GET method not allowed") + ErrUnauthorized = fmt.Errorf("unauthorized (401)") + ErrLegacySSEServer = fmt.Errorf("server returned 4xx for initialize POST, likely a legacy SSE server") + + retryInterval = 1 * time.Second // a variable is convenient for testing +) + +func (c *StreamableHTTP) createGETConnectionToServer(ctx context.Context) error { + resp, err := c.sendHTTP(ctx, http.MethodGet, nil, "text/event-stream", nil) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + // Cancel the context before closing the body to prevent HTTP/2 drain hangs, + // matching the pattern used in SendRequest and SendNotification. + defer func() { resp.Body.Close() }() + + // Check if we got an error response + if resp.StatusCode == http.StatusMethodNotAllowed { + return ErrGetMethodNotAllowed + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + // handle SSE response. Parse the media type to tolerate parameters such as + // "text/event-stream; charset=utf-8" (same handling as SendRequest). + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if mediaType != "text/event-stream" { + return fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type")) + } + + // When ignoreResponse is true, the function will never return expect context is done. + // NOTICE: Due to the ambiguity of the specification, other SDKs may use the GET connection to transfer the response + // messages. To be more compatible, we should handle this response, however, as the transport layer is message-based, + // currently, there is no convenient way to handle this response. + // So we ignore the response here. It's not a bug, but may be not compatible with other SDKs. + _, err = c.handleSSEResponse(ctx, resp.Body, true) + if err != nil { + return fmt.Errorf("failed to handle SSE response: %w", err) + } + + return nil +} + +// handleIncomingRequest processes requests from the server (like sampling requests) +func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSONRPCRequest) { + c.requestMu.RLock() + handler := c.requestHandler + c.requestMu.RUnlock() + + if handler == nil { + c.logger.Error("received request from server but no handler set", "method", request.Method) + // Send method not found error + errorResponse := NewJSONRPCErrorResponse( + request.ID, + mcp.METHOD_NOT_FOUND, + fmt.Sprintf("no handler configured for method: %s", request.Method), + nil, + ) + c.sendResponseToServer(ctx, errorResponse) + return + } + + // Handle the request in a goroutine to avoid blocking the SSE reader + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic handling server request", "method", request.Method, "panic", r) + // Attempt to send an internal error response so the server doesn't hang. + // Use a nested recover to prevent sendResponseToServer from propagating + // a secondary panic (e.g., nil serverURL during shutdown). + func() { + defer func() { + if r2 := recover(); r2 != nil { + c.logger.Error("failed to send error response after panic", "err", r2) + } + }() + errorResponse := NewJSONRPCErrorResponse( + request.ID, + mcp.INTERNAL_ERROR, + fmt.Sprintf("internal error: panic in request handler: %v", r), + nil, + ) + c.sendResponseToServer(ctx, errorResponse) + }() + } + }() + // Create a new context with timeout for request handling, respecting parent context + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + response, err := handler(requestCtx, request) + if err != nil { + c.logger.Error("error handling request", "method", request.Method, "err", err) + + // Determine appropriate JSON-RPC error code based on error type + var errorCode int + var errorMessage string + + // Check for specific sampling-related errors + if errors.Is(err, context.Canceled) { + errorCode = mcp.REQUEST_INTERRUPTED + errorMessage = "request was cancelled" + } else if errors.Is(err, context.DeadlineExceeded) { + errorCode = mcp.REQUEST_INTERRUPTED + errorMessage = "request timed out" + } else { + // Generic error cases + switch request.Method { + case string(mcp.MethodSamplingCreateMessage): + errorCode = mcp.INTERNAL_ERROR + errorMessage = fmt.Sprintf("sampling request failed: %v", err) + default: + errorCode = mcp.INTERNAL_ERROR + errorMessage = err.Error() + } + } + + // Send error response + errorResponse := NewJSONRPCErrorResponse(request.ID, errorCode, errorMessage, nil) + c.sendResponseToServer(requestCtx, errorResponse) + return + } + + if response != nil { + c.sendResponseToServer(requestCtx, response) + } + }() +} + +// sendResponseToServer sends a response back to the server via HTTP POST +func (c *StreamableHTTP) sendResponseToServer(ctx context.Context, response *JSONRPCResponse) { + if response == nil { + c.logger.Error("cannot send nil response to server") + return + } + + responseBody, err := json.Marshal(response) + if err != nil { + c.logger.Error("failed to marshal response", "err", err) + return + } + + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(responseBody), "application/json, text/event-stream", nil) + if err != nil { + cancel() + c.logger.Error("failed to send response to server", "err", err) + return + } + defer func() { cancel(); resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + c.logger.Error("server rejected response", "status", resp.StatusCode, "body_len", len(body)) + } +} + +func (c *StreamableHTTP) contextAwareOfClientClose(ctx context.Context) (context.Context, context.CancelFunc) { + newCtx, cancel := context.WithCancel(ctx) + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in context-close watcher", "panic", r) + } + }() + select { + case <-c.closed: + cancel() + case <-newCtx.Done(): + // The original context was canceled + cancel() + } + }() + return newCtx, cancel +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go b/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go new file mode 100644 index 000000000..2d0e847da --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go @@ -0,0 +1,26 @@ +package transport + +import ( + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +// NewJSONRPCErrorResponse creates a new JSONRPCResponse with an error. +func NewJSONRPCErrorResponse(id mcp.RequestId, code int, message string, data any) *JSONRPCResponse { + details := mcp.NewJSONRPCErrorDetails(code, message, data) + return &JSONRPCResponse{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: id, + Error: &details, + } +} + +// NewJSONRPCResultResponse creates a new JSONRPCResponse with a result. +func NewJSONRPCResultResponse(id mcp.RequestId, result json.RawMessage) *JSONRPCResponse { + return &JSONRPCResponse{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: id, + Result: result, + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 2d1c491b6..8ef964777 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -73,6 +73,8 @@ github.com/jackc/puddle/v2/internal/genstack github.com/joho/godotenv # github.com/mark3labs/mcp-go v0.58.0 ## explicit; go 1.25.5 +github.com/mark3labs/mcp-go/client +github.com/mark3labs/mcp-go/client/transport github.com/mark3labs/mcp-go/mcp github.com/mark3labs/mcp-go/server github.com/mark3labs/mcp-go/tracing