diff --git a/.gitattributes b/.gitattributes index a5a3a9e..98a0477 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,6 @@ /docs export-ignore /recipe export-ignore /composer-dependency-analyser.php export-ignore +/phpstan.php85.neon export-ignore +/CONTRIBUTING.md export-ignore +/phpunit-baseline.xml export-ignore diff --git a/.github/nightly-issue.sh b/.github/nightly-issue.sh new file mode 100755 index 0000000..6eea709 --- /dev/null +++ b/.github/nightly-issue.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Opens one issue per failing nightly job, or comments on the open one. A person closes it after the fix. +# Usage: nightly-issue.sh [report file]. The text of the report file, if it exists, goes into the issue or comment. +set -euo pipefail +job="$1" +title="Nightly: ${job} failed" +run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +gh label create nightly --color B60205 --description "A scheduled upstream or release check failed" 2>/dev/null || true +number="$(gh issue list --label nightly --state open --limit 100 --json number,title --jq ".[] | select(.title == \"${title}\") | .number" | head -n 1)" +body="The nightly job \`${job}\` failed: ${run_url}" +report="${2:-}" +if [ -n "${report}" ] && [ -s "${report}" ]; then + # An issue body holds at most 65536 characters. + body+=$'\n\n'"$(head -c 60000 "${report}")" +fi +if [ -n "${number}" ]; then + gh issue comment "${number}" --body "${body}" +else + gh issue create --title "${title}" --label nightly --body "${body}" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d76c4b..53d341e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,12 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Each job's timeout-minutes is about three times its usual run time; GitHub's default is 6 hours. jobs: static: name: Static analysis runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -33,13 +35,33 @@ jobs: - run: composer normalize --dry-run - run: vendor/bin/php-cs-fixer check --diff - run: vendor/bin/rector process --dry-run - - run: vendor/bin/phpstan analyse --no-progress --error-format=github + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon - run: vendor/bin/composer-dependency-analyser - run: composer audit + static-lowest: + name: Static analysis (lowest dependencies) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: lowest + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon + tests: name: Tests (PHP ${{ matrix.php }}, ${{ matrix.deps }}) runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -61,6 +83,7 @@ jobs: tests-next: name: Tests (PHP 8.6, experimental) runs-on: ubuntu-latest + timeout-minutes: 15 continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -73,11 +96,15 @@ jobs: - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 with: dependency-versions: highest - - run: vendor/bin/phpunit + # ergebnis/composer-normalize caps PHP below 8.6. + composer-options: --ignore-platform-req=php+ + # The baseline holds the deprecations that PHP 8.6 raises inside a dependency (opis/json-schema). + - run: vendor/bin/phpunit --use-baseline phpunit-baseline.xml coverage: name: Coverage runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -132,9 +159,10 @@ jobs: if: github.event_name != 'pull_request' run: vendor/bin/infection --threads=max --logger-github - mcp-sdk-main: - name: mcp/sdk main (PHP 8.5) + mcp-sdk-0-9: + name: mcp/sdk 0.9.0 (PHP 8.5) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -148,8 +176,8 @@ jobs: dependency-versions: highest - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 run: composer remove --dev --no-update symfony/mcp-bundle - - name: Install mcp/sdk main at the commit this release was verified against - run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" + - name: Install the mcp/sdk 0.9.0 tag + run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:0.9.0" - run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E @@ -177,11 +205,54 @@ jobs: env: AGENTCAT_RUNTIME_TESTS: '1' + package: + name: Package probe (PHP 8.3, mcp/sdk ${{ matrix.mcp-sdk }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + mcp-sdk: ['0.8.1', '0.9.0'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + run: tests/bin/package-probe.sh "$MCP_SDK" + env: + MCP_SDK: ${{ matrix.mcp-sdk }} + + bc: + name: Backward compatibility + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + extensions: bcmath + coverage: none + - name: Compare with the latest 1.x tag + run: | + base="$(git tag --list 'v1.*' --sort=-v:refname | head -n 1)" + if [ -z "$base" ]; then echo "No v1.* tag yet: nothing to compare against (D63)."; exit 0; fi + composer global require --no-interaction roave/backward-compatibility-check:8.19.0 + "$(composer global config bin-dir --absolute)/roave-backward-compatibility-check" --from="$base" --format=github-actions --install-development-dependencies + all-green: name: All green if: always() - needs: [static, tests, coverage, mutation, mcp-sdk-main, runtime] + needs: [static, static-lowest, tests, coverage, mutation, mcp-sdk-0-9, runtime, package, bc] runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..83434da --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,408 @@ +name: Nightly + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + force_failure: + description: 'A job name to fail on purpose, to prove the issue step (for example audit)' + required: false + default: '' + type: string + pull_request: + paths: + - '.github/workflows/nightly.yml' + - '.github/nightly-issue.sh' + +permissions: + contents: read + issues: write + +# A new push cancels an older run of the same pull request; scheduled and dispatched runs are never cancelled. +concurrency: + group: nightly-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + mcp-sdk-dev-main: + name: mcp/sdk dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install mcp/sdk dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "mcp/sdk:dev-main as 0.9.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.no-bundle.neon + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + laravel-mcp-dev-main: + name: laravel/mcp dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install laravel/mcp dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "laravel/mcp:dev-main as 1.0.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + mcp-bundle-dev-main: + name: symfony/mcp-bundle dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install symfony/mcp-bundle dev-main with mcp/sdk 0.9 + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "symfony/mcp-bundle:dev-main as 0.14.99" "mcp/sdk:^0.9" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + php-nightly: + name: Tests (PHP nightly) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: 'nightly' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # ergebnis/composer-normalize caps PHP below the nightly. + composer-options: --ignore-platform-req=php+ + # display_errors=stderr: a dependency's deprecation (opis/json-schema) prints to STDERR, never before a test's headers; the log keeps it. + # The baseline holds the deprecations that PHP raises inside a dependency. + - run: vendor/bin/phpunit -d display_errors=stderr --use-baseline phpunit-baseline.xml + timeout-minutes: 20 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + mutation-full: + name: Mutation testing (full run) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: pcov + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/infection --threads=max --logger-github + timeout-minutes: 50 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + audit: + name: Composer audit (highest dependencies) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: composer audit + timeout-minutes: 5 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + network: + name: Network tests (live OpenAPI schema) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/phpunit --group network + timeout-minutes: 5 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + runtime-latest: + name: Runtime delivery (Docker, latest image patches) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Build the CLI and Swoole images on the latest bases and pull the others + timeout-minutes: 8 + run: | + docker build --pull --build-arg BASE=php:8.3-cli-bookworm --tag agentcat-runtime-cli:latest tests/Runtime/docker + docker build --pull --build-arg BASE=phpswoole/swoole:6.2-php8.3 --tag agentcat-runtime-swoole:latest tests/Runtime/docker/swoole + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm dunglas/frankenphp:1-php8.3-bookworm unit:php8.3; do docker pull "$image"; done + - name: Record the resolved PHP version of each image + timeout-minutes: 2 + run: | + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm agentcat-runtime-cli:latest dunglas/frankenphp:1-php8.3-bookworm agentcat-runtime-swoole:latest unit:php8.3; do + printf '%s: ' "$image" + docker run --rm --pull never --network none --entrypoint php "$image" -r 'echo PHP_VERSION, "\n";' + done + - run: vendor/bin/phpunit --testsuite Runtime --group runtime + timeout-minutes: 15 + env: + AGENTCAT_RUNTIME_TESTS: '1' + AGENTCAT_RUNTIME_IMAGES: latest + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + package-dev-main: + name: Package probe (PHP 8.3, mcp/sdk dev-main) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + timeout-minutes: 8 + run: tests/bin/package-probe.sh "dev-main as 0.9.99" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + member-snapshot: + name: Member snapshot (${{ matrix.library }} dev-main) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - library: mcp/sdk + constraint: 'mcp/sdk:dev-main as 0.9.99' + - library: laravel/mcp + constraint: 'laravel/mcp:dev-main as 1.0.99' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + if: matrix.library == 'mcp/sdk' + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install ${{ matrix.library }} dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "${{ matrix.constraint }}" + - name: Diff the members against the newest committed snapshot + timeout-minutes: 5 + env: + LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md + run: | + set -o pipefail + prefix="tests/Contract/snapshots/${LIBRARY//\//-}-" + newest="$(printf '%s\n' "$prefix"*.json | sort -V | tail -n 1)" + php tests/bin/member-snapshot.php "$LIBRARY" --diff "$newest" 2>&1 | tee "$REPORT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md + run: .github/nightly-issue.sh "member-snapshot (${LIBRARY})" "$REPORT" + + branding-drift: + name: README branding drift (TypeScript SDK main) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Compare the shared README sections and images with the TypeScript SDK's main branch + timeout-minutes: 3 + env: + GITHUB_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/branding-drift.md + run: | + set -o pipefail + php tests/bin/branding-drift.php 2>&1 | tee "$REPORT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/branding-drift.md + run: .github/nightly-issue.sh "${{ github.job }}" "$REPORT" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e8d0e95 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `AgentCat::track()` and the `mcp/sdk` adapter: one call on the `Mcp\Server\Builder` tracks every tool of the server. +- The `laravel/mcp` bridge: every server of a Laravel application is tracked, with `config/agentcat.php` and `php artisan agentcat:install`. +- The Symfony bundle, `AgentCat\Symfony\AgentCatBundle`, for `symfony/mcp-bundle` servers. +- The `session_id`, `agent_id` and `context` parameters that AgentCat injects into each tool, and the `get_more_tools` tool. +- The hooks: `identify`, `resolveSessionId`, `eventTags`, `eventProperties`, `redactSensitiveInformation` and `redactEvent`. +- Custom events, with `AgentCat::publishCustomEvent()`. +- The four exporters: OpenTelemetry (OTLP), Datadog, Sentry and PostHog. +- Diagnostics, which send AgentCat the SDK's own log lines to find broken installs, and their opt-out (`disableDiagnostics`, `AGENTCAT_DISABLE_DIAGNOSTICS`). +- Delivery after the response on every runtime: the client has its answer before AgentCat runs the hooks and sends the events. +- `AgentCat::fake()` for your own tests, with `assertTracked()`, `assertNothingTracked()`, `assertTrackedCount()` and `assertCustomEventPublished()`. + +### Changed + +- AgentCat reads `tools/list` pages until it finds the called tool, so a tool on a later page keeps its own parameters. + The TypeScript SDK reads only the first page, and for a tool on a later page it guesses which arguments to remove: the guess can remove the tool's own `session_id` or `context` argument. + Under PHP-FPM and the other runtimes that serve each request in a fresh process, your own `tools/list` handler (one that you add to the server, or a `laravel/mcp` class that answers `tools/list`) then runs once for each page that AgentCat reads during a `tools/call`, so its queries, metrics and log lines repeat that many times. + A call to the last-page tool of a 300-tool `laravel/mcp` server takes 57.6 ms (2.7 ms before); a call to a page-1 tool costs the same as before. + +[Unreleased]: https://github.com/agentcathq/agentcat-php-sdk/commits/main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d9a238f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,201 @@ +# Contributing to AgentCat ๐ŸŽ‰ + +Thank you for your interest in contributing to AgentCat! We're excited to have you join our community of developers building analytics tools for MCP servers. + +## Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR-USERNAME/agentcat-php-sdk.git + cd agentcat-php-sdk + ``` +3. **Install dependencies** using Composer (PHP 8.3 or later, with ext-curl, ext-json and ext-mbstring): + ```bash + composer install + ``` +4. **Create a branch** for your feature or fix: + ```bash + git checkout -b feature/your-feature-name + # or + git checkout -b fix/your-bug-fix + ``` + +## Development Process + +### Making Changes + +1. **Write your code** following our PHP standards: `declare(strict_types=1)`, `final` classes, native types, and `@internal` on every class that is not public API +2. **Add tests** for new features (required for feature additions) +3. **Run every check** to ensure everything passes: + ```bash + composer check + ``` +4. **Fix the code style** and apply Rector when `composer check` reports changes: + ```bash + composer cs:fix # Run php-cs-fixer + vendor/bin/rector process # Apply the Rector rules + ``` + +### Commit Conventions + +We follow [Conventional Commits](https://www.conventionalcommits.org/). Your commit messages should be structured as: + +``` +: + +[optional body] +``` + +**Types:** + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `test`: Adding or updating tests +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `ci`: Changes to the CI workflows +- `build`: Changes to the dependencies or the package +- `chore`: Changes to build process or auxiliary tools + +**Examples:** + +```bash +git commit -m "feat: add telemetry exporters for observability" +git commit -m "fix: handle edge case in session tracking" +git commit -m "docs: update API documentation" +``` + +## Pull Request Process + +1. **Push your changes** to your fork: + + ```bash + git push origin feature/your-feature-name + ``` + +2. **Create a Pull Request** from your fork to our `main` branch + +3. **Fill out the PR description** with: + + - What changes you've made + - Why these changes are needed + - Any relevant context or screenshots + +4. **Wait for review** - We will review your PR as soon as we can + +5. **Address feedback** if any changes are requested + +6. **Celebrate** ๐ŸŽ‰ once your PR is merged! + +### The Required Check + +The one required check is `All green`. It passes when every CI job passes: static analysis on the lowest and the highest dependencies, the tests on PHP 8.3, 8.4 and 8.5, coverage, mutation testing, the `mcp/sdk` 0.9.0 leg, the runtime suite, the package probe and the backward-compatibility check. + +### No Issue Required + +You don't need to open an issue before submitting a PR. Feel free to submit pull requests directly with your improvements! + +## Good First Issues + +Looking for a place to start? Check out issues labeled [`good first issue`](https://github.com/agentcathq/agentcat-php-sdk/labels/good%20first%20issue) - these are great for newcomers to the codebase. + +## Testing + +- New features **should include tests** to ensure reliability +- Run tests locally with `vendor/bin/phpunit` +- We use [PHPUnit](https://phpunit.de/) for our test suite and [Infection](https://infection.github.io/) for mutation testing +- Test files live under `tests/`, in the suite that fits (`Unit`, `Integration`, `Contract`, `E2E`, โ€ฆ), at the path of the code they test with a `Test.php` suffix: `tests/Unit/Internal/Engine/ToolListingTest.php` tests `src/Internal/Engine/ToolListing.php` + +Some tests need more than PHPUnit: + +```bash +# Mutation testing: every mutant of src/, or only the files you changed +vendor/bin/infection --threads=max +vendor/bin/infection --threads=max --filter=src/Internal/Engine/ToolListing.php + +# PHPStan and the adapter suites against mcp/sdk 0.9.0, in a throwaway copy under .cache/ +tests/bin/mcp-sdk-leg.sh + +# The runtime suite: PHP-FPM, FrankenPHP, Apache, NGINX Unit, the CLI and Swoole, in Docker +AGENTCAT_RUNTIME_TESTS=1 vendor/bin/phpunit --testsuite Runtime --group runtime + +# The package probe: installs the dist (git archive HEAD, so commit first) in a fresh project and tracks a server +tests/bin/package-probe.sh 0.9.0 +``` + +The runtime containers write files as root or www-data into the test home under `.cache/test-home`, and a test that ends removes them. When you kill a runtime run before it ends (for example with Ctrl+C), those files can stay there: remove them with `docker run --rm -v "$PWD/.cache:/cache" php:8.3-cli rm -rf /cache/test-home`, or with `sudo rm -rf .cache/test-home`. + +## Code Quality + +Before submitting your PR, ensure your code passes all checks: + +```bash +# Run all checks at once +composer check + +# Or run them individually +composer validate --strict # Validate composer.json +composer normalize --dry-run # Check that composer.json is normalized +composer cs:check # Check code style +composer rector:check # Check the Rector rules +composer stan # Check types with PHPStan (level max, in both configurations) +composer deps # Check the declared dependencies +composer test # Run tests +``` + +Our CI will run these same checks on your PR. + +## Dependencies + +While we don't restrict adding new dependencies, they are generally **discouraged** unless absolutely necessary. If you need to add a dependency: + +1. Consider if the functionality can be achieved with existing dependencies +2. Check if the dependency is well-maintained and lightweight +3. Ensure it's compatible with our MIT license +4. Add it using Composer: `composer require ` + +## Project Structure + +``` +agentcat-php-sdk/ +โ”œโ”€โ”€ src/ # Source code +โ”‚ โ”œโ”€โ”€ AgentCat.php # Main entry point: track(), publishCustomEvent(), fake() +โ”‚ โ”œโ”€โ”€ McpSdk/ # The mcp/sdk adapter +โ”‚ โ”œโ”€โ”€ LaravelMcp/ # The laravel/mcp adapter +โ”‚ โ”œโ”€โ”€ Laravel/ # The Laravel service provider and agentcat:install +โ”‚ โ”œโ”€โ”€ Symfony/ # The Symfony bundle +โ”‚ โ”œโ”€โ”€ Exporters/ # The OTLP, Datadog, Sentry and PostHog exporters +โ”‚ โ”œโ”€โ”€ Testing/ # AgentCat::fake() and its assertions +โ”‚ โ””โ”€โ”€ Internal/ # The engine, delivery, telemetry and diagnostics +โ”œโ”€โ”€ config/ # config/agentcat.php for Laravel +โ”œโ”€โ”€ resources/boost/ # The Laravel Boost guidelines for AI agents +โ”œโ”€โ”€ recipe/ # The Symfony Flex recipe +โ”œโ”€โ”€ tests/ # Test files +โ””โ”€โ”€ docs/ # The README images +``` + +## Community + +- **Discord**: Join our [Discord server](https://discord.gg/n9qpyhzp2u) for discussions +- **Documentation**: Visit [docs.agentcat.com](https://docs.agentcat.com) for detailed guides +- **Issues**: Browse [open issues](https://github.com/agentcathq/agentcat-php-sdk/issues) for areas needing help + +## Versioning + +The AgentCat team handles versioning and releases. Your contributions will be included in the next appropriate release based on semantic versioning principles: everything that is not marked `@internal` is public API, and the backward-compatibility check compares each PR with the latest 1.x release. + +## Recognition + +All contributors are recognized in our repository. Your contributions help make AgentCat better for everyone building MCP servers! + +## Questions? + +If you have questions about contributing, feel free to: + +- Ask in our [Discord server](https://discord.gg/n9qpyhzp2u) +- Open an [issue](https://github.com/agentcathq/agentcat-php-sdk/issues) on GitHub + +We answer issues and pull requests as soon as we can. + +Thank you for contributing to AgentCat! ๐Ÿฑ diff --git a/README.md b/README.md index 0a4192f..0abbbc0 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,273 @@ -# agentcat/sdk +
+ AgentCat โ€” see exactly how agents experience your product +
+

+ Getting Started + ยท + Features + ยท + Docs + ยท + Website + ยท + Open Source + ยท + Schedule a Demo +

+

+ Packagist version + Packagist downloads + License: MIT + PHP + GitHub issues + CI +

-Analytics for MCP (Model Context Protocol) servers. +> [!NOTE] +> AgentCat for PHP is compatible with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) as the PHP libraries put it into effect: [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk) over HTTP since 0.8 and over stdio since 0.9.0, and [`laravel/mcp`](https://github.com/laravel/mcp) 1.x. The stateless transition has a massive impact on analytics, as sessions were a built-in concept tying related tool calls together. AgentCat has now migrated its session tracking under guidance of the MCP core team's recommendations of using [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567). +> +> As a result AgentCat now injects a `session_id` on every MCP tool call to associate them under the same task umbrella. Our evals show much higher tool correlation accuracy at the cost of < 1% additional context pollution. -> **Status: in development.** This package is the official AgentCat PHP SDK -> for MCP servers. The first release is not published yet, so do not depend on -> it in production. A full README will ship with `1.0.0`. +AgentCat is an analytics platform for MCP server owners ๐Ÿฑ. It captures user intentions and behavior patterns to help you understand what AI users actually need from your tools โ€” eliminating guesswork and accelerating product development all with one-line of code. -AgentCat captures how AI agents actually use your MCP server: which tools get -called, how often, by whom, and โ€” via intent capture โ€” why. +This SDK also provides a free and simple way to forward telemetry like logs, traces, and errors to any Open Telemetry collector or popular tools like Datadog, Sentry, and PostHog. -## Available today +```bash +composer require agentcat/sdk +``` -| Language | Package | -| ---------- | ------------------------------------------------------------------------ | -| TypeScript | [`agentcat`](https://www.npmjs.com/package/agentcat) | -| Python | [`agentcat`](https://pypi.org/project/agentcat/) | -| Go | [`go.agentcat.com/sdk`](https://github.com/agentcathq/agentcat-go-sdk) | -| Ruby | [`agentcat`](https://rubygems.org/gems/agentcat) (placeholder) | -| .NET | [`AgentCat`](https://www.nuget.org/packages/AgentCat) (placeholder) | -| Rust | [`agentcat-sdk`](https://crates.io/crates/agentcat-sdk) (placeholder) | -| PHP | *in development* | +To learn more about us, check us out [here](https://agentcat.com). For detailed guides visit our [documentation](https://docs.agentcat.com). -- Website โ€” https://agentcat.com -- Docs โ€” https://docs.agentcat.com +## Why use AgentCat? ๐Ÿค” -## License +AgentCat helps builders of MCP servers, Claude Connectors, and ChatGPT Plugins learn how to improve them by capturing any agents goals and detecting when they get stuck. -MIT ยฉ AgentCat, Inc. +Use AgentCat for: + +- **Agent session replay** ๐ŸŽฌ. Follow alongside your users and their agents to understand why they're using your MCP servers, what functionality you're missing, and what clients they're coming from. +- **Trace debugging** ๐Ÿ”. See where your users are getting stuck, track and find when LLMs get confused by your API, and debug sessions across all deployments of your MCP server. +- **Existing platform support** ๐Ÿ“Š. Get logging and tracing out of the box for your existing observability platforms (OpenTelemetry, Datadog, Sentry) โ€” eliminating the tedious work of implementing telemetry yourself. + +AgentCat architecture โ€” the AgentCat SDK inside your MCP server sends analytics to your observability vendors and session replay to the AgentCat dashboard + +## How it works + +AgentCat works as a lightweight middleware inside your MCP server. When you call `track()`, it seamlessly modifies your registered tool schemas in place, following the MCP core team's [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) guidelines. Concretely, AgentCat adds the following to your server: + +- **`session_id`** โ€” a parameter injected into each tool's input schema. Agents echo it back on every call, letting AgentCat group related tool calls into one task even over stateless transports. Values are validated: anything that is not a well-formed AgentCat session ID is rejected rather than adopted, and the agent is told to re-send the ID it was given. +- **`agent_id`** _(off by default)_ โ€” enabled with `enableAgentTracking: true`. Each agent self-generates its own ID, keeping parallel agents working the same task individually attributable. +- **`context`** โ€” a parameter asking the agent to explain, in one sentence, why it is making this call. This is where intent data comes from. +- **`get_more_tools`** โ€” an additional tool, prompt-engineered so that agents readily report the features and tools they looked for but couldn't find โ€” surfacing your missing functionality directly from real usage. + +Injected parameters are stripped from arguments before your tool handler runs, so your code never sees them. For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `mcp_session`), so clients that only read structured results still receive them. + +## Getting Started + +To get started with AgentCat, first create an account and obtain your project ID by signing up at [agentcat.com](https://agentcat.com). For detailed setup instructions visit our [documentation](https://docs.agentcat.com). + +Once you have your project ID, set up AgentCat for your MCP library below; the [quickstart](https://docs.agentcat.com/quickstart) walks through each setup step by step. AgentCat stays off while your tests run, and `AgentCat::fake()` records the events of a test for your assertions. + +### Plain `mcp/sdk` + +Install AgentCat next to `mcp/sdk` (`composer require agentcat/sdk mcp/sdk`), then track the `Mcp\Server\Builder` with one call, before `build()`: + +```php +setServerInfo('echo-mcp', '0.1.0') + ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message'); + +// Track the server with AgentCat +AgentCat::track($builder, 'proj_0000000'); + +$builder->build()->run(new StdioTransport()); +``` + +With `null` instead of the ID, AgentCat reads it from `AGENTCAT_PROJECT_ID`. Under PHP-FPM every request runs your script, so every request builds and tracks a fresh server, and tracking it costs no more than tracking one long-lived server. Within one process, calling `track()` per instance is cheap โ€” the event queue, telemetry exporters, and diagnostics are initialized once and shared across instances. The [`mcp/sdk` guide](https://docs.agentcat.com/sdk/php/mcp-sdk) has the details, HTTP servers included. + +### Laravel + +Install the package and run its install command, which asks for your project ID and writes it to `.env` as `AGENTCAT_PROJECT_ID`: + +```bash +composer require agentcat/sdk +php artisan agentcat:install +``` + +That is all: every `laravel/mcp` server of the application is tracked, except Laravel Boost's, also when `laravel/mcp` builds the server for each request. To change the options, publish `config/agentcat.php`: + +```bash +php artisan vendor:publish --tag=agentcat-config +``` + +The [Laravel guide](https://docs.agentcat.com/sdk/php/laravel) has the details. + +### Symfony + +Register the bundle in `config/bundles.php`: + +```php +return [ + // ... + AgentCat\Symfony\AgentCatBundle::class => ['all' => true], +]; +``` + +Then set your project ID in `.env.local` (or in the environment of your server), and every `symfony/mcp-bundle` server of the application is tracked: + +```dotenv +AGENTCAT_PROJECT_ID=proj_0000000 +``` + +A `config/packages/agentcat.yaml` file is optional: `php bin/console config:dump-reference agentcat` lists the options it can hold. The [Symfony guide](https://docs.agentcat.com/sdk/php/symfony) has the details. + +### Identifying users + +We strongly encourage identifying every actor. If you can't resolve a real user, return a stable anonymized ID instead โ€” for example, a hash of the auth token or API key โ€” so that all events from the same end user still roll up to one actor in your dashboard rather than scattering into anonymous one-off sessions. + +`identify` (like every AgentCat hook) runs after the client has your tool's answer, so it never adds latency to that tool call: a slow lookup โ€” or a hook that fails outright โ€” costs analytics data for that event, never that call's response time. + +The callback receives a `ToolCall`: the tool name, the raw arguments and `_meta`, the HTTP headers, the client's name and version, and the request and context objects of your MCP library. Most identity signals live in its `headers`, with lower-case names (empty on stdio): + +```php +use AgentCat\AgentCat; +use AgentCat\Identity; +use AgentCat\Options; +use AgentCat\ToolCall; + +AgentCat::track($builder, 'proj_0000000', new Options( + identify: static function (ToolCall $call): ?Identity { + $token = $call->headers['authorization'] ?? null; + if ($token === null) { + return null; + } + + return new Identity( + userId: hash('sha256', $token), // a stable anonymized ID, or your own user ID + userData: ['orgId' => $call->headers['x-org-id'] ?? null], + ); + }, +)); +``` + +In Laravel, the `identify` key of `config/agentcat.php` names an invokable class that receives the same `ToolCall`; in Symfony, the `identify` key of the `agentcat` configuration names an invokable service. + +### Redacting sensitive data + +AgentCat redacts all data sent to its servers and encrypts at rest, but for additional security, it offers a hook to do your own redaction on all text data returned back to our servers. + +```php +use AgentCat\AgentCat; +use AgentCat\Options; + +AgentCat::track($builder, 'proj_0000000', new Options( + redactSensitiveInformation: static fn(string $text): string => preg_replace('/\bsk_live_\w+/', '[REDACTED]', $text) ?? $text, +)); +``` + +For redaction decisions that need more context than a single string โ€” such as which tool was called or what type of event is being published โ€” use the event-level `redactEvent` hook. It receives the full event object and returns a modified event, or `null` to drop the event entirely. It can be combined with `redactSensitiveInformation`. + +```php +use AgentCat\AgentCat; +use AgentCat\Event; +use AgentCat\Options; + +AgentCat::track($builder, 'proj_0000000', new Options( + redactEvent: static function (Event $event): ?Event { + // Drop events from tools that handle secrets entirely + if ($event->resourceName === 'get_credentials') { + return null; + } + // Strip response payloads from a specific tool + if ($event->resourceName === 'export_report') { + $event->response = null; + } + + return $event; + }, +)); +``` + +### Vendor Support + +AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously: + +```php +use AgentCat\AgentCat; +use AgentCat\Exporters\DatadogExporter; +use AgentCat\Exporters\OtlpExporter; +use AgentCat\Exporters\PostHogExporter; +use AgentCat\Exporters\SentryExporter; +use AgentCat\Options; + +// The project ID can be '' (or null without AGENTCAT_PROJECT_ID) if you just want to forward telemetry +AgentCat::track($builder, 'proj_0000000', new Options( + exporters: [ + 'otlp' => new OtlpExporter(endpoint: 'http://localhost:4318/v1/traces'), + 'datadog' => new DatadogExporter( + apiKey: (string) getenv('DD_API_KEY'), + site: 'datadoghq.com', + service: 'my-mcp-server', + ), + 'sentry' => new SentryExporter( + dsn: (string) getenv('SENTRY_DSN'), + environment: 'production', + ), + 'posthog' => new PostHogExporter( + apiKey: (string) getenv('POSTHOG_API_KEY'), + host: 'https://us.i.posthog.com', // Optional: defaults to US region + ), + ], +)); +``` + +Learn more about our free and open source [telemetry integrations](https://docs.agentcat.com/telemetry/integrations). + +### Internal diagnostics + +To help us catch and fix broken installs, the SDK sends AgentCat every line it logs, with its time and level. The lines can contain session IDs, actor IDs, tool names, error messages and the answers of exporters that failed. Records also carry operational metadata, such as your project ID (or, when none is set, an install ID that hashes the host name and the install path), the OS, and the versions of PHP, the SDK and `mcp/sdk`. Your local log is unchanged: `~/agentcat.log` (or the PSR-3 logger of your `mcp/sdk` Builder) in plain PHP, `storage/logs/agentcat.log` in Laravel and `%kernel.logs_dir%/agentcat.log` in Symfony. + +Diagnostics are on by default and can be turned off completely with either: + +- `AgentCat::track($builder, $projectId, new Options(disableDiagnostics: true))`, or +- the `AGENTCAT_DISABLE_DIAGNOSTICS` (or `DISABLE_DIAGNOSTICS`) environment variable. + +## PHP runtimes + +- **PHP-FPM**: AgentCat runs the hooks and delivers the events after `fastcgi_finish_request()`, when the client has its answer. +- **FrankenPHP**: classic mode works like PHP-FPM; in worker mode, a plain `mcp/sdk` worker calls `AgentCat::flush(1.0)` after each request in its `frankenphp_handle_request()` loop, and Laravel Octane and Symfony need nothing. +- **Apache mod_php and NGINX Unit**: the client gets its answer before AgentCat delivers; on mod_php the answer needs a `Content-Length`, which the Laravel and Symfony bridges set for you and which a plain `mcp/sdk` script must set on the answer itself. +- **The CLI**: a stdio server runs the hooks and delivers the events of a call when the next `tools/call` or `tools/list` arrives and when its input ends, so a slow hook delays that next request; AgentCat writes nothing to STDOUT or STDERR, so stdout stays JSON-RPC. +- **Containers**: start a stdio server with `docker run --init`, so that a stop signal ends PHP and AgentCat (with ext-pcntl) first delivers the queued events. +- **Swoole, RoadRunner and Workerman**: with Laravel or Symfony, AgentCat delivers in the framework's terminate hooks (Laravel's `terminating()` callbacks, also under Octane, and Symfony's `kernel.terminate`), waiting at most 1 s after each request; a plain `mcp/sdk` worker calls `AgentCat::flush(1.0)` after each request. + +AgentCat supports PHP 8.3, 8.4 and 8.5; `mcp/sdk` 0.8.1 or later (tested 0.8.1 and 0.9.0); `laravel/mcp` 1.x (tested 1.0.0 and 1.1.0) with Laravel 12.41.1 or later and 13; and `symfony/mcp-bundle` 0.13 and 0.14 with Symfony 7.3 and 8.x. The [runtimes guide](https://docs.agentcat.com/sdk/php/runtimes) covers each runtime, containers and hardened hosts, and the [limits page](https://docs.agentcat.com/sdk/php/limits) lists the differences from the TypeScript SDK and the known limits. + +## Free for open source + +AgentCat is free for qualified open source projects. We believe in supporting the ecosystem that makes MCP possible. If you maintain an open source MCP server, you can access our full analytics platform at no cost. + +**How to apply**: Email [hi@agentcat.com](mailto:hi@agentcat.com) with your repository link + +_Already using AgentCat? We'll upgrade your account immediately._ + +## Community Cats ๐Ÿฑ + +Meet the cats behind AgentCat! Add your cat to our community by submitting a PR with your cat's photo in the `docs/cats/` directory of the [TypeScript SDK](https://github.com/agentcathq/agentcat-typescript-sdk/tree/main/docs/cats), whose cats this README shows too. + +
+ bibi + zelda + void +
+ +_Want to add your cat? Create a PR adding your cat's photo to_ [`docs/cats/` of the TypeScript SDK](https://github.com/agentcathq/agentcat-typescript-sdk/tree/main/docs/cats) _and update its README!_ diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 046bf9b..4f4bf15 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -8,9 +8,13 @@ return (new Configuration()) // ext-pcntl, ext-posix and ext-session are optional: FileLogger, SignalDrain and RequestEnd guard their use with function_exists(). ->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix', 'ext-session'], [ErrorType::SHADOW_DEPENDENCY]) + // ext-tokenizer reads the attributes of the test files in TraceabilityTest; php-cs-fixer/shim, a dev dependency, requires it. + ->ignoreErrorsOnExtensionAndPath('ext-tokenizer', __DIR__ . '/tests/Architecture/TraceabilityTest.php', [ErrorType::SHADOW_DEPENDENCY]) + // ext-dom and ext-libxml read the PHPUnit baseline in PackageFilesTest; phpunit/phpunit requires them. + ->ignoreErrorsOnExtensionsAndPaths(['ext-dom', 'ext-libxml'], [__DIR__ . '/tests/Architecture/PackageFilesTest.php'], [ErrorType::SHADOW_DEPENDENCY]) // Test fixture functions, loaded with require_once by ExceptionCaptureTest (not autoloadable). ->ignoreUnknownFunctionsRegex('~^AgentCat\\\\Tests\\\\Fixtures\\\\Errors\\\\~') - // mcp/sdk is optional (01 AC-90): a dev dependency and a suggestion, used only by the adapter. + // mcp/sdk is optional (ac-01-90): a dev dependency and a suggestion, used only by the adapter. ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/McpSdk', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // AgentCatFake asserts with PHPUnit only when the class is loaded (class_exists()); phpunit stays a dev dependency. ->ignoreErrorsOnPackageAndPath('phpunit/phpunit', __DIR__ . '/src/Testing', [ErrorType::DEV_DEPENDENCY_IN_PROD]) @@ -20,7 +24,7 @@ ->ignoreErrorsOnPackageAndPath('psr/event-dispatcher', __DIR__ . '/tests/Support/McpSdk/RecordingDispatcher.php', [ErrorType::SHADOW_DEPENDENCY]) // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixtures build their request from the globals with it, like a customer. ->ignoreErrorsOnPackageAndPaths('php-http/discovery', [__DIR__ . '/tests/E2E/Fixtures', __DIR__ . '/tests/Runtime/Fixtures'], [ErrorType::SHADOW_DEPENDENCY]) - // laravel/mcp and Laravel are optional (01 AC-90): dev dependencies used only by the Laravel adapter and bridge. + // laravel/mcp and Laravel are optional (ac-01-90): dev dependencies used only by the Laravel adapter and bridge. ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) @@ -28,7 +32,7 @@ // False positives: the analyser reads AgentCat's own `Env::get()` and `new Event(` as Laravel's global env() and // event() helpers. PHPat keeps Laravel out of these files (LayeringTest). ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Internal/Support/BridgeConfig.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) - // Symfony is optional (01 AC-90): dev dependencies used only by the Symfony bundle. + // Symfony is optional (ac-01-90): dev dependencies used only by the Symfony bundle. ->ignoreErrorsOnPackagesAndPaths(['symfony/config', 'symfony/dependency-injection', 'symfony/http-foundation', 'symfony/http-kernel'], [__DIR__ . '/src/Symfony'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) // The bundle configures the Mcp\Server\Builder services of symfony/mcp-bundle, which requires mcp/sdk. ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) diff --git a/composer.json b/composer.json index 54172b0..97338f2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "agentcat/sdk", - "description": "Analytics for MCP (Model Context Protocol) servers: session replay, intent, and telemetry for PHP MCP servers.", + "description": "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights", "license": "MIT", "type": "library", "keywords": [ @@ -9,7 +9,26 @@ "analytics", "agentcat", "ai", - "llm" + "llm", + "authentication", + "observability", + "ai-agents", + "ai-platform", + "ai-agent", + "mcps", + "aiagents", + "ai-agent-tools", + "mcp-servers", + "mcp-server", + "mcp-tools", + "agent-runtime", + "mcp-framework", + "mcp-analytics", + "agentcat-analytics", + "php", + "laravel", + "symfony", + "mcp-sdk" ], "authors": [ { @@ -30,7 +49,7 @@ "ext-json": "*", "ext-mbstring": "*", "composer-runtime-api": "^2.2", - "agentcat/api": "~0.0.3", + "agentcat/api": "~0.0.4", "psr/log": "^2.0 || ^3.0" }, "require-dev": { diff --git a/docs/cats/bibi.png b/docs/cats/bibi.png new file mode 100644 index 0000000..263651e Binary files /dev/null and b/docs/cats/bibi.png differ diff --git a/docs/cats/void.jpg b/docs/cats/void.jpg new file mode 100644 index 0000000..11486d1 Binary files /dev/null and b/docs/cats/void.jpg differ diff --git a/docs/cats/zelda.jpg b/docs/cats/zelda.jpg new file mode 100644 index 0000000..cada64d Binary files /dev/null and b/docs/cats/zelda.jpg differ diff --git a/docs/static/architecture.png b/docs/static/architecture.png new file mode 100644 index 0000000..d89d36c Binary files /dev/null and b/docs/static/architecture.png differ diff --git a/docs/static/og-image.png b/docs/static/og-image.png new file mode 100644 index 0000000..a8fe6de Binary files /dev/null and b/docs/static/og-image.png differ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b7b587d..74a96be 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,6 +15,13 @@ parameters: # analysed), and it has no Eloquent models. noEnvCallsOutsideOfConfig: false checkModelProperties: false + ignoreErrors: + - + # The probe runs in a fresh project (tests/bin/package-probe.sh) whose vendor/ does not exist in this repository. + message: '#^Path in require\(\) .* is not a file or it does not exist\.$#' + identifier: require.fileNotFound + path: tests/Fixtures/package-probe/probe.php + count: 1 type_coverage: return: 100 param: 100 @@ -33,8 +40,10 @@ parameters: message: 'Reflection is for contract tests only' allowIn: - tests/Contract/* - # builds a cyclic `previous` chain and a corrupt trace (05 E10) + # builds a cyclic `previous` chain and a corrupt trace (ac-05-E10) - tests/Unit/Internal/Errors/ExceptionCaptureTest.php + # reads libcurl's queue of finished transfers off the transport's multi handle: no time can show it + - tests/Support/UnreadTransfers.php services: - class: AgentCat\Tests\Architecture\LayeringTest diff --git a/phpunit-baseline.xml b/phpunit-baseline.xml new file mode 100644 index 0000000..71e2bce --- /dev/null +++ b/phpunit-baseline.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..41d4033 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,77 @@ +## AgentCat (agentcat/sdk) + +AgentCat is analytics for the MCP servers of this application. It records one event for each tool call of every `laravel/mcp` server, with the reason the agent gives for the call, and sends the events to an AgentCat project. + +### Install + +- Install the package with `composer require agentcat/sdk`. Laravel discovers its service provider. +- Run `php artisan agentcat:install`. It asks for the project ID (`proj_` and 27 letters or digits) and writes it to `.env` as `AGENTCAT_PROJECT_ID`. `php artisan agentcat:install proj_โ€ฆ --config` takes the ID as an argument and also publishes `config/agentcat.php`. +- Without the command, set `AGENTCAT_PROJECT_ID` in `.env`. Without a project ID, no event goes to AgentCat. + +### What AgentCat tracks with no code + +- Every `laravel/mcp` server of the application, also when `laravel/mcp` builds the server for each request. The server of Laravel Boost is excluded. +- Every tool call. AgentCat adds a `session_id` parameter to the input schema of each tool, a `context` parameter unless `enable_tool_call_context` is false, and `agent_id` when `enable_agent_tracking` is true. It removes them from the arguments before the tool runs. Do not declare or read these parameters in a tool. +- A `get_more_tools` tool, which agents call to report a tool that they looked for and did not find. Do not give a tool of your own this name. +- AgentCat runs its hooks and sends the events after the client has the answer of the call (over HTTP, in the `terminating()` callbacks of Laravel), so it adds no latency to that call. + +### Configuration + +Publish `config/agentcat.php` with `php artisan vendor:publish --tag=agentcat-config`. The keys for hooks and exclusion: + +- `exclude`: server classes that AgentCat never tracks. A class also excludes its subclasses. +- `projects`: another project for some servers, as server class => project ID. +- `enabled`: true or false for every server (`AGENTCAT_ENABLED`). Unset: on, except while tests run. +- `identify`, `resolve_session_id`, `event_tags`, `event_properties`, `redact_sensitive_information` and `redact_event`: the hooks. Each hook is the class name of an invokable class, which the container resolves when the hook runs. A closure cannot go in this file, because `php artisan config:cache` cannot store it. + +An `identify` hook receives the `AgentCat\ToolCall` (tool name, arguments, lower-case HTTP headers, client name and version) and returns who made the call, or null: + +```php +namespace App\AgentCat; + +use AgentCat\Identity; +use AgentCat\ToolCall; + +final class IdentifyActor +{ + public function __invoke(ToolCall $call): ?Identity + { + $token = $call->headers['authorization'] ?? null; + + return $token === null ? null : new Identity(userId: hash('sha256', $token)); + } +} +``` + +In `config/agentcat.php`: `'identify' => App\AgentCat\IdentifyActor::class,`. + +### Tests + +AgentCat is off while tests run. `AgentCat::fake()` records the events of a test and sends nothing. Call it before the test builds a server, and restore the real client with `AgentCat::setClient(null)`: + +```php +use AgentCat\AgentCat; +use AgentCat\Event; +use App\Mcp\Servers\WeatherServer; +use App\Mcp\Tools\CurrentWeatherTool; + +public function test_the_weather_tool_call_is_tracked(): void +{ + $fake = AgentCat::fake(); + + WeatherServer::tool(CurrentWeatherTool::class, ['location' => 'Paris'])->assertHasNoErrors(); + + $fake->assertTrackedCount(1); + $fake->assertTracked(fn (Event $event): bool => $event->isError !== true); +} + +protected function tearDown(): void +{ + AgentCat::setClient(null); + parent::tearDown(); +} +``` + +The fake also has `assertNothingTracked()`, `assertCustomEventPublished()`, `events()` and `logs()` (the lines AgentCat logged, hook errors included). + +The Laravel guide has the details: https://docs.agentcat.com/sdk/php/laravel diff --git a/src/Client.php b/src/Client.php index 82d23ae..d4f5542 100644 --- a/src/Client.php +++ b/src/Client.php @@ -117,6 +117,12 @@ public function track(object $server, ?string $projectId = null, ?Options $optio Log::write('WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored'); } $library = Libraries::for($server); + $tracked = TrackedServers::get($server) instanceof TrackedServer; + if (!$library instanceof Library) { + // The Server that a tracked Builder built: it is tracked through its Builder, which holds the handlers. + $library = Libraries::trackerOf($server); + $tracked = true; + } if (!$library instanceof Library) { $signals = Libraries::fingerprint($server); Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, Libraries::supportMatrix())); @@ -127,7 +133,6 @@ public function track(object $server, ?string $projectId = null, ?Options $optio if ($logger instanceof LoggerInterface) { Log::adopt($logger); // D30, D38 } - $tracked = TrackedServers::get($server) instanceof TrackedServer; if (!$tracked) { $library->inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing } @@ -135,7 +140,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio $this->publisher->queue()->configure($options->apiBaseUrl); // before the already-tracked check; the last call wins } $project = $projectId !== '' ? $projectId : '(telemetry-only)'; - Log::write(sprintf('AgentCat setup started | project %s | sdk %s | signals %s', $project, $library->sdk(), implode(',', $library->signals($server)))); + Log::write(sprintf('AgentCat setup started | project %s | sdk %s | signals %s', $project, $library->sdk($server), implode(',', $library->signals($server)))); if ($tracked) { Log::write('[SESSION DEBUG] track() - Server already being tracked, skipping initialization'); @@ -226,6 +231,11 @@ public function publishCustomEvent(object|string $serverOrSessionId, ?string $pr return; } + $advice = Libraries::untrackedAdvice($serverOrSessionId); + if ($advice !== null) { + throw new \InvalidArgumentException('Server is not tracked: ' . $advice); + } + throw new \InvalidArgumentException('Server is not tracked. Please call agentcat.track() first or provide a session ID string.'); } if ($sessionId === '') { diff --git a/src/Internal/ClientSeams.php b/src/Internal/ClientSeams.php index 08756ff..84f1a54 100644 --- a/src/Internal/ClientSeams.php +++ b/src/Internal/ClientSeams.php @@ -33,7 +33,7 @@ private function __construct( * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`) * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework (the Symfony * bundle's `RequestStack`), for every server the client tracks; - * null reads the SAPI globals (plan E2 header conflict 5) + * null reads the SAPI globals */ public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true, ?\Closure $httpContext = null): self { diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 5db4ea7..a14b5f1 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -6,6 +6,7 @@ use AgentCat\Event; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\RepeatedLines; use AgentCat\Internal\Pipeline\EventProcessor; use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Support\ErrorText; @@ -365,6 +366,7 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg return false; } $this->busy = true; + RepeatedLines::begin(); // a failure of every pass is logged once try { $until = $deadline(); @@ -379,9 +381,10 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg if ($this->transport->now() >= $until) { return false; } - // Slots freed by the last pump are refilled at once; waiting would sleep with idle slots. - if ($this->queued() === 0 || $this->transport->inFlight() >= self::CONCURRENCY) { - $this->wait($until); + // Slots freed by the last pump are refilled at once; waiting would sleep with idle slots. A transport + // that cannot wait (a host without a sleep function) ends the delivery: no pass spins to the deadline. + if (($this->queued() === 0 || $this->transport->inFlight() >= self::CONCURRENCY) && !$this->wait($until)) { + return false; } } } catch (\Throwable $e) { @@ -390,23 +393,25 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg return false; } finally { $this->busy = false; + RepeatedLines::end(); } } /** * Waits on the transport alone, or, while side requests are pending, a slice on each that never ends past `$until`. * `$until` is never NaN. The time left goes through `CurlSupport::waitSeconds()`: within 0 and the slice, and 0 for - * the NaN of INF - INF (a test clock that jumped to an INF deadline). + * the NaN of INF - INF (a test clock that jumped to an INF deadline). False when the transport alone would wait and + * cannot (`Transport::waitForActivity()`); while side requests are pending, their transfers block the slice. */ - private function wait(float $until): void + private function wait(float $until): bool { if ($this->channel->pending() === 0) { - $this->transport->waitForActivity($until); - - return; + return $this->transport->waitForActivity($until); } $this->transport->waitForActivity(min($until, $this->transport->now() + self::SLICE_SECONDS)); $this->channel->waitForActivity(CurlSupport::waitSeconds($until - $this->transport->now(), self::SLICE_SECONDS)); + + return true; } /** @@ -430,7 +435,7 @@ private function pumpOnce(bool $tick = true): void } $this->transport->pump(); } catch (\Throwable $e) { - Log::write(self::GAVE_UP . ErrorText::of($e)); + RepeatedLines::write(self::GAVE_UP . ErrorText::of($e)); } if ($tick) { foreach ($this->companions as $companion) { diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index e861bfb..7d59358 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -4,6 +4,8 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Support\Env; + /** * The SAPI function that sends the HTTP response to the client before the script ends, so that delivery at * shutdown does not delay the response. @@ -20,7 +22,7 @@ final class FinishRequest * * @param array $server `$_SERVER` * @param \Closure(string): bool $functionExists - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function functionFor(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): ?string { @@ -39,11 +41,11 @@ public static function functionFor(string $sapi, array $server, \Closure $functi * FrankenPHP worker mode: `$_SERVER['FRANKENPHP_WORKER']` is set, or the `FRANKENPHP_WORKER` environment variable. * * @param array $server `$_SERVER` - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = null): bool { - return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false; + return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? Env::lookup(...))('FRANKENPHP_WORKER') !== false; } /** @@ -64,7 +66,7 @@ private static function isUnit(array $server): bool * stdio server) or FrankenPHP worker mode. * * @param array $server `$_SERVER` - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function outlivesRequest(string $sapi, array $server, ?\Closure $getenv = null): bool { @@ -77,7 +79,7 @@ public static function outlivesRequest(string $sapi, array $server, ?\Closure $g * * @param array $server `$_SERVER` * @param \Closure(string): bool $functionExists - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function responseEndsWithTheScript(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): bool { @@ -92,18 +94,24 @@ public static function detect(): ?\Closure /** * Calls `$function` after `ignore_user_abort(true)`, which keeps the script alive when the client - * disconnects after its response. + * disconnects after its response. A host can disable `ignore_user_abort()` (`disable_functions`): the call then + * runs alone. + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `ignore_user_abort()` exists; default `function_exists()` * * @return (\Closure(): void)|null null when `$function` is not callable */ - public static function closureFor(?string $function): ?\Closure + public static function closureFor(?string $function, ?\Closure $functionExists = null): ?\Closure { if ($function === null || !is_callable($function)) { return null; } + $ignoreAbort = ($functionExists ?? function_exists(...))('ignore_user_abort'); - return static function () use ($function): void { - ignore_user_abort(true); + return static function () use ($function, $ignoreAbort): void { + if ($ignoreAbort) { + ignore_user_abort(true); + } $function(); }; } diff --git a/src/Internal/Delivery/RequestEnd.php b/src/Internal/Delivery/RequestEnd.php index 6ccc5bd..e858c03 100644 --- a/src/Internal/Delivery/RequestEnd.php +++ b/src/Internal/Delivery/RequestEnd.php @@ -5,12 +5,12 @@ namespace AgentCat\Internal\Delivery; /** - * What the last shutdown pass does on a web SAPI before it delivers (plan E1 header conflicts 14 and 15, D57): it - * writes and closes an active native session, so its lock is not held while AgentCat delivers; it gives the drain its - * own time limit, so a request that used most of `max_execution_time` does not end the drain with a fatal error; and, - * without a finish-request function, it sends the output buffers, so a response that carries a Content-Length - * reaches the client before hooks and delivery run, unless PHP cannot ignore an aborted client. Nothing runs on the - * command line. Every step is guarded: a failing step skips only itself. + * What the last shutdown pass does on a web SAPI before it delivers (D57): it writes and closes an active native + * session, so its lock is not held while AgentCat delivers; it gives the drain its own time limit, so a request that + * used most of `max_execution_time` does not end the drain with a fatal error; and, without a finish-request function, + * it sends the output buffers, so a response that carries a Content-Length reaches the client before hooks and delivery + * run, unless PHP cannot ignore an aborted client. Nothing runs on the command line. Every step is guarded: a failing + * step skips only itself. * * @internal */ diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php index 37ca6ab..01f598d 100644 --- a/src/Internal/Delivery/RequestEndWait.php +++ b/src/Internal/Delivery/RequestEndWait.php @@ -5,12 +5,11 @@ namespace AgentCat\Internal\Delivery; /** - * How long a framework's request-end hook waits for delivery (D58, plan E1 header conflict 13). A process that - * outlives the request โ€” Octane on Swoole or RoadRunner, a Symfony Runtime worker, FrankenPHP worker mode โ€” waits at - * most `WORKER_SECONDS`, so the worker is soon free for the next request. Anywhere else the hook only starts the - * delivery (0: one non-blocking pass): a per-request SAPI runs AgentCat's shutdown function after the hook, and a - * console command ends with the process; both deliver within the queue's one `EventQueue::SHUTDOWN_TIMEOUT_SECONDS` - * deadline (D47). + * How long a framework's request-end hook waits for delivery (D58). A process that outlives the request โ€” Octane on + * Swoole or RoadRunner, a Symfony Runtime worker, FrankenPHP worker mode โ€” waits at most `WORKER_SECONDS`, so the + * worker is soon free for the next request. Anywhere else the hook only starts the delivery (0: one non-blocking pass): + * a per-request SAPI runs AgentCat's shutdown function after the hook, and a console command ends with the process; + * both deliver within the queue's one `EventQueue::SHUTDOWN_TIMEOUT_SECONDS` deadline (D47). * * The wait is `EventQueue::flush()`: a window of its own that fixes no shutdown deadline, keeps the queue open, ticks * the companions without finishing them, and waits for events and exporter requests but not for diagnostics. A wait of @@ -31,7 +30,7 @@ final class RequestEndWait * @param bool $console true for a console command (Laravel `runningInConsole()`; * Octane workers report false); Symfony's kernel.terminate passes false * @param array|null $server `$_SERVER`; null reads it - * @param (\Closure(string): (string|false))|null $getenv default `getenv(...)` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function seconds(bool $console, string $sapi = PHP_SAPI, ?array $server = null, ?\Closure $getenv = null): float { diff --git a/src/Internal/Delivery/ShutdownFlusher.php b/src/Internal/Delivery/ShutdownFlusher.php index 1f0afc7..5553654 100644 --- a/src/Internal/Delivery/ShutdownFlusher.php +++ b/src/Internal/Delivery/ShutdownFlusher.php @@ -4,6 +4,8 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Logging\Log; + /** * Delivers the global queue when the request or script ends. * @@ -12,14 +14,18 @@ * `ShutdownHandler` pattern). When the queue has work (events, exporter requests or diagnostics lines), the final * pass first ends the request (`RequestEnd`: the session, the time limit and, without a finish call, the output * buffers) and sends the HTTP response to the client when the SAPI can (`fastcgi_finish_request()` and friends), then - * shuts the queue down within its drain budget. + * shuts the queue down within its drain budget. Where a host disables `register_shutdown_function()` + * (`disable_functions`), nothing is registered and one line says so: the events still waiting when the script ends are + * lost. * * @internal */ final class ShutdownFlusher { - /** @var \Closure(\Closure(): void): void */ - private readonly \Closure $register; + private const string NO_SHUTDOWN = 'WARN: AgentCat cannot send waiting events when the script ends: register_shutdown_function is disabled'; + + /** @var (\Closure(\Closure(): void): void)|null null where a host disabled `register_shutdown_function()` */ + private readonly ?\Closure $register; private bool $registered = false; @@ -28,22 +34,33 @@ final class ShutdownFlusher * @param (\Closure(\Closure(): void): void)|null $register default `register_shutdown_function(...)` * @param (\Closure(): void)|null $finishRequest sends the response to the client (`FinishRequest::detect()`) * @param RequestEnd|null $requestEnd runs before the finish call when the queue has work + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `register_shutdown_function()` exists; default `function_exists()` */ public function __construct( private readonly \Closure $queue, ?\Closure $register = null, private readonly ?\Closure $finishRequest = null, private readonly ?RequestEnd $requestEnd = null, + ?\Closure $functionExists = null, ) { - $this->register = $register ?? register_shutdown_function(...); + $this->register = $register ?? (($functionExists ?? function_exists(...))('register_shutdown_function') ? register_shutdown_function(...) : null); } - /** Registers the shutdown function once. A registrar error reaches the caller (the queue logs it). */ + /** + * Registers the shutdown function once; without `register_shutdown_function()`, logs NO_SHUTDOWN once instead. A + * registrar error reaches the caller (the queue logs it). + */ public function ensureRegistered(): void { if ($this->registered) { return; } + if (!$this->register instanceof \Closure) { + $this->registered = true; + Log::write(self::NO_SHUTDOWN); + + return; + } ($this->register)($this->onShutdown(...)); $this->registered = true; } @@ -52,7 +69,9 @@ public function ensureRegistered(): void public function onShutdown(): void { try { - ($this->register)($this->onFinalShutdown(...)); + ($this->register ?? static function (\Closure $pass): void { + $pass(); // no registrar: the final pass runs now + })($this->onFinalShutdown(...)); } catch (\Throwable) { $this->onFinalShutdown(); // cannot move to the end of the list: flush now } diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php index 038d45b..020316f 100644 --- a/src/Internal/Delivery/SignalDrain.php +++ b/src/Internal/Delivery/SignalDrain.php @@ -4,13 +4,17 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Logging\Log; + /** * Delivers the global queue when a long-running CLI server (an MCP stdio server) gets SIGTERM or SIGINT. Without * a handler, these signals end PHP at once: no shutdown function runs, and the waiting events are lost. * * The global queue calls `install()` when it gets an event, so nothing happens at autoload. It installs once, only - * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. It installs nothing when ext-swoole - * or ext-openswoole is loaded. A signal that the application ignores in PHP (`pcntl_signal(โ€ฆ, SIG_IGN)`) stays + * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. Where it would install but cannot, + * it logs one line instead, with the fix: a host disabled a pcntl function the drain needs (`disable_functions`), or + * PHP lacks ext-pcntl (not on Windows, which has none). It installs nothing when ext-swoole or ext-openswoole is + * loaded. A signal that the application ignores in PHP (`pcntl_signal(โ€ฆ, SIG_IGN)`) stays * ignored; an ignore inherited from the parent process is not visible to PHP, so AgentCat installs its drain for that * signal. When it installs a handler, `install()` also turns on `pcntl_async_signals(true)`, which is process wide: * from then on every PHP signal handler of the process runs between two opcodes, not at `pcntl_signal_dispatch()` or @@ -26,6 +30,15 @@ */ final class SignalDrain { + /** The line of a host whose ext-pcntl lacks a function the drain needs; `%s` lists them. */ + private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s); remove them from disable_functions'; + + /** The line of a PHP without ext-pcntl. */ + private const string NO_PCNTL = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl is not loaded; install ext-pcntl'; + + /** The pcntl functions the drain calls. */ + private const array FUNCTIONS = ['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals']; + /** @var \Closure(int): void */ private readonly \Closure $raise; @@ -35,6 +48,9 @@ final class SignalDrain /** @var \Closure(string): bool */ private readonly \Closure $extensionLoaded; + /** @var \Closure(int): never */ + private readonly \Closure $exit; + private bool $installed = false; private bool $disabled = false; @@ -43,9 +59,12 @@ final class SignalDrain * @param \Closure(): EventQueue $queue the queue to deliver, looked up at the signal * @param (\Closure(int): void)|null $raise raises the signal with the default disposition: default * `pcntl_signal($signal, SIG_DFL)`, then `posix_kill()` to this - * process, or `exit(128 + $signal)` without ext-posix + * process, or `exit(128 + $signal)` without `posix_kill()` or + * `posix_getpid()` * @param (\Closure(string): bool)|null $functionExists default `function_exists(...)` * @param (\Closure(string): bool)|null $extensionLoaded default `extension_loaded(...)` + * @param (\Closure(int): never)|null $exit tests only: ends the process with a status; default `exit()` + * @param string $osFamily `PHP_OS_FAMILY` */ public function __construct( private readonly \Closure $queue, @@ -53,10 +72,15 @@ public function __construct( ?\Closure $functionExists = null, private readonly string $sapi = PHP_SAPI, ?\Closure $extensionLoaded = null, + ?\Closure $exit = null, + private readonly string $osFamily = PHP_OS_FAMILY, ) { $this->raise = $raise ?? $this->raiseWithDefaultDisposition(...); $this->functionExists = $functionExists ?? function_exists(...); $this->extensionLoaded = $extensionLoaded ?? extension_loaded(...); + $this->exit = $exit ?? static function (int $status): never { + exit($status); + }; } /** Never installs after this. False when the handlers are installed already (then nothing changes). */ @@ -70,7 +94,10 @@ public function disable(): bool return true; } - /** Installs the handlers once. Does nothing outside the CLI SAPI, under Swoole or OpenSwoole, or without ext-pcntl. */ + /** + * Installs the handlers once. Does nothing outside the CLI SAPI or under Swoole or OpenSwoole. Without a function of + * FUNCTIONS it logs one line once: `DISABLED` when ext-pcntl is loaded, else `NO_PCNTL` (none on Windows). + */ public function install(): void { if ($this->installed || $this->disabled || $this->sapi !== 'cli') { @@ -81,10 +108,16 @@ public function install(): void return; // a coroutine server stops its workers with C-level handlers PHP cannot see or chain } } - foreach (['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals'] as $function) { - if (!($this->functionExists)($function)) { - return; + $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); + if ($missing !== []) { + $this->disabled = true; // checked once + if (($this->extensionLoaded)('pcntl')) { + Log::write(sprintf(self::DISABLED, implode(', ', $missing))); // the extension is there: a host disabled them + } elseif ($this->osFamily !== 'Windows') { + Log::write(self::NO_PCNTL); } + + return; } $this->installed = true; $installedAny = false; @@ -105,8 +138,8 @@ public function install(): void } /** - * Shuts the queue down, then calls the earlier handler (`03` AC-11: the process keeps running) or raises the - * signal again (`03` AC-10). + * Shuts the queue down, then calls the earlier handler (ac-03-11: the process keeps running) or raises the + * signal again (ac-03-10). * * @param mixed $previous the handler before ours: a callable, or `SIG_DFL` * @param mixed $siginfo PHP's second handler argument (an array), passed on to `$previous` unchanged @@ -129,11 +162,11 @@ public function onSignal(int $signal, mixed $previous, mixed $siginfo = null): v private function raiseWithDefaultDisposition(int $signal): void { pcntl_signal($signal, \SIG_DFL); - if (($this->functionExists)('posix_kill')) { + if (($this->functionExists)('posix_kill') && ($this->functionExists)('posix_getpid')) { posix_kill(posix_getpid(), $signal); // the default disposition of SIGTERM and SIGINT ends the process here return; } - exit(128 + $signal); // the shell's exit status for "ended by this signal" + ($this->exit)(128 + $signal); // the shell's exit status for "ended by this signal" } } diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index 8c5313e..b66c74d 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -10,6 +10,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\BridgeConfig; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\Env; use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Transport\HttpRequest; @@ -106,8 +107,9 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue $queue, DiagnosticsAttributes::resource($projectId), DiagnosticsAttributes::record(), - $clock ?? static fn(): float => hrtime(true) / 1e9, - $wallMillis ?? static fn(): int => (int) (microtime(true) * 1000), // whole milliseconds, like Date.now() + $clock ?? Clock::monotonic(), + // Whole milliseconds, like Date.now(), from DateTimeImmutable: a host can disable microtime(). + $wallMillis ?? static fn(): int => (int) (new \DateTimeImmutable())->format('Uv'), FinishRequest::outlivesRequest($sapi ?? PHP_SAPI, $_SERVER), ); self::$active = $diagnostics; diff --git a/src/Internal/Engine/CallInterceptor.php b/src/Internal/Engine/CallInterceptor.php index 393e6b3..cf03f78 100644 --- a/src/Internal/Engine/CallInterceptor.php +++ b/src/Internal/Engine/CallInterceptor.php @@ -18,8 +18,8 @@ use AgentCat\Internal\Tools\GetMoreTools; /** - * Port of the TypeScript tools/call wrapper (callWrap.ts, docs/audit/02-engine.md ยง2.8). One event per traced call; - * customer hooks never run here (D04). Rethrows the library's exceptions unchanged and adds none of its own (D32). + * Port of the TypeScript tools/call wrapper (callWrap.ts). One event per traced call; customer hooks never run here + * (D04). Rethrows the library's exceptions unchanged and adds none of its own (D32). * * @internal */ @@ -59,7 +59,7 @@ public function intercept(TrackedServer $server, CallSeam $seam): mixed $start = ($this->clock)(); try { - ToolListing::ensureRegistries($server, $seam->listOriginal(...)); + ToolListing::ensureRegistries($server, $name, $seam->listOriginal(...)); $arguments = $seam->arguments(); $resolution = HandleResolver::resolve($arguments, $server->hookMode(), $options->enableAgentTracking, !$server->injection->hasDeclaredSessionParam($name)); $call = $this->start($server, $name, $arguments, $resolution, $seam->describe(), $start, $intercept); @@ -155,7 +155,7 @@ private function start( sessionId: $resolution->sessionId, eventType: Constants::TOOLS_CALL_EVENT_TYPE, timestamp: $start, - // Header conflict 4: mcp/sdk cannot tell empty arguments from absent ones. + // mcp/sdk cannot tell empty arguments from absent ones, so empty arguments count no input tokens. inputTokens: $arguments instanceof \stdClass && get_object_vars($arguments) === [] ? null : TokenEstimator::input($arguments), resourceName: $name === '' ? 'Unknown Tool' : $name, parameters: (object) ['request' => $context->request, 'extra' => $context->extra], @@ -191,7 +191,7 @@ private function finish(TracedCall $call, CallSeam $seam, mixed $result, ?Observ try { $observed ??= $seam->observe($result); if ($observed->protocolError !== null) { - // A JSON-RPC error answer: the TS catch path, no mint-back (header conflict 3). + // A JSON-RPC error answer: the TS catch path, no mint-back. $this->publishFailure($call, $observed->thrown ?? $observed->protocolError); return $result; @@ -240,7 +240,7 @@ private function publish(TracedCall $call): void { if ($call->invalidIntent !== null) { // B16: a number, a boolean or an object as the intent. TypeScript loses this event at truncation, after - // its hooks ran; here they never run (N10). A list intent is published with no intent (PHP divergence: + // its hooks ran; here they never run. A list intent is published with no intent (PHP divergence: // TypeScript sends the raw list). Log::write(sprintf('Failed to truncate event: UnexpectedValueException: userIntent is not a string (%s)', $call->invalidIntent)); diff --git a/src/Internal/Engine/CallSeam.php b/src/Internal/Engine/CallSeam.php index c7dd50e..bb8de3b 100644 --- a/src/Internal/Engine/CallSeam.php +++ b/src/Internal/Engine/CallSeam.php @@ -65,11 +65,12 @@ public function replace(mixed $result, \stdClass $tree): mixed; public function answer(\stdClass $tree): mixed; /** - * The `tools` of the library's own first `tools/list` page, or null when there is none. May throw. + * One page of the library's own `tools/list`: the tools of the page at `$cursor` (null for the first) and its next + * cursor (`ToolListing::nextCursor()`; null on the last page), or null when the answer has no tool list. May throw. * - * @return list|null + * @return array{list, ?string}|null */ - public function listOriginal(): ?array; + public function listOriginal(?string $cursor): ?array; /** * For a result that completes later (a stream), a result that calls `$complete` with the final result, and yields diff --git a/src/Internal/Engine/CallTrees.php b/src/Internal/Engine/CallTrees.php index 0129bfc..5c4f7e2 100644 --- a/src/Internal/Engine/CallTrees.php +++ b/src/Internal/Engine/CallTrees.php @@ -16,8 +16,8 @@ final class CallTrees { /** - * The protocol version the call claims in its `_meta`: never a version the session negotiated (02 ยง3.3). Null - * when the claim is not a non-empty string. + * The protocol version the call claims in its `_meta`: never a version the session negotiated. Null when the claim + * is not a non-empty string. * * @param array|null $meta */ @@ -30,7 +30,7 @@ public static function claimedVersion(?array $meta): ?string /** * `{"method":"tools/call","params":{โ€ฆ}}`, raw arguments included. The arguments are an object: empty arguments are - * `{}` and integer keys stay keys (N8). A sent `_meta` is an object too, so a sent `{}` stays `{}`. + * `{}` and integer keys stay keys. A sent `_meta` is an object too, so a sent `{}` stays `{}`. * * @param array $params the request's params, as the library holds them * @param array $arguments diff --git a/src/Internal/Engine/HttpContext.php b/src/Internal/Engine/HttpContext.php index a9fabc6..cddaca9 100644 --- a/src/Internal/Engine/HttpContext.php +++ b/src/Internal/Engine/HttpContext.php @@ -103,7 +103,7 @@ public function toRequestInfo(): \stdClass /** * The full URL as a web server passes it to PHP: the scheme `HTTPS` gives, the `HTTP_HOST` and the raw - * `REQUEST_URI`, query and trailing slash as sent. Null without a host (C15). + * `REQUEST_URI`, query and trailing slash as sent. Null without a host: no URL is made up. * * @param array $server */ @@ -111,7 +111,7 @@ private static function url(array $server): ?string { $host = $server['HTTP_HOST'] ?? null; if (!is_string($host) || $host === '') { - return null; // no host, no full URL (C15) + return null; // no host, no full URL } $https = $server['HTTPS'] ?? null; $scheme = is_string($https) && $https !== '' && strtolower($https) !== 'off' ? 'https' : 'http'; diff --git a/src/Internal/Engine/InjectionState.php b/src/Internal/Engine/InjectionState.php index d79954d..eb13ea8 100644 --- a/src/Internal/Engine/InjectionState.php +++ b/src/Internal/Engine/InjectionState.php @@ -17,7 +17,8 @@ final class InjectionState /** @var array */ private array $reportedConflicts = []; - private bool $shadowWarned = false; + /** A page of this server named the customer's own get_more_tools. Only grows. */ + private bool $customerGetMoreTools = false; /** * Tools whose own schema declares `session_id`. Only grows. PHP turns numeric-string names into int keys. @@ -50,14 +51,20 @@ public function reportConflictOnce(string $tool): bool return true; } - /** True the first time the get_more_tools shadowing is warned about. */ - public function warnShadowOnce(): bool + /** Records that a page named the customer's own get_more_tools. True the first time: the shadowing is warned about once. */ + public function markCustomerGetMoreTools(): bool { - if ($this->shadowWarned) { + if ($this->customerGetMoreTools) { return false; } - $this->shadowWarned = true; + $this->customerGetMoreTools = true; return true; } + + /** A page that this server read (in this process) named the customer's own get_more_tools. */ + public function hasCustomerGetMoreTools(): bool + { + return $this->customerGetMoreTools; + } } diff --git a/src/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php index 94daed0..ef07da7 100644 --- a/src/Internal/Engine/Libraries.php +++ b/src/Internal/Engine/Libraries.php @@ -48,6 +48,27 @@ public static function supportMatrix(): string return implode(' ', array_map(static fn(Library $library): string => $library->supportMatrix(), self::all())); } + /** What to pass instead of an object that no library can map to a tracked server; null when there is no advice. */ + public static function untrackedAdvice(object $server): ?string + { + return McpSdkLibrary::untrackedAdvice($server); + } + + /** + * The library that tracks the object through the object it was made from, as mcp/sdk tracks the Server that a + * tracked Builder built; null when no library does. Never throws. + */ + public static function trackerOf(object $server): ?Library + { + foreach (self::all() as $library) { + if ($library->trackedServer($server) instanceof TrackedServer) { + return $library; + } + } + + return null; + } + public static function trackedServer(object $server): ?TrackedServer { foreach (self::all() as $library) { diff --git a/src/Internal/Engine/Library.php b/src/Internal/Engine/Library.php index 0fdb2ba..0557026 100644 --- a/src/Internal/Engine/Library.php +++ b/src/Internal/Engine/Library.php @@ -18,8 +18,8 @@ interface Library /** Whether this library instruments the object. Takes any object; never throws. */ public function supports(object $server): bool; - /** The `sdk` field of the setup line, for example `mcp-sdk/builder`. Never throws. */ - public function sdk(): string; + /** The `sdk` field of the setup line for the object, for example `mcp-sdk/builder`. Takes any object; never throws. */ + public function sdk(object $server): string; /** The sentence that says which versions and objects of this library AgentCat supports. Never throws. */ public function supportMatrix(): string; diff --git a/src/Internal/Engine/Publisher.php b/src/Internal/Engine/Publisher.php index 7ee7f9a..3f91983 100644 --- a/src/Internal/Engine/Publisher.php +++ b/src/Internal/Engine/Publisher.php @@ -83,7 +83,7 @@ private function stamp(TrackedServer $server, Event $event, ?string $clientName, { [$serverName, $serverVersion] = $server->serverInfo(); (new SessionInfo($serverName, $serverVersion, $clientName, $clientVersion))->stamp($event); - $event->projectId = $server->projectId !== '' ? $server->projectId : null; // telemetry-only mode has no project (C16) + $event->projectId = $server->projectId !== '' ? $server->projectId : null; // telemetry-only mode has no project if (!Js::truthy($event->duration)) { $event->duration = self::elapsedMs($event->timestamp, ($this->clock)()); } diff --git a/src/Internal/Engine/ThrowableStash.php b/src/Internal/Engine/ThrowableStash.php index 5a2eaa5..7cc9e79 100644 --- a/src/Internal/Engine/ThrowableStash.php +++ b/src/Internal/Engine/ThrowableStash.php @@ -6,7 +6,7 @@ /** * The exception a tool threw, kept for the request it ran for. The MCP libraries catch tool exceptions and answer - * with an error result or a JSON-RPC error; the event needs the exception itself (05 E5). Entries go away with their + * with an error result or a JSON-RPC error; the event needs the exception itself (ac-05-E5). Entries go away with their * request. * * @internal diff --git a/src/Internal/Engine/ToolListInjector.php b/src/Internal/Engine/ToolListInjector.php index e9e8d88..fad9504 100644 --- a/src/Internal/Engine/ToolListInjector.php +++ b/src/Internal/Engine/ToolListInjector.php @@ -13,7 +13,8 @@ /** * Port of the TypeScript `buildInjectedList`: adds `session_id`, `agent_id` and `context` to each listed tool's - * input schema, appends get_more_tools, and declares `mcp_session` in output schemas. + * input schema, appends get_more_tools to the last page (D66) unless a page names the customer's own, and declares + * `mcp_session` in output schemas. * * @internal */ @@ -23,7 +24,11 @@ final class ToolListInjector private const string UNKNOWN_TOOL = 'unknown'; - private const string SHADOW_WARNING = 'WARN: This server registers its own "get_more_tools" tool, which is shadowed by AgentCat\'s report-missing tool: calls to it are answered by AgentCat and never reach your handler. Rename your tool, or set enableReportMissing: false to keep yours reachable.'; + /** + * TypeScript's line, and the PHP case of a paged listing: a process that has not read the page with the customer's + * tool cannot know it, so it appends AgentCat's to the last page. + */ + private const string SHADOW_WARNING = 'WARN: This server registers its own "get_more_tools" tool, which is shadowed by AgentCat\'s report-missing tool: calls to it are answered by AgentCat and never reach your handler. Rename your tool, or set enableReportMissing: false to keep yours reachable. If your tool is not on the last page of tools/list, AgentCat\'s get_more_tools is listed too, on the last page, by every process that has not read your tool\'s page (under PHP-FPM, every request), so clients see two tools of that name: rename your tool.'; private const string CONFLICT_ERROR = 'ERROR: Tool "%s" already declares a \'session_id\' parameter. AgentCat will not inject its own, and calls to this tool are published without a session, so they cannot be correlated. Your parameter is untouched and still reaches your handler. If you already manage sessions, pass a resolveSessionId hook to track() โ€” AgentCat will derive its session from your identifier and stop injecting session_id entirely.'; @@ -36,17 +41,20 @@ final class ToolListInjector private function __construct( private readonly InjectionConfig $config, private readonly InjectionState $state, + private readonly bool $lastPage, ) {} /** * Never throws. The input is never mutated; changed tools are deep copies. * * @param list $tools the `tools` of a `tools/list` result + * @param bool $lastPage the page has no next cursor: only the last page gets get_more_tools (D66, B10). A + * one-page listing is its own last page, as in TypeScript. */ - public static function inject(array $tools, InjectionConfig $config, InjectionState $state): InjectedList + public static function inject(array $tools, InjectionConfig $config, InjectionState $state, bool $lastPage = true): InjectedList { try { - return (new self($config, $state))->build($tools); + return (new self($config, $state, $lastPage))->build($tools); } catch (\Throwable $throwable) { ToolListing::injectionFailed($throwable); @@ -89,16 +97,15 @@ private function appendGetMoreTools(array $tools): array return $tools; } foreach ($tools as $tool) { - if ($this->nameOf($tool) === GetMoreTools::NAME) { - // The customer's tool stays listed, but AgentCat answers its calls. - if ($this->state->warnShadowOnce()) { - Log::write(self::SHADOW_WARNING); - } - - return $tools; + // The customer's tool stays listed, but AgentCat answers its calls, on whichever page it is. + if ($this->nameOf($tool) === GetMoreTools::NAME && $this->state->markCustomerGetMoreTools()) { + Log::write(self::SHADOW_WARNING); } } - $tools[] = GetMoreTools::descriptor(); + // A page that this server read named the customer's tool, this one or an earlier one: no second get_more_tools. + if ($this->lastPage && !$this->state->hasCustomerGetMoreTools()) { + $tools[] = GetMoreTools::descriptor(); + } return $tools; } diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index 8dbf1b8..2056077 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -5,11 +5,24 @@ namespace AgentCat\Internal\Engine; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\ErrorText; /** @internal */ final class ToolListing { + /** TS `REGISTRY_REBUILD_TIMEOUT_MS`: a rebuild asks for no further page after this many seconds (D66). */ + public const float REBUILD_BUDGET_SECONDS = 5.0; + + /** The most pages one rebuild reads (D66): 1,500 tools at laravel/mcp's default page size, 5,000 at mcp/sdk's. */ + public const int REBUILD_MAX_PAGES = 100; + + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + + private const string REBUILD_FAILED = 'Warning: registry rebuild-on-demand failed - '; + + private const string REBUILD_STOPPED = 'Warning: registry rebuild-on-demand stopped after page %d - %s'; + /** The listing is served as the library answered it, after this line (TS listWrap, ErrorText::message()). */ public static function injectionFailed(\Throwable $e): void { @@ -18,20 +31,22 @@ public static function injectionFailed(\Throwable $e): void /** * TS listWrap after the original handler: a listing without a tool list or with an empty one is served as is - * (`!Array.isArray(tools) || tools.length === 0`, B11, D15); a degraded injection keeps the previous registries - * (Plan A ledger carry). + * (`!Array.isArray(tools) || tools.length === 0`, B11, D15); a degraded injection keeps the previous registries. + * PHP divergence (D66, B10 fixed): only the last page gets get_more_tools, and each page's registries merge into the + * server's. * * @param list|null $tools null when the answer has no tool list + * @param bool $lastPage the answer has no next cursor (`nextCursor()`) * @return list|null the tools to advertise, or null to serve the original listing */ - public static function apply(TrackedServer $server, ?array $tools): ?array + public static function apply(TrackedServer $server, ?array $tools, bool $lastPage): ?array { if ($tools === null || $tools === []) { Log::write(ToolListInjector::NO_TOOLS_WARNING); return null; } - $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection); + $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection, $lastPage); if ($list->degraded) { return null; } @@ -41,28 +56,114 @@ public static function apply(TrackedServer $server, ?array $tools): ?array } /** - * TS ensureRegistries (D17: no cache; runs on every call until a listing succeeds). No time bound in synchronous PHP. + * The cursor of the next page: a non-empty string. Anything else ends the listing, as `while (cursor)` does in the + * TypeScript client. `'0'` is a cursor. + */ + public static function nextCursor(mixed $cursor): ?string + { + return is_string($cursor) && $cursor !== '' ? $cursor : null; + } + + /** + * TS ensureRegistries (D17: no cache), reading the pages from the first (D66, B10 fixed). It runs on a call whose + * tool no listing of this server named, until one rebuild has read every page; under PHP-FPM that is every call, + * as before. It stops at the first page that names the tool (D66), so a tool of page 1 costs one page, as before; + * a tool that no page names reads every page. A get_more_tools call rebuilds too: a customer's tool of that name is + * found on its page, so its own parameters stay its own; AgentCat's own is on no page, and the read to the last + * page gives it its exact entry. A page that cannot be read ends the rebuild: the pages before it are kept, the + * other tools keep the heuristic strip, and the next call for one of them tries again. A cursor already followed + * ends the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next page. * - * @param \Closure(): (list|null) $listOriginal the library's own first page; may throw + * @param \Closure(?string): (array{list, ?string}|null) $listPage the library's own page at a cursor (null + * for the first): its tools and its next + * cursor, or null when the answer has no + * tool list; may throw + * @param (\Closure(): float)|null $clock seconds on a monotonic clock; null reads `Clock::monotonic()` */ - public static function ensureRegistries(TrackedServer $server, \Closure $listOriginal): void + public static function ensureRegistries(TrackedServer $server, string $toolName, \Closure $listPage, ?\Closure $clock = null): void { - if ($server->registry !== null) { + if ($server->everyPageRead || ($server->registry !== null && array_key_exists($toolName, $server->registry))) { return; } try { - $tools = $listOriginal(); - if ($tools === null) { + $read = self::readPages($toolName, $listPage, $clock ?? Clock::monotonic()); + if ($read === null) { return; } - $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection); + [$tools, $lastPage, $stopped] = $read; + $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection, $lastPage); if ($list->degraded) { return; } $server->adopt($list); - Log::write('Rebuilt injection registries on demand (tools/call before tools/list on this instance)'); + if ($stopped !== null) { + Log::write($stopped); + + return; + } + $server->everyPageRead = $lastPage; + Log::write(self::REBUILT); } catch (\Throwable $e) { - Log::write('Warning: registry rebuild-on-demand failed - ' . ErrorText::of($e)); + Log::write(self::REBUILD_FAILED . ErrorText::of($e)); } } + + /** + * Reads the pages from the first one, following each next cursor, up to the first page that names the tool. Null + * when the first page has no tool list (nothing is logged, as before); what listing the first page throws passes. + * + * @param \Closure(?string): (array{list, ?string}|null) $listPage + * @param \Closure(): float $clock + * @return array{list, bool, ?string}|null every tool read, whether the last page was read, and the line that + * says why the read stopped before the tool's page and the last page + * (null when it reached one of them) + */ + private static function readPages(string $toolName, \Closure $listPage, \Closure $clock): ?array + { + $deadline = $clock() + self::REBUILD_BUDGET_SECONDS; + $tools = []; + $followed = []; // the cursors followed, as keys and values + $cursor = null; + $read = 0; // pages read + while (true) { + try { + $page = $listPage($cursor); + } catch (\Throwable $e) { + if ($read === 0) { + throw $e; + } + + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, ErrorText::of($e))]; + } + if ($page === null) { + return $read === 0 ? null : [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('page %d has no tool list', $read + 1))]; + } + ++$read; + [$listed, $cursor] = $page; + $named = false; + foreach ($listed as $tool) { + $tools[] = $tool; + $named = $named || self::names($tool, $toolName); + } + if ($cursor === null || isset($followed[$cursor])) { + return [$tools, true, null]; // the last page, or a cursor back to a page already read + } + if ($named) { + return [$tools, false, null]; + } + $followed[$cursor] = $cursor; + if ($read === self::REBUILD_MAX_PAGES) { + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('the limit is %d pages', self::REBUILD_MAX_PAGES))]; + } + if ($clock() >= $deadline) { + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('the budget of %s s is used up', self::REBUILD_BUDGET_SECONDS))]; + } + } + } + + /** The tool is named `$name`. A JSON tree's members are read without accessors, so this never throws. */ + private static function names(mixed $tool, string $name): bool + { + return $tool instanceof \stdClass && (get_object_vars($tool)['name'] ?? null) === $name; + } } diff --git a/src/Internal/Engine/TracedCall.php b/src/Internal/Engine/TracedCall.php index 2e6bdcb..f12052f 100644 --- a/src/Internal/Engine/TracedCall.php +++ b/src/Internal/Engine/TracedCall.php @@ -17,7 +17,7 @@ /** * @param list<\Closure(Event): void> $pending the customer hooks, run later by the event queue (D04) * @param string|null $invalidIntent the type of a `context` intent that is a number, a boolean or an object; the - * event is then not published (header conflict 2, B16). A list intent is no + * event is then not published (B16). A list intent is no * intent and the event is published (PHP divergence: TypeScript sends the raw list). */ public function __construct( diff --git a/src/Internal/Engine/TrackedServer.php b/src/Internal/Engine/TrackedServer.php index 2bc7bd3..88fbbff 100644 --- a/src/Internal/Engine/TrackedServer.php +++ b/src/Internal/Engine/TrackedServer.php @@ -22,13 +22,16 @@ final class TrackedServer /** @var list|null tools whose outputSchema declares `mcp_session` */ public ?array $outputRegistry = null; + /** True once a rebuild read every page (D66): a tool the registry lacks is then in no listing. */ + public bool $everyPageRead = false; + /** * @param string $projectId '' in telemetry-only mode * @param \Closure(): array{0: ?string, 1: ?string} $serverInfo server name and version, read when an event is published * @param Publisher $publisher the publisher of the client that tracked the server * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework; null reads the * SAPI globals. The laravel/mcp adapter reads the server's own - * transport instead (plan E1 header conflict 9) + * transport instead */ public function __construct( public readonly string $projectId, @@ -63,17 +66,24 @@ public function injectionConfig(): InjectionConfig ); } - /** Mirror the minted session into a result only when the tool declares `mcp_session` (02 ยง2.8). */ + /** Mirror the minted session into a result only when the tool declares `mcp_session`. */ public function mirrorAllowed(string $toolName): bool { return $this->outputRegistry === null || in_array($toolName, $this->outputRegistry, true); } - /** Replaces both registries wholesale. */ + /** + * Merges one page's registries into the server's (D66, B10 fixed; TypeScript replaces them): the page's entries + * replace those of the same tools, and the output registry keeps the tools of the other pages. `array_replace()` + * keeps the int keys that PHP makes of numeric-string names, and `array_diff()` compares them as strings. A tool + * without a name is in the output registry as `unknown` and can have no registry entry, so `array_unique()` keeps + * one copy of it when the page is listed again. + */ public function adopt(InjectedList $list): void { - $this->registry = $list->registry; - $this->outputRegistry = $list->outputRegistry; + $this->registry = array_replace($this->registry ?? [], $list->registry); + $kept = array_diff($this->outputRegistry ?? [], array_keys($list->registry)); + $this->outputRegistry = [...array_unique([...$kept, ...$list->outputRegistry])]; } /** @return array{0: ?string, 1: ?string} */ diff --git a/src/Internal/Id/Ksuid.php b/src/Internal/Id/Ksuid.php index 897c96e..9d6e75b 100644 --- a/src/Internal/Id/Ksuid.php +++ b/src/Internal/Id/Ksuid.php @@ -35,7 +35,8 @@ public static function fromParts(int $timestampMs, string $payload): string public static function random(?int $timestampMs = null): string { - return self::fromParts($timestampMs ?? (int) (microtime(true) * 1000), random_bytes(self::PAYLOAD_LENGTH)); + // The clock of DateTimeImmutable, not microtime(): a host can disable a function (`disable_functions`). + return self::fromParts($timestampMs ?? (int) (new \DateTimeImmutable())->format('Uv'), random_bytes(self::PAYLOAD_LENGTH)); } /** diff --git a/src/Internal/Json/JsonTree.php b/src/Internal/Json/JsonTree.php index d48eafa..662ceb4 100644 --- a/src/Internal/Json/JsonTree.php +++ b/src/Internal/Json/JsonTree.php @@ -27,7 +27,7 @@ public static function of(mixed $value): mixed /** * The tree of an associative array as an object, for example a `tools/call` request's arguments: an empty array - * is `{}` and integer keys stay keys (N8). Nested values are encoded as they are. Never throws: `{}` when JSON + * is `{}` and integer keys stay keys. Nested values are encoded as they are. Never throws: `{}` when JSON * cannot carry the value. * * @param array $value @@ -45,7 +45,7 @@ public static function object(array $value): \stdClass /** * The tree of a tool result. A library's PHP arrays cannot tell `{}` from `[]`, so an empty-list - * `structuredContent` is `{}` (C1 header conflict 4). + * `structuredContent` is `{}`. * * @throws \JsonException for a value JSON cannot carry */ diff --git a/src/Internal/Logging/FileLogger.php b/src/Internal/Logging/FileLogger.php index 2c0f776..da7986d 100644 --- a/src/Internal/Logging/FileLogger.php +++ b/src/Internal/Logging/FileLogger.php @@ -4,6 +4,7 @@ namespace AgentCat\Internal\Logging; +use AgentCat\Internal\Support\Env; use Psr\Log\AbstractLogger; /** @internal */ @@ -17,7 +18,7 @@ final class FileLogger extends AbstractLogger public function __construct(?string $path = null, ?\Closure $homeLookup = null) { if ($path === null) { - $home = ($homeLookup ?? static fn(): ?string => self::resolveHome(getenv('HOME'), self::posixHome(...)))(); + $home = ($homeLookup ?? static fn(): ?string => self::resolveHome(Env::lookup('HOME'), self::posixHome(...)))(); $path = $home === null ? null : $home . '/agentcat.log'; } $this->path = $path; diff --git a/src/Internal/Logging/Log.php b/src/Internal/Logging/Log.php index 1284631..c936ccd 100644 --- a/src/Internal/Logging/Log.php +++ b/src/Internal/Logging/Log.php @@ -121,7 +121,7 @@ public static function info(string $message): void private static function emit(string $level, string $message): void { - if (self::$sink instanceof \Closure && self::$beforeDiversion === null) { // a fake sends nothing (spec conflict 9) + if (self::$sink instanceof \Closure && self::$beforeDiversion === null) { // a fake sends nothing (no diagnostics) try { (self::$sink)(LogLine::format($message)); } catch (\Throwable) { diff --git a/src/Internal/Logging/LogLine.php b/src/Internal/Logging/LogLine.php index 4d82570..39f6a3e 100644 --- a/src/Internal/Logging/LogLine.php +++ b/src/Internal/Logging/LogLine.php @@ -15,13 +15,13 @@ final class LogLine { private static ?string $prefix = null; - /** `[]` for this process, resolved once (03 AC-38). */ + /** `[]` for this process, resolved once (ac-03-38). */ public static function prefix(): string { return self::$prefix ??= '[' . self::versions(SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk'), SdkVersion::installed('laravel/mcp')) . ']'; } - /** `sdk= php=` and the installed MCP libraries; `mcp=unknown` when none is (TS `mcp=`/`mcp2=`, 03 ยง5.2). */ + /** `sdk= php=` and the installed MCP libraries; `mcp=unknown` when none is (TS `mcp=`/`mcp2=`). */ public static function versions(string $sdk, string $php, ?string $mcpSdk, ?string $laravelMcp): string { $parts = ['sdk=' . $sdk, 'php=' . $php]; diff --git a/src/Internal/Logging/OncePerObject.php b/src/Internal/Logging/OncePerObject.php index 956a9ac..82f2273 100644 --- a/src/Internal/Logging/OncePerObject.php +++ b/src/Internal/Logging/OncePerObject.php @@ -5,9 +5,9 @@ namespace AgentCat\Internal\Logging; /** - * Warnings that are logged once per object, for example the compatibility line of an adapter that cannot read a - * server on the request path (README ยง4.5 rule 4): the first one for an object is logged, later ones with the same - * line are not. Entries go away with their object. + * Warnings that are logged once per object, for example the compatibility line of an adapter that cannot read a server + * on the request path: the first one for an object is logged, later ones with the same line are not. Entries go away + * with their object. * * @internal */ diff --git a/src/Internal/Logging/RepeatedLines.php b/src/Internal/Logging/RepeatedLines.php new file mode 100644 index 0000000..6f97b75 --- /dev/null +++ b/src/Internal/Logging/RepeatedLines.php @@ -0,0 +1,66 @@ + each line logged in the running delivery, with its repeats */ + private static array $repeats = []; + + /** A delivery starts. */ + public static function begin(): void + { + ++self::$depth; + } + + /** Logs `$line`, except a line the running delivery logged already: that one is counted. */ + public static function write(string $line): void + { + if (self::$depth > 0) { + if (array_key_exists($line, self::$repeats)) { + ++self::$repeats[$line]; + + return; + } + self::$repeats[$line] = 0; + } + Log::write($line); + } + + /** A delivery ends; the outermost one logs one line for each line that repeated, with the count. */ + public static function end(): void + { + if (self::$depth === 0 || --self::$depth > 0) { + return; + } + $repeats = self::$repeats; + self::$repeats = []; + foreach ($repeats as $line => $count) { + if ($count > 0) { + Log::write(sprintf(self::REPEATED, $line, $count)); + } + } + } + + /** Tests only. */ + public static function reset(): void + { + self::$depth = 0; + self::$repeats = []; + } +} diff --git a/src/Internal/SdkVersion.php b/src/Internal/SdkVersion.php index 7ee655b..9e42210 100644 --- a/src/Internal/SdkVersion.php +++ b/src/Internal/SdkVersion.php @@ -6,7 +6,12 @@ use Composer\InstalledVersions; -/** @internal */ +/** + * The versions that AgentCat reports: its own and those of the MCP libraries, as Composer installed them, without the + * `v` of a tag name (`v0.9.0` is `0.9.0`, as the TypeScript SDK and the backend name versions). + * + * @internal + */ final class SdkVersion { public static function get(): string @@ -16,18 +21,20 @@ public static function get(): string ); } - /** The Composer pretty version of a package, or null when it is not installed. Never throws. */ + /** The Composer pretty version of a package without the `v` of a tag, or null when it is not installed. Never throws. */ public static function installed(string $package): ?string { try { - return InstalledVersions::getPrettyVersion($package); + $pretty = InstalledVersions::getPrettyVersion($package); } catch (\Throwable) { return null; } + + return $pretty === null ? null : self::bare($pretty); } /** - * Turns a version lookup into a non-empty string. Never throws. + * Turns a version lookup into a non-empty string without the `v` of a tag. Never throws. * * @param \Closure(): ?string $lookup */ @@ -39,6 +46,14 @@ public static function resolve(\Closure $lookup): string return 'dev'; } - return ($pretty === null || $pretty === '') ? 'dev' : $pretty; + $version = $pretty === null ? '' : self::bare($pretty); + + return $version === '' ? 'dev' : $version; + } + + /** A pretty version without the leading `v` of a tag name; a branch (`dev-main`, `1.x-dev`) has none. */ + private static function bare(string $pretty): string + { + return str_starts_with($pretty, 'v') ? substr($pretty, 1) : $pretty; } } diff --git a/src/Internal/Support/BridgeConfig.php b/src/Internal/Support/BridgeConfig.php index ea0db95..5750f1e 100644 --- a/src/Internal/Support/BridgeConfig.php +++ b/src/Internal/Support/BridgeConfig.php @@ -5,11 +5,10 @@ namespace AgentCat\Internal\Support; /** - * The values of a framework bridge's configuration that every client of the process reads before the environment - * (plan E1 header conflict 12): with a cached configuration (`php artisan config:cache`), Laravel does not load the - * `.env` file, so `Env::get()` cannot see what the configuration got from it. The bridge sets them when it boots - * (`fromBridge()`). The last call wins; they are process-wide, like the environment. `TestMode::configure()` does the - * same for the switch. + * The values of a framework bridge's configuration that every client of the process reads before the environment: with + * a cached configuration (`php artisan config:cache`), Laravel does not load the `.env` file, so `Env::get()` cannot + * see what the configuration got from it. The bridge sets them when it boots (`fromBridge()`). The last call wins; they + * are process-wide, like the environment. `TestMode::configure()` does the same for the switch. * * @internal */ @@ -38,11 +37,10 @@ public static function configure(array $values, bool $diagnosticsOff): void } /** - * Hands a framework bridge's `agentcat` configuration to every client of the process when the bridge boots (plan E1 - * header conflict 12, final ruling I2): the switch, from the D44 words of `enabled` (unset or unknown: no switch, - * `AGENTCAT_ENABLED` decides), every name AgentCat reads before the environment, and the application's environment - * for diagnostics. A value that is no string is unset here. Nothing is logged: the bridge's tracker logs the bad - * values when it tracks a server. + * Hands a framework bridge's `agentcat` configuration to every client of the process when the bridge boots: the + * switch, from the D44 words of `enabled` (unset or unknown: no switch, `AGENTCAT_ENABLED` decides), every name + * AgentCat reads before the environment, and the application's environment for diagnostics. A value that is no + * string is unset here. Nothing is logged: the bridge's tracker logs the bad values when it tracks a server. * * @param array $agentcat the bridge's `agentcat` configuration * @param mixed $appEnvironment the application's environment: Laravel's `app.env`, Symfony's diff --git a/src/Internal/Support/BridgeKeys.php b/src/Internal/Support/BridgeKeys.php index 19b5403..2722e71 100644 --- a/src/Internal/Support/BridgeKeys.php +++ b/src/Internal/Support/BridgeKeys.php @@ -6,9 +6,9 @@ /** * The keys of a framework bridge's `agentcat` configuration that the bridge reads itself (not options), read with their - * types (plan E1 Review Focus 3): a value of a wrong type logs one line through `$warn`, and the default is used, so a - * typo never goes unseen. Null is an unset value (`env()` of an unset variable, `%env(default::โ€ฆ)%` in Symfony): the - * default, no line. The lines name the key as the customer wrote it, `agentcat.`. + * types: a value of a wrong type logs one line through `$warn`, and the default is used, so a typo never goes unseen. + * Null is an unset value (`env()` of an unset variable, `%env(default::โ€ฆ)%` in Symfony): the default, no line. The + * lines name the key as the customer wrote it, `agentcat.`. * * @internal */ @@ -40,8 +40,8 @@ public function string(string $key): ?string } /** - * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one; a null entry is - * unset. + * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one, but a blank string + * names nothing: the default, with one line. A null entry is unset. * * @param list $default the value when the key is unset, and when it is of a wrong type * @@ -50,6 +50,11 @@ public function string(string $key): ?string public function strings(string $key, array $default): array { $value = $this->config[$key] ?? $default; + if (is_string($value) && trim($value) === '') { + ($this->warn)(sprintf(self::WRONG_TYPE, $key, 'array or non-empty string', $value === '' ? 'empty string' : 'a blank string')); // names no server + + return $default; + } if (is_string($value)) { return [$value]; } diff --git a/src/Internal/Support/Clock.php b/src/Internal/Support/Clock.php new file mode 100644 index 0000000..a707440 --- /dev/null +++ b/src/Internal/Support/Clock.php @@ -0,0 +1,30 @@ + hrtime(true) / 1e9; + } + + return static fn(): float => microtime(true); + } +} diff --git a/src/Internal/Support/EnabledGate.php b/src/Internal/Support/EnabledGate.php index 395d087..bcf9fdc 100644 --- a/src/Internal/Support/EnabledGate.php +++ b/src/Internal/Support/EnabledGate.php @@ -7,8 +7,8 @@ use AgentCat\Internal\Logging\Log; /** - * The enabled gate of a framework bridge, from its `agentcat.enabled` configuration (plan E1 header conflict 12): the - * configuration decides, because a cached configuration hides the `.env` file from `Env::get()`. + * The enabled gate of a framework bridge, from its `agentcat.enabled` configuration: the configuration decides, because + * a cached configuration hides the `.env` file from `Env::get()`. * * @internal */ @@ -16,9 +16,10 @@ final class EnabledGate { /** * The D44 words decide (`TestMode::word()`). Unset or blank: `TestMode::sdkEnabled()`, which reads - * `AGENTCAT_ENABLED` of the process and is off in test environments (D31, D44). Any other value logs one line and - * the D31 default is used: off in test environments, on elsewhere. It does not read `AGENTCAT_ENABLED` again, which - * holds the same value when `env()` filled the configuration, and would log a second line for it. + * `AGENTCAT_ENABLED` of the process and is off in test environments (D31, D44). Any other value logs one line, and + * `AGENTCAT_ENABLED` of the process decides when it holds a D44 word, as it does for the shared client. Else the D31 + * default is used: off in test environments, on elsewhere. The variable is read without a line of its own, because + * it holds the same unknown word when `env()` filled the configuration. * * @return \Closure(): bool */ @@ -33,6 +34,6 @@ public static function of(mixed $configured): \Closure } Log::write(sprintf('WARN: AgentCat config "agentcat.enabled" is "%s", not one of true, 1, yes, on, false, 0, no, off; the default is used', is_scalar($configured) ? (string) $configured : get_debug_type($configured))); - return static fn(): bool => !TestMode::inTestEnvironment(); + return static fn(): bool => TestMode::environmentWord() ?? !TestMode::inTestEnvironment(); } } diff --git a/src/Internal/Support/Env.php b/src/Internal/Support/Env.php index 68e46a2..708d6cb 100644 --- a/src/Internal/Support/Env.php +++ b/src/Internal/Support/Env.php @@ -5,7 +5,8 @@ namespace AgentCat\Internal\Support; /** - * Environment variable lookup that works under CLI, PHP-FPM and `variables_order` without `E`. + * Environment variable lookup that works under CLI, PHP-FPM, `variables_order` without `E`, and on a host that + * disables `getenv()`. * * @internal */ @@ -14,7 +15,7 @@ final class Env /** The first non-empty string among `getenv()`, `$_ENV` and `$_SERVER` (JS `||` skips empty strings). */ public static function get(string $name): ?string { - foreach ([getenv($name), $_ENV[$name] ?? null, $_SERVER[$name] ?? null] as $value) { + foreach ([self::lookup($name), $_ENV[$name] ?? null, $_SERVER[$name] ?? null] as $value) { if (is_string($value) && $value !== '') { return $value; } @@ -22,4 +23,21 @@ public static function get(string $name): ?string return null; } + + /** + * `getenv($name)`: the value, or false when the variable is not set. A host can disable `getenv()` + * (`disable_functions`); the value then comes from `$_ENV`, else `$_SERVER`, where the CLI and PHP-FPM copy the + * process environment. + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `getenv()` exists; default `function_exists()` + */ + public static function lookup(string $name, ?\Closure $functionExists = null): string|false + { + if (($functionExists ?? function_exists(...))('getenv')) { + return getenv($name); + } + $value = $_ENV[$name] ?? $_SERVER[$name] ?? null; + + return is_string($value) ? $value : false; + } } diff --git a/src/Internal/Support/Sleep.php b/src/Internal/Support/Sleep.php new file mode 100644 index 0000000..f252f41 --- /dev/null +++ b/src/Internal/Support/Sleep.php @@ -0,0 +1,39 @@ + $event->isError, 'error' => $event->error, 'tags' => ExportJson::map($event->tags), 'properties' => ExportJson::map($event->properties), ]; foreach ($optional as $key => $value) { - if ($value !== null) { // JSON.stringify leaves undefined out (spec conflict 4) + if ($value !== null) { // JSON.stringify leaves undefined out $mcp->{$key} = $value; } } diff --git a/src/Internal/Telemetry/ExportJson.php b/src/Internal/Telemetry/ExportJson.php index 2b81a83..dd8e456 100644 --- a/src/Internal/Telemetry/ExportJson.php +++ b/src/Internal/Telemetry/ExportJson.php @@ -7,8 +7,9 @@ use AgentCat\Internal\Wire\WireValue; /** - * `JSON.stringify` for exporter and diagnostics bodies: shortest round-trip floats whatever php.ini says (06 AC-64), - * no escaped slashes or Unicode, U+FFFD for invalid UTF-8. + * `JSON.stringify` for exporter and diagnostics bodies: shortest round-trip floats whatever php.ini says (ac-06-64), + * no escaped slashes or Unicode, U+FFFD for invalid UTF-8. On a host that disables `ini_set()` or `ini_get()` + * (`disable_functions`), floats follow php.ini's `serialize_precision` (PHP's default, -1, is the shortest round trip). * * @internal */ @@ -17,11 +18,17 @@ final class ExportJson private const int FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR; /** + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `ini_set()` and `ini_get()` exist; default `function_exists()` + * * @throws \JsonException * @throws \UnexpectedValueException on a cycle or too deep nesting */ - public static function encode(mixed $value): string + public static function encode(mixed $value, ?\Closure $functionExists = null): string { + $exists = $functionExists ?? function_exists(...); + if (!$exists('ini_set') || !$exists('ini_get')) { + return json_encode(WireValue::from($value), self::FLAGS); + } $precision = ini_get('serialize_precision'); ini_set('serialize_precision', '-1'); @@ -34,7 +41,7 @@ public static function encode(mixed $value): string } } - /** An empty PHP array in a map position is `{}` (06 AC-46). */ + /** An empty PHP array in a map position is `{}` (ac-06-46). */ public static function map(mixed $value): mixed { return $value === [] ? new \stdClass() : $value; diff --git a/src/Internal/Telemetry/ExporterConfigs.php b/src/Internal/Telemetry/ExporterConfigs.php index 1b5a57e..9cbc1ee 100644 --- a/src/Internal/Telemetry/ExporterConfigs.php +++ b/src/Internal/Telemetry/ExporterConfigs.php @@ -45,7 +45,7 @@ public static function fromArray(array $entries): array $config = match (true) { $entry instanceof OtlpExporter, $entry instanceof DatadogExporter, $entry instanceof SentryExporter, $entry instanceof PostHogExporter => $entry, is_array($entry) => self::one($name, $entry), - default => throw new \InvalidArgumentException('exporter configuration must be an array, got ' . get_debug_type($entry)), + default => throw new \InvalidArgumentException(sprintf('exporter "exporters.%s" must be an array, got %s; write an array or omit the entry', $name, get_debug_type($entry))), }; } catch (\InvalidArgumentException $e) { Log::write(sprintf('Failed to initialize exporter %s: %s', $name, ErrorText::of($e))); @@ -118,14 +118,14 @@ private static function headers(array $entry): array $value = $entry['headers'] ?? []; $headers = []; foreach (is_array($value) ? $value : throw self::invalid('headers', 'array', $value) as $header => $text) { - $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers.' . $header, 'string', $text); + $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers.' . $header, 'a string', $text, 'write a string or omit the entry'); } return $headers; } - private static function invalid(string $key, string $expected, mixed $value): \InvalidArgumentException + private static function invalid(string $key, string $expected, mixed $value, string $fix = ''): \InvalidArgumentException { - return new \InvalidArgumentException(sprintf('exporter option "%s" must be %s, got %s', $key, $expected, get_debug_type($value))); + return new \InvalidArgumentException(sprintf('exporter option "%s" must be %s, got %s%s', $key, $expected, get_debug_type($value), $fix === '' ? '' : '; ' . $fix)); } } diff --git a/src/Internal/Telemetry/ExporterFactory.php b/src/Internal/Telemetry/ExporterFactory.php index dbf86fa..7d5e64d 100644 --- a/src/Internal/Telemetry/ExporterFactory.php +++ b/src/Internal/Telemetry/ExporterFactory.php @@ -28,7 +28,7 @@ final class ExporterFactory { /** * Null, after the TypeScript `Unknown exporter type: ` line, for anything that is not one of the four - * configuration classes (`Options::$exporters` entries are not validated, Task 4). + * configuration classes (`new Options()` does not validate the entries of `$exporters`). * * @throws \InvalidArgumentException an invalid Sentry DSN */ diff --git a/src/Internal/Telemetry/Otlp/OtlpPayload.php b/src/Internal/Telemetry/Otlp/OtlpPayload.php index 261ead1..2c665cb 100644 --- a/src/Internal/Telemetry/Otlp/OtlpPayload.php +++ b/src/Internal/Telemetry/Otlp/OtlpPayload.php @@ -90,7 +90,7 @@ public function request(Event $event): HttpRequest /** * TS `start + BigInt(duration) * 1e6` when the duration is truthy. Exact in integers while the end fits an int; - * beyond that float arithmetic (spec conflict 7). + * beyond that float arithmetic. * * @throws JsError a truthy duration that is not an integer */ diff --git a/src/Internal/Telemetry/TelemetryManager.php b/src/Internal/Telemetry/TelemetryManager.php index c875f77..7e7fd95 100644 --- a/src/Internal/Telemetry/TelemetryManager.php +++ b/src/Internal/Telemetry/TelemetryManager.php @@ -61,7 +61,7 @@ public static function fromConfigs(array $configs, HttpChannel $http): self /** * First wins (TypeScript: process-global; here per queue, which is the process queue outside tests). Null - * changes nothing; a later different set logs one WARN line (09 ยง5.2); a sink that is not ours stays. + * changes nothing; a later different set logs one WARN line; a sink that is not ours stays. * * @param array|null $configs */ @@ -101,8 +101,8 @@ public function count(): int } /** - * Ruling P3: a set that cannot be serialized (a closure) or whose serialization warns counts as different, and - * nothing throws or reaches the output (D32). + * A set that cannot be serialized (a closure) or whose serialization warns counts as different, and nothing throws + * or reaches the output (D32). * * @param array $configs * @param array $installed diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index c18af66..02e321b 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -35,8 +35,6 @@ final class CurlHttpChannel implements HttpChannel /** Upper bound of one wait, so a waiting caller re-checks its state at least once a second. */ private const float MAX_WAIT_SECONDS = 1.0; - private const string NO_CURL = 'WARN: AgentCat cannot send telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; - private const string FULL = 'WARN: AgentCat telemetry export queue full, dropped the oldest %d request(s)'; /** The reason phrase of a status line; the last status line of a response wins. */ @@ -86,7 +84,7 @@ public function __construct( ) { $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); - $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->curl = new CurlSupport($functionExists); $this->multi = new CurlMulti($multiStatus); $this->inCallback = new CallbackFlag(); } diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php index d0da7c9..c6374f4 100644 --- a/src/Internal/Transport/CurlSupport.php +++ b/src/Internal/Transport/CurlSupport.php @@ -9,8 +9,8 @@ /** * What `CurlTransport` and `CurlHttpChannel` share about ext-curl: the check that every curl function they call - * exists (a PHP without ext-curl, or with some of its functions in `disable_functions`), made once and logged once - * (D32), the User-Agent, the timeouts and the options of a `POST`, the bound of one wait, and the texts of a failed + * exists (a PHP without ext-curl, or with some of its functions in `disable_functions`), made once by each and logged + * once per process (D32), the User-Agent, the timeouts and the options of a `POST`, the bound of one wait, and the texts of a failed * transfer and of a failed multi handle. `CurlMulti` runs the transfers. * * @internal @@ -20,6 +20,9 @@ final class CurlSupport /** The longest curl timeout: 2^31 - 1 ms, what a 32-bit C `long` (curl's option type on Windows) carries. */ public const float MAX_TIMEOUT_SECONDS = 2_147_483.647; + /** The line of a PHP that lacks a function of FUNCTIONS; `%s` lists them. Both users need every one of them. */ + private const string MISSING = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; + /** Every curl function `CurlTransport` and `CurlHttpChannel` call. */ private const array FUNCTIONS = [ 'curl_init', 'curl_setopt_array', 'curl_error', 'curl_strerror', 'curl_getinfo', 'curl_multi_init', @@ -30,32 +33,39 @@ final class CurlSupport /** Whether every function in FUNCTIONS exists; null until `usable()` checked it. */ private ?bool $usable = null; + /** Whether a check of this process logged MISSING: the event transport and the side channel share one line. */ + private static bool $reported = false; + /** @var \Closure(string): bool */ private readonly \Closure $functionExists; - /** - * @param string $missingLine the line logged when a function is missing; `%s` is the comma-separated list - * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. - */ - public function __construct(private readonly string $missingLine, ?\Closure $functionExists = null) + /** @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. */ + public function __construct(?\Closure $functionExists = null) { $this->functionExists = $functionExists ?? function_exists(...); } - /** Whether every curl function exists. The first call checks them; a false answer logs one line. */ + /** Whether every curl function exists. The first call checks them; the first false answer of the process logs MISSING. */ public function usable(): bool { if ($this->usable === null) { $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); $this->usable = $missing === []; - if (!$this->usable) { - Log::write(sprintf($this->missingLine, implode(', ', $missing))); + if (!$this->usable && !self::$reported) { + self::$reported = true; + Log::write(sprintf(self::MISSING, implode(', ', $missing))); } } return $this->usable; } + /** Tests only: the next check that finds a missing function logs MISSING again. */ + public static function reset(): void + { + self::$reported = false; + } + /** True once `usable()` answered true. Checks nothing. */ public function confirmed(): bool { diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index df767db..c8049a5 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -5,13 +5,18 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\RepeatedLines; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\ErrorText; +use AgentCat\Internal\Support\Sleep; /** * One `POST` per event over ext-curl: `curl_multi` transfers, at most five at once, with TypeScript's retry * policy scheduled by time (`pump()` never sleeps) and connect/total timeouts. Nothing reaches STDOUT/STDERR. * The multi handle starts with the first delivery. When a curl function it calls is missing or disabled, every - * delivery is dropped and one line is logged (D32). + * delivery is dropped, and one line per process says so (`CurlSupport`, D32). A wait for a retry sleeps with `usleep()` + * or `time_nanosleep()` (`Sleep`); where both are disabled, a wait for a retry that is not due ends the delivery, with + * one line, instead of spinning until its deadline. * * @internal */ @@ -28,7 +33,8 @@ final class CurlTransport implements Transport /** Upper bound of one wait, so a waiting caller re-checks its state at least once a second. */ private const float MAX_WAIT_SECONDS = 1.0; - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled (%s)'; + /** The line of a host without a function that sleeps. */ + private const string NO_SLEEP = 'WARN: AgentCat cannot wait for the retry of a failed event: usleep and time_nanosleep are disabled; a delivery ends at the first retry that is not due yet; remove one of them from disable_functions'; /** The ext-curl check, made at the first delivery. */ private readonly CurlSupport $curl; @@ -49,11 +55,17 @@ final class CurlTransport implements Transport /** @var list retries waiting for their time */ private array $scheduled = []; + /** @var \Closure(string): bool */ + private readonly \Closure $functionExists; + + /** @var (\Closure(float): void)|false|null the sleep of a wait for a retry, made at the first one (`Sleep`); false on a host without one */ + private \Closure|false|null $sleep = null; + /** - * @param (\Closure(): float)|null $clock monotonic seconds (tests); must not throw. Default `hrtime(true) / 1e9`. + * @param (\Closure(): float)|null $clock monotonic seconds (tests); must not throw. Default `Clock::monotonic()`. * @param (\Closure(string, int): int)|null $multiStatus tests only: receives the function name and the CURLM code of each * `curl_multi_add_handle()` and `curl_multi_exec()` call, returns the code to act on - * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl or sleep function exists. Default `function_exists()`. * * @throws \InvalidArgumentException for a timeout curl cannot take (`CurlSupport::timeoutMilliseconds()`) */ @@ -67,8 +79,9 @@ public function __construct( ) { $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); - $this->clock = $clock ?? static fn(): float => hrtime(true) / 1e9; - $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->clock = $clock ?? Clock::monotonic(); + $this->functionExists = $functionExists ?? function_exists(...); + $this->curl = new CurlSupport($this->functionExists); $this->multi = new CurlMulti($multiStatus); } @@ -121,9 +134,10 @@ public function pump(): void } #[\Override] - public function waitForActivity(float $deadline): void + public function waitForActivity(float $deadline): bool { - $this->guarded(function () use ($deadline): void { + $waited = true; + $this->guarded(function () use ($deadline, &$waited): void { if ($this->ready !== [] && $this->multi->count() < self::MAX_CONCURRENT) { return; // a waiting attempt can start now (a slot freed up after the last launch) } @@ -139,37 +153,48 @@ public function waitForActivity(float $deadline): void return; } - usleep((int) ($timeout * 1e6)); + $waited = $timeout === 0.0 || $this->sleep($timeout); }); + + return $waited; } #[\Override] public function drain(float $deadline): bool { + RepeatedLines::begin(); // a failure of every pass is logged once + try { while (true) { $this->pump(); if ($this->inFlight() === 0) { return true; } - if ($this->now() >= $deadline) { + if ($this->now() >= $deadline || !$this->waitForActivity($deadline)) { return false; } - $this->waitForActivity($deadline); } } catch (\Throwable $error) { Log::write(self::GAVE_UP . ErrorText::of($error)); return false; + } finally { + RepeatedLines::end(); } } #[\Override] public function send(Delivery $delivery, float $deadline = INF): bool { - $this->start($delivery); + RepeatedLines::begin(); // the first step in start() is a pass of the same delivery - return $this->drain($deadline); + try { + $this->start($delivery); + + return $this->drain($deadline); + } finally { + RepeatedLines::end(); + } } #[\Override] @@ -194,7 +219,7 @@ public function curlOptions(Delivery $delivery): array /** * Runs transport work: PHP warnings are suppressed (a framework handler may turn them into exceptions) and - * any throwable is logged, never thrown (D32). + * any throwable is logged, never thrown (D32); inside a delivery, a repeated one once (`RepeatedLines`). * * @param \Closure(): void $work */ @@ -205,12 +230,32 @@ private function guarded(\Closure $work): void try { $work(); } catch (\Throwable $error) { - Log::write(self::GAVE_UP . ErrorText::of($error)); + RepeatedLines::write(self::GAVE_UP . ErrorText::of($error)); } finally { restore_error_handler(); } } + /** + * Sleeps `$seconds`. False on a host without a sleep function. The first call checks the functions once, and logs + * NO_SLEEP when there is none. + */ + private function sleep(float $seconds): bool + { + if ($this->sleep === null) { + $this->sleep = Sleep::closure($this->functionExists) ?? false; + if ($this->sleep === false) { + Log::write(self::NO_SLEEP); + } + } + if ($this->sleep === false) { + return false; + } + ($this->sleep)($seconds); + + return true; + } + /** Starts waiting attempts while slots are free; the next collect() runs their first curl_multi_exec(). */ private function launch(): void { diff --git a/src/Internal/Transport/Transport.php b/src/Internal/Transport/Transport.php index b0144d2..8a26b4d 100644 --- a/src/Internal/Transport/Transport.php +++ b/src/Internal/Transport/Transport.php @@ -19,8 +19,11 @@ public function start(Delivery $delivery): void; /** Collects finished transfers, logs outcomes, schedules retries by time and starts due ones. Never blocks or sleeps. */ public function pump(): void; - /** Blocks until a transfer can progress, a retry is due, or `$deadline`. Does no work. */ - public function waitForActivity(float $deadline): void; + /** + * Blocks until a transfer can progress, a retry is due, or `$deadline`. Does no work. False when it cannot block: only + * retries wait and PHP has no sleep function (`disable_functions`), so a waiting loop ends instead of spinning. + */ + public function waitForActivity(float $deadline): bool; /** Pumps and waits until nothing is in flight (true) or `$deadline` passes (false). */ public function drain(float $deadline): bool; diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index ff8700a..4598ffe 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -38,7 +38,7 @@ public function boot(): void $this->commands([InstallCommand::class]); } // `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before - // the environment, which a cached configuration hides (header conflict 12). A fake still records. + // the environment, which a cached configuration hides. A fake still records. $config = $this->app->make('config'); $agentcat = $config->get('agentcat'); BridgeConfig::fromBridge(is_array($agentcat) ? $agentcat : [], $config->get('app.env')); diff --git a/src/Laravel/Console/InstallCommand.php b/src/Laravel/Console/InstallCommand.php index 2f27a96..f3ff2f2 100644 --- a/src/Laravel/Console/InstallCommand.php +++ b/src/Laravel/Console/InstallCommand.php @@ -9,7 +9,7 @@ use Illuminate\Foundation\Application; /** - * `php artisan agentcat:install`: writes `AGENTCAT_PROJECT_ID` into `.env` and an empty key into `.env.example`, and + * `php artisan agentcat:install`: writes `AGENTCAT_PROJECT_ID` into `.env` (creating the file, with that key only, when it is missing) and an empty key into `.env.example`, and * publishes `config/agentcat.php` with `--config`. It sends nothing over the network. * * @internal @@ -39,6 +39,7 @@ public function handle(Application $app): int return self::FAILURE; } $env = $app->environmentFilePath(); + $created = !file_exists($env); try { EnvFile::set($env, 'AGENTCAT_PROJECT_ID', $project); EnvFile::ensure($env . '.example', 'AGENTCAT_PROJECT_ID'); @@ -47,6 +48,9 @@ public function handle(Application $app): int return self::FAILURE; } + if ($created) { + $this->line(sprintf('Created %s with AGENTCAT_PROJECT_ID only.', $env)); + } if ($this->option('config') === true) { $this->call('vendor:publish', ['--tag' => 'agentcat-config']); } diff --git a/src/Laravel/Internal/AfterRequest.php b/src/Laravel/Internal/AfterRequest.php index fdd75a8..fc8f6f3 100644 --- a/src/Laravel/Internal/AfterRequest.php +++ b/src/Laravel/Internal/AfterRequest.php @@ -9,9 +9,9 @@ use Illuminate\Contracts\Foundation\Application; /** - * When a Laravel application delivers (plan E1 header conflict 13, D58): after every request, in `terminating()`, for - * at most `RequestEndWait::seconds()` โ€” 1 s in an Octane worker, a single non-blocking pass elsewhere, where the - * shutdown function delivers within the 5 s budget after the hook (D47) โ€”, and for good when an Octane worker stops. + * When a Laravel application delivers (D58): after every request, in `terminating()`, for at most + * `RequestEndWait::seconds()` โ€” 1 s in an Octane worker, a single non-blocking pass elsewhere, where the shutdown + * function delivers within the 5 s budget after the hook (D47) โ€”, and for good when an Octane worker stops. * * @internal */ diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index c012c9d..5cfd32e 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -22,8 +22,8 @@ use Laravel\Mcp\Server; /** - * Tracks each laravel/mcp server the container resolves (plan E1 header conflicts 10-12). Never throws: the - * customer's server must come out of the container whatever AgentCat does. + * Tracks each laravel/mcp server the container resolves. Never throws: the customer's server must come out of the + * container whatever AgentCat does. * * One tracker serves the application (an Octane worker too): it builds its client and the options from * config/agentcat.php at the first server it tracks, so each bad value logs once, and it reads `exclude` and @@ -64,7 +64,7 @@ public function track(Server $server, Container $app): void $keys->string('diagnostics_token'); $this->warnOnce($bad->getArrayCopy()); $client = AgentCat::client(); - // A fake records every server (header conflict 18); the shared client is never replaced (header conflict 11). + // A fake records every server whatever `agentcat.enabled` says; the shared client is never replaced. $client = FakeLog::of($client) instanceof FakeLog ? $client : ($this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null)))); $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->resolve(...)); $client->track($server, $project, $this->options); @@ -79,7 +79,7 @@ public function track(Server $server, Container $app): void /** * A hook's class, resolved when the hook runs from the current container: under Octane, the sandbox of the - * request, never a container of an earlier request (Review Focus 4). + * request, never a container of an earlier request. */ private function resolve(string $class): mixed { diff --git a/src/LaravelMcp/Internal/CapturingCallTool.php b/src/LaravelMcp/Internal/CapturingCallTool.php index d19acb6..90c4f9f 100644 --- a/src/LaravelMcp/Internal/CapturingCallTool.php +++ b/src/LaravelMcp/Internal/CapturingCallTool.php @@ -13,9 +13,9 @@ use Laravel\Mcp\Transport\JsonRpcResponse; /** - * laravel/mcp's `CallTool`, whose tool invoker also keeps the tool's own exception for the request (plan E1 header - * conflict 5): a copy of `CallTool::handle()` (laravel/mcp 1.0.0 to 1.0.2) with `CapturingToolInvoker`. The contract - * test `ExtendedClassesTest` keeps both answers equal. + * laravel/mcp's `CallTool`, whose tool invoker also keeps the tool's own exception for the request: a copy of + * `CallTool::handle()` (laravel/mcp 1.0.0 to 1.1.0) with `CapturingToolInvoker`. The contract test + * `ExtendedClassesTest` keeps both answers equal. * * @internal */ diff --git a/src/LaravelMcp/Internal/CapturingToolInvoker.php b/src/LaravelMcp/Internal/CapturingToolInvoker.php index 1430325..62aabbe 100644 --- a/src/LaravelMcp/Internal/CapturingToolInvoker.php +++ b/src/LaravelMcp/Internal/CapturingToolInvoker.php @@ -10,9 +10,9 @@ use Laravel\Mcp\Transport\JsonRpcRequest; /** - * laravel/mcp's tool invoker, which also keeps the tool's own exception for the request it ran for (plan E1 header - * conflict 5). laravel/mcp turns every tool exception into an error result in `toErrorResponse()`, in the plain and - * the streamed path alike. No constructor: laravel/mcp 1.0.0 has none and 1.0.2 has one with a default. + * laravel/mcp's tool invoker, which also keeps the tool's own exception for the request it ran for. laravel/mcp turns + * every tool exception into an error result in `toErrorResponse()`, in the plain and the streamed path alike. No + * constructor: laravel/mcp 1.0.0 has none and 1.1.0 has one with a default. * * @internal */ diff --git a/src/LaravelMcp/Internal/HandshakeClient.php b/src/LaravelMcp/Internal/HandshakeClient.php index 9ece466..6348025 100644 --- a/src/LaravelMcp/Internal/HandshakeClient.php +++ b/src/LaravelMcp/Internal/HandshakeClient.php @@ -7,9 +7,9 @@ use AgentCat\Internal\Support\Values; /** - * The client info that a legacy client sent in `initialize`, kept for one server (plan E1 header conflict 8). Over - * stdio one server answers the whole process, so its later tool calls name that client; over HTTP every request builds - * a new server, and the value is lost. + * The client info that a legacy client sent in `initialize`, kept for one server. Over stdio one server answers the + * whole process, so its later tool calls name that client; over HTTP every request builds a new server, and the value + * is lost. * * @internal */ diff --git a/src/LaravelMcp/Internal/LaravelCallSeam.php b/src/LaravelMcp/Internal/LaravelCallSeam.php index 46adf79..07751fa 100644 --- a/src/LaravelMcp/Internal/LaravelCallSeam.php +++ b/src/LaravelMcp/Internal/LaravelCallSeam.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\ThrowableStash; +use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Json\JsonTree; use Illuminate\Container\Container; use Laravel\Mcp\Request as McpRequest; @@ -33,7 +34,7 @@ final class LaravelCallSeam implements CallSeam * the handler the server's * entry named before * AgentCat's, built at the - * call (Task 4 review M2) + * call, after the strip * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $listDelegate the same for * the server's * `tools/list` @@ -76,8 +77,8 @@ public function callOriginal(): iterable|JsonRpcResponse /** * Sends a copy of the request without the names, and removes them from the `mcp.request` that laravel/mcp bound - * for this call: the tool's own `Request`, and any other the container builds now, copy its arguments (05 E5). - * The handler is built after that (Task 4 review M2). + * for this call: the tool's own `Request`, and any other the container builds now, copy its arguments (ac-05-E5). + * The handler is built after that, so it sees the request without the names. * * @param list $names */ @@ -126,7 +127,7 @@ public function observe(mixed $result): Observed #[\Override] public function replace(mixed $result, \stdClass $tree): iterable|JsonRpcResponse { - // A JSON-RPC error is never decorated (C1 header conflict 3). + // A JSON-RPC error is never decorated: it reaches the client as the library built it. return $result instanceof JsonRpcResponse && isset($result->content['result']) ? new JsonRpcResponse([...$result->content, 'result' => LaravelTrees::nativeResult($tree)]) : $result; @@ -139,17 +140,25 @@ public function answer(\stdClass $tree): JsonRpcResponse } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { - return $this->listDelegate instanceof \Closure - ? LaravelTrees::tools(($this->listDelegate)(new JsonRpcRequest('agentcat-rebuild', 'tools/list', []))) - : null; + if (!$this->listDelegate instanceof \Closure) { + return null; + } + $answer = ($this->listDelegate)(new JsonRpcRequest('agentcat-rebuild', 'tools/list', $cursor === null ? [] : ['cursor' => $cursor])); + $tools = LaravelTrees::tools($answer); + if ($tools === null || !$answer instanceof JsonRpcResponse) { + return null; + } + $result = $answer->content['result'] ?? null; + + return [$tools, ToolListing::nextCursor(is_array($result) ? ($result['nextCursor'] ?? null) : null)]; } /** * A streamed answer (a generator, or another iterable of messages) passes message by message. Its first result or * JSON-RPC error completes the call and goes out as `$complete` returns it; what the stream throws before that - * fails the call, then passes on. A stream the client abandons completes nothing (plan E1 header conflict 6). + * fails the call, then passes on. A stream the client abandons completes nothing: no event. * * @param iterable|JsonRpcResponse $result * @param \Closure(iterable|JsonRpcResponse): (iterable|JsonRpcResponse) $complete diff --git a/src/LaravelMcp/Internal/LaravelHttpContext.php b/src/LaravelMcp/Internal/LaravelHttpContext.php index e5768b9..4e6f6ab 100644 --- a/src/LaravelMcp/Internal/LaravelHttpContext.php +++ b/src/LaravelMcp/Internal/LaravelHttpContext.php @@ -9,7 +9,7 @@ /** * The HTTP context of the request a laravel/mcp server answers, read from that request rather than from `$_SERVER`, - * so Octane's workers record it too (plan E1 header conflict 9). + * so Octane's workers record it too. * * @internal */ diff --git a/src/LaravelMcp/Internal/LaravelRequestReader.php b/src/LaravelMcp/Internal/LaravelRequestReader.php index d312bc8..dc8a22f 100644 --- a/src/LaravelMcp/Internal/LaravelRequestReader.php +++ b/src/LaravelMcp/Internal/LaravelRequestReader.php @@ -14,9 +14,9 @@ use Laravel\Mcp\Transport\JsonRpcRequest; /** - * Reads what an event records and the hooks see from a laravel/mcp `tools/call` request (plan E1 header conflict 8): - * the client from the call's `_meta`, else from the legacy handshake of the same server; the protocol version only - * from the call's `_meta`. laravel/mcp 1.x has no transport session and no MCP-level auth object. + * Reads what an event records and the hooks see from a laravel/mcp `tools/call` request: the client from the call's + * `_meta`, else from the legacy handshake of the same server; the protocol version only from the call's `_meta`. + * laravel/mcp 1.x has no transport session and no MCP-level auth object. * * @internal */ diff --git a/src/LaravelMcp/Internal/MethodKey.php b/src/LaravelMcp/Internal/MethodKey.php index ebf07da..9214540 100644 --- a/src/LaravelMcp/Internal/MethodKey.php +++ b/src/LaravelMcp/Internal/MethodKey.php @@ -7,9 +7,9 @@ use Laravel\Mcp\Server; /** - * The container key under which AgentCat binds its method object for one server (plan E1 header conflict 3). laravel/mcp - * resolves each entry of a server's method map with `Container::make()`, so the entry can name a key. A reused object - * ID belongs to a dead server, so overwriting its binding is correct. + * The container key under which AgentCat binds its method object for one server. laravel/mcp resolves each entry of a + * server's method map with `Container::make()`, so the entry can name a key. A reused object ID belongs to a dead + * server, so overwriting its binding is correct. * * @internal */ diff --git a/src/LaravelMcp/Internal/TrackedCallTool.php b/src/LaravelMcp/Internal/TrackedCallTool.php index 7a993b5..57036af 100644 --- a/src/LaravelMcp/Internal/TrackedCallTool.php +++ b/src/LaravelMcp/Internal/TrackedCallTool.php @@ -18,11 +18,10 @@ /** * Answers `tools/call` (TS callWrap): `CallInterceptor` resolves the handles, strips them, lets the handler the * server's entry named before AgentCat's run the tool, decorates the answer and queues one event. A customer's handler - * answers itself (header conflict 4). laravel/mcp's own `CallTool` is built by the container at each call, as - * laravel/mcp does, so a binding, an `extend()` decorator (an authorization check, say) and the `resolving()` - * callbacks of `CallTool` run: when the container builds exactly `CallTool`, AgentCat answers with - * `CapturingCallTool`, which keeps the tool's exception (plan E1 header conflict 5); anything else answers itself. A - * call without a string tool name goes to the handler untraced. + * answers itself. laravel/mcp's own `CallTool` is built by the container at each call, as laravel/mcp does, so a + * binding, an `extend()` decorator (an authorization check, say) and the `resolving()` callbacks of `CallTool` run: + * when the container builds exactly `CallTool`, AgentCat answers with `CapturingCallTool`, which keeps the tool's + * exception; anything else answers itself. A call without a string tool name goes to the handler untraced. * * @internal */ diff --git a/src/LaravelMcp/Internal/TrackedListTools.php b/src/LaravelMcp/Internal/TrackedListTools.php index 48efaa9..022faf0 100644 --- a/src/LaravelMcp/Internal/TrackedListTools.php +++ b/src/LaravelMcp/Internal/TrackedListTools.php @@ -14,8 +14,8 @@ /** * Answers `tools/list` (TS listWrap): the handler the server's entry named before AgentCat's lists, then AgentCat - * injects its parameters and appends get_more_tools to every page. Streams, JSON-RPC errors, exceptions and answers - * without a tool list pass unchanged (02 AC-10). + * injects its parameters into every page and appends get_more_tools to the last one (D66). Streams, JSON-RPC errors, + * exceptions and answers without a tool list pass unchanged (ac-02-10). * * @internal */ @@ -28,14 +28,15 @@ public function __construct(private TrackedServer $server, private string $origi public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse { $this->publisher->pump(); // stdio servers deliver between requests - $response = Delegate::handle($this->original, $request, $context); // throws pass unchanged (02 AC-10) + $response = Delegate::handle($this->original, $request, $context); // throws pass unchanged (ac-02-10) if (!$response instanceof JsonRpcResponse || array_key_exists('error', $response->content)) { return $response; // a stream or a JSON-RPC error passes unchanged, like the mcp/sdk adapter's } try { // AgentCat changes only the result's `tools`; its other members (the cursor, `_meta`) stay. - $tools = ToolListing::apply($this->server, LaravelTrees::tools($response)); $result = $response->content['result'] ?? null; + $lastPage = ToolListing::nextCursor(is_array($result) ? ($result['nextCursor'] ?? null) : null) === null; + $tools = ToolListing::apply($this->server, LaravelTrees::tools($response), $lastPage); if ($tools === null || !is_array($result)) { return $response; } diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php index ecc881e..40416dd 100644 --- a/src/LaravelMcp/LaravelMcpAdapter.php +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -31,8 +31,8 @@ /** * Detects and instruments laravel/mcp servers. AgentCat changes a server only through its public `addMethod()`: the - * entries it points at AgentCat's own method objects, bound in the container under per-server keys (plan E1 header - * conflict 3), which delegate to the classes the entries named before. + * entries it points at AgentCat's own method objects, bound in the container under per-server keys, which delegate to + * the classes the entries named before. * * @internal */ @@ -107,9 +107,9 @@ public static function serverInfo(Server $server): \Closure } /** - * The HTTP request the server answers, read from its own transport when a call is recorded (plan E1 header - * conflict 9); null on stdio and once the server is gone. Holds the server weakly, like `serverInfo()`. Never - * throws; the call is then recorded without HTTP context: + * The HTTP request the server answers, read from its own transport when a call is recorded, so an Octane worker + * records the request it serves; null on stdio and once the server is gone. Holds the server weakly, like + * `serverInfo()`. Never throws; the call is then recorded without HTTP context: * - when the transport or its request cannot be read (drift), and the compatibility line is logged once per server; * - when Laravel cannot read the request (Symfony refuses an invalid `X-HTTP-Method-Override` header, which the * client sends), and nothing is logged. diff --git a/src/LaravelMcp/LaravelMcpLibrary.php b/src/LaravelMcp/LaravelMcpLibrary.php index 383b675..0663ba8 100644 --- a/src/LaravelMcp/LaravelMcpLibrary.php +++ b/src/LaravelMcp/LaravelMcpLibrary.php @@ -26,7 +26,7 @@ public function supports(object $server): bool } #[\Override] - public function sdk(): string + public function sdk(object $server): string { return 'laravel-mcp/server'; } diff --git a/src/McpSdk/Internal/CapturingReferenceHandler.php b/src/McpSdk/Internal/CapturingReferenceHandler.php index 500e138..2891d0e 100644 --- a/src/McpSdk/Internal/CapturingReferenceHandler.php +++ b/src/McpSdk/Internal/CapturingReferenceHandler.php @@ -10,9 +10,9 @@ use Mcp\Capability\Registry\ToolReference; /** - * The inner tap (plan header conflict 7): runs every tool, prompt and resource through the reference handler the - * Builder would use, and stashes a tool's exception for the request it ran for before mcp/sdk catches it. It - * changes nothing else: the arguments and the result pass unchanged, and the exception is rethrown. + * The inner tap: runs every tool, prompt and resource through the reference handler the Builder would use, and stashes + * a tool's exception for the request it ran for before mcp/sdk catches it. It changes nothing else: the arguments and + * the result pass unchanged, and the exception is rethrown. * * @internal */ diff --git a/src/McpSdk/Internal/Delegates.php b/src/McpSdk/Internal/Delegates.php index faf6caf..d130dad 100644 --- a/src/McpSdk/Internal/Delegates.php +++ b/src/McpSdk/Internal/Delegates.php @@ -27,7 +27,7 @@ public function __construct(private readonly Builder $builder) {} /** * The first handler after `$self` that supports the request. mcp/sdk always has one of its own, so null means - * that its internals changed (C6): the list cannot be read, `$self` is not in it, or nothing after `$self` + * that its internals changed: the list cannot be read, `$self` is not in it, or nothing after `$self` * answers. That drift is logged once per Builder, and AgentCat's handlers step aside. * * @return RequestHandlerInterface|null diff --git a/src/McpSdk/Internal/Drift.php b/src/McpSdk/Internal/Drift.php index 31bd424..65827bb 100644 --- a/src/McpSdk/Internal/Drift.php +++ b/src/McpSdk/Internal/Drift.php @@ -10,8 +10,9 @@ use Mcp\Server\Builder; /** - * Runtime drift in what a Builder's build() assembled, or in a built server (C6, README ยง4.5 rule 4). The first drift - * on an object logs the compatibility line; later ones on the same object log nothing. + * Runtime drift in what a Builder's build() assembled, or in a built server: mcp/sdk's internals changed, and AgentCat + * cannot read them. The first drift on an object logs the compatibility line; later ones on the same object log + * nothing. * * @internal */ diff --git a/src/McpSdk/Internal/ListToolsInterceptor.php b/src/McpSdk/Internal/ListToolsInterceptor.php index a4f7209..9d095dc 100644 --- a/src/McpSdk/Internal/ListToolsInterceptor.php +++ b/src/McpSdk/Internal/ListToolsInterceptor.php @@ -15,8 +15,8 @@ use Mcp\Server\Session\SessionInterface; /** - * Answers `tools/list` first (TS listWrap): the next handler lists, then AgentCat injects its parameters and - * appends get_more_tools. Errors, exceptions and other answers pass unchanged (02 AC-10). + * Answers `tools/list` first (TS listWrap): the next handler lists, then AgentCat injects its parameters into the page + * and appends get_more_tools to the last one (D66). Errors, exceptions and other answers pass unchanged (ac-02-10). * * @implements RequestHandlerInterface * @@ -46,14 +46,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er if (!$delegate instanceof RequestHandlerInterface) { return Delegates::notFound($request); } - $response = $delegate->handle($request, $session); // throws and JSON-RPC errors pass unchanged (02 AC-10) + $response = $delegate->handle($request, $session); // throws and JSON-RPC errors pass unchanged (ac-02-10) if (!$response instanceof Response) { return $response; } try { // The library's own wire tree: AgentCat changes only its `tools`. Null: another answer, or no tool list. $listing = Trees::listing($response->result); - $tools = ToolListing::apply($this->server, $listing[2] ?? null); + $lastPage = ToolListing::nextCursor($listing[1]->nextCursor ?? null) === null; + $tools = ToolListing::apply($this->server, $listing[2] ?? null, $lastPage); if ($listing === null || $tools === null) { return $response; } diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index 7b76491..74a81dc 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\ThrowableStash; +use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Json\JsonTree; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -107,7 +108,7 @@ public function observe(mixed $result): Observed #[\Override] public function replace(mixed $result, \stdClass $tree): Response|Error { - // A JSON-RPC error is never decorated (header conflict 3). + // A JSON-RPC error is never decorated: it reaches the client as the library built it. return $result instanceof Response ? new Response($result->getId(), new WireCallToolResult(Trees::callToolResult($result->result), $tree)) : $result; @@ -121,15 +122,16 @@ public function answer(\stdClass $tree): Response } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { $delegate = ($this->listDelegate)(); if (!$delegate instanceof RequestHandlerInterface) { return null; } - $answer = $delegate->handle((new ListToolsRequest())->withId('agentcat-rebuild'), $this->session); + $answer = $delegate->handle((new ListToolsRequest($cursor))->withId('agentcat-rebuild'), $this->session); + $listing = $answer instanceof Response ? Trees::listing($answer->result) : null; - return $answer instanceof Response ? (Trees::listing($answer->result)[2] ?? null) : null; + return $listing === null ? null : [$listing[2], ToolListing::nextCursor($listing[1]->nextCursor ?? null)]; } /** mcp/sdk answers are complete when `handle()` returns: a tool that waits suspends its fiber inside it. */ diff --git a/src/McpSdk/Internal/RequestReader.php b/src/McpSdk/Internal/RequestReader.php index c64c5c3..ce7a9b8 100644 --- a/src/McpSdk/Internal/RequestReader.php +++ b/src/McpSdk/Internal/RequestReader.php @@ -48,7 +48,7 @@ public function read(CallToolRequest $request, SessionInterface $session): CallC $meta[Constants::META_CLIENT_INFO_KEY] ?? null, $session->get('client_info'), ); - // Never the session's negotiated `protocol_version` (02 ยง3.3): only what the call itself claims. + // Never the session's negotiated `protocol_version`: only what the call itself claims. $protocolVersion = $requestMeta instanceof RequestMeta ? $requestMeta->protocolVersion : CallTrees::claimedVersion($meta); $transportSessionId = $requestMeta instanceof RequestMeta ? null : (string) $session->getId(); // the Mcp-Session-Id $http = ($this->http)(); diff --git a/src/McpSdk/Internal/Trees.php b/src/McpSdk/Internal/Trees.php index c992b80..540875b 100644 --- a/src/McpSdk/Internal/Trees.php +++ b/src/McpSdk/Internal/Trees.php @@ -28,7 +28,7 @@ public static function of(mixed $value): mixed /** * The tree of a tool result (`JsonTree::result()`): mcp/sdk cannot tell `{}` from `[]`, so an empty-list - * `structuredContent` is `{}` (header conflict 4). + * `structuredContent` is `{}`. * * @throws \JsonException for a value JSON cannot carry */ diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php index 08d7862..16a1381 100644 --- a/src/McpSdk/McpSdkAdapter.php +++ b/src/McpSdk/McpSdkAdapter.php @@ -96,7 +96,7 @@ public static function inspect(Builder $builder): void } } - /** The PSR-3 logger set on the Builder; null when none is set or it cannot be read (C13). Never warns or throws. */ + /** The PSR-3 logger set on the Builder; null when none is set or it cannot be read. Never warns or throws. */ public static function logger(Builder $builder): ?LoggerInterface { try { @@ -108,7 +108,7 @@ public static function logger(Builder $builder): ?LoggerInterface /** * Reads the server name and version when an event is published: build() sets them. When they cannot be read - * (C6), they are unknown, and the drift is logged once per Builder. + * (`Drift`), they are unknown, and the drift is logged once per Builder. * * @return \Closure(): array{0: ?string, 1: ?string} */ @@ -130,10 +130,10 @@ public static function serverInfo(Builder $builder): \Closure } /** - * The tracking state of a server built from a tracked Builder (01 AC-69): AgentCat's `tools/call` handler, among + * The tracking state of a server built from a tracked Builder (ac-01-69): AgentCat's `tools/call` handler, among * the server's request handlers, holds it. A state found is kept for the server. Never throws. * - * When the request handlers cannot be read (C6), the compatibility line is logged once per server, and + * When the request handlers cannot be read (`Drift`), the compatibility line is logged once per server, and * `TrackedServers::incomplete()` turns true. * * @return TrackedServer|null null when the object is not an mcp/sdk server, its Builder was not tracked, or its @@ -165,8 +165,8 @@ public static function trackedServer(object $server): ?TrackedServer } /** - * Wraps the reference handler, then registers AgentCat's request handlers, which run before the library's own - * (02 ยง1.4). Every read comes before the first change, so a read that throws leaves the Builder untouched. + * Wraps the reference handler, then registers AgentCat's request handlers, which run before the library's own. + * Every read comes before the first change, so a read that throws leaves the Builder untouched. */ public static function install(Builder $builder, TrackedServer $server, Publisher $publisher, CallInterceptor $interceptor): void { @@ -188,8 +188,8 @@ public static function install(Builder $builder, TrackedServer $server, Publishe } /** - * The container the Builder holds at the first call, which build() also used. When it cannot be read (C6), the - * drift is logged once per Builder and a fresh default container keeps the tools working. + * The container the Builder holds at the first call, which build() also used. When it cannot be read (`Drift`), + * the drift is logged once per Builder and a fresh default container keeps the tools working. */ private static function container(Builder $builder): ?ContainerInterface { diff --git a/src/McpSdk/McpSdkLibrary.php b/src/McpSdk/McpSdkLibrary.php index 4c1a687..b89a283 100644 --- a/src/McpSdk/McpSdkLibrary.php +++ b/src/McpSdk/McpSdkLibrary.php @@ -8,7 +8,9 @@ use AgentCat\Internal\Engine\Library; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; +use Mcp\Server; use Mcp\Server\Builder; +use Mcp\Server\Stateless\StatelessProtocol; use Psr\Log\LoggerInterface; /** @@ -18,6 +20,14 @@ */ final readonly class McpSdkLibrary implements Library { + /** What to pass instead, when the object is what `Builder::buildStateless()` returns; null for any other object. */ + public static function untrackedAdvice(object $server): ?string + { + return $server instanceof StatelessProtocol + ? 'pass the Mcp\Server\Builder (or the Mcp\Server built from it) to AgentCat::publishCustomEvent(); Builder::buildStateless() returns a StatelessProtocol, which AgentCat cannot map to its Builder' + : null; + } + #[\Override] public function supports(object $server): bool { @@ -25,9 +35,9 @@ public function supports(object $server): bool } #[\Override] - public function sdk(): string + public function sdk(object $server): string { - return 'mcp-sdk/builder'; + return $server instanceof Server ? 'mcp-sdk/server' : 'mcp-sdk/builder'; } #[\Override] diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index 0db02a2..53a6a1d 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -33,6 +33,15 @@ final class AgentCatBundle extends AbstractBundle private const array BOOLS = ['enable_report_missing' => true, 'enable_tracing' => true, 'enable_tool_call_context' => true]; + /** The sentence of every bool key's info() text. */ + private const string BOOL_WORDS = 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'; + + private const array BOOL_INFO = [ + 'enable_report_missing' => 'Adds the get_more_tools tool, so agents can report tools they miss. Default: true. ' . self::BOOL_WORDS, + 'enable_tracing' => 'Publishes one event for every tool call. Default: true. ' . self::BOOL_WORDS, + 'enable_tool_call_context' => 'Adds a context parameter to your tools, so agents explain each call. Default: true. ' . self::BOOL_WORDS, + ]; + /** The configuration's root key is `agentcat` (D55), not the `agent_cat` that Symfony derives from the class name. */ public function __construct() { @@ -51,10 +60,10 @@ public function configure(DefinitionConfigurator $definition): void $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp, written here, not in an environment variable. Unset: their own file, agentcat.log in the log directory.')->end(); $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL, the TypeScript SDK\'s name, is read too.')->end(); foreach (self::BOOLS as $key => $default) { - $children->scalarNode($key)->defaultValue($default)->end(); + $children->scalarNode($key)->defaultValue($default)->info(self::BOOL_INFO[$key])->end(); } - $children->scalarNode('custom_context_description')->defaultNull()->end(); - $children->scalarNode('enable_agent_tracking')->defaultFalse()->end(); + $children->scalarNode('custom_context_description')->defaultNull()->info('Replaces the default description of the context parameter. Unset: the default description.')->end(); + $children->scalarNode('enable_agent_tracking')->defaultFalse()->info('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. ' . self::BOOL_WORDS)->end(); foreach (self::HOOKS as $hook) { $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, written here, not in an environment variable. The service is resolved when the hook runs.')->end(); } @@ -62,7 +71,7 @@ public function configure(DefinitionConfigurator $definition): void $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS, the TypeScript SDK\'s name, is read too.')->end(); $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end(); $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end(); - $children->scalarNode('disable_signal_handlers')->defaultFalse()->end(); + $children->scalarNode('disable_signal_handlers')->defaultFalse()->info('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. ' . self::BOOL_WORDS)->end(); } /** @param array $config */ @@ -104,7 +113,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C '%agentcat.servers%', // every server name, which TrackBuildersPass writes $variables, ]); - // D58: the wait for delivery after each response, last of kernel.terminate (header conflict 7). + // D58: the wait for delivery after each response, last of kernel.terminate. $builder->register('agentcat.terminate_listener', TerminateListener::class) ->addTag('kernel.event_listener', ['event' => KernelEvents::TERMINATE, 'priority' => TerminateListener::PRIORITY]); // D62: boot() makes it a kernel.response listener where responses end with the script. @@ -141,10 +150,10 @@ public function build(ContainerBuilder $container): void /** * `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before the - * environment (plan E1 header conflict 12): a literal, a parameter or a secret of the configuration is not in the - * environment. Diagnostics get the kernel's environment. A fake still records. The tracker logs the bad values; - * nothing is logged here, and boot tracks nothing. Where this process's SAPI sends a response only when the script - * ends, the answers of tracked servers get their length (D62). + * environment: a literal, a parameter or a secret of the configuration is not in the environment. Diagnostics get + * the kernel's environment. A fake still records. The tracker logs the bad values; nothing is logged here, and boot + * tracks nothing. Where this process's SAPI sends a response only when the script ends, the answers of tracked + * servers get their length (D62). */ #[\Override] public function boot(): void diff --git a/src/Symfony/Internal/BuilderConfigurator.php b/src/Symfony/Internal/BuilderConfigurator.php index 1938da6..1329796 100644 --- a/src/Symfony/Internal/BuilderConfigurator.php +++ b/src/Symfony/Internal/BuilderConfigurator.php @@ -10,8 +10,8 @@ /** * The configurator of one symfony/mcp-bundle server builder (`TrackBuildersPass`). The container calls it after every * method call of the builder's definition, so AgentCat tracks a Builder that has its logger, its reference handler and - * its container (plan E2 header conflict 2), when the container builds it: an MCP request, `mcp:server`, the profiler. - * Booting the kernel tracks nothing. + * its container, when the container builds it: an MCP request, `mcp:server`, the profiler. Booting the kernel tracks + * nothing. * * @internal */ diff --git a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php index 62b121f..d6cd251 100644 --- a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php +++ b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php @@ -12,15 +12,15 @@ use Symfony\Component\DependencyInjection\Reference; /** - * Gives each symfony/mcp-bundle server builder a configurator that tracks it (plan E2 header conflicts 1-3, 14). It - * reads the bundle's service definitions only, by name: the services tagged `mcp.server.builder` with their `server` - * attribute, and the `addRequestHandlers` method call with the tagged request handlers, its single argument. That call - * moves into the configurator, which tracks first and adds the handlers after AgentCat's: mcp/sdk answers with the - * first handler that supports a request, so a customer's `tools/call` handler becomes the handler AgentCat delegates - * to. A builder without that call, or with a call of another shape, keeps its calls and is tracked after its handlers - * (header conflict 3): the customer's handlers never go missing. A server that `agentcat.exclude_servers` names (the - * names written in the configuration; the bundle reads those from environment variables when it builds a server) - * keeps its definition. The pass writes every server name to `agentcat.servers`, for those runtime names. + * Gives each symfony/mcp-bundle server builder a configurator that tracks it. It reads the bundle's service definitions + * only, by name: the services tagged `mcp.server.builder` with their `server` attribute, and the `addRequestHandlers` + * method call with the tagged request handlers, its single argument. That call moves into the configurator, which + * tracks first and adds the handlers after AgentCat's: mcp/sdk answers with the first handler that supports a request, + * so a customer's `tools/call` handler becomes the handler AgentCat delegates to. A builder without that call, or with + * a call of another shape, keeps its calls and is tracked after its handlers: the customer's handlers never go missing. + * A server that `agentcat.exclude_servers` names (the names written in the configuration; the bundle reads those from + * environment variables when it builds a server) keeps its definition. The pass writes every server name to + * `agentcat.servers`, for those runtime names. * * It runs before the optimization passes at priority -100, after symfony/mcp-bundle's `McpAppPass` (10) and `McpPass` * (0), so the configurator sees every method call they add. @@ -60,9 +60,9 @@ private function track(ContainerBuilder $container, string $id, string $server): $method = is_array($call) ? ($call[0] ?? null) : null; $arguments = is_array($call) ? ($call[1] ?? null) : null; if (is_string($method) && strtolower($method) === 'addrequesthandlers' && is_array($arguments) && count($arguments) === 1 && ($arguments[0] ?? null) instanceof TaggedIteratorArgument) { - $handlers[] = $arguments[0]; // added after AgentCat's own handlers (header conflict 1) + $handlers[] = $arguments[0]; // added after AgentCat's own handlers } else { - $calls[] = $call; // a call of another shape too: its handlers then come first (header conflict 3) + $calls[] = $call; // a call of another shape too: its handlers then come first } } $builder->setMethodCalls($calls); diff --git a/src/Symfony/Internal/RequestStackContext.php b/src/Symfony/Internal/RequestStackContext.php index a095d43..27b4c9d 100644 --- a/src/Symfony/Internal/RequestStackContext.php +++ b/src/Symfony/Internal/RequestStackContext.php @@ -11,7 +11,7 @@ /** * The HTTP context of an mcp/sdk call in a Symfony application: the main request of the `RequestStack`, read when the * call is recorded, not `$_SERVER`, so a Symfony Runtime worker (RoadRunner, Swoole, FrankenPHP worker mode) records - * the request it serves (plan E2 header conflict 5). + * the request it serves. * * @internal */ diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php index bfa918d..aad99e3 100644 --- a/src/Symfony/Internal/ServerTracker.php +++ b/src/Symfony/Internal/ServerTracker.php @@ -23,8 +23,8 @@ use Symfony\Component\HttpFoundation\RequestStack; /** - * Tracks each symfony/mcp-bundle server when the container builds its Builder (plan E2 header conflicts 2, 5, 6, 14). - * Never throws: the customer's server must come out of the container whatever AgentCat does. + * Tracks each symfony/mcp-bundle server when the container builds its Builder. Never throws: the customer's server must + * come out of the container whatever AgentCat does. * * One tracker serves the container (a worker's too). At the first server, it reads the configuration, whose environment * variables resolve then, so each bad value logs once, at the first server it tracks. A server that `exclude_servers` @@ -99,8 +99,8 @@ public function track(Builder $builder, string $server): void $this->lines = []; $client = AgentCat::client(); $context = $this->requests instanceof RequestStack ? RequestStackContext::of($this->requests) : null; - // A fake records every server (header conflict 6), with the request of each call, as production sends it - // (D41); the shared client is never replaced. + // A fake records every server whatever `agentcat.enabled` says, with the request of each call, as + // production sends it (D41); the shared client is never replaced. if (!FakeLog::of($client) instanceof FakeLog) { $client = $this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null), httpContext: $context)); } elseif ($context instanceof \Closure) { diff --git a/src/Symfony/Internal/TerminateListener.php b/src/Symfony/Internal/TerminateListener.php index c06c901..76faf63 100644 --- a/src/Symfony/Internal/TerminateListener.php +++ b/src/Symfony/Internal/TerminateListener.php @@ -8,9 +8,9 @@ use AgentCat\Internal\Delivery\RequestEndWait; /** - * Delivers the request's events on kernel.terminate (plan E2 header conflict 7, D58), after the response, and last: - * for at most `RequestEndWait::WORKER_SECONDS` in a Symfony Runtime worker, and in one non-blocking pass under a - * per-request SAPI, where AgentCat's shutdown function delivers within the 5 s budget after it (D47). + * Delivers the request's events on kernel.terminate (D58), after the response, and last: for at most + * `RequestEndWait::WORKER_SECONDS` in a Symfony Runtime worker, and in one non-blocking pass under a per-request SAPI, + * where AgentCat's shutdown function delivers within the 5 s budget after it (D47). * * @internal */ diff --git a/src/Testing/Internal/InstantTransport.php b/src/Testing/Internal/InstantTransport.php index ac146f8..012f251 100644 --- a/src/Testing/Internal/InstantTransport.php +++ b/src/Testing/Internal/InstantTransport.php @@ -4,6 +4,7 @@ namespace AgentCat\Testing\Internal; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\Transport; @@ -12,8 +13,16 @@ * * @internal */ -final class InstantTransport implements Transport +final readonly class InstantTransport implements Transport { + /** @var \Closure(): float */ + private \Closure $clock; + + public function __construct() + { + $this->clock = Clock::monotonic(); + } + #[\Override] public function start(Delivery $delivery): void { @@ -26,7 +35,10 @@ public function start(Delivery $delivery): void public function pump(): void {} #[\Override] - public function waitForActivity(float $deadline): void {} + public function waitForActivity(float $deadline): bool + { + return true; + } #[\Override] public function drain(float $deadline): bool @@ -51,6 +63,6 @@ public function inFlight(): int #[\Override] public function now(): float { - return hrtime(true) / 1e9; + return ($this->clock)(); } } diff --git a/tests/Architecture/LayeringTest.php b/tests/Architecture/LayeringTest.php index f1ad014..c0bf66e 100644 --- a/tests/Architecture/LayeringTest.php +++ b/tests/Architecture/LayeringTest.php @@ -34,7 +34,7 @@ public function onlyTheAdapterDependsOnMcpSdk(): Rule ->excluding(Selector::inNamespace('AgentCat\McpSdk'), Selector::inNamespace('AgentCat\Symfony'), Selector::inNamespace('AgentCat\Tests')) ->shouldNot()->dependOn() ->classes(Selector::inNamespace('Mcp')) - ->because('mcp/sdk is optional: only AgentCat\McpSdk and the Symfony bundle, which configures Mcp\Server\Builder services, may reference it (01 AC-90)'); + ->because('mcp/sdk is optional: only AgentCat\McpSdk and the Symfony bundle, which configures Mcp\Server\Builder services, may reference it (ac-01-90)'); } #[TestRule] @@ -45,7 +45,7 @@ public function onlyTheLaravelPartsDependOnLaravel(): Rule ->excluding(Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\Tests')) ->shouldNot()->dependOn() ->classes(Selector::inNamespace('Laravel'), Selector::inNamespace('Illuminate')) - ->because('laravel/mcp and Laravel are optional (01 AC-90)'); + ->because('laravel/mcp and Laravel are optional (ac-01-90)'); } #[TestRule] @@ -73,7 +73,7 @@ public function onlyTheSymfonyBundleDependsOnSymfonyFrameworkParts(): Rule Selector::inNamespace('Symfony\Component\EventDispatcher'), Selector::inNamespace('Symfony\Bundle'), ) - ->because('Symfony is optional (01 AC-90)'); + ->because('Symfony is optional (ac-01-90)'); } #[TestRule] diff --git a/tests/Architecture/PackageFilesTest.php b/tests/Architecture/PackageFilesTest.php new file mode 100644 index 0000000..7f49391 --- /dev/null +++ b/tests/Architecture/PackageFilesTest.php @@ -0,0 +1,441 @@ +lines(self::ROOT . '/CHANGELOG.md'); + + $this->assertSame(self::CHANGELOG_HEADER, array_slice($lines, 0, count(self::CHANGELOG_HEADER))); + $this->assertSame('## [Unreleased]', $lines[count(self::CHANGELOG_HEADER)] ?? null, 'the next release collects its changes under Unreleased, on top'); + } + + public function testEveryReleaseHasADateAndEveryChangeAKnownType(): void + { + foreach ($this->lines(self::ROOT . '/CHANGELOG.md') as $line) { + if (str_starts_with($line, '## ')) { + $this->assertMatchesRegularExpression('/\A## \[(?:Unreleased\]|\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2})\z/', $line, 'a release is "[Unreleased]" or "[x.y.z] - YYYY-MM-DD"'); + } + } + $releases = $this->releases(); + $this->assertNotSame([], $releases); + + foreach ($releases as $name => $sections) { + $types = array_keys($sections); + $this->assertSame(array_values(array_intersect(self::CHANGE_TYPES, $types)), $types, sprintf('release %s: only the Keep a Changelog types, in their order, once each', $name)); + if ($name !== 'Unreleased') { + $this->assertNotSame([], $sections, sprintf('release %s lists its changes', $name)); + } + foreach ($sections as $type => $items) { + $this->assertNotSame([], $items, sprintf('release %s: %s lists at least one change', $name, $type)); + } + } + } + + /** + * The first release, Unreleased until it has its version, is the last one in the file. It names only what 1.0.0 + * ships: the Symfony Flex recipe reaches Flex after the release, and its line comes with the release that follows. + * Its lines speak to customers: no decision or bug ID, which no reader can look up. + */ + public function testTheFirstReleaseListsItsFeaturesAndTheToolListChange(): void + { + $releases = $this->releases(); + $first = end($releases); + $this->assertIsArray($first); + + $added = $first['Added'] ?? []; + $this->assertCount(10, $added, 'one line for each feature of 1.0.0'); + $this->assertSame([], preg_grep('/\bFlex\b/i', $added), 'no Flex recipe in 1.0.0'); + $changed = $first['Changed'] ?? []; + $this->assertCount(1, $changed); + $this->assertStringStartsWith('AgentCat reads `tools/list` pages until it finds the called tool, so a tool on a later page keeps its own parameters', $changed[0]); + $this->assertStringContainsString('your own `tools/list` handler', $changed[0], 'the change says whose code runs more often'); + $this->assertStringContainsString('runs once for each page that AgentCat reads during a `tools/call`', $changed[0]); + $this->assertStringContainsString('its queries, metrics and log lines', $changed[0]); + $this->assertStringContainsString('57.6 ms (2.7 ms before)', $changed[0], 'the change states its cost'); + foreach ($releases as $sections) { + foreach ($sections as $items) { + $this->assertSame([], preg_grep('/\b[A-Z][0-9]{1,2}\b/', $items), 'a decision or bug ID means nothing to a customer'); + } + } + } + + public function testTheLinksOfTheReleasesEndTheChangelog(): void + { + $lines = $this->lines(self::ROOT . '/CHANGELOG.md'); + while ($lines !== [] && end($lines) === '') { + array_pop($lines); + } + $names = array_keys($this->releases()); + + $links = array_slice($lines, -count($names)); + $this->assertCount(count($names), $links); + foreach ($names as $index => $name) { + $this->assertStringStartsWith(sprintf('[%s]: %s', $name, self::REPOSITORY), $links[$index] ?? '', 'one link for each release, in the order of the releases'); + } + $this->assertSame('', $lines[count($lines) - count($names) - 1] ?? null, 'a blank line before the links'); + } + + public function testTheComposerMetadataIsTheTypeScriptPackageMetadata(): void + { + $composer = json_decode((string) file_get_contents(self::ROOT . '/composer.json'), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($composer); + + $this->assertSame(self::TYPESCRIPT_DESCRIPTION, $composer['description'] ?? null); + $keywords = $composer['keywords'] ?? null; + $this->assertIsList($keywords); + $strings = array_values(array_filter($keywords, is_string(...))); + $this->assertSame($keywords, $strings, 'every keyword is a string'); + $keywords = $strings; + $this->assertSame([], array_values(array_diff([...self::TYPESCRIPT_KEYWORDS, ...self::PHP_KEYWORDS], $keywords)), 'every TypeScript keyword, and the PHP ones'); + $this->assertSame(array_values(array_unique($keywords)), $keywords, 'each keyword once'); + } + + public function testTheBoostGuidelineNamesTheSetup(): void + { + $guideline = $this->guideline(); + + foreach (['composer require agentcat/sdk', 'AGENTCAT_PROJECT_ID', 'php artisan agentcat:install', 'AgentCat::fake()', 'php artisan vendor:publish --tag=agentcat-config', 'https://docs.agentcat.com/sdk/php/laravel'] as $text) { + $this->assertStringContainsString($text, $guideline); + } + } + + public function testTheBoostGuidelineNamesOnlyKeysOfTheConfiguration(): void + { + $keys = require self::ROOT . '/config/agentcat.php'; + $this->assertIsArray($keys); + $guideline = $this->guideline(); + $this->assertSame(1, preg_match('/^### Configuration\n(.*?)^### /ms', $guideline, $section), 'the guideline has a Configuration section'); + preg_match_all('/^- (.+?): /m', $section[1], $items); + $named = []; + foreach ($items[1] as $item) { + preg_match_all('/`([a-z]+(?:_[a-z]+)*)`/', $item, $codes); + array_push($named, ...$codes[1]); + } + + $this->assertContains('exclude', $named, 'the guideline lists the keys of config/agentcat.php'); + $this->assertContains('identify', $named); + foreach ($named as $key) { + $this->assertArrayHasKey($key, $keys, 'a key the guideline names is a key of config/agentcat.php'); + } + } + + /** The contributing guide makes no promise of a response time (D70): issues and pull requests get an answer as soon as we can. */ + public function testTheContributingGuidePromisesNoResponseTime(): void + { + $guide = (string) file_get_contents(self::ROOT . '/CONTRIBUTING.md'); + + $this->assertDoesNotMatchRegularExpression('/\b(?:business\s+)?(?:days?|hours?|weeks?)\b/i', $guide, 'no deadline for an answer'); + $this->assertStringContainsString('as soon as we can', $guide); + } + + /** Blade compiles the guideline to itself: no `@` directive, echo or PHP tag in the Markdown by accident. */ + public function testBladeLeavesTheGuidelineAsItIs(): void + { + $guideline = $this->guideline(); + $compiler = new BladeCompiler(new Filesystem(), self::ROOT . '/.cache/blade'); // compileString() writes nothing + + $this->assertSame($guideline, $compiler->compileString($guideline)); + } + + public function testTheChangelogAndTheGuidelinesShipAndTheContributingGuideDoesNot(): void + { + $attributes = array_map(trim(...), $this->lines(self::ROOT . '/.gitattributes')); + + $this->assertContains('/CONTRIBUTING.md export-ignore', $attributes); + foreach ($attributes as $line) { + $this->assertDoesNotMatchRegularExpression('#\A/?(?:CHANGELOG\.md|resources)\b#', $line, 'CHANGELOG.md and resources/ go into the dist'); + } + } + + /** + * Only the PHP 8.6 jobs read `phpunit-baseline.xml`, and an unreadable baseline is a PHPUnit warning there, not a + * failure here. PHPUnit joins the baseline's directory and each path without resolving "..": a path matches only + * when it names a file from the repository root. + */ + public function testThePhpunitBaselineIsWellFormedAndNamesFilesFromTheRoot(): void + { + $document = new \DOMDocument(); + $errors = libxml_use_internal_errors(true); + try { + $loaded = $document->load(self::ROOT . '/phpunit-baseline.xml'); + $problems = array_map(static fn(\LibXMLError $error): string => trim($error->message), libxml_get_errors()); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($errors); + } + + $this->assertTrue($loaded, 'phpunit-baseline.xml is not well-formed: ' . implode('; ', $problems)); + $files = $document->getElementsByTagName('file'); + $this->assertGreaterThan(0, $files->length); + foreach ($files as $file) { + $path = $file->getAttribute('path'); + $this->assertStringNotContainsString('..', $path); + $this->assertFileExists(self::ROOT . '/' . $path); + } + $this->assertContains('/phpunit-baseline.xml export-ignore', array_map(trim(...), $this->lines(self::ROOT . '/.gitattributes'))); + } + + /** A comment states its rule: no committed file points at the documents the project was planned in. */ + #[MaximumDuration(3000)] // reads every file of the public paths + public function testNoCommittedFileNamesThePlanningDocuments(): void + { + $found = []; + $files = 0; + foreach (self::PUBLIC_PATHS as $path) { + foreach ($this->files(self::ROOT . '/' . $path) as $file) { + if (str_ends_with($file, '/' . self::SELF)) { + continue; + } + ++$files; + $text = file_get_contents($file); + $this->assertIsString($text, $file); + if (preg_match('//u', $text) !== 1) { + continue; // not UTF-8 text: an image or another binary fixture + } + // A reference that wraps onto the next comment line still counts: the line breaks become spaces. + $references = $this->references((string) preg_replace('~\R[ \t]*(?:\*(?!/)|//|#)?[ \t]*~u', ' ', $text)); + if ($references !== []) { + $found[] = sprintf('%s: %s', substr($file, strlen(self::ROOT) + 1), implode(', ', $references)); + } + } + } + + $this->assertGreaterThan(500, $files, 'the scan reads the public paths'); + $this->assertSame([], $found, "These files point at planning documents; state the rule instead:\n" . implode("\n", $found)); + } + + /** @return iterable a text, and whether it points at a planning document */ + public static function planningTexts(): iterable + { + $references = [ + // the families, in any case + 'plan E1', 'Plan F', 'PLAN F', 'plan f', 'PLAN E1', 'plan e1', 'Plans C1 and C2', 'Plan E1 Task 6 parity', + 'Task 13', 'task 13', 'TASK 13', 'Task 4 review M2', 'Task 2', + 'ledger', 'the E2 ledger', 'E2 LEDGER', 'A ledger of payments', 'ruling', 'ruling P8', 'Ruling R4', + 'RULING R4', 'ruling r4', 'the court ruling', 'Review Focus 2', 'REVIEW FOCUS', 'review-focus-2', + 'header conflict 3', 'HEADER CONFLICT 3', 'two header conflicts', 'Spec conflicts 1', + 'docs/audit/02-engine.md', 'DOCS/AUDIT/', 'Docs/Audit/x', 'docs/superpowers/plans', '.superpowers/sdd', + 'Appendix A', 'appendix a', 'Appendix A of the plan', 'finding F1', 'finding f1', 'open question Q3', + 'audit 3.3', 'AUDIT 3.3', 'DECISIONS item 6', + // audit sections and the audit's spelling of a criterion + "02 \u{00A7}3.3", "README \u{00A7}4.5", "`07` \u{00A7}6.7", '03 AC-11', '`03` AC-11', 'D07 over 06 AC-61', + // review-note IDs as references + '(C6)', '(c6)', '(B16, N10)', '(C6, README', 'A4: the opt-out', 'M3: every bool key', 'Q3: no timer', + ]; + foreach ($references as $text) { + yield 'reference: ' . $text => [$text, true]; + } + $ordinary = [ + // IDs that stay + 'D07', 'D66, B10 fixed', '(D16)', '(D47, D58)', 'ac-05-E5', "'ac-05-C2'", 'ac-03-11', + "RFC 9110 \u{00A7}8.6", + // code and data + '$e1 = new \Exception()', "pack('C4', \$bytes)", '\u{a1}A9\u{5b}', 'E2E', 'A1:A5', 'C4)', 'mcp/sdk 0.9.0', + 'PHP 8.5', 'Swoole 6.2', 'Q3 2026', '(released in Q3)', 'an Apple M2 chip', + // ordinary words + 'a secret plan', 'the plan is pro', 'a plan B', 'Plan B', 'Plan A is faster', 'a plan, e.g. a fallback', + 'a plan e.g. for B10', 'tasks in the queue', 'Task: none', 'composer audit', 'Audit 2 users', + 'the header line', 'a ruler', 'Finding 3 items', 'Appendix B of RFC 9110', + // MCP's own tasks feature + 'tasks/list', 'mcp:tasks/cancel', 'notifications/tasks/status', 'a test about task 2 of an MCP server', + 'Task 1 of the queue', 'task 1 of 3', 'task 2 is cancelled', + ]; + foreach ($ordinary as $text) { + yield 'ordinary: ' . $text => [$text, false]; + } + } + + /** + * Every example of the review: a bare task number counts as a plan reference, as the plan wrote it ("Task 4:", + * "(Task 6 review)"); MCP's tasks have string IDs, and an ordinal names its set ("task 2 of an MCP server"). + */ + #[DataProvider('planningTexts')] + public function testThePlanningReferencePatternFindsReferencesOnly(string $text, bool $reference): void + { + $this->assertSame($reference, $this->references($text) !== [], implode(', ', $this->references($text))); + } + + /** @return list the references to planning documents in `$text`, each once */ + private function references(string $text): array + { + $text = (string) preg_replace(self::ALLOWED_PHRASES, ' ', $text); + preg_match_all(self::PLANNING_REFERENCE, $text, $matches); + + return array_values(array_unique($matches[0])); + } + + /** @return list the files at `$path`, recursively */ + private function files(string $path): array + { + if (is_file($path)) { + return [$path]; + } + $this->assertDirectoryExists($path, 'a public path is a file or a directory of the repository'); + $files = []; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $file) { + if ($file instanceof \SplFileInfo && $file->isFile()) { + $files[] = $file->getPathname(); + } + } + sort($files); + + return $files; + } + + private function guideline(): string + { + $guideline = file_get_contents(self::GUIDELINE); + $this->assertIsString($guideline, 'resources/boost/guidelines/core.blade.php exists'); + + return $guideline; + } + + /** @return list */ + private function lines(string $file): array + { + $lines = file($file, FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines, $file); + + return $lines; + } + + /** + * Each release (the name in the brackets of its heading), with its `### ` sections and their `- ` items; an + * indented line continues the item before it. + * + * @return array>> + */ + private function releases(): array + { + $releases = []; + $release = null; + $type = null; + foreach ($this->lines(self::ROOT . '/CHANGELOG.md') as $line) { + if (preg_match('/\A## \[([^\]]+)\]/', $line, $heading) === 1) { + $release = $heading[1]; + $this->assertArrayNotHasKey($release, $releases, 'each release once'); + $releases[$release] = []; + $type = null; + + continue; + } + if ($release === null) { + continue; + } + $sections = $releases[$release] ?? []; + if (str_starts_with($line, '### ')) { + $type = substr($line, 4); + $this->assertContains($type, self::CHANGE_TYPES, sprintf('release %s: a Keep a Changelog type', $release)); + $this->assertArrayNotHasKey($type, $sections, sprintf('release %s: %s once', $release, $type)); + $sections[$type] = []; + } elseif ($type !== null) { + $items = $sections[$type] ?? []; + if (str_starts_with($line, '- ')) { + $items[] = substr($line, 2); + } elseif (str_starts_with($line, ' ') && $items !== []) { + $items[] = array_pop($items) . ' ' . trim($line); + } + $sections[$type] = $items; + } + $releases[$release] = $sections; + } + + return $releases; + } +} diff --git a/tests/Architecture/ReadmeTest.php b/tests/Architecture/ReadmeTest.php new file mode 100644 index 0000000..541aa33 --- /dev/null +++ b/tests/Architecture/ReadmeTest.php @@ -0,0 +1,328 @@ +]+)/m'; + + /** Every HTML or Markdown image URL. */ + private const string IMAGES = '/]*\bsrc="([^"]*)"|!\[[^\]]*\]\(([^)\s]*)/'; + + private const float LINT_SECONDS = 30.0; + + public function testTheSharedSectionsMatchTheTypeScriptReadme(): void + { + $typescript = Branding::sections($this->typescriptReadme()); + $php = Branding::sections($this->readme()); + $exceptions = $this->exceptions(); + $this->assertSame([], array_values(array_diff(array_keys($exceptions), array_keys($typescript))), 'every exception names a shared section'); + + foreach ($typescript as $name => $prose) { + $this->assertNotNull($prose, sprintf('the TypeScript README has the section "%s"', $name)); + $expected = $prose; + foreach ($exceptions[$name] ?? [] as $exception) { + $this->assertSame(1, substr_count($expected, $exception['typescript']), sprintf('section "%s": the TypeScript README has this text once: %s', $name, $exception['typescript'])); + $expected = str_replace($exception['typescript'], $exception['php'] ?? '', $expected); + } + $expected = trim((string) preg_replace("/\n{3,}/", "\n\n", $expected)); + $actual = $php[$name] ?? null; + $this->assertNotNull($actual, sprintf('the README has the section "%s"', $name)); + $this->assertSame($expected, $actual, sprintf('section "%s" differs from the TypeScript README with exceptions.php, %s', $name, Branding::firstDifference($expected, $actual))); + } + } + + public function testTheOutlineIsTheTypeScriptOutline(): void + { + $typescript = $this->typescriptReadme(); + $readme = $this->readme(); + + $this->assertSame(['text', 'quote', 'quote', 'text', 'text', 'code', 'text'], $this->preambleKinds($typescript), 'TypeScript: the header, the NOTE, the MCPcat box, the tagline, the install command, learn more'); + // D33: no notice at the top. The NOTE is the README's only callout before the first heading. + $this->assertSame(['text', 'quote', 'text', 'text', 'code', 'text'], $this->preambleKinds($readme), 'the header, the NOTE, the tagline, the install command, learn more'); + $this->assertCount(9, Branding::sharedHeadings($typescript), 'TypeScript has every shared heading once'); + $this->assertSame(Branding::sharedHeadings($typescript), Branding::sharedHeadings($readme), 'the shared sections come once each, in the TypeScript order and at its heading levels'); + } + + public function testEveryExceptionQuotesTypeScriptAndStatesItsReason(): void + { + $count = 0; + foreach ($this->exceptions() as $name => $entries) { + $this->assertNotSame([], $entries, $name . ' lists its replaced sentences'); + foreach ($entries as $entry) { + $this->assertNotSame('', trim($entry['typescript']), $name . ': the TypeScript text'); + $this->assertNotSame($entry['typescript'], $entry['php'], $name . ': a replacement changes the text'); + $this->assertNotSame('', trim($entry['reason']), $name . ': the reason'); + $this->assertStringNotContainsString("\n", $entry['reason'] . $entry['typescript'] . ($entry['php'] ?? ''), $name . ': one line each'); + ++$count; + } + } + $this->assertGreaterThan(0, $count); + } + + public function testTheImagesAreTheTypeScriptImages(): void + { + $images = $this->images(); + $this->assertNotSame([], $images); + foreach ($images as $path => $hash) { + $file = dirname(__DIR__, 2) . '/' . $path; + $this->assertFileExists($file); + $contents = file_get_contents($file); + $this->assertIsString($contents); + $this->assertSame($hash, sha1('blob ' . strlen($contents) . "\0" . $contents), $path . ' is the TypeScript image byte for byte'); + } + $attributes = file(dirname(__DIR__, 2) . '/.gitattributes', FILE_IGNORE_NEW_LINES); + $this->assertIsArray($attributes); + $this->assertContains('/docs export-ignore', array_map(trim(...), $attributes), 'the images stay out of the Composer package'); + } + + public function testEveryLinkAndImageIsAbsolute(): void + { + $text = Branding::withoutCode($this->readme()); + $targets = $this->captures(self::TARGETS, $text); + $this->assertNotSame([], $targets); + foreach ($targets as $target) { + $this->assertMatchesRegularExpression('/\A(?:https:\/\/|mailto:|#)/', $target, 'a link or image outside GitHub needs an absolute URL'); + } + + $used = []; + foreach ($this->captures(self::IMAGES, $text) as $url) { + if ($this->isBadge($url)) { + continue; + } + $this->assertStringStartsWith(Branding::IMAGE_BASE . 'docs/', $url, 'an image of the README is a file of this repository on main'); + $used[] = substr($url, strlen(Branding::IMAGE_BASE)); + } + $expected = array_keys($this->images()); + sort($expected); + $used = array_values(array_unique($used)); + sort($used); + $this->assertSame($expected, $used, 'the README shows the copied TypeScript images, and only them'); + } + + public function testEveryAnchorExists(): void + { + foreach (['README.md' => $this->readme(), 'the TypeScript README' => $this->typescriptReadme()] as $name => $markdown) { + $slugs = array_map(Branding::slug(...), Branding::headings($markdown)); + $anchors = array_values(array_filter($this->captures(self::TARGETS, Branding::withoutCode($markdown)), static fn(string $target): bool => str_starts_with($target, '#'))); + $this->assertNotSame([], $anchors, $name . ' links its own headings'); + foreach ($anchors as $anchor) { + $this->assertContains(substr($anchor, 1), $slugs, sprintf('%s: %s names a heading', $name, $anchor)); + } + } + } + + #[MaximumDuration(5000)] // one php -l process for every sample + public function testEveryPhpSampleIsValid(): void + { + $samples = Branding::codeBlocks($this->readme(), 'php'); + $this->assertNotSame([], $samples); + $home = getenv('HOME'); + $this->assertIsString($home); + $directory = $home . '/readme-samples'; + FileTree::remove($directory); + $this->assertTrue(mkdir($directory, 0o700)); + + try { + $files = []; + foreach ($samples as $index => $sample) { + $file = sprintf('%s/sample-%d.php', $directory, $index + 1); + $this->assertNotFalse(file_put_contents($file, (str_starts_with($sample, 'run(); + $output = $lint->getOutput() . $lint->getErrorOutput(); + $this->assertSame(0, $lint->getExitCode(), $output); + $this->assertSame(count($files), substr_count($output, 'No syntax errors detected'), $output); + } finally { + FileTree::remove($directory); + } + } + + public function testTheDocsLinksAreTheFinalUrls(): void + { + $links = $this->docsLinks($this->readme()); + $allowed = [...self::DOCS_PAGES, self::DOCS_ROOT, ...$this->docsLinks($this->typescriptReadme())]; + foreach ($links as $link) { + $this->assertContains($link, $allowed, 'a docs link is a final URL of the PHP pages, the site root, or a link of the TypeScript README'); + } + foreach (self::DOCS_PAGES as $page) { + $this->assertContains($page, $links, 'the README links ' . $page); + } + } + + public function testTheDriftCheckNamesEachChangedSectionAndImage(): void + { + $copy = $this->typescriptReadme(); + $images = $this->images(); + $this->assertSame([], Branding::drift($copy, $copy, $images, $images), 'no change, nothing named'); + + $upstream = str_replace( + ['Concretely, AgentCat adds the following to your server:', "## Free for open source\n"], + ['AgentCat adds the following to your server:', "## Free for open-source projects\n"], + $copy, + ); + $upstreamImages = [...$images, 'docs/cats/void.jpg' => str_repeat('0', 40)]; + unset($upstreamImages['docs/cats/bibi.png']); + $changes = Branding::drift($copy, $upstream, $images, $upstreamImages); + + $this->assertCount(5, $changes, implode("\n", $changes)); + $this->assertSame('outline (the blocks before the first heading, then the headings): line 15: "## Free for open source" became "## Free for open-source projects"', $changes[0]); + $this->assertStringStartsWith('section "How it works": line 3: "AgentCat works as a lightweight middleware', $changes[1]); + $this->assertStringContainsString('" became "AgentCat works as a lightweight middleware', $changes[1]); + $this->assertSame('section "Free for open source": not found on main', $changes[2]); + $this->assertSame('image docs/cats/bibi.png: not found on main', $changes[3]); + $this->assertSame('image docs/cats/void.jpg: blob ' . str_repeat('0', 40) . ' on main, 11486d1ffb74367d5a135272ee0468bb85cad40b here', $changes[4]); + + $callout = str_replace("\n\nAgentCat is an analytics platform", "\n\n> [!WARNING]\n> A new callout.\n\nAgentCat is an analytics platform", $copy); + $this->assertSame(['outline (the blocks before the first heading, then the headings): line 4: "text" became "quote"'], Branding::drift($copy, $callout, $images, $images), 'a callout that changes no shared section still shows'); + } + + public function testTheTreeHashesAreTheBlobsUnderTheImageDirectories(): void + { + $tree = json_encode(['sha' => 'f00', 'truncated' => false, 'tree' => [ + ['path' => 'README.md', 'type' => 'blob', 'sha' => 'aaa'], + ['path' => 'docs/cats', 'type' => 'tree', 'sha' => 'bbb'], + ['path' => 'docs/cats/bibi.png', 'type' => 'blob', 'sha' => 'ccc'], + ['path' => 'docs/static/og-image.png', 'type' => 'blob', 'sha' => 'ddd'], + ['path' => 'docs/staticfile.png', 'type' => 'blob', 'sha' => 'eee'], + ]], JSON_THROW_ON_ERROR); + + $this->assertSame(['docs/cats/bibi.png' => 'ccc', 'docs/static/og-image.png' => 'ddd'], Branding::treeHashes($tree)); + $truncated = Thrown::by(\UnexpectedValueException::class, static fn(): array => Branding::treeHashes('{"truncated":true,"tree":[]}')); + $this->assertSame('GitHub truncated the tree', $truncated->getMessage()); + $empty = Thrown::by(\UnexpectedValueException::class, static fn(): array => Branding::treeHashes('{"message":"Not Found"}')); + $this->assertSame('the GitHub answer holds no tree', $empty->getMessage()); + } + + private function readme(): string + { + $readme = file_get_contents(dirname(__DIR__, 2) . '/README.md'); + $this->assertIsString($readme); + + return $readme; + } + + private function typescriptReadme(): string + { + return Branding::typescriptReadme(self::FIXTURES . '/typescript-readme.md'); + } + + /** @return array path => git blob SHA-1 */ + private function images(): array + { + $images = require self::FIXTURES . '/images.php'; + $this->assertIsArray($images); + $typed = []; + foreach ($images as $path => $hash) { + $this->assertIsString($path); + $this->assertIsString($hash); + $typed[$path] = $hash; + } + + return $typed; + } + + /** @return array> */ + private function exceptions(): array + { + $exceptions = require self::FIXTURES . '/exceptions.php'; + $this->assertIsArray($exceptions); + $typed = []; + foreach ($exceptions as $name => $entries) { + $this->assertIsString($name); + $this->assertIsArray($entries); + $typed[$name] = []; + foreach ($entries as $entry) { + $this->assertIsArray($entry); + $this->assertSame(['typescript', 'php', 'reason'], array_keys($entry), $name . ': each entry has typescript, php and reason'); + [$typescript, $php, $reason] = [$entry['typescript'] ?? null, $entry['php'] ?? null, $entry['reason'] ?? null]; + $this->assertIsString($typescript); + $this->assertIsString($reason); + if ($php !== null) { + $this->assertIsString($php); + } + $typed[$name][] = ['typescript' => $typescript, 'php' => $php, 'reason' => $reason]; + } + } + + return $typed; + } + + /** @return list the first non-empty capture group of each match */ + private function captures(string $pattern, string $text): array + { + preg_match_all($pattern, $text, $matches, PREG_SET_ORDER); + $found = []; + foreach ($matches as $match) { + $groups = array_values(array_filter(array_slice($match, 1), static fn(string $group): bool => $group !== '')); + $found[] = $groups[0] ?? ''; + } + + return $found; + } + + /** @return list */ + private function docsLinks(string $markdown): array + { + return array_values(array_filter($this->captures(self::TARGETS, Branding::withoutCode($markdown)), static fn(string $target): bool => preg_match('#\Ahttps?://docs\.agentcat\.com(?:/|\z)#', $target) === 1)); + } + + /** @return list the kind of each block before the first heading */ + private function preambleKinds(string $markdown): array + { + $kinds = []; + foreach (Branding::outline($markdown) as $entry) { + if (str_starts_with($entry, '#')) { + break; + } + $kinds[] = $entry; + } + + return $kinds; + } + + private function isBadge(string $url): bool + { + foreach (self::BADGE_HOSTS as $prefix) { + if (str_starts_with($url, $prefix)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Architecture/TraceabilityTest.php b/tests/Architecture/TraceabilityTest.php new file mode 100644 index 0000000..7f7e03a --- /dev/null +++ b/tests/Architecture/TraceabilityTest.php @@ -0,0 +1,250 @@ +-`: a number, a letter and a number, or `g` and a number for a golden body. */ + private const string ID = '/\Aac-0[1-7]-(?:[1-9][0-9]*|[A-Z][1-9][0-9]*|g[1-9])\z/'; + + private const int TITLE_LENGTH = 120; + + /** Tokens inside an attribute that are not code. */ + private const array NOT_CODE = [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT]; + + /** Tokens that can name the attribute class: `Group`, `Attributes\Group`, `\PHPUnit\โ€ฆ\Group`. */ + private const array NAMES = [\T_STRING, \T_NAME_QUALIFIED, \T_NAME_FULLY_QUALIFIED]; + + /** @var array>|null the groups found by the first scan of this run: the files do not change */ + private static ?array $scanned = null; + + #[MaximumDuration(2000)] // the first of these tests reads every test file + public function testEveryCriterionHasATestOrAStatedGap(): void + { + $tested = $this->acGroups(); + $this->assertNotSame([], $tested, 'the scan finds the ac- groups of the tests'); + + $missing = array_values(array_diff(array_keys($this->criteria()), array_keys($tested), array_keys($this->gaps()))); + + $this->assertSame([], $missing, "These criteria have no test and no stated gap:\n" . implode("\n", $missing)); + } + + #[MaximumDuration(2000)] // the first of these tests reads every test file + public function testEveryGroupNamesACriterion(): void + { + $unknown = $this->places(array_diff_key($this->acGroups(), $this->criteria())); + + $this->assertSame([], $unknown, "These ac- groups name no criterion:\n" . implode("\n", $unknown)); + } + + #[MaximumDuration(2000)] // the first of these tests reads every test file + public function testACriterionWithATestIsNoGap(): void + { + $both = $this->places(array_intersect_key($this->acGroups(), $this->gaps())); + + $this->assertSame([], $both, "These gaps have a test now; remove them from gaps.php:\n" . implode("\n", $both)); + } + + public function testEveryGapIsACriterionWithAReason(): void + { + $gaps = $this->gaps(); + + $this->assertSame([], array_keys(array_diff_key($gaps, $this->criteria())), 'every gap is a criterion'); + foreach ($gaps as $id => $reason) { + $this->assertNotSame('', trim($reason), $id . ' states its reason'); + $this->assertStringNotContainsString("\n", $reason, $id . ': the reason is one line'); + } + } + + public function testEveryCriterionHasAnIdAndAOneLineTitle(): void + { + $criteria = $this->criteria(); + $this->assertNotSame([], $criteria); + + foreach ($criteria as $id => $title) { + $this->assertMatchesRegularExpression(self::ID, $id); + $this->assertNotSame('', trim($title), $id . ' has a title'); + $this->assertStringNotContainsString("\n", $title, $id . ': the title is one line'); + $this->assertLessThanOrEqual(self::TITLE_LENGTH, mb_strlen($title), $id . ': the title has at most 120 characters'); + } + } + + public function testOnlyTheGroupAttributesOfTheCodeCount(): void + { + $source = <<<'PHP_WRAP' + assertSame([['ac-01-4', 5], ['ac-01-5', 6], ['ac-01-6', 8], ['ac-01-7', 10]], $this->groupsIn($source)); + } + + /** + * Every `ac-` group attribute in the PHP files under `tests/`, with the `file:line` of each use. + * + * @return array> + */ + private function acGroups(): array + { + return self::$scanned ??= $this->scan(); + } + + /** @return array> */ + private function scan(): array + { + $root = dirname(__DIR__); + $groups = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $this->assertInstanceOf(\SplFileInfo::class, $file); + if ($file->getExtension() !== 'php') { + continue; + } + $source = file_get_contents($file->getPathname()); + $this->assertIsString($source, $file->getPathname()); + foreach ($this->groupsIn($source) as [$id, $line]) { + $groups[$id][] = substr($file->getPathname(), strlen($root) + 1) . ':' . $line; + } + } + ksort($groups); + + return $groups; + } + + /** + * The `ac-` names of the group attributes of PHP source, with their lines. A comment or a string holds no + * attribute token, so a group written there does not count. + * + * @return list + */ + private function groupsIn(string $source): array + { + $groups = []; + $tokens = token_get_all($source); + foreach ($tokens as $index => $token) { + if (is_array($token) && $token[0] === \T_ATTRIBUTE) { + array_push($groups, ...$this->groupsOf($this->attribute($tokens, $index + 1))); + } + } + + return $groups; + } + + /** + * The code tokens of an attribute, from `$start` (after its `#[`) to its closing bracket. + * + * @param array $tokens + * + * @return list + */ + private function attribute(array $tokens, int $start): array + { + $code = []; + $depth = 1; + foreach (array_slice($tokens, $start) as $token) { + $depth += match ($token) { + '[' => 1, + ']' => -1, + default => 0, + }; + if ($depth === 0) { + break; + } + if (!is_array($token) || !in_array($token[0], self::NOT_CODE, true)) { + $code[] = $token; + } + } + + return $code; + } + + /** + * The `ac-` names that the `Group(โ€ฆ)` calls of one attribute pass as their first argument, with their lines. + * + * @param list $code + * + * @return list + */ + private function groupsOf(array $code): array + { + $groups = []; + foreach ($code as $index => $token) { + $isGroup = is_array($token) && in_array($token[0], self::NAMES, true) && ($token[1] === 'Group' || str_ends_with($token[1], '\Group')); + if (!$isGroup || ($code[$index + 1] ?? null) !== '(') { + continue; + } + $argument = $code[$index + 2] ?? null; + if (is_array($argument) && $argument[1] === 'name' && ($code[$index + 3] ?? null) === ':') { + $argument = $code[$index + 4] ?? null; + } + if (is_array($argument) && $argument[0] === \T_CONSTANT_ENCAPSED_STRING && str_starts_with(substr($argument[1], 1), 'ac-')) { + $groups[] = [substr($argument[1], 1, -1), $argument[2]]; + } + } + + return $groups; + } + + /** + * @param array> $groups + * + * @return list `id at file:line`, one per use + */ + private function places(array $groups): array + { + $places = []; + foreach ($groups as $id => $uses) { + foreach ($uses as $use) { + $places[] = $id . ' at ' . $use; + } + } + + return $places; + } + + /** @return array */ + private function criteria(): array + { + return $this->load('criteria.php'); + } + + /** @return array */ + private function gaps(): array + { + return $this->load('gaps.php'); + } + + /** @return array */ + private function load(string $file): array + { + $list = require __DIR__ . '/../Traceability/' . $file; + $this->assertIsArray($list, $file); + $entries = []; + foreach ($list as $id => $text) { + $this->assertIsString($id, $file); + $this->assertIsString($text, $file); + $entries[$id] = $text; + } + + return $entries; + } +} diff --git a/tests/Contract/LaravelMcp/ExtendedClassesTest.php b/tests/Contract/LaravelMcp/ExtendedClassesTest.php index 9ecd8a6..02835db 100644 --- a/tests/Contract/LaravelMcp/ExtendedClassesTest.php +++ b/tests/Contract/LaravelMcp/ExtendedClassesTest.php @@ -20,9 +20,8 @@ /** * AgentCat answers laravel/mcp's `tools/call` with `CapturingCallTool`, a copy of `CallTool::handle()` whose invoker - * overrides `ToolInvoker::toErrorResponse()` to keep the tool's own exception (plan E1 header conflict 5). A future - * laravel/mcp that makes either parent or method final or private, changes their signatures, or answers differently - * breaks it. + * overrides `ToolInvoker::toErrorResponse()` to keep the tool's own exception. A future laravel/mcp that makes either + * parent or method final or private, changes their signatures, or answers differently breaks it. */ #[Group('contract')] final class ExtendedClassesTest extends LaravelTestCase diff --git a/tests/Contract/McpSdk/RuntimeDriftTest.php b/tests/Contract/McpSdk/RuntimeDriftTest.php index a1b6f90..4dae94a 100644 --- a/tests/Contract/McpSdk/RuntimeDriftTest.php +++ b/tests/Contract/McpSdk/RuntimeDriftTest.php @@ -4,11 +4,15 @@ namespace AgentCat\Tests\Contract\McpSdk; +use AgentCat\Client; +use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; +use AgentCat\McpSdk\Internal\Access\BuilderAccess; use AgentCat\McpSdk\Internal\CallToolInterceptor; use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\McpSdkAdapter; @@ -35,7 +39,7 @@ /** * `BuilderAccess::probe()` runs at track(), before build() fills `Builder::$parts`, so it cannot check what the * built parts hold. When a future mcp/sdk changes them, the request path logs one compatibility line and leaves - * the server to the library (README ยง4.5 rule 4): `tools/list` and `tools/call` alike. + * the server to the library: `tools/list` and `tools/call` alike. */ #[Group('contract')] final class RuntimeDriftTest extends TestCase @@ -60,6 +64,7 @@ protected function setUp(): void #[\Override] protected function tearDown(): void { + TrackedServers::resetIncomplete(); // a failed track() marks the process Log::reset(); } @@ -78,6 +83,32 @@ public function testInspectReportsDriftThatProbeFinds(): void $this->assertSame([self::COMPATIBILITY], $this->logger->messages()); } + /** + * The server name is read from `$parts['configuration']->serverInfo`. When only that read fails at track() (the + * configuration is a string here, as a changed mcp/sdk could make it, while the request handlers stay readable), + * track() returns the Builder untracked, and the failure line names `serverInfo`. + */ + #[Group('ac-02-3')] + public function testTrackLeavesABuilderUntrackedWhenItsServerInfoCannotBeRead(): void + { + $builder = $this->builder(); + $this->setParts($builder, ['requestHandlers' => [], 'configuration' => 'configuration']); + $client = new Client(ClientSeams::of($this->queue, static fn(): bool => true)); + + $this->assertSame($builder, $client->track($builder, 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa')); + + $this->assertSame([ + self::COMPATIBILITY, + sprintf( + 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: mcp/sdk %s internals changed (ErrorException: Attempt to read property "serverInfo" on string). %s', + McpSdkAdapter::version(), + McpSdkAdapter::SUPPORT_MATRIX, + ), + ], $this->logger->messages()); + $this->assertNull(TrackedServers::get($builder), 'nothing is tracked'); + $this->assertNull(BuilderAccess::container($builder), 'nothing is installed'); + } + public function testSignalsLeaveOutBuiltWhenPartsCannotBeRead(): void { $builder = Server::builder(); diff --git a/tests/Contract/McpSdk/ServerMembersTest.php b/tests/Contract/McpSdk/ServerMembersTest.php index 6aedc51..b07c240 100644 --- a/tests/Contract/McpSdk/ServerMembersTest.php +++ b/tests/Contract/McpSdk/ServerMembersTest.php @@ -27,7 +27,7 @@ /** * The members of a built `Mcp\Server` that AgentCat reads to find the tracking state of a server built from a tracked - * Builder (Ruling R3). The shape of each member is checked with the others in `BuilderMembersTest`. + * Builder. The shape of each member is checked with the others in `BuilderMembersTest`. */ #[Group('contract')] final class ServerMembersTest extends TestCase @@ -131,7 +131,7 @@ public function testDriftIsReportedOncePerServer(): void $this->assertTrue(TrackedServers::incomplete(), 'publishCustomEvent() then logs instead of throwing'); } - /** Ruling R3 and finding F1: `publishCustomEvent()` on such a server logs and queues nothing, and never throws. */ + /** `publishCustomEvent()` on such a server logs and queues nothing, and never throws. */ public function testPublishingForAServerWhoseInternalsChangedLogsAndReturns(): void { $server = new Server((new \ReflectionClass(Protocol::class))->newInstanceWithoutConstructor()); @@ -144,7 +144,7 @@ public function testPublishingForAServerWhoseInternalsChangedLogsAndReturns(): v $this->assertSame([['level' => LogLevel::WARNING, 'message' => McpSdkAdapter::COMPATIBILITY_LINE], $warning, $warning], $this->fixture->logger->records); } - /** Ruling R3: the state kept for a server is used without reading the server again. */ + /** The state kept for a server is used without reading the server again. */ public function testTheStateKeptForAServerIsUsedFirst(): void { $server = new Server((new \ReflectionClass(Protocol::class))->newInstanceWithoutConstructor()); // unreadable diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php index 464974a..ec05f41 100644 --- a/tests/Contract/PrivateMemberManifest.php +++ b/tests/Contract/PrivateMemberManifest.php @@ -11,7 +11,7 @@ use Mcp\Schema\Result\ListToolsResult; /** - * The manifest of library internals AgentCat depends on, for Plan F's snapshot diff over `members()`: every + * The manifest of library internals AgentCat depends on, for the nightly snapshot diff over `members()`: every * #[TouchesPrivateMember] in src//Internal/Access, every library class AgentCat extends, and the * symfony/mcp-bundle service definitions AgentCat's bundle reads at compile time. */ diff --git a/tests/Contract/Snapshot/CommittedSnapshots.php b/tests/Contract/Snapshot/CommittedSnapshots.php new file mode 100644 index 0000000..88402c3 --- /dev/null +++ b/tests/Contract/Snapshot/CommittedSnapshots.php @@ -0,0 +1,94 @@ +-.json`. + * + * @phpstan-import-type Snapshot from MemberSnapshot + */ +final readonly class CommittedSnapshots +{ + public function __construct( + private string $directory, + ) {} + + public static function committed(): self + { + return new self(__DIR__ . '/../snapshots'); + } + + /** The file of exactly this version, or null. */ + public function exact(string $library, string $version): ?string + { + $path = $this->directory . '/' . $this->prefix($library) . $version . '.json'; + + return is_file($path) ? $path : null; + } + + /** The file of the highest version below this one, or null when there is none. */ + public function newestBelow(string $library, string $version): ?string + { + $newest = null; + foreach ($this->versions($library) as $candidate) { + if (version_compare($candidate, $version, '<') && ($newest === null || version_compare($candidate, $newest, '>'))) { + $newest = $candidate; + } + } + + return $newest === null ? null : $this->directory . '/' . $this->prefix($library) . $newest . '.json'; + } + + /** + * The live snapshot against the file of exactly this version, or, for a release without a file, against the + * newest lower one. Null when there is no file at or below the version. + * + * @param Snapshot $live + * @param list $readMembers + * + * @return array{path: string, exact: bool, diff: SnapshotDiff}|null + */ + public function compare(string $library, string $version, array $live, array $readMembers): ?array + { + $exact = $this->exact($library, $version); + $path = $exact ?? $this->newestBelow($library, $version); + if ($path === null) { + return null; + } + + return [ + 'path' => $path, + 'exact' => $exact !== null, + 'diff' => SnapshotDiff::between(MemberSnapshot::fromJson((string) file_get_contents($path)), $live, $readMembers), + ]; + } + + /** @return list the path of every snapshot of the library */ + public function all(string $library): array + { + return array_map( + fn(string $version): string => $this->directory . '/' . $this->prefix($library) . $version . '.json', + $this->versions($library), + ); + } + + /** @return list */ + private function versions(string $library): array + { + $prefix = $this->prefix($library); + $versions = []; + $paths = glob($this->directory . '/' . $prefix . '*.json'); + foreach ($paths === false ? [] : $paths as $path) { + $versions[] = substr(basename($path), strlen($prefix), -strlen('.json')); + } + + return $versions; + } + + private function prefix(string $library): string + { + return str_replace('/', '-', $library) . '-'; + } +} diff --git a/tests/Contract/Snapshot/MemberSnapshot.php b/tests/Contract/Snapshot/MemberSnapshot.php new file mode 100644 index 0000000..12e6886 --- /dev/null +++ b/tests/Contract/Snapshot/MemberSnapshot.php @@ -0,0 +1,215 @@ +, return: string, static: bool, visibility: string} + * @phpstan-type ConstantShape array{visibility: string} + * @phpstan-type ClassShape array{abstract: bool, abstractMethods: list, constants: array, final: bool, methods: array, properties: array, readonly: bool} + * @phpstan-type Snapshot array{classes: array, library: string, version: string} + */ +final class MemberSnapshot +{ + /** The library's root namespace, by Composer package name. */ + private const array NAMESPACES = [ + 'laravel/mcp' => 'Laravel\\Mcp\\', + 'mcp/sdk' => 'Mcp\\', + ]; + + /** @return list the Composer package names a snapshot can be taken of */ + public static function libraries(): array + { + return array_keys(self::NAMESPACES); + } + + /** + * The installed version of the library, without a leading `v` (`0.8.1`; `dev-main` on a branch install). + * + * @throws \InvalidArgumentException when the library is unknown or not installed + */ + public static function installedVersion(string $library): string + { + self::namespaceOf($library); + $version = InstalledVersions::isInstalled($library) ? InstalledVersions::getPrettyVersion($library) : null; + if ($version === null) { + throw new \InvalidArgumentException($library . ' is not installed'); + } + + return ltrim($version, 'v'); + } + + /** + * @return Snapshot + * + * @throws \InvalidArgumentException when the library is unknown or not installed + */ + public static function of(string $library): array + { + $classes = []; + foreach (self::classesOf($library) as $class) { + $classes[$class] = self::ofClass($class); + } + ksort($classes, SORT_STRING); + + return ['classes' => $classes, 'library' => $library, 'version' => self::installedVersion($library)]; + } + + /** + * The library's classes that the manifest names and that exist in the installed version, sorted. + * + * @return list + */ + public static function classesOf(string $library): array + { + $namespace = self::namespaceOf($library); + $names = PrivateMemberManifest::extendedClasses(); + array_push($names, ...array_keys(PrivateMemberManifest::EXTENDED)); + foreach (PrivateMemberManifest::entries() as $entry) { + $names[] = $entry['member']->class; + } + + $classes = []; + foreach (array_unique($names) as $name) { + if (str_starts_with($name, $namespace) && class_exists($name)) { + $classes[] = $name; + } + } + sort($classes, SORT_STRING); + + return $classes; + } + + /** @param Snapshot $snapshot */ + public static function toJson(array $snapshot): string + { + return json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; + } + + /** + * @return Snapshot + * + * @throws \InvalidArgumentException when the text is not the JSON of a snapshot + * @throws \JsonException + */ + public static function fromJson(string $json): array + { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !is_array($data['classes'] ?? null) || !is_string($data['library'] ?? null) || !is_string($data['version'] ?? null)) { + throw new \InvalidArgumentException('not a member snapshot'); + } + + /** @var Snapshot $data */ + return $data; + } + + /** + * @param class-string $class + * + * @return ClassShape + */ + private static function ofClass(string $class): array + { + $reflection = new \ReflectionClass($class); + + $constants = []; + foreach ($reflection->getReflectionConstants() as $constant) { + if ($constant->getDeclaringClass()->getName() === $class) { + $constants[$constant->getName()] = ['visibility' => self::visibility($constant->isPublic(), $constant->isProtected())]; + } + } + + $properties = []; + foreach ($reflection->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() === $class) { + $properties[$property->getName()] = [ + 'hasDefault' => $property->hasDefaultValue(), + 'readonly' => $property->isReadOnly(), + 'static' => $property->isStatic(), + 'type' => self::type($property->getType(), $class), + 'visibility' => self::visibility($property->isPublic(), $property->isProtected()), + ]; + } + } + + $methods = []; + $abstractMethods = []; + foreach ($reflection->getMethods() as $method) { + if ($method->isAbstract()) { + $abstractMethods[] = $method->getName(); + } + if ($method->getDeclaringClass()->getName() === $class) { + $parameters = []; + foreach ($method->getParameters() as $parameter) { + $parameters[] = [ + 'byReference' => $parameter->isPassedByReference(), + 'hasDefault' => $parameter->isDefaultValueAvailable(), + 'name' => $parameter->getName(), + 'type' => self::type($parameter->getType(), $class), + 'variadic' => $parameter->isVariadic(), + ]; + } + $methods[$method->getName()] = [ + 'abstract' => $method->isAbstract(), + 'final' => $method->isFinal(), + 'parameters' => $parameters, + 'return' => self::type($method->getReturnType(), $class), + 'static' => $method->isStatic(), + 'visibility' => self::visibility($method->isPublic(), $method->isProtected()), + ]; + } + } + + ksort($constants, SORT_STRING); + ksort($properties, SORT_STRING); + ksort($methods, SORT_STRING); + sort($abstractMethods, SORT_STRING); + + return [ + 'abstract' => $reflection->isAbstract(), + 'abstractMethods' => $abstractMethods, + 'constants' => $constants, + 'final' => $reflection->isFinal(), + 'methods' => $methods, + 'properties' => $properties, + 'readonly' => $reflection->isReadOnly(), + ]; + } + + /** + * The type as written, with `self` and `parent` spelled as the class names: PHP 8.5 reports the class names, + * earlier versions report the keywords, and a snapshot must not depend on the PHP version that took it. + * + * @param class-string $class + */ + private static function type(?\ReflectionType $type, string $class): string + { + $parent = get_parent_class($class); + return preg_replace_callback( + '/(? $match[1] === 'self' ? $class : ($parent === false ? 'parent' : $parent), + (string) $type, + ) ?? (string) $type; + } + + private static function visibility(bool $public, bool $protected): string + { + return $public ? 'public' : ($protected ? 'protected' : 'private'); + } + + /** The root namespace of the library's classes, with its trailing backslash. */ + public static function namespaceOf(string $library): string + { + return self::NAMESPACES[$library] ?? throw new \InvalidArgumentException('unknown library ' . $library . ' (expected ' . implode(' or ', self::libraries()) . ')'); + } +} diff --git a/tests/Contract/Snapshot/MemberSnapshotCliTest.php b/tests/Contract/Snapshot/MemberSnapshotCliTest.php new file mode 100644 index 0000000..ce5f090 --- /dev/null +++ b/tests/Contract/Snapshot/MemberSnapshotCliTest.php @@ -0,0 +1,110 @@ +directory = getenv('HOME') . '/snapshot-cli-' . bin2hex(random_bytes(4)); + mkdir($this->directory); + } + + #[\Override] + protected function tearDown(): void + { + FileTree::remove($this->directory); + } + + public function testACleanDiffExitsZero(): void + { + $file = $this->snapshotFile(MemberSnapshot::of('mcp/sdk')); + + $process = $this->script(['mcp/sdk', '--diff', $file]); + + $this->assertSame(0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput()); + $this->assertStringContainsString('#### breaking (0)', $process->getOutput()); + } + + public function testABreakingLineExitsOneAndNamesTheMember(): void + { + $tampered = $this->withTamperedType(MemberSnapshot::of('mcp/sdk'), \Mcp\Server::class, 'protocol'); + + $process = $this->script(['mcp/sdk', '--diff', $this->snapshotFile($tampered)]); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString('#### breaking (1)', $process->getOutput()); + $this->assertStringContainsString('Mcp\Server::$protocol: private readonly Tampered $protocol โ†’ private readonly Mcp\Server\Protocol $protocol', $process->getOutput()); + } + + public function testTheReportGoesToTheStepSummaryWhenItIsSet(): void + { + $summary = $this->directory . '/summary.md'; + + $process = $this->script(['mcp/sdk', '--diff', $this->snapshotFile(MemberSnapshot::of('mcp/sdk'))], ['GITHUB_STEP_SUMMARY' => $summary]); + + $this->assertSame(0, $process->getExitCode()); + $this->assertSame($process->getOutput(), (string) file_get_contents($summary)); + } + + public function testAnUnknownLibraryExitsTwo(): void + { + $this->assertSame(2, $this->script(['symfony/mcp-bundle'])->getExitCode()); + } + + public function testAMissingSnapshotFileExitsTwo(): void + { + $this->assertSame(2, $this->script(['mcp/sdk', '--diff', $this->directory . '/missing.json'])->getExitCode()); + } + + public function testNoArgumentsExitTwo(): void + { + $this->assertSame(2, $this->script([])->getExitCode()); + } + + /** @param Snapshot $snapshot */ + private function snapshotFile(array $snapshot): string + { + $path = $this->directory . '/committed.json'; + file_put_contents($path, MemberSnapshot::toJson($snapshot)); + + return $path; + } + + /** + * @param list $arguments + * @param array $env the summary file is unset unless a test sets it, so a CI run never writes to its own + */ + private function script(array $arguments, array $env = []): Process + { + $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/member-snapshot.php', ...$arguments], null, ['GITHUB_STEP_SUMMARY' => false, ...$env], null, 60); + $process->run(); + + return $process; + } + + /** + * The snapshot with the type of one property changed. + * + * @param Snapshot $snapshot + * + * @return Snapshot + */ + private function withTamperedType(array $snapshot, string $class, string $name): array + { + $changed = array_replace_recursive($snapshot, ['classes' => [$class => ['properties' => [$name => ['type' => 'Tampered']]]]]); + + return MemberSnapshot::fromJson(json_encode($changed, JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Contract/Snapshot/SnapshotDiff.php b/tests/Contract/Snapshot/SnapshotDiff.php new file mode 100644 index 0000000..1e72a3c --- /dev/null +++ b/tests/Contract/Snapshot/SnapshotDiff.php @@ -0,0 +1,244 @@ + $breaking + * @param list $other + */ + private function __construct( + private array $breaking, + private array $other, + ) {} + + /** + * @param Snapshot $old + * @param Snapshot $new + * @param list $readMembers `Class::$property` and `Class::method(...)`; any other string is ignored + */ + public static function between(array $old, array $new, array $readMembers): self + { + ['properties' => $properties, 'methods' => $methods] = self::readMembers($readMembers); + + $breaking = []; + $other = []; + $classes = array_unique([...array_keys($old['classes']), ...array_keys($new['classes'])]); + sort($classes, SORT_STRING); + foreach ($classes as $class) { + $oldClass = $old['classes'][$class] ?? null; + $newClass = $new['classes'][$class] ?? null; + $readProperties = $properties[$class] ?? []; + $readMethods = $methods[$class] ?? []; + + if ($newClass === null) { + $other[] = $class . ': class removed'; + foreach ($readProperties as $name) { + if (isset($oldClass['properties'][$name])) { + $breaking[] = $class . '::$' . $name . ': ' . self::property($name, $oldClass['properties'][$name]) . ' โ†’ removed'; + } + } + foreach ($readMethods as $name) { + if (isset($oldClass['methods'][$name])) { + $breaking[] = $class . '::' . $name . '(): ' . self::method($name, $oldClass['methods'][$name]) . ' โ†’ removed'; + } + } + + continue; + } + if ($oldClass === null) { + $other[] = $class . ': class added'; + + continue; + } + + if ($oldClass['final'] !== $newClass['final']) { + $line = $class . ': ' . ($oldClass['final'] ? 'final' : 'not final') . ' โ†’ ' . ($newClass['final'] ? 'final' : 'not final'); + if ($newClass['final'] && $readMethods !== []) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + + if ($newClass['readonly'] && !$oldClass['readonly'] && $readMethods !== []) { + // A class that extends a readonly class must be readonly itself: AgentCat's subclass is a fatal error. + $breaking[] = $class . ': not readonly โ†’ readonly'; + } + foreach (array_diff($newClass['abstractMethods'], $oldClass['abstractMethods']) as $name) { + $line = $class . '::' . $name . '(): added (abstract)'; + if ($readMethods !== []) { + // AgentCat's subclass does not implement it: a fatal error at load. + $breaking[] = $line; + } else { + $other[] = $line; + } + } + + self::compareProperties($class, $oldClass, $newClass, $readProperties, $breaking, $other); + self::compareMethods($class, $oldClass, $newClass, $readMethods, $breaking, $other); + self::compareConstants($class, $oldClass, $newClass, $other); + } + + sort($breaking, SORT_STRING); + sort($other, SORT_STRING); + + return new self($breaking, $other); + } + + /** + * The members AgentCat reads, by class: `Class::$property` entries and `Class::method(...)` entries. Any other + * string (the bundle's service definitions) is ignored. + * + * @param list $readMembers + * + * @return array{properties: array>, methods: array>} + */ + public static function readMembers(array $readMembers): array + { + $properties = []; + $methods = []; + foreach ($readMembers as $member) { + if (preg_match('/^([A-Za-z0-9_\\\\]+)::\$(\w+)$/', $member, $match) === 1) { + $properties[$match[1]][] = $match[2]; + } elseif (preg_match('/^([A-Za-z0-9_\\\\]+)::(\w+)\(/', $member, $match) === 1) { + $methods[$match[1]][] = $match[2]; + } + } + + return ['properties' => $properties, 'methods' => $methods]; + } + + /** @return list the changes that break AgentCat, each naming `Class::member` and old โ†’ new */ + public function breaking(): array + { + return $this->breaking; + } + + /** @return list every other difference */ + public function other(): array + { + return $this->other; + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $read + * @param list $breaking + * @param list $other + */ + private static function compareProperties(string $class, array $old, array $new, array $read, array &$breaking, array &$other): void + { + $added = array_diff_key($new['properties'], $old['properties']); + foreach ($old['properties'] as $name => $oldShape) { + $subject = $class . '::$' . $name; + $isRead = in_array($name, $read, true); + $newShape = $new['properties'][$name] ?? null; + if ($newShape === $oldShape) { + continue; + } + if ($newShape === null) { + $renamedTo = array_search($oldShape, $added, true); + if ($renamedTo !== false) { + unset($added[$renamedTo]); + $line = $subject . ': renamed to $' . $renamedTo; + } else { + $line = $subject . ': ' . self::property($name, $oldShape) . ' โ†’ removed'; + } + } else { + $line = $subject . ': ' . self::property($name, $oldShape) . ' โ†’ ' . self::property($name, $newShape); + } + if ($isRead) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + foreach ($added as $name => $shape) { + $other[] = $class . '::$' . $name . ': added (' . self::property($name, $shape) . ')'; + } + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $read + * @param list $breaking + * @param list $other + */ + private static function compareMethods(string $class, array $old, array $new, array $read, array &$breaking, array &$other): void + { + foreach ($old['methods'] as $name => $oldShape) { + $newShape = $new['methods'][$name] ?? null; + if ($newShape === $oldShape) { + continue; + } + $line = $class . '::' . $name . '(): ' . self::method($name, $oldShape) . ' โ†’ ' . ($newShape === null ? 'removed' : self::method($name, $newShape)); + if (in_array($name, $read, true)) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + foreach (array_diff_key($new['methods'], $old['methods']) as $name => $shape) { + $other[] = $class . '::' . $name . '(): added (' . self::method($name, $shape) . ')'; + } + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $other + */ + private static function compareConstants(string $class, array $old, array $new, array &$other): void + { + foreach ($old['constants'] as $name => $shape) { + $newShape = $new['constants'][$name] ?? null; + if ($newShape !== $shape) { + $other[] = $class . '::' . $name . ': ' . $shape['visibility'] . ' โ†’ ' . ($newShape === null ? 'removed' : $newShape['visibility']); + } + } + foreach (array_diff_key($new['constants'], $old['constants']) as $name => $shape) { + $other[] = $class . '::' . $name . ': added (' . $shape['visibility'] . ')'; + } + } + + /** @param array{hasDefault: bool, readonly: bool, static: bool, type: string, visibility: string} $shape */ + private static function property(string $name, array $shape): string + { + return self::words([$shape['visibility'], $shape['static'] ? 'static' : '', $shape['readonly'] ? 'readonly' : '', $shape['type'], '$' . $name . ($shape['hasDefault'] ? ' = โ€ฆ' : '')]); + } + + /** @param array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} $shape */ + private static function method(string $name, array $shape): string + { + $parameters = []; + foreach ($shape['parameters'] as $parameter) { + $parameters[] = self::words([ + $parameter['type'], + ($parameter['byReference'] ? '&' : '') . ($parameter['variadic'] ? '...' : '') . '$' . $parameter['name'] . ($parameter['hasDefault'] ? ' = โ€ฆ' : ''), + ]); + } + + return self::words([$shape['visibility'], $shape['abstract'] ? 'abstract' : '', $shape['final'] ? 'final' : '', $shape['static'] ? 'static' : '', 'function']) + . ' ' . $name . '(' . implode(', ', $parameters) . ')' . ($shape['return'] === '' ? '' : ': ' . $shape['return']); + } + + /** @param list $words */ + private static function words(array $words): string + { + return implode(' ', array_filter($words, static fn(string $word): bool => $word !== '')); + } +} diff --git a/tests/Contract/Snapshot/SnapshotDiffTest.php b/tests/Contract/Snapshot/SnapshotDiffTest.php new file mode 100644 index 0000000..6ef2035 --- /dev/null +++ b/tests/Contract/Snapshot/SnapshotDiffTest.php @@ -0,0 +1,278 @@ +snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['partList' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + $this->assertSame(['Mcp\\Server\\Builder::$parts: renamed to $partList'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testARenamedMemberThatNoOneReadsIsNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['partList' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, []); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder::$parts: renamed to $partList'], $diff->other()); + } + + public function testARemovedOrRetypedMemberIsBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $retyped = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('?array')])]); + $removed = $this->snapshot([self::BUILDER => $this->shape()]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts โ†’ private ?array $parts'], + SnapshotDiff::between($old, $retyped, [self::PARTS])->breaking(), + ); + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts โ†’ removed'], + SnapshotDiff::between($old, $removed, [self::PARTS])->breaking(), + ); + } + + public function testARemovedClassBreaksTheMembersAgentCatReads(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $this->snapshot([]), [self::PARTS]); + + $this->assertSame(['Mcp\\Server\\Builder::$parts: private array $parts โ†’ removed'], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder: class removed'], $diff->other()); + } + + public function testAnAddedMethodIsNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')], methods: ['build' => $this->method([], \Mcp\Server::class)])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder::build(): added (public function build(): Mcp\\Server)'], $diff->other()); + } + + public function testAnAddedClassAndAChangedConstantAreNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(constants: ['A' => 'public', 'B' => 'public'])]); + $new = $this->snapshot([ + self::BUILDER => $this->shape(constants: ['A' => 'protected', 'C' => 'public']), + self::RESULT => $this->shape(), + ]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS, self::CONSTRUCTOR]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame( + [ + 'Mcp\\Schema\\Result\\ListToolsResult: class added', + 'Mcp\\Server\\Builder::A: public โ†’ protected', + 'Mcp\\Server\\Builder::B: public โ†’ removed', + 'Mcp\\Server\\Builder::C: added (public)', + ], + $diff->other(), + ); + } + + public function testAFinalParentIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + $new = $this->snapshot([self::RESULT => $this->shape(final: true, methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult: not final โ†’ final'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testAClassNobodyExtendsTurningFinalIsNotBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::BUILDER => $this->shape()]), + $this->snapshot([self::BUILDER => $this->shape(final: true)]), + [self::PARTS, self::CONSTRUCTOR], + ); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder: not final โ†’ final'], $diff->other()); + } + + public function testAChangedExtendedSignatureIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + $new = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string', 'limit' => 'int'], '')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame( + ['Mcp\\Schema\\Result\\ListToolsResult::__construct(): public function __construct(array $tools, ?string $nextCursor) โ†’ public function __construct(array $tools, ?string $nextCursor, int $limit)'], + $diff->breaking(), + ); + } + + public function testAnExtendedClassTurningReadonlyIsBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::RESULT => $this->shape()]), + $this->snapshot([self::RESULT => $this->shape(readonly: true)]), + [self::CONSTRUCTOR], + ); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult: not readonly โ†’ readonly'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testAClassNobodyExtendsTurningReadonlyIsNotBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::BUILDER => $this->shape()]), + $this->snapshot([self::BUILDER => $this->shape(readonly: true)]), + [self::PARTS, self::CONSTRUCTOR], + ); + + $this->assertSame([], $diff->breaking()); + } + + public function testAnAddedAbstractMethodOfAnExtendedClassIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(abstractMethods: ['first'])]); + $new = $this->snapshot([self::RESULT => $this->shape(abstractMethods: ['first', 'second'])]); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::second(): added (abstract)'], SnapshotDiff::between($old, $new, [self::CONSTRUCTOR])->breaking()); + $this->assertSame([], SnapshotDiff::between($old, $new, [self::PARTS])->breaking()); + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::second(): added (abstract)'], SnapshotDiff::between($old, $new, [self::PARTS])->other()); + } + + public function testAReadPropertyThatLosesItsDefaultIsBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array', hasDefault: true)])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts = โ€ฆ โ†’ private array $parts'], + SnapshotDiff::between($old, $new, [self::PARTS])->breaking(), + ); + $this->assertSame([], SnapshotDiff::between($old, $new, [])->breaking()); + } + + public function testAReadPropertyMovedToAParentClassIsBreaking(): void + { + $parent = 'Mcp\\Server\\BaseBuilder'; + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')]), $parent => $this->shape()]); + $new = $this->snapshot([self::BUILDER => $this->shape(), $parent => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + // The accessor reads the property in the scope of the declaring class named in the manifest, so a move breaks it. + $this->assertSame(['Mcp\\Server\\Builder::$parts: private array $parts โ†’ removed'], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\BaseBuilder::$parts: added (private array $parts)'], $diff->other()); + } + + public function testAChangedMethodNobodyDependsOnIsNotBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['jsonSerialize' => $this->method([], 'array')])]); + $new = $this->snapshot([self::RESULT => $this->shape(methods: ['jsonSerialize' => $this->method([], 'mixed')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::jsonSerialize(): public function jsonSerialize(): array โ†’ public function jsonSerialize(): mixed'], $diff->other()); + } + + public function testAnIdenticalSnapshotHasNoLinesAndTheBundleEntriesAreIgnored(): void + { + $snapshot = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($snapshot, $snapshot, [self::PARTS, 'symfony/mcp-bundle: builder without a configurator']); + + $this->assertSame([], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testTheLinesAreSorted(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape()]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['b' => $this->property('int'), 'a' => $this->property('int')])]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$a: added (private int $a)', 'Mcp\\Server\\Builder::$b: added (private int $b)'], + SnapshotDiff::between($old, $new, [])->other(), + ); + } + + /** + * @param array $classes + * + * @return Snapshot + */ + private function snapshot(array $classes): array + { + return ['classes' => $classes, 'library' => 'mcp/sdk', 'version' => '0.0.0']; + } + + /** + * @param array $properties + * @param array, return: string, static: bool, visibility: string}> $methods + * @param array $constants constant name => visibility + * @param list $abstractMethods + * + * @return ClassShape + */ + private function shape(bool $final = false, array $properties = [], array $methods = [], array $constants = [], bool $readonly = false, array $abstractMethods = []): array + { + return [ + 'abstract' => $abstractMethods !== [], + 'abstractMethods' => $abstractMethods, + 'readonly' => $readonly, + 'constants' => array_map(static fn(string $visibility): array => ['visibility' => $visibility], $constants), + 'final' => $final, + 'methods' => $methods, + 'properties' => $properties, + ]; + } + + /** @return array{hasDefault: bool, readonly: bool, static: bool, type: string, visibility: string} */ + private function property(string $type, bool $hasDefault = false): array + { + return ['hasDefault' => $hasDefault, 'readonly' => false, 'static' => false, 'type' => $type, 'visibility' => 'private']; + } + + /** + * @param array $parameters type by name + * + * @return array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} + */ + private function method(array $parameters, string $return): array + { + $list = []; + foreach ($parameters as $name => $type) { + $list[] = ['byReference' => false, 'hasDefault' => false, 'name' => $name, 'type' => $type, 'variadic' => false]; + } + + return ['abstract' => false, 'final' => false, 'parameters' => $list, 'return' => $return, 'static' => false, 'visibility' => 'public']; + } +} diff --git a/tests/Contract/Snapshot/SnapshotTest.php b/tests/Contract/Snapshot/SnapshotTest.php new file mode 100644 index 0000000..5bb7bc5 --- /dev/null +++ b/tests/Contract/Snapshot/SnapshotTest.php @@ -0,0 +1,223 @@ + */ + public static function libraries(): array + { + $rows = []; + foreach (MemberSnapshot::libraries() as $library) { + $rows[$library] = [$library]; + } + + return $rows; + } + + #[DataProvider('libraries')] + public function testTheInstalledVersionMatchesItsCommittedSnapshot(string $library): void + { + $version = MemberSnapshot::installedVersion($library); + $files = CommittedSnapshots::committed(); + if ($files->exact($library, $version) === null && str_starts_with($version, 'dev-')) { + self::markTestSkipped('no committed snapshot for ' . $library . ' ' . $version . ' (a dev-main leg)'); + } + // A release that has no snapshot yet (a new patch) is compared with the newest lower one: only a change to a + // member AgentCat reads fails, so an upstream patch does not break the build until someone commits its snapshot. + $result = $files->compare($library, $version, MemberSnapshot::of($library), PrivateMemberManifest::members()); + $this->assertNotNull($result, 'no committed snapshot of ' . $library . ' at or below ' . $version); + + $report = $library . ' ' . $version . ' against ' . basename($result['path']) . ":\n" . implode("\n", $result['diff']->other()); + $this->assertSame([], $result['diff']->breaking(), 'breaking changes in ' . $report); + if ($result['exact']) { + $this->assertSame([], $result['diff']->other(), 'changes in ' . $report); + } + } + + public function testAVersionWithoutASnapshotIsComparedWithTheNewestLowerOne(): void + { + $directory = getenv('HOME') . '/snapshots-' . bin2hex(random_bytes(4)); + mkdir($directory); + + try { + foreach (['0.9.0', '0.10.0', '0.8.1', '0.11.0-rc1'] as $version) { + file_put_contents($directory . '/mcp-sdk-' . $version . '.json', '{}'); + } + $files = new CommittedSnapshots($directory); + + $this->assertSame($directory . '/mcp-sdk-0.9.0.json', $files->newestBelow('mcp/sdk', '0.9.5')); + $this->assertSame($directory . '/mcp-sdk-0.10.0.json', $files->newestBelow('mcp/sdk', '0.10.1')); + $this->assertNull($files->newestBelow('mcp/sdk', '0.8.0')); + $this->assertNull($files->newestBelow('laravel/mcp', '9.9.9')); + $this->assertSame($directory . '/mcp-sdk-0.9.0.json', $files->exact('mcp/sdk', '0.9.0')); + $this->assertNull($files->exact('mcp/sdk', '0.9.1')); + $this->assertCount(4, $files->all('mcp/sdk')); + } finally { + FileTree::remove($directory); + } + } + + #[DataProvider('libraries')] + public function testAFakeNewPatchPassesWhenNothingAgentCatReadsChanged(string $library): void + { + $live = MemberSnapshot::of($library); + $directory = $this->directoryWith($library, '0.0.1', $live); + + try { + $result = (new CommittedSnapshots($directory))->compare($library, '0.0.2', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNotNull($result); + $this->assertFalse($result['exact']); + $this->assertSame([], $result['diff']->breaking()); + $this->assertSame([], $result['diff']->other()); + } + + #[DataProvider('libraries')] + public function testAFakeNewPatchFailsWhenAMemberAgentCatReadsChanged(string $library): void + { + $live = MemberSnapshot::of($library); + $tampered = $live; + $owner = null; + foreach (SnapshotDiff::readMembers(PrivateMemberManifest::members())['properties'] as $class => $names) { + if (!str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + continue; + } + foreach ($names as $name) { + $owner = $class . '::$' . $name; + $tampered = $this->withTamperedType($live, $class, $name); + + break 2; + } + } + $directory = $this->directoryWith($library, '0.0.1', $tampered); + + try { + $result = (new CommittedSnapshots($directory))->compare($library, '0.0.2', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNotNull($owner); + $this->assertNotNull($result); + $breaking = $result['diff']->breaking(); + $this->assertCount(1, $breaking); + $this->assertStringStartsWith($owner . ': ', implode('', $breaking)); + $this->assertStringContainsString('Tampered', implode('', $breaking)); + } + + public function testAFakeVersionBelowEverySnapshotHasNothingToCompareWith(): void + { + $live = MemberSnapshot::of('mcp/sdk'); + $directory = $this->directoryWith('mcp/sdk', '0.5.0', $live); + + try { + $result = (new CommittedSnapshots($directory))->compare('mcp/sdk', '0.4.0', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNull($result); + } + + public function testEveryMemberTheManifestListsIsInEveryCommittedSnapshotOfItsLibrary(): void + { + $read = SnapshotDiff::readMembers(PrivateMemberManifest::members()); + $checked = 0; + foreach (MemberSnapshot::libraries() as $library) { + foreach (CommittedSnapshots::committed()->all($library) as $path) { + $classes = MemberSnapshot::fromJson((string) file_get_contents($path))['classes']; + foreach ($read['properties'] as $class => $names) { + if (str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + foreach ($names as $name) { + $this->assertArrayHasKey($name, $classes[$class]['properties'] ?? [], basename($path) . ': ' . $class . '::$' . $name); + ++$checked; + } + } + } + foreach ($read['methods'] as $class => $names) { + if (str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + foreach ($names as $name) { + $this->assertArrayHasKey($name, $classes[$class]['methods'] ?? [], basename($path) . ': ' . $class . '::' . $name . '()'); + ++$checked; + } + } + } + } + } + + $this->assertGreaterThan(30, $checked); + } + + public function testTheSnapshotCoversEveryClassTheManifestNamesForTheLibrary(): void + { + $classes = array_keys(MemberSnapshot::of('mcp/sdk')['classes']); + + $this->assertContains(\Mcp\Server\Builder::class, $classes); + $this->assertContains(\Mcp\Schema\Result\ListToolsResult::class, $classes); + $this->assertNotContains(\Laravel\Mcp\Server::class, $classes); + $this->assertContains(\Laravel\Mcp\Server::class, array_keys(MemberSnapshot::of('laravel/mcp')['classes'])); + } + + public function testSelfIsSpelledAsTheClassWhateverThePhpVersion(): void + { + $success = MemberSnapshot::of('mcp/sdk')['classes'][\Mcp\Schema\Result\CallToolResult::class]['methods']['success'] ?? null; + + $this->assertNotNull($success); + $this->assertSame(\Mcp\Schema\Result\CallToolResult::class, $success['return']); + } + + public function testAnUnknownLibraryIsRejected(): void + { + $e = Thrown::by(\InvalidArgumentException::class, static fn(): array => MemberSnapshot::of('symfony/mcp-bundle')); + + $this->assertSame('unknown library symfony/mcp-bundle (expected laravel/mcp or mcp/sdk)', $e->getMessage()); + } + + public function testASnapshotSurvivesItsJson(): void + { + $snapshot = MemberSnapshot::of('mcp/sdk'); + + $this->assertSame($snapshot, MemberSnapshot::fromJson(MemberSnapshot::toJson($snapshot))); + } + + /** @param Snapshot $snapshot */ + private function directoryWith(string $library, string $version, array $snapshot): string + { + $directory = getenv('HOME') . '/snapshots-' . bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory . '/' . str_replace('/', '-', $library) . '-' . $version . '.json', MemberSnapshot::toJson($snapshot)); + + return $directory; + } + + /** + * The snapshot with the type of one property changed. + * + * @param Snapshot $snapshot + * + * @return Snapshot + */ + private function withTamperedType(array $snapshot, string $class, string $name): array + { + $changed = array_replace_recursive($snapshot, ['classes' => [$class => ['properties' => [$name => ['type' => 'Tampered']]]]]); + + return MemberSnapshot::fromJson(json_encode($changed, JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Contract/Symfony/BundleServicesTest.php b/tests/Contract/Symfony/BundleServicesTest.php index d2cb792..98d75d8 100644 --- a/tests/Contract/Symfony/BundleServicesTest.php +++ b/tests/Contract/Symfony/BundleServicesTest.php @@ -15,11 +15,11 @@ use Symfony\Component\Routing\RouterInterface; /** - * AgentCat's bundle finds each symfony/mcp-bundle server by these service definitions, read at compile time (Plan E2 - * header conflict 3): the builder's service ID, its tag and `server` attribute, the `addRequestHandlers` method call - * with the tagged request handlers as its single argument, and no configurator of the bundle's own. A bundle release that changes one of them - * breaks the tracking of every bundle server. The route of each HTTP server names the server's controller, by which - * the bundle finds a server's answers at runtime (D62). + * AgentCat's bundle finds each symfony/mcp-bundle server by these service definitions, read at compile time: the + * builder's service ID, its tag and `server` attribute, the `addRequestHandlers` method call with the tagged request + * handlers as its single argument, and no configurator of the bundle's own. A bundle release that changes one of them + * breaks the tracking of every bundle server. The route of each HTTP server names the server's controller, by which the + * bundle finds a server's answers at runtime (D62). */ #[Group('contract')] final class BundleServicesTest extends SymfonyTestCase diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.0.json b/tests/Contract/snapshots/laravel-mcp-1.0.0.json new file mode 100644 index 0000000..2a06a1f --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.0.0.json @@ -0,0 +1,912 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "hasDefault": true, + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + }, + "readonly": false + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [], + "readonly": false + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": [], + "readonly": false + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + }, + "readonly": false + } + }, + "library": "laravel/mcp", + "version": "1.0.0" +} diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.2.json b/tests/Contract/snapshots/laravel-mcp-1.0.2.json new file mode 100644 index 0000000..cfc5c05 --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.0.2.json @@ -0,0 +1,936 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "hasDefault": true, + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + }, + "readonly": false + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [], + "readonly": false + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "mirrorStructuredContent", + "type": "bool", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": { + "mirrorStructuredContent": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "protected" + } + }, + "readonly": false + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + }, + "readonly": false + } + }, + "library": "laravel/mcp", + "version": "1.0.2" +} diff --git a/tests/Contract/snapshots/laravel-mcp-1.1.0.json b/tests/Contract/snapshots/laravel-mcp-1.1.0.json new file mode 100644 index 0000000..20a71bd --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.1.0.json @@ -0,0 +1,936 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "hasDefault": true, + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + }, + "readonly": false + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [], + "readonly": false + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "mirrorStructuredContent", + "type": "bool", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": { + "mirrorStructuredContent": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "protected" + } + }, + "readonly": false + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + }, + "readonly": false + } + }, + "library": "laravel/mcp", + "version": "1.1.0" +} diff --git a/tests/Contract/snapshots/mcp-sdk-0.8.1.json b/tests/Contract/snapshots/mcp-sdk-0.8.1.json new file mode 100644 index 0000000..9d2f35b --- /dev/null +++ b/tests/Contract/snapshots/mcp-sdk-0.8.1.json @@ -0,0 +1,2277 @@ +{ + "classes": { + "Mcp\\Schema\\Result\\CallToolResult": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "isError", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "structuredContent", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "error": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "success": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + } + }, + "properties": { + "content": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + }, + "isError": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "bool", + "visibility": "public" + }, + "meta": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?array", + "visibility": "public" + }, + "structuredContent": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "mixed", + "visibility": "public" + } + }, + "readonly": false + }, + "Mcp\\Schema\\Result\\ListToolsResult": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tools", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nextCursor", + "type": "?string", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\ListToolsResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "nextCursor": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?string", + "visibility": "public" + }, + "tools": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + } + }, + "readonly": false + }, + "Mcp\\Server": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocol", + "type": "Mcp\\Server\\Protocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "statelessProtocol", + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "builder": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": true, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "public" + } + }, + "properties": { + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "protocol": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Protocol", + "visibility": "private" + }, + "statelessProtocol": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Server\\Builder": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "add": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "definition", + "type": "Mcp\\Schema\\Tool|Mcp\\Schema\\ResourceDefinition|Mcp\\Schema\\ResourceTemplate|Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\ElementHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoader": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoaders": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loaders", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Notification\\NotificationHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Request\\RequestHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "size", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\ToolAnnotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputSchema", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "outputSchema", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "assemble": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "build": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server", + "static": false, + "visibility": "public" + }, + "buildStateless": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "supportedVersions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Stateless\\StatelessProtocol", + "static": false, + "visibility": "public" + }, + "createDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "static": false, + "visibility": "private" + }, + "detectCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eagerlyLoaded", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\ServerCapabilities", + "static": false, + "visibility": "private" + }, + "enableExtension": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "extensions", + "type": "Mcp\\Schema\\Extension\\ExtensionInterface", + "variadic": true + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "requestStateCodec": { + "abstract": false, + "final": false, + "parameters": [], + "return": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "static": false, + "visibility": "private" + }, + "resolve": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "setCachePolicy": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "policy", + "type": "Mcp\\Server\\Wire\\CachePolicy", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "serverCapabilities", + "type": "Mcp\\Schema\\ServerCapabilities", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setContainer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "container", + "type": "Psr\\Container\\ContainerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "discoverer", + "type": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscovery": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "basePath", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "scanDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "excludeDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cache", + "type": "?Psr\\SimpleCache\\CacheInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "namePatterns", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setEventDispatcher": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setHeaderValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "headerValidation", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInputRequiredLimits": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "maxRounds", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "roundTimeout", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInstructions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "instructions", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLazyLoading": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "lazyLoading", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLogger": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setModernVersions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "versions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setNotificationBus": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "bus", + "type": "Mcp\\Server\\Subscription\\NotificationBusInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setPaginationLimit": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "paginationLimit", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setProtocolVersion": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocolVersion", + "type": "Mcp\\Schema\\Enum\\ProtocolVersion", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setReferenceHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRegistry": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRequestState": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "ttl", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setResourceSubscriptionManager": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "subscriptionManager", + "type": "Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaGenerator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaGenerator", + "type": "Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setServerInfo": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "version", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "websiteUrl", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "sessionStore", + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionManager", + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcProbability", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcDivisor", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSubscriptionLifetime": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "seconds", + "type": "float", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutInputRequiredShim": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutModernEra": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + } + }, + "properties": { + "cachePolicy": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Wire\\CachePolicy", + "visibility": "private" + }, + "container": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\Container\\ContainerInterface", + "visibility": "private" + }, + "discoverer": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", + "visibility": "private" + }, + "discoveryBasePath": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "discoveryCache": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\SimpleCache\\CacheInterface", + "visibility": "private" + }, + "discoveryExcludeDirs": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryNamePatterns": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "discoveryScanDirs": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "explicitPrompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitTools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMessages": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMethods": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "gcDivisor": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "gcProbability": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "hasCustomRegistry": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "headerValidation": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredRounds": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "inputRequiredShim": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredTimeout": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "instructions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "lazyLoading": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loaders": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "logger": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "modernVersions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "notificationBus": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", + "visibility": "private" + }, + "notificationHandlers": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "paginationLimit": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "parts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "protocolVersion": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", + "visibility": "private" + }, + "referenceHandler": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "requestHandlers": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateKey": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "requestStateTtl": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "resourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "schemaGenerator": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "visibility": "private" + }, + "serverCapabilities": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\ServerCapabilities", + "visibility": "private" + }, + "serverInfo": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Implementation", + "visibility": "private" + }, + "sessionManager": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + }, + "sessionStore": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "visibility": "private" + }, + "subscriptionLifetime": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "float", + "visibility": "private" + }, + "subscriptionManager": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "visibility": "private" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Server\\Protocol": { + "abstract": false, + "abstractMethods": [], + "constants": { + "INTERNAL_ERROR_MESSAGE": { + "visibility": "private" + }, + "SESSION_ACTIVE_REQUEST_META": { + "visibility": "public" + }, + "SESSION_LOGGING_LEVEL": { + "visibility": "public" + }, + "SESSION_OUTGOING_QUEUE": { + "visibility": "private" + }, + "SESSION_PENDING_REQUESTS": { + "visibility": "private" + }, + "SESSION_REQUEST_ID_COUNTER": { + "visibility": "private" + }, + "SESSION_RESPONSES": { + "visibility": "private" + } + }, + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "notificationHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messageFactory", + "type": "Mcp\\JsonRpc\\MessageFactory", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionManager", + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputRequiredShim", + "type": "?Mcp\\Server\\InputRequiredShim", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "requestStateCodec", + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "checkResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error|null", + "static": false, + "visibility": "public" + }, + "connect": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "consumeOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "destroySession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "dispatchEvent": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "object", + "static": false, + "visibility": "private" + }, + "doProcessInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "findResponseId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "getPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "handleFiberYield": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "yieldedValue", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleInvalidMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "exception", + "type": "Mcp\\Exception\\InvalidInputMessageException", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "hasInitializeRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "processInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "queueOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification|Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "resolveSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "?Mcp\\Server\\Session\\SessionInterface", + "static": false, + "visibility": "private" + }, + "sendNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "int", + "static": false, + "visibility": "public" + }, + "sendResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "?Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + } + }, + "properties": { + "eventDispatcher": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "inputRequiredShim": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\InputRequiredShim", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "messageFactory": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\JsonRpc\\MessageFactory", + "visibility": "private" + }, + "notificationHandlers": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestHandlers": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateCodec": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "visibility": "private" + }, + "sessionManager": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + } + }, + "readonly": false + } + }, + "library": "mcp/sdk", + "version": "0.8.1" +} diff --git a/tests/Contract/snapshots/mcp-sdk-0.9.0.json b/tests/Contract/snapshots/mcp-sdk-0.9.0.json new file mode 100644 index 0000000..8976bff --- /dev/null +++ b/tests/Contract/snapshots/mcp-sdk-0.9.0.json @@ -0,0 +1,2789 @@ +{ + "classes": { + "Mcp\\Schema\\Result\\CallToolResult": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "isError", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "structuredContent", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "error": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "success": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + } + }, + "properties": { + "content": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + }, + "isError": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "bool", + "visibility": "public" + }, + "meta": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?array", + "visibility": "public" + }, + "structuredContent": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "mixed", + "visibility": "public" + } + }, + "readonly": false + }, + "Mcp\\Schema\\Result\\ListToolsResult": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tools", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nextCursor", + "type": "?string", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\ListToolsResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "nextCursor": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?string", + "visibility": "public" + }, + "tools": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + } + }, + "readonly": false + }, + "Mcp\\Server": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocol", + "type": "Mcp\\Server\\Protocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "statelessProtocol", + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handshakeVersions", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "builder": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": true, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handshakeVersions": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?array", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "protocol": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Protocol", + "visibility": "private" + }, + "statelessProtocol": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Server\\Builder": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "add": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "definition", + "type": "Mcp\\Schema\\Tool|Mcp\\Schema\\ResourceDefinition|Mcp\\Schema\\ResourceTemplate|Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\ElementHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoader": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoaders": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loaders", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Notification\\NotificationHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Request\\RequestHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "size", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\ToolAnnotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputSchema", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "outputSchema", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "assemble": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "build": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server", + "static": false, + "visibility": "public" + }, + "buildStateless": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "supportedVersions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Stateless\\StatelessProtocol", + "static": false, + "visibility": "public" + }, + "createDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "static": false, + "visibility": "private" + }, + "detectCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eagerlyLoaded", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Schema\\ServerCapabilities", + "static": false, + "visibility": "private" + }, + "enableExtension": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "extensions", + "type": "Mcp\\Schema\\Extension\\ExtensionInterface", + "variadic": true + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "requestStateCodec": { + "abstract": false, + "final": false, + "parameters": [], + "return": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "static": false, + "visibility": "private" + }, + "resolve": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "setCachePolicy": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "policy", + "type": "Mcp\\Server\\Wire\\CachePolicy", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "serverCapabilities", + "type": "Mcp\\Schema\\ServerCapabilities", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setContainer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "container", + "type": "Psr\\Container\\ContainerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "discoverer", + "type": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscovery": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "basePath", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "scanDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "excludeDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cache", + "type": "?Psr\\SimpleCache\\CacheInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "namePatterns", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setEventDispatcher": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setHeaderValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "headerValidation", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInputRequiredLimits": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "maxRounds", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "roundTimeout", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInstructions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "instructions", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLazyLoading": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "lazyLoading", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLogger": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setModernVersions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "versions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setNotificationBus": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "bus", + "type": "Mcp\\Server\\Subscription\\NotificationBusInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setPaginationLimit": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "paginationLimit", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setProtocolVersion": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocolVersion", + "type": "Mcp\\Schema\\Enum\\ProtocolVersion", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setReferenceHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRegistry": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRequestState": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "ttl", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setResourceSubscriptionManager": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "subscriptionManager", + "type": "Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaGenerator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaGenerator", + "type": "Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaValidator", + "type": "Mcp\\Capability\\Discovery\\SchemaValidator", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setServerInfo": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "version", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "websiteUrl", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "sessionStore", + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionManager", + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcProbability", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcDivisor", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionLock", + "type": "Mcp\\Server\\Session\\SessionLockInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSubscriptionLifetime": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "seconds", + "type": "float", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutInputRequiredShim": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutModernEra": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + } + }, + "properties": { + "cachePolicy": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Wire\\CachePolicy", + "visibility": "private" + }, + "container": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\Container\\ContainerInterface", + "visibility": "private" + }, + "discoverer": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", + "visibility": "private" + }, + "discoveryBasePath": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "discoveryCache": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\SimpleCache\\CacheInterface", + "visibility": "private" + }, + "discoveryExcludeDirs": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryNamePatterns": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryScanDirs": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "explicitPrompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitTools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMessages": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMethods": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "gcDivisor": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "gcProbability": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "hasCustomRegistry": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "headerValidation": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredRounds": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "inputRequiredShim": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredTimeout": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "instructions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "lazyLoading": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loaders": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "logger": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "modernVersions": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "notificationBus": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", + "visibility": "private" + }, + "notificationHandlers": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "paginationLimit": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "parts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "protocolVersion": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", + "visibility": "private" + }, + "referenceHandler": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "requestHandlers": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateKey": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "requestStateTtl": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "resourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "schemaGenerator": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "visibility": "private" + }, + "schemaValidator": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaValidator", + "visibility": "private" + }, + "serverCapabilities": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\ServerCapabilities", + "visibility": "private" + }, + "serverInfo": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Implementation", + "visibility": "private" + }, + "sessionLock": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "visibility": "private" + }, + "sessionManager": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + }, + "sessionStore": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "visibility": "private" + }, + "subscriptionLifetime": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "float", + "visibility": "private" + }, + "subscriptionManager": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "visibility": "private" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Server\\Protocol": { + "abstract": false, + "abstractMethods": [], + "constants": { + "ABANDONED_REQUEST_GRACE": { + "visibility": "private" + }, + "INTERNAL_ERROR_MESSAGE": { + "visibility": "private" + }, + "SESSION_ACTIVE_REQUEST_META": { + "visibility": "public" + }, + "SESSION_BUSY_MESSAGE": { + "visibility": "private" + }, + "SESSION_LOGGING_LEVEL": { + "visibility": "public" + }, + "SESSION_OUTGOING_QUEUE": { + "visibility": "private" + }, + "SESSION_PENDING_REQUESTS": { + "visibility": "private" + }, + "SESSION_RESPONSES": { + "visibility": "private" + } + }, + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "notificationHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messageFactory", + "type": "Mcp\\JsonRpc\\MessageFactory", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionManager", + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputRequiredShim", + "type": "?Mcp\\Server\\InputRequiredShim", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "requestStateCodec", + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionLock", + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "acquireSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "wait", + "type": "bool", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "checkResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error|null", + "static": false, + "visibility": "public" + }, + "connect": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "consumeOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "destroySession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "dispatchEvent": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "object", + "static": false, + "visibility": "private" + }, + "dispatchResult": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "result", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "doProcessInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "dropAbandonedRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "encodeOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "?array", + "static": false, + "visibility": "private" + }, + "errorForThrowable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "expireRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "?Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "findRequestId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "findResponseId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "getAwaitedPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "fiber", + "type": "Fiber", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "getPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "handleFiberYield": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "yieldedValue", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "transport", + "type": "?Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "?Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "public" + }, + "handleInvalidMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "exception", + "type": "Mcp\\Exception\\InvalidInputMessageException", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "hasInitializeRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "hasTimedOut": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "pending", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "processInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "queueFiberOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "queueOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "registerRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "private" + }, + "releaseSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "resolveSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "?Mcp\\Server\\Session\\SessionInterface", + "static": false, + "visibility": "private" + }, + "saveResumedSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "sendNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "public" + }, + "sendResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "?Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "settleAwaitedRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "id", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "takeFiberOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "?Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "trackAwaitedRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "fiber", + "type": "Fiber", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "tryAcquireSessionLockForPoll": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + } + }, + "properties": { + "awaitedRequestIds": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "WeakMap", + "visibility": "private" + }, + "droppedAwaitedRequestIds": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "fiberOutgoingMessages": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "WeakMap", + "visibility": "private" + }, + "inputRequiredShim": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\InputRequiredShim", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "messageFactory": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\JsonRpc\\MessageFactory", + "visibility": "private" + }, + "notificationHandlers": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestHandlers": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateCodec": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "visibility": "private" + }, + "sessionLock": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "visibility": "private" + }, + "sessionManager": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + } + }, + "readonly": false + } + }, + "library": "mcp/sdk", + "version": "0.9.0" +} diff --git a/tests/E2E/BufferedOutputTest.php b/tests/E2E/BufferedOutputTest.php index be929c3..8993218 100644 --- a/tests/E2E/BufferedOutputTest.php +++ b/tests/E2E/BufferedOutputTest.php @@ -29,7 +29,7 @@ final class BufferedOutputTest extends TestCase private static ?BuiltInServer $server = null; - /** HOME (agentcat.log) of the server, under the test HOME (Ruling R8). */ + /** HOME (agentcat.log) of the server, under the test HOME. */ private static string $dir = ''; #[\Override] diff --git a/tests/E2E/BuiltInServerTest.php b/tests/E2E/BuiltInServerTest.php index ca644fa..b0d8f45 100644 --- a/tests/E2E/BuiltInServerTest.php +++ b/tests/E2E/BuiltInServerTest.php @@ -17,7 +17,7 @@ final class BuiltInServerTest extends TestCase { private const float DEADLINE_SECONDS = 5.0; - /** Markers of the hung server, under the test HOME (Ruling R8). */ + /** Markers of the hung server, under the test HOME. */ private string $dir = ''; #[\Override] diff --git a/tests/E2E/Fixtures/scenario-server.php b/tests/E2E/Fixtures/scenario-server.php new file mode 100644 index 0000000..caeb2b5 --- /dev/null +++ b/tests/E2E/Fixtures/scenario-server.php @@ -0,0 +1,59 @@ + is_string($_SERVER['HTTP_' . $name] ?? null) ? $_SERVER['HTTP_' . $name] : null; +$scenario = Scenario::tryFrom($header('X_E2E_SCENARIO') ?? ''); +$collector = $header('X_E2E_COLLECTOR'); +$sink = $header('X_E2E_SINK'); +$pageSize = $header('X_E2E_PAGE_SIZE'); +$quote = static fn(?string $value): string => json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +$refusal = match (true) { + !$scenario instanceof Scenario => 'unknown X-E2E-Scenario ' . $quote($header('X_E2E_SCENARIO')), + $collector !== null && preg_match('#\Ahttp://(?:127\.0\.0\.1|localhost):[0-9]{1,5}(?:/|\z)#', $collector) !== 1 => 'X-E2E-Collector must be on http://127.0.0.1 or http://localhost, got ' . $quote($collector), + $sink !== null && preg_match('/\A[0-9a-f]{16}\z/', $sink) !== 1 => 'malformed X-E2E-Sink ' . $quote($sink), + $pageSize !== null && preg_match('/\A[1-9][0-9]?\z/', $pageSize) !== 1 => 'malformed X-E2E-Page-Size ' . $quote($pageSize), + default => null, +}; +if ($refusal !== null) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32603, 'message' => 'scenario-server: ' . $refusal]], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + exit; +} + +$builder = ScenarioToolkit::builder(new Sink($sink !== null ? getenv('AGENTCAT_E2E_SINKS') . '/' . $sink . '.jsonl' : null)) + ->setSession(new FileSessionStore((string) getenv('AGENTCAT_E2E_SESSIONS'))); +if ($pageSize !== null) { + $builder->setPaginationLimit((int) $pageSize); +} +AgentCat::track($builder, ScenarioToolkit::PROJECT_ID, $scenario->options($collector)); + +$factory = new Psr17Factory(); +$response = $builder->build()->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); +$body = (string) $response->getBody(); +http_response_code($response->getStatusCode()); +foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header($name . ': ' . $value, false); + } +} +header('Content-Length: ' . strlen($body)); // the client can finish reading before the shutdown delivery runs +echo $body; +flush(); diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index 0a3f9ef..003e2e6 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -50,7 +50,7 @@ final class HttpEndToEndTest extends TestCase private static ?BuiltInServer $server = null; - /** HOME (agentcat.log), sessions and markers of the server, under the test HOME (Ruling R8). */ + /** HOME (agentcat.log), sessions and markers of the server, under the test HOME. */ private static string $dir = ''; /** `AGENTCAT_API_URL` of the server: a closed port on 127.0.0.1, so a request without a collector stays local. */ @@ -103,7 +103,7 @@ protected function setUp(): void $this->scenario = $this->collector()->scenario(); } - /** M6: one tracked call through the handshake, the session store and the shutdown delivery. */ + /** One tracked call through the handshake, the session store and the shutdown delivery. */ #[Group('ac-02-53')] #[Group('ac-02-43')] #[Group('ac-04-49')] @@ -179,9 +179,9 @@ public function testAStrictToolAcceptsTheInjectedParameters(): void } /** - * Header conflict 6: the tool result does not wait for the hooks. The fixture's `identify` sleeps 3 s and then - * writes the marker; it runs in the shutdown delivery, after the response. The 2 s bound leaves room for a slow - * runner; the marker proves the order. + * The tool result does not wait for the hooks. The fixture's `identify` sleeps 3 s and then writes the marker; it + * runs in the shutdown delivery, after the response. The 2 s bound leaves room for a slow runner; the marker proves + * the order. */ #[Group('ac-01-37')] #[Group('ac-02-48')] @@ -261,9 +261,9 @@ public function testGetMoreToolsIsAnsweredByAgentCat(): void } /** - * Review Focus 5. Repeated Accept lines differ by PHP version (Ruling R7): `php -S` on PHP 8.3 keeps the first - * line, on PHP 8.4 and 8.5 it joins them with `, `. The fixture records the value the SAPI gave PHP, and the - * event must carry exactly that. + * The event carries the headers as the SAPI gave them. Repeated Accept lines differ by PHP version: `php -S` on + * PHP 8.3 keeps the first line, on PHP 8.4 and 8.5 it joins them with `, `. The fixture records the value the SAPI + * gave PHP, and the event must carry exactly that. */ #[MaximumDuration(10000)] public function testOddHeadersReachTheEventAsStrings(): void @@ -291,9 +291,9 @@ public function testOddHeadersReachTheEventAsStrings(): void } /** - * D47 (open question Q3): under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, - * after its response. Each POST is tied to its request by the session in its `Successfully sent event` line: each - * call mints its own session. + * D47: under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, after its response. + * Each POST is tied to its request by the session in its `Successfully sent event` line: each call mints its own + * session. */ #[Group('ac-06-62')] #[MaximumDuration(15000)] diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php index 17ded66..db76baa 100644 --- a/tests/E2E/LaravelEndToEndTest.php +++ b/tests/E2E/LaravelEndToEndTest.php @@ -23,8 +23,8 @@ * with laravel/mcp's `conformance-server`: served over HTTP by `php -S` through Laravel's own front-controller steps * (`Laravel/http.php`), and over stdio by `php artisan mcp:start` (`Laravel/artisan.php`). Under `php -S` every request * is a fresh PHP request with a fresh application, like PHP-FPM, and AgentCat's shutdown function delivers its event - * within the 5 s budget (plan E1 header conflict 13). A stdio server is one process for the whole MCP session, like a - * server that an MCP host starts; it delivers at stdin EOF and at SIGTERM. + * within the 5 s budget. A stdio server is one process for the whole MCP session, like a server that an MCP host + * starts; it delivers at stdin EOF and at SIGTERM. */ final class LaravelEndToEndTest extends TestCase { @@ -32,7 +32,7 @@ final class LaravelEndToEndTest extends TestCase private const string HANDSHAKE_PROTOCOL_VERSION = '2025-06-18'; - /** How long a test waits for its event at the collector (ruling P8). */ + /** How long a test waits for its event at the collector. */ private const float COLLECTOR_DEADLINE_SECONDS = 5.0; /** The hook sleeps first, then the delivery has its usual time. */ @@ -47,7 +47,7 @@ final class LaravelEndToEndTest extends TestCase private static ?BuiltInServer $http = null; - /** The directory of this class under the test HOME (Ruling R8): one HOME per server, each with its application. */ + /** The directory of this class under the test HOME: one HOME per server, each with its application. */ private static string $dir = ''; /** The HOME of the `php -S` server: its application, with AgentCat's log, and the hook's marker. */ diff --git a/tests/E2E/Scenarios/PerRequestModernScenariosTest.php b/tests/E2E/Scenarios/PerRequestModernScenariosTest.php new file mode 100644 index 0000000..dae3a60 --- /dev/null +++ b/tests/E2E/Scenarios/PerRequestModernScenariosTest.php @@ -0,0 +1,15 @@ + 'true', + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), // a request without X-E2E-Collector stays local + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'HOME' => self::$dir . '/home', + 'AGENTCAT_E2E_SESSIONS' => self::$dir . '/sessions', + 'AGENTCAT_E2E_SINKS' => self::$dir . '/sinks', + ]); + } catch (\Throwable $e) { + self::tearDownAfterClass(); // PHPUnit skips it when this method fails + + throw $e; + } + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$server?->stop(); + self::$server = null; + self::$collector?->stop(); + self::$collector = null; + FileTree::remove(self::$dir); + } + + #[\Override] + protected function setUp(): void + { + $this->scenario = $this->collector()->scenario(); + $this->sinkId = bin2hex(random_bytes(8)); + } + + /** Whether this lane speaks the 2026-07-28 era. */ + abstract protected static function modernEra(): bool; + + /** + * 21. A `tools/call` before any `tools/list`: the fresh instance rebuilds its registries from the tool listing, + * so the strip is exact, not the heuristic. + */ + #[Group('ac-02-53')] + #[Group('ac-02-20')] + #[MaximumDuration(10000)] + public function testRebuildOnDemand(): void + { + $client = $this->open(Scenario::RebuildOnDemand); + $before = $this->rebuildLines(); + + $result = $client->call('echo', ['msg' => 'cold start', 'context' => 'rebuild on demand']); + + $sessionId = $this->issuedSessionId($result); + $this->assertSame('cold start', $this->text($result, 1)); + $event = $this->event(); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertSame('rebuild on demand', JsonAssert::at($event, 'user_intent')); + $this->assertSame(['{"msg":"cold start"}'], $this->sink()->seen('echo')); + $this->assertSame($before + 1, $this->rebuildLines(), 'the call rebuilt the registries'); + } + + /** + * 21 on page 2 (D66): with mcp/sdk's page size at 1, `loose` is alone on page 2, and the rebuild for a cold call + * reads the pages up to it. The strip is exact: agent tracking is off, so the `agent_id` the call sends is the + * customer's and reaches the tool, where the heuristic strip of a tool that no page named would remove it. + */ + #[Group('ac-02-53')] + #[Group('ac-02-20')] + #[MaximumDuration(10000)] + public function testRebuildOnDemandForAToolOnPage2(): void + { + $client = $this->client(Scenario::RebuildOnDemand, ['X-E2E-Page-Size' => '1']); + $before = $this->rebuildLines(); + + $result = $client->call('loose', ['msg' => 'cold start', 'customer_extra' => 'x', 'agent_id' => 'theirs', 'context' => 'page two', 'session_id' => 'start']); + + $this->issuedSessionId($result); + $this->assertSame(['{"msg":"cold start","customer_extra":"x","agent_id":"theirs"}'], $this->sink()->seen('loose')); + $event = $this->event(); + $this->assertSame('loose', JsonAssert::at($event, 'resource_name')); + $this->assertSame('page two', JsonAssert::at($event, 'user_intent')); + $this->assertSame($before + 1, $this->rebuildLines(), 'the call rebuilt the registries'); + } + + #[\Override] + protected function open(Scenario $scenario): ScenarioClient + { + return $this->client($scenario); + } + + #[\Override] + protected function modern(): bool + { + return static::modernEra(); + } + + #[\Override] + protected function deliveries(int $count): array + { + return $this->collector()->waitFor($this->scenario, static fn(array $requests): bool => count($requests) >= $count, self::COLLECTOR_DEADLINE_SECONDS); + } + + #[\Override] + protected function assertNothingDelivered(ScenarioClient $client): void + { + usleep((int) (self::NO_EVENT_SETTLE_SECONDS * 1_000_000)); + $this->assertSame([], $this->collector()->requests($this->scenario), 'no event'); + } + + #[\Override] + protected function sink(): Sink + { + return new Sink(self::$dir . '/sinks/' . $this->sinkId . '.jsonl'); + } + + /** + * A client of the scenario's server, whose list and call requests carry the test's headers and `$headers`. + * + * @param array $headers + */ + private function client(Scenario $scenario, array $headers = []): ScenarioClient + { + $this->assertInstanceOf(BuiltInServer::class, self::$server); + + return ScenarioClient::connect(self::$server->baseUrl() . '/', static::modernEra(), [ + 'X-E2E-Scenario' => $scenario->value, + 'X-E2E-Collector' => $this->scenario, + 'X-E2E-Sink' => $this->sinkId, + ...$headers, + ]); + } + + /** How often the server's agentcat.log says that a request rebuilt the registries. */ + private function rebuildLines(): int + { + $file = self::$dir . '/home/agentcat.log'; + $log = is_file($file) ? file_get_contents($file) : ''; // a 2026-07-28 test can come first: no request logged yet + $this->assertIsString($log); + + return substr_count($log, self::REBUILT); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/E2E/Scenarios/PerRequestScenariosTest.php b/tests/E2E/Scenarios/PerRequestScenariosTest.php new file mode 100644 index 0000000..e2a3dac --- /dev/null +++ b/tests/E2E/Scenarios/PerRequestScenariosTest.php @@ -0,0 +1,15 @@ + 'enterprise']; + + public const string CUSTOMER_TAG_KEY = 'e2e_customer_tag'; + + public const string CUSTOMER_TAG_VALUE = 'customer-tag-present'; + + /** The customer's value for an SDK tag: the SDK's own value must win. */ + public const string COLLIDING_TAG_VALUE = 'customer-collision-loses'; + + public const string CUSTOMER_PROP_KEY = 'e2e_customer_prop'; + + public const string CUSTOMER_PROP_VALUE = 'prop-value'; + + case BaselineDefaults = 'baseline-defaults'; + case TracingOff = 'tracing-off'; + case TracingOffReportMissingOn = 'tracing-off-x-report-missing-on'; + case ReportMissingOff = 'report-missing-off'; + case ReportMissingCall = 'report-missing-call'; + case ContextOff = 'context-off'; + case ContextCustomDescription = 'context-custom-description'; + case AgentTrackingMint = 'agent-tracking-mint'; + case AgentTrackingSupplied = 'agent-tracking-supplied'; + case AgentTrackingHook = 'agent-tracking-x-hook'; + case TaskHookDeterministic = 'task-hook-deterministic'; + case TaskHookNull = 'task-hook-null'; + case TaskSuppliedContinuity = 'task-supplied-continuity'; + case MintBackStructuredMirror = 'mint-back-structured-mirror'; + case StrictSchemaInjection = 'strict-schema-injection'; + case StripProofLoose = 'strip-proof-loose'; + case ErrorPath = 'error-path'; + case HooksIdentityTagsProps = 'hooks-identity-tags-props'; + case Redaction = 'redaction'; + case ClientIdentityLadder = 'client-identity-ladder'; + case RebuildOnDemand = 'rebuild-on-demand'; + case SessionContinuityStateful = 'session-continuity-stateful'; + + /** The options of the scenario's server, with `$apiBaseUrl` as the AgentCat API (the test's collector scenario). */ + public function options(?string $apiBaseUrl): Options + { + return match ($this) { + self::TracingOff, self::TracingOffReportMissingOn => new Options(enableTracing: false, apiBaseUrl: $apiBaseUrl), + self::ReportMissingOff => new Options(enableReportMissing: false, apiBaseUrl: $apiBaseUrl), + self::ContextOff => new Options(enableToolCallContext: false, apiBaseUrl: $apiBaseUrl), + self::ContextCustomDescription => new Options(customContextDescription: self::CUSTOM_CONTEXT_DESCRIPTION, apiBaseUrl: $apiBaseUrl), + self::AgentTrackingMint, self::AgentTrackingSupplied => new Options(enableAgentTracking: true, apiBaseUrl: $apiBaseUrl), + self::AgentTrackingHook => new Options( + enableAgentTracking: true, + resolveSessionId: static fn(): string => self::AGENT_SESSION_HOOK_VALUE, + apiBaseUrl: $apiBaseUrl, + ), + self::TaskHookDeterministic => new Options(resolveSessionId: static fn(): string => self::SESSION_HOOK_VALUE, apiBaseUrl: $apiBaseUrl), + self::TaskHookNull => new Options(resolveSessionId: static fn(): ?string => null, apiBaseUrl: $apiBaseUrl), + self::HooksIdentityTagsProps => new Options( + identify: static fn(): Identity => new Identity(self::IDENTITY_USER_ID, self::IDENTITY_USER_NAME, self::IDENTITY_USER_DATA), + apiBaseUrl: $apiBaseUrl, + eventTags: static fn(): array => [ + Constants::TAG_SESSION_SOURCE => self::COLLIDING_TAG_VALUE, // a collision on an SDK tag: the SDK's value must win + self::CUSTOMER_TAG_KEY => self::CUSTOMER_TAG_VALUE, + ], + eventProperties: static fn(): array => [self::CUSTOMER_PROP_KEY => self::CUSTOMER_PROP_VALUE], + ), + self::Redaction => new Options( + redactSensitiveInformation: static fn(string $text): string => str_replace(self::REDACTION_SECRET, self::REDACTION_REPLACEMENT, $text), + apiBaseUrl: $apiBaseUrl, + ), + default => new Options(apiBaseUrl: $apiBaseUrl), + }; + } +} diff --git a/tests/E2E/Scenarios/ScenarioClient.php b/tests/E2E/Scenarios/ScenarioClient.php new file mode 100644 index 0000000..37b2031 --- /dev/null +++ b/tests/E2E/Scenarios/ScenarioClient.php @@ -0,0 +1,78 @@ + 'agentcat-e2e-client', 'version' => '1.0.0']; + + /** @param array $headers */ + private function __construct(private McpHttpClient $http, private bool $modern, private array $headers) {} + + /** + * A client of the MCP endpoint `$url`, after the handshake unless `$modern`. + * + * @param array $headers the headers of every request + */ + public static function connect(string $url, bool $modern, array $headers = []): self + { + $http = new McpHttpClient($url); + if (!$modern) { + $http->initialize(self::CLIENT_INFO['name'], self::CLIENT_INFO['version'], $headers); + } + + return new self($http, $modern, $headers); + } + + /** + * The tools of a `tools/list` answer. + * + * @return list<\stdClass> + */ + public function listTools(): array + { + $result = $this->modern ? $this->http->listToolsModern(self::CLIENT_INFO, $this->headers) : $this->http->listTools($this->headers); + $tools = $result->tools ?? null; + Assert::assertIsList($tools); + + return array_map(static function (mixed $tool): \stdClass { + Assert::assertInstanceOf(\stdClass::class, $tool); + + return $tool; + }, $tools); + } + + /** + * The `result` of a `tools/call`; a JSON-RPC error fails the test. + * + * @param array $arguments + * @param array $meta the call's own `_meta` (handshake era only) + */ + public function call(string $name, array $arguments, array $meta = []): \stdClass + { + Assert::assertFalse($this->modern && $meta !== [], 'a 2026-07-28 call carries the envelope in its _meta'); + $answer = $this->modern + ? $this->http->callToolModern($name, $arguments, self::CLIENT_INFO, $this->headers) + : $this->http->callTool($name, $arguments, $this->headers, $meta); + + return HttpHarness::result($answer['message']); + } + + /** The `Mcp-Session-Id` of the handshake; null in the 2026-07-28 era. */ + public function sessionId(): ?string + { + return $this->http->sessionId(); + } +} diff --git a/tests/E2E/Scenarios/ScenarioToolkit.php b/tests/E2E/Scenarios/ScenarioToolkit.php new file mode 100644 index 0000000..0b393ba --- /dev/null +++ b/tests/E2E/Scenarios/ScenarioToolkit.php @@ -0,0 +1,99 @@ + 'string', 'description' => 'Message to echo']; + + public static function builder(Sink $sink): Builder + { + return Server::builder() + ->setServerInfo(self::SERVER_NAME, self::SERVER_VERSION) + ->add( + new Tool(name: 'echo', title: null, inputSchema: ['type' => 'object', 'properties' => ['msg' => self::MSG], 'required' => ['msg']], description: 'Echo a message back', annotations: null), + self::handler($sink, 'echo', static fn(array $arguments): string => self::msg($arguments)), + ) + ->add( + new Tool(name: 'loose', title: null, inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => []], description: 'Loose schema: unknown keys pass through to the handler', annotations: null), + self::handler($sink, 'loose', static fn(array $arguments): string => json_encode((object) $arguments, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)), + ) + // A Tool definition cannot carry additionalProperties: a reflected handler gets the one parameter it + // declares. mcp/sdk validates the call against this schema, so a handle that reached it would fail the call. + ->addTool( + static function (string $msg) use ($sink): string { + $sink->record('strict', ['msg' => $msg]); + + return $msg; + }, + name: 'strict', + description: 'Strict schema: additionalProperties are rejected', + inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg'], 'additionalProperties' => false], + ) + ->add( + new Tool( + name: 'structured', + title: null, + inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']], + description: 'Returns structuredContent per its outputSchema', + annotations: null, + outputSchema: ['type' => 'object', 'properties' => ['echoed' => ['type' => 'string', 'description' => 'The echoed message']], 'required' => ['echoed']], + ), + self::handler($sink, 'structured', static fn(array $arguments): CallToolResult => new CallToolResult( + [new TextContent(self::msg($arguments))], + structuredContent: ['echoed' => self::msg($arguments)], + )), + ) + ->add( + new Tool(name: 'boom', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: 'Always throws', annotations: null), + self::handler($sink, 'boom', static function (): never { + throw new ToolCallException('boom'); + }), + ); + } + + /** @param \Closure(array): mixed $respond */ + private static function handler(Sink $sink, string $tool, \Closure $respond): RecordingTool + { + return new RecordingTool(static function (array $arguments) use ($sink, $tool, $respond): mixed { + $sink->record($tool, $arguments); + + return $respond($arguments); + }); + } + + /** @param array $arguments */ + private static function msg(array $arguments): string + { + return is_string($arguments['msg'] ?? null) ? $arguments['msg'] : ''; + } +} diff --git a/tests/E2E/Scenarios/Scenarios.php b/tests/E2E/Scenarios/Scenarios.php new file mode 100644 index 0000000..348520e --- /dev/null +++ b/tests/E2E/Scenarios/Scenarios.php @@ -0,0 +1,704 @@ + + */ + abstract protected function deliveries(int $count): array; + + /** Proves that no event of this test reached the collector: the lane's barrier, then an empty collector scenario. */ + abstract protected function assertNothingDelivered(ScenarioClient $client): void; + + /** The sink of this test's tool calls. */ + abstract protected function sink(): Sink; + + /** 1. The listing injects context and session_id; a call without session_id mints; one event; the sink is stripped. */ + #[Group('ac-02-53')] + #[Group('ac-02-6')] + #[Group('ac-02-30')] + #[MaximumDuration(10000)] + public function testBaselineDefaults(): void + { + $client = $this->open(Scenario::BaselineDefaults); + + $tools = $client->listTools(); + $this->assertContains('echo', $this->names($tools)); + $this->assertContains(GetMoreTools::NAME, $this->names($tools)); + $echo = $this->tool($tools, 'echo'); + $this->assertSame(Constants::DEFAULT_CONTEXT_PARAMETER_DESCRIPTION, JsonAssert::at($this->properties($echo), 'context', 'description')); + $this->assertContains(Constants::PARAM_SESSION_ID, $this->required($echo)); + $this->assertContains(Constants::PARAM_CONTEXT, $this->required($echo)); + $this->assertSame(Constants::SESSION_ID_PARAM_PATTERN, JsonAssert::at($this->properties($echo), 'session_id', 'pattern')); + + // Requiredness is for schema-aware clients: a call without session_id still mints and announces it. + $result = $client->call('echo', ['msg' => 'hi', 'context' => 'e2e smoke intent']); + + $sessionId = $this->issuedSessionId($result); + $event = $this->event(); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertSame('e2e smoke intent', JsonAssert::at($event, 'user_intent')); + $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertSame('{"msg":"hi","context":"e2e smoke intent"}', $this->json($this->rawArguments($event)), 'the event keeps the raw call'); + $this->assertSame(['{"msg":"hi"}'], $this->sink()->seen('echo'), 'the tool saw neither session_id nor context'); + $this->assertSame($this->modern() ? '2026-07-28' : null, $this->tag($event, Constants::TAG_PROTOCOL_VERSION), 'the envelope tags its era'); + } + + /** 2. Tracing off: no handles are listed, but context is; the call is plain; no event. */ + #[Group('ac-02-53')] + #[Group('ac-02-52')] + #[MaximumDuration(10000)] + public function testTracingOff(): void + { + $client = $this->open(Scenario::TracingOff); + + $properties = $this->properties($this->tool($client->listTools(), 'echo')); + $this->assertObjectNotHasProperty(Constants::PARAM_SESSION_ID, $properties); + $this->assertObjectNotHasProperty(Constants::PARAM_AGENT_ID, $properties); + $this->assertObjectHasProperty(Constants::PARAM_CONTEXT, $properties, 'enableToolCallContext governs context, as in TypeScript'); + + $result = $client->call('echo', ['msg' => 'hi']); + + $this->assertSame(['hi'], $this->texts($result)); + $this->assertNull($this->mintBack($result)); + $this->assertNothingDelivered($client); + $this->assertSame(['{"msg":"hi"}'], $this->sink()->seen('echo')); + } + + /** 3. Tracing off, report-missing on: get_more_tools is listed and answered, with no block and no event. */ + #[Group('ac-02-53')] + #[Group('ac-02-42')] + #[MaximumDuration(10000)] + public function testTracingOffWithReportMissingOn(): void + { + $client = $this->open(Scenario::TracingOffReportMissingOn); + $this->assertContains(GetMoreTools::NAME, $this->names($client->listTools())); + + $result = $client->call(GetMoreTools::NAME, ['context' => 'need a search tool']); + + $this->assertStringContainsString(self::FULL_TOOL_LIST, $this->text($result, 0)); + $this->assertNull($this->mintBack($result)); + $this->assertNothingDelivered($client); + } + + /** 4. Report-missing off: no get_more_tools; injection, minting and the event still work. */ + #[Group('ac-02-53')] + #[Group('ac-01-16')] + #[MaximumDuration(10000)] + public function testReportMissingOff(): void + { + $client = $this->open(Scenario::ReportMissingOff); + + $tools = $client->listTools(); + $this->assertNotContains(GetMoreTools::NAME, $this->names($tools)); + $properties = $this->properties($this->tool($tools, 'echo')); + $this->assertObjectHasProperty(Constants::PARAM_SESSION_ID, $properties); + $this->assertObjectHasProperty(Constants::PARAM_CONTEXT, $properties); + + $result = $client->call('echo', ['msg' => 'hi', 'context' => 'no gmt']); + + $sessionId = $this->issuedSessionId($result); + $this->assertSame($sessionId, JsonAssert::at($this->event(), 'session_id')); + } + + /** 5. A get_more_tools call: the canned text after the block, and one event with the context as intent. */ + #[Group('ac-02-53')] + #[Group('ac-02-40')] + #[MaximumDuration(10000)] + public function testReportMissingCall(): void + { + $client = $this->open(Scenario::ReportMissingCall); + $client->listTools(); + $missing = 'Need a database query tool for SQL operations'; + + $result = $client->call(GetMoreTools::NAME, ['context' => $missing]); + + $this->assertStringContainsString('Unfortunately', $this->text($result, 1)); + $this->assertStringContainsString(self::FULL_TOOL_LIST, $this->text($result, 1)); + $sessionId = $this->issuedSessionId($result); + $event = $this->event(); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at($event, 'resource_name')); + $this->assertSame($missing, JsonAssert::at($event, 'user_intent')); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertSame($missing, JsonAssert::at($this->rawArguments($event), 'context'), 'get_more_tools\' own context stays on the event'); + $this->assertStringNotContainsString('[session_id', $this->json($event->response ?? null), 'the block is wire-only'); + } + + /** 6. Context off: no context parameter; a supplied session is honored; no intent. */ + #[Group('ac-02-53')] + #[Group('ac-01-17')] + #[MaximumDuration(10000)] + public function testContextOff(): void + { + $client = $this->open(Scenario::ContextOff); + + $properties = $this->properties($this->tool($client->listTools(), 'echo')); + $this->assertObjectNotHasProperty(Constants::PARAM_CONTEXT, $properties); + $this->assertObjectHasProperty(Constants::PARAM_SESSION_ID, $properties); + + $client->call('echo', ['msg' => 'hi', 'session_id' => $this->sid('ctxoff')]); + + $event = $this->event(); + $this->assertSame($this->sid('ctxoff'), JsonAssert::at($event, 'session_id')); + $this->assertSame('supplied', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertNull($event->user_intent ?? null); + } + + /** 7. A custom context description on the customer's tools; get_more_tools keeps its own. */ + #[Group('ac-02-53')] + #[Group('ac-01-18')] + #[MaximumDuration(10000)] + public function testContextCustomDescription(): void + { + $client = $this->open(Scenario::ContextCustomDescription); + + $tools = $client->listTools(); + $this->assertSame(Scenario::CUSTOM_CONTEXT_DESCRIPTION, JsonAssert::at($this->properties($this->tool($tools, 'echo')), 'context', 'description')); + $this->assertSame( + JsonAssert::at(Golden::json('handles/strings.json'), 'GET_MORE_TOOLS_DESCRIPTOR', 'inputSchema', 'properties', 'context', 'description'), + JsonAssert::at($this->properties($this->tool($tools, GetMoreTools::NAME)), 'context', 'description'), + 'get_more_tools keeps its own context description', + ); + + $client->call('echo', ['msg' => 'hi', 'context' => 'custom description intent']); + + $event = $this->event(); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertSame('custom description intent', JsonAssert::at($event, 'user_intent')); + } + + /** 8. Agent tracking: both handles required everywhere; start mints a session but never an agent. */ + #[Group('ac-02-53')] + #[Group('ac-01-23')] + #[Group('ac-02-7')] + #[MaximumDuration(10000)] + public function testAgentTrackingMint(): void + { + $client = $this->open(Scenario::AgentTrackingMint); + + $tools = $client->listTools(); + foreach (['echo', 'structured', GetMoreTools::NAME] as $name) { + $tool = $this->tool($tools, $name); + $this->assertObjectHasProperty(Constants::PARAM_AGENT_ID, $this->properties($tool), $name); + $this->assertContains(Constants::PARAM_AGENT_ID, $this->required($tool), $name); + $this->assertContains(Constants::PARAM_SESSION_ID, $this->required($tool), $name); + } + + $result = $client->call('structured', ['msg' => 'hi', 'context' => 'agent minting', 'session_id' => 'start']); + + $block = $this->mintBack($result); + $this->assertIsString($block); + $this->assertStringNotContainsString(Constants::PARAM_AGENT_ID, $block); + $sessionId = $this->issuedSessionId($result); + $this->assertSame('{"session_id":"' . $sessionId . '","status":"issued"}', $this->json(JsonAssert::at($result, 'structuredContent', Constants::MCP_SESSION_KEY)), 'no agent_id in the mirror'); + $this->assertSame('hi', JsonAssert::at($result, 'structuredContent', 'echoed')); + $event = $this->event(); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertNull($this->tag($event, Constants::TAG_AGENT_ID)); + $this->assertNull($this->tag($event, Constants::TAG_AGENT_SOURCE)); + } + + /** 9. Agent tracking: a supplied agent_id on the second call is tagged verbatim; the first call has no agent tag. */ + #[Group('ac-02-53')] + #[Group('ac-01-22')] + #[MaximumDuration(10000)] + public function testAgentTrackingSupplied(): void + { + $client = $this->open(Scenario::AgentTrackingSupplied); + $client->listTools(); + $first = $client->call('echo', ['msg' => 'one', 'context' => 'agent supplied 1', 'session_id' => 'start']); + $block = $this->mintBack($first); + $this->assertIsString($block); + $this->assertStringNotContainsString(Constants::PARAM_AGENT_ID, $block); + $sessionId = $this->issuedSessionId($first); + + $second = $client->call('echo', ['msg' => 'two', 'context' => 'agent supplied 2', 'session_id' => $sessionId, 'agent_id' => self::AGENT_ID]); + + $this->assertNull($this->mintBack($second), 'nothing minted, no block'); + [$one, $two] = $this->twoEvents(); + $this->assertNull($this->tag($one, Constants::TAG_AGENT_ID)); + $this->assertSame($sessionId, JsonAssert::at($two, 'session_id')); + $this->assertSame('supplied', $this->tag($two, Constants::TAG_SESSION_SOURCE)); + $this->assertSame(self::AGENT_ID, $this->tag($two, Constants::TAG_AGENT_ID)); + $this->assertSame('supplied', $this->tag($two, Constants::TAG_AGENT_SOURCE)); + } + + /** 10. Agent tracking in hook mode: no session_id parameter, agent_id required; no block; one derived session. */ + #[Group('ac-02-53')] + #[Group('ac-04-17')] + #[MaximumDuration(10000)] + public function testAgentTrackingWithASessionHook(): void + { + $client = $this->open(Scenario::AgentTrackingHook); + $tools = $client->listTools(); + foreach (['echo', GetMoreTools::NAME] as $name) { + $tool = $this->tool($tools, $name); + $this->assertObjectNotHasProperty(Constants::PARAM_SESSION_ID, $this->properties($tool), $name); + $this->assertObjectHasProperty(Constants::PARAM_AGENT_ID, $this->properties($tool), $name); + $this->assertContains(Constants::PARAM_AGENT_ID, $this->required($tool), $name); + } + + $first = $client->call('echo', ['msg' => 'one', 'context' => 'hook plus agent', 'agent_id' => self::AGENT_ID]); + $second = $client->call('echo', ['msg' => 'two', 'context' => 'hook plus agent', 'agent_id' => self::AGENT_ID]); + + $this->assertNull($this->mintBack($first), 'hook mode never adds a block'); + $this->assertNull($this->mintBack($second)); + $events = $this->twoEvents(); + $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $this->string(JsonAssert::at($events[0], 'session_id'))); + $this->assertSame(JsonAssert::at($events[0], 'session_id'), JsonAssert::at($events[1], 'session_id'), 'one hook value, one session'); + foreach ($events as $event) { + $this->assertSame('hook', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertSame(self::AGENT_ID, $this->tag($event, Constants::TAG_AGENT_ID)); + $this->assertSame('supplied', $this->tag($event, Constants::TAG_AGENT_SOURCE)); + } + } + + /** 11. A deterministic session hook: two calls (two server instances on a per-request lane) share one session. */ + #[Group('ac-02-53')] + #[Group('ac-01-27')] + #[MaximumDuration(10000)] + public function testTaskHookDeterministic(): void + { + $client = $this->open(Scenario::TaskHookDeterministic); + $client->listTools(); + + $first = $client->call('echo', ['msg' => 'one', 'context' => 'hook determinism']); + $second = $client->call('echo', ['msg' => 'two', 'context' => 'hook determinism']); + + $this->assertNull($this->mintBack($first)); + $this->assertNull($this->mintBack($second)); + $events = $this->twoEvents(); + $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $this->string(JsonAssert::at($events[0], 'session_id'))); + $this->assertSame(JsonAssert::at($events[0], 'session_id'), JsonAssert::at($events[1], 'session_id')); + foreach ($events as $event) { + $this->assertSame('hook', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + } + } + + /** 12. A session hook that returns null: each call mints its own session silently. */ + #[Group('ac-02-53')] + #[Group('ac-01-29')] + #[MaximumDuration(10000)] + public function testTaskHookNull(): void + { + $client = $this->open(Scenario::TaskHookNull); + $client->listTools(); + + $first = $client->call('echo', ['msg' => 'one', 'context' => 'hook null']); + $second = $client->call('echo', ['msg' => 'two', 'context' => 'hook null']); + + $this->assertNull($this->mintBack($first), 'the agent can never learn a silently minted session'); + $this->assertNull($this->mintBack($second)); + $events = $this->twoEvents(); + foreach ($events as $event) { + $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $this->string(JsonAssert::at($event, 'session_id'))); + $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + } + $this->assertNotSame(JsonAssert::at($events[0], 'session_id'), JsonAssert::at($events[1], 'session_id')); + } + + /** 13. start mints a session; the agent sends it back; both events carry it, minted then supplied. */ + #[Group('ac-02-53')] + #[Group('ac-02-32')] + #[MaximumDuration(10000)] + public function testTaskSuppliedContinuity(): void + { + $client = $this->open(Scenario::TaskSuppliedContinuity); + $client->listTools(); + $sessionId = $this->issuedSessionId($client->call('echo', ['msg' => 'one', 'context' => 'continuity 1', 'session_id' => 'start'])); + + $second = $client->call('echo', ['msg' => 'two', 'context' => 'continuity 2', 'session_id' => $sessionId]); + + $this->assertNull($this->mintBack($second)); + [$one, $two] = $this->twoEvents(); + $this->assertSame($sessionId, JsonAssert::at($one, 'session_id')); + $this->assertSame($sessionId, JsonAssert::at($two, 'session_id')); + $this->assertSame('minted', $this->tag($one, Constants::TAG_SESSION_SOURCE)); + $this->assertSame('supplied', $this->tag($two, Constants::TAG_SESSION_SOURCE)); + } + + /** + * 14. The structured mirror: mcp_session first in structuredContent, matching the block; the result passes the + * output schema the client listed (the JSON Schema validator of mcp/sdk stands in for the client's); wire-only. + */ + #[Group('ac-02-53')] + #[Group('ac-02-36')] + #[MaximumDuration(10000)] + public function testMintBackStructuredMirror(): void + { + $client = $this->open(Scenario::MintBackStructuredMirror); + $outputSchema = JsonAssert::at($this->tool($client->listTools(), 'structured'), 'outputSchema'); + $this->assertObjectHasProperty(Constants::MCP_SESSION_KEY, $this->object(JsonAssert::at($outputSchema, 'properties'))); + + $result = $client->call('structured', ['msg' => 'mirrored', 'context' => 'structured mirror', 'session_id' => 'start']); + + $sessionId = $this->issuedSessionId($result); + $structured = JsonAssert::at($result, 'structuredContent'); + $this->assertInstanceOf(\stdClass::class, $structured); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], array_keys(get_object_vars($structured)), 'the mirror leads structuredContent'); + $this->assertSame('{"session_id":"' . $sessionId . '","status":"issued"}', $this->json($structured->mcp_session ?? null)); + $this->assertSame('mirrored', $structured->echoed ?? null); + $this->assertInstanceOf(\stdClass::class, $outputSchema); + $this->assertSame([], (new SchemaValidator())->validateAgainstJsonSchema($structured, $outputSchema), 'the result passes the listed output schema'); + $event = $this->event(); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertStringNotContainsString(Constants::MCP_SESSION_KEY, $this->json($event->response ?? null), 'the mirror is wire-only'); + $this->assertStringNotContainsString('[session_id', $this->json($event->response ?? null), 'the block is wire-only'); + } + + /** 15. A strict schema: additionalProperties is dropped from the listing, and the handles are stripped before validation. */ + #[Group('ac-02-53')] + #[Group('ac-02-17')] + #[MaximumDuration(10000)] + public function testStrictSchemaInjection(): void + { + $client = $this->open(Scenario::StrictSchemaInjection); + $strict = $this->tool($client->listTools(), 'strict'); + $this->assertObjectNotHasProperty('additionalProperties', $this->object(JsonAssert::at($strict, 'inputSchema'))); + $this->assertObjectHasProperty(Constants::PARAM_SESSION_ID, $this->properties($strict)); + $this->assertObjectHasProperty(Constants::PARAM_CONTEXT, $this->properties($strict)); + + $result = $client->call('strict', ['msg' => 'strict ok', 'context' => 'strict schema test', 'session_id' => $this->sid('strict1')]); + + $this->assertFalse(JsonAssert::at($result, 'isError')); + $this->assertSame(['strict ok'], $this->texts($result)); + $this->assertSame($this->sid('strict1'), JsonAssert::at($this->event(), 'session_id')); + // The strip proof is the successful call: mcp/sdk validates it against the original strict schema. + $this->assertSame(['{"msg":"strict ok"}'], $this->sink()->seen('strict')); + } + + /** 16. The strip proof on a loose schema: the customer's extra key reaches the tool, the handles do not; the event keeps all. */ + #[Group('ac-02-53')] + #[Group('ac-02-18')] + #[MaximumDuration(10000)] + public function testStripProofLoose(): void + { + $client = $this->open(Scenario::StripProofLoose); + $client->listTools(); + + $client->call('loose', ['msg' => 'keep', 'customer_extra' => 'customer says hi', 'context' => 'strip proof', 'session_id' => $this->sid('raw1')]); + + $this->assertSame(['{"msg":"keep","customer_extra":"customer says hi"}'], $this->sink()->seen('loose')); + $event = $this->event(); + $this->assertSame( + '{"msg":"keep","customer_extra":"customer says hi","context":"strip proof","session_id":"' . $this->sid('raw1') . '"}', + $this->json($this->rawArguments($event)), + ); + $this->assertSame($this->sid('raw1'), JsonAssert::at($event, 'session_id')); + $this->assertSame('strip proof', JsonAssert::at($event, 'user_intent')); + } + + /** 17. The error path: an isError result with the block, an isError event, and the server keeps serving. */ + #[Group('ac-02-53')] + #[Group('ac-02-24')] + #[Group('ac-02-29')] + #[Group('ac-02-33')] + #[MaximumDuration(10000)] + public function testErrorPath(): void + { + $client = $this->open(Scenario::ErrorPath); + $client->listTools(); + + $result = $client->call('boom', ['context' => 'error path intent']); + + $this->assertTrue(JsonAssert::at($result, 'isError')); + $sessionId = $this->issuedSessionId($result); // an isError result still announces the session + $error = $this->event(); + $this->assertSame('boom', JsonAssert::at($error, 'resource_name')); + $this->assertTrue(JsonAssert::at($error, 'is_error')); + $this->assertStringContainsString('boom', $this->string(JsonAssert::at($error, 'error', 'message'))); + $this->assertSame('error path intent', JsonAssert::at($error, 'user_intent')); + $this->assertSame($sessionId, JsonAssert::at($error, 'session_id')); + $this->assertSame('minted', $this->tag($error, Constants::TAG_SESSION_SOURCE)); + $this->assertStringNotContainsString('[session_id', $this->json($error->response ?? null), 'the block is wire-only'); + + $followUp = $client->call('echo', ['msg' => 'still alive', 'context' => 'post-error']); + + $this->assertFalse(JsonAssert::at($followUp, 'isError')); + $events = $this->twoEvents(); + $this->assertSame('echo', JsonAssert::at($events[1], 'resource_name')); + $this->assertObjectNotHasProperty('is_error', $events[1]); + } + + /** 18. identify, eventTags (with a collision on an SDK tag, which the SDK wins) and eventProperties on the event. */ + #[Group('ac-02-53')] + #[Group('ac-02-49')] + #[Group('ac-01-41')] + #[MaximumDuration(10000)] + public function testHooksIdentityTagsAndProperties(): void + { + $client = $this->open(Scenario::HooksIdentityTagsProps); + $client->listTools(); + + $client->call('echo', ['msg' => 'hi', 'context' => 'hooks combo']); + + $event = $this->event(); + $this->assertSame(Scenario::IDENTITY_USER_ID, JsonAssert::at($event, 'identify_actor_given_id')); + $this->assertSame(Scenario::IDENTITY_USER_NAME, JsonAssert::at($event, 'identify_actor_name')); + $this->assertSame($this->json(Scenario::IDENTITY_USER_DATA), $this->json(JsonAssert::at($event, 'identify_data'))); + $this->assertSame(Scenario::CUSTOMER_TAG_VALUE, $this->tag($event, Scenario::CUSTOMER_TAG_KEY)); + $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE), 'the SDK wins the collision'); + $this->assertSame(Scenario::CUSTOMER_PROP_VALUE, JsonAssert::at($event, 'properties', Scenario::CUSTOMER_PROP_KEY)); + } + + /** 19. String redaction: the secret is replaced in the response, the arguments and the intent; protected fields stay. */ + #[Group('ac-02-53')] + #[Group('ac-01-48')] + #[MaximumDuration(10000)] + public function testRedaction(): void + { + $client = $this->open(Scenario::Redaction); + $client->listTools(); + + $client->call('echo', [ + 'msg' => 'payload with ' . Scenario::REDACTION_SECRET . ' inside', + 'context' => 'intent mentioning ' . Scenario::REDACTION_SECRET, + 'session_id' => $this->sid('redact'), + ]); + + $event = $this->event(); + $response = $this->json($event->response ?? null); + $this->assertStringContainsString(Scenario::REDACTION_REPLACEMENT, $response); + $this->assertStringNotContainsString(Scenario::REDACTION_SECRET, $response); + $this->assertSame('payload with ' . Scenario::REDACTION_REPLACEMENT . ' inside', JsonAssert::at($this->rawArguments($event), 'msg')); + $this->assertSame('intent mentioning ' . Scenario::REDACTION_REPLACEMENT, JsonAssert::at($event, 'user_intent')); + $this->assertSame($this->sid('redact'), JsonAssert::at($event, 'session_id')); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + } + + /** + * 20. The client identity ladder, per lane. In the handshake era the identity comes from `initialize`: on the + * long-lived lane from the instance that served it; on the per-request lane from the session store, which every + * fresh instance reads (TypeScript's per-request instances keep no session, and find no identity). The handshake + * era has no protocol tag, but a `_meta` claim on a call gives one; mcp/sdk routes a call that claims 2026-07-28 to + * the other era, so the claim here is the negotiated revision. In the 2026-07-28 era, the envelope of each request + * carries the identity and the tag. + */ + #[Group('ac-02-53')] + #[Group('ac-02-44')] + #[Group('ac-02-45')] + #[MaximumDuration(10000)] + public function testClientIdentityLadder(): void + { + $client = $this->open(Scenario::ClientIdentityLadder); + $client->listTools(); + + $client->call('echo', ['msg' => 'who am I', 'context' => 'identity ladder']); + + $event = $this->event(); + $this->assertSame(ScenarioClient::CLIENT_INFO['name'], JsonAssert::at($event, 'client_name')); + $this->assertSame(ScenarioClient::CLIENT_INFO['version'], JsonAssert::at($event, 'client_version')); + if ($this->modern()) { + $this->assertSame('2026-07-28', $this->tag($event, Constants::TAG_PROTOCOL_VERSION)); + + return; + } + $this->assertNull($this->tag($event, Constants::TAG_PROTOCOL_VERSION), 'a negotiated version is no claim'); + + $client->call('echo', ['msg' => 'again', 'context' => 'identity ladder meta'], [Constants::META_PROTOCOL_VERSION_KEY => '2025-06-18']); + + $events = $this->twoEvents(); + $this->assertSame('2025-06-18', $this->tag($events[1], Constants::TAG_PROTOCOL_VERSION)); + } + + /** Waits for the one event of this test. */ + private function event(): \stdClass + { + return $this->events(1)[0]; + } + + /** + * Waits for the two events of this test, in call order. + * + * @return array{\stdClass, \stdClass} + */ + private function twoEvents(): array + { + $events = $this->events(2); + $second = $events[1] ?? null; + $this->assertInstanceOf(\stdClass::class, $second); + + return [$events[0], $second]; + } + + /** + * Waits for exactly `$count` events of this test, in call order: by `timestamp`, the start of each call. A + * per-request lane delivers each event at the end of its own request, so two requests can deliver out of order. + * + * @return non-empty-list<\stdClass> + */ + private function events(int $count): array + { + $events = array_map(static function (\stdClass $request): \stdClass { + self::assertSame('POST', $request->method ?? null); + $body = $request->body ?? null; + self::assertIsString($body); + $event = Json::decode($body); + self::assertInstanceOf(\stdClass::class, $event, $body); + + return $event; + }, $this->deliveries($count)); + $this->assertCount($count, $events, 'events of this test'); + usort($events, fn(\stdClass $a, \stdClass $b): int => strcmp($this->string($a->timestamp ?? null), $this->string($b->timestamp ?? null))); + $this->assertNotSame([], $events); + + return $events; + } + + /** + * The `ses_` ID of the issued block at the start of a result; fails unless the block is there. + */ + private function issuedSessionId(\stdClass $result): string + { + $block = $this->mintBack($result); + $this->assertIsString($block, 'the issued block'); + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED . "\n", $block); + $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $block, $match), $block); + + return $match[1]; + } + + /** The text of the session block at the start of a result, or null. */ + private function mintBack(\stdClass $result): ?string + { + $first = $this->texts($result)[0] ?? null; + + return is_string($first) && str_starts_with($first, '[session_id') ? $first : null; + } + + /** @return list the `text` of every content block */ + private function texts(\stdClass $result): array + { + $content = $result->content ?? null; + $this->assertIsList($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); + } + + private function text(\stdClass $result, int $index): string + { + return $this->string($this->texts($result)[$index] ?? null); + } + + /** + * @param list<\stdClass> $tools + * + * @return list + */ + private function names(array $tools): array + { + return array_map(static fn(\stdClass $tool): mixed => $tool->name ?? null, $tools); + } + + /** @param list<\stdClass> $tools */ + private function tool(array $tools, string $name): \stdClass + { + foreach ($tools as $tool) { + if (($tool->name ?? null) === $name) { + return $tool; + } + } + + self::fail('No tool ' . $name . ' in ' . $this->json($this->names($tools))); + } + + private function properties(\stdClass $tool): \stdClass + { + $properties = JsonAssert::at($tool, 'inputSchema', 'properties'); + $this->assertInstanceOf(\stdClass::class, $properties); + + return $properties; + } + + /** @return list */ + private function required(\stdClass $tool): array + { + $required = JsonAssert::at($tool, 'inputSchema', 'required'); + $this->assertIsList($required); + + return $required; + } + + /** The raw arguments of the call, as the event records them. */ + private function rawArguments(\stdClass $event): mixed + { + return JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments'); + } + + /** An event tag, or null when the event has no such tag. */ + private function tag(\stdClass $event, string $name): mixed + { + $tags = $event->tags ?? null; + $this->assertInstanceOf(\stdClass::class, $tags); + + return $tags->{$name} ?? null; + } + + /** A valid `ses_` session ID made from `$label`, as TypeScript's `sid()` makes it. */ + private function sid(string $label): string + { + return 'ses_' . substr(preg_replace('/[^0-9A-Za-z]/', '', $label) . str_repeat('0', 27), 0, 27); + } + + private function object(mixed $value): \stdClass + { + $this->assertInstanceOf(\stdClass::class, $value); + + return $value; + } + + private function string(mixed $value): string + { + $this->assertIsString($value); + + return $value; + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/E2E/Scenarios/Sink.php b/tests/E2E/Scenarios/Sink.php new file mode 100644 index 0000000..a0bbf1b --- /dev/null +++ b/tests/E2E/Scenarios/Sink.php @@ -0,0 +1,48 @@ + $arguments */ + public function record(string $tool, array $arguments): void + { + if ($this->file !== null) { + file_put_contents($this->file, json_encode(['tool' => $tool, 'args' => (object) $arguments], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n", FILE_APPEND | LOCK_EX); + } + } + + /** + * The arguments that `$tool` received, as JSON, one item per call in call order. + * + * @return list + */ + public function seen(string $tool): array + { + $contents = $this->file !== null && is_file($this->file) ? file_get_contents($this->file) : ''; + Assert::assertIsString($contents); + $seen = []; + foreach (array_filter(explode("\n", $contents), static fn(string $line): bool => $line !== '') as $line) { + $entry = Json::decode($line); + Assert::assertInstanceOf(\stdClass::class, $entry, $line); + if (($entry->tool ?? null) === $tool) { + $seen[] = json_encode($entry->args ?? null, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + } + + return $seen; + } +} diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index 9065ca4..21b2664 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -33,7 +33,7 @@ final class StdioEndToEndTest extends TestCase /** The 5 s shutdown budget, plus room for a slow runner. */ private const float EOF_EXIT_SECONDS = 6.0; - /** Review Focus 3: the 5 s shutdown budget with every delivery slot held by a collector that answers after 8 s. */ + /** The 5 s shutdown budget with every delivery slot held by a collector that answers after 8 s. */ private const float SLOW_COLLECTOR_EXIT_SECONDS = 6.5; private const float SIGTERM_EXIT_SECONDS = 3.0; @@ -49,7 +49,7 @@ final class StdioEndToEndTest extends TestCase private static ?MockCollector $collector = null; - /** The HOME (agentcat.log) of every server of this class, under the test HOME (Ruling R8). */ + /** The HOME (agentcat.log) of every server of this class, under the test HOME. */ private static string $dir = ''; /** The collector scenario of the current test. */ @@ -94,7 +94,7 @@ protected function tearDown(): void $this->client = null; } - /** M6: one stdio session through the handshake; both events reach the collector by the time stdin closes. */ + /** One stdio session through the handshake; both events reach the collector by the time stdin closes. */ #[Group('ac-02-53')] #[MaximumDuration(15000)] public function testAHandshakeSessionIsTrackedAndDeliveredAtEof(): void @@ -197,8 +197,8 @@ public function testAnEventIsDeliveredBetweenRequestsWhileStdinStaysOpen(): void } /** - * Review Focus 3: stdin closes while the collector holds every delivery. The pump at each handler entry starts at - * most 5 deliveries, so 2 of the 7 events still wait when the 5 s shutdown budget ends. + * Stdin closes while the collector holds every delivery. The pump at each handler entry starts at most 5 + * deliveries, so 2 of the 7 events still wait when the 5 s shutdown budget ends. */ #[Group('ac-03-9')] #[MaximumDuration(15000)] @@ -244,7 +244,7 @@ public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void } /** - * 06 AC-62 in a real process: stdin EOF posts the diagnostics buffer exactly once, with the setup beacons and the + * ac-06-62 in a real process: stdin EOF posts the diagnostics buffer exactly once, with the setup beacons and the * per-event line logged during the EOF drain. */ #[Group('ac-06-62')] @@ -272,11 +272,11 @@ public function testStdinEofSendsTheDiagnosticsOnce(): void $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); } - /** mcp/sdk main serves the 2026-07-28 era over stdio when the first request carries its envelope. */ + /** mcp/sdk 0.9.0 and main serve the 2026-07-28 era over stdio when the first request carries its envelope. */ #[MaximumDuration(15000)] public function testAModernEraFirstRequestIsTrackedWithoutAHandshake(): void { - if (!McpSdkVersion::isMain()) { + if (!McpSdkVersion::hasModernEra()) { self::markTestSkipped('mcp/sdk 0.8.1 serves only the handshake era over stdio'); } $client = $this->start(); diff --git a/tests/E2E/Support/McpHttpClient.php b/tests/E2E/Support/McpHttpClient.php index 6d946c7..02f8d28 100644 --- a/tests/E2E/Support/McpHttpClient.php +++ b/tests/E2E/Support/McpHttpClient.php @@ -47,26 +47,45 @@ public function post(array $message, array $headers = []): array return $this->exchange($message, $headers, true); } - /** Sends `initialize` and `notifications/initialized`, and keeps the `Mcp-Session-Id`. */ - public function initialize(string $name = 'e2e-client', string $version = '1.0.0'): void + /** + * Sends `initialize` and `notifications/initialized`, both with `$headers`, and keeps the `Mcp-Session-Id`. + * + * @param array> $headers + */ + public function initialize(string $name = 'e2e-client', string $version = '1.0.0', array $headers = []): void { $result = HttpHarness::result($this->request('initialize', [ 'protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => $name, 'version' => $version], - ], [], true)['message']); + ], $headers, true)['message']); $this->protocolVersion = is_string($result->protocolVersion ?? null) ? $result->protocolVersion : self::HANDSHAKE_PROTOCOL_VERSION; Assert::assertNotNull($this->sessionId, 'initialize issued no Mcp-Session-Id'); - $response = $this->post(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + $response = $this->post(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], $headers); Assert::assertSame(202, $response['status'], 'notifications/initialized'); Assert::assertSame('', $response['body'], 'notifications/initialized'); } - /** The `result` of a `tools/list` request. */ - public function listTools(): \stdClass + /** + * The `result` of a `tools/list` request. + * + * @param array> $headers + */ + public function listTools(array $headers = []): \stdClass { - return HttpHarness::result($this->request('tools/list', [], [], true)['message']); + return HttpHarness::result($this->request('tools/list', [], $headers, true)['message']); + } + + /** + * The `result` of a 2026-07-28 `tools/list` request: no session, the envelope and headers of `HttpHarness::modern()`. + * + * @param array{name: string, version: string} $clientInfo + * @param array> $headers + */ + public function listToolsModern(array $clientInfo, array $headers = []): \stdClass + { + return HttpHarness::result($this->request('tools/list', HttpHarness::modernParams([], $clientInfo), [...HttpHarness::modernHeaders('tools/list', null), ...$headers], false)['message']); } /** @@ -74,12 +93,15 @@ public function listTools(): \stdClass * * @param array $arguments * @param array> $headers + * @param array $meta the request's `_meta`, if any * * @return array{message: \stdClass, receivedAt: float} */ - public function callTool(string $name, array $arguments, array $headers = []): array + public function callTool(string $name, array $arguments, array $headers = [], array $meta = []): array { - return $this->request('tools/call', ['name' => $name, 'arguments' => (object) $arguments], $headers, true); + $params = ['name' => $name, 'arguments' => (object) $arguments]; + + return $this->request('tools/call', $meta === [] ? $params : [...$params, '_meta' => $meta], $headers, true); } /** diff --git a/tests/Fixtures/branding/exceptions.php b/tests/Fixtures/branding/exceptions.php new file mode 100644 index 0000000..13da840 --- /dev/null +++ b/tests/Fixtures/branding/exceptions.php @@ -0,0 +1,127 @@ + [ + [ + 'typescript' => 'npm version', + 'php' => 'Packagist version', + 'reason' => 'The PHP package is agentcat/sdk on Packagist, not agentcat on npm.', + ], + [ + 'typescript' => 'npm downloads', + 'php' => 'Packagist downloads', + 'reason' => 'The PHP package is agentcat/sdk on Packagist, not agentcat on npm.', + ], + [ + 'typescript' => 'TypeScript', + 'php' => 'PHP', + 'reason' => 'The SDK is written in PHP; the badge shows the PHP versions that the package requires.', + ], + [ + 'typescript' => 'GitHub issues', + 'php' => 'GitHub issues', + 'reason' => 'The issues of the PHP SDK are in its own repository.', + ], + [ + 'typescript' => 'CI', + 'php' => 'CI', + 'reason' => 'The badge shows the CI workflow of this repository.', + ], + ], + 'note' => [ + [ + 'typescript' => 'AgentCat v2 introduces compatibility with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) and the coinciding [MCP TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk/releases#release-@modelcontextprotocol/server@2.0.0) release that puts it into effect.', + 'php' => 'AgentCat for PHP is compatible with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) as the PHP libraries put it into effect: [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk) over HTTP since 0.8 and over stdio since 0.9.0, and [`laravel/mcp`](https://github.com/laravel/mcp) 1.x.', + 'reason' => 'PHP servers get the 2026-07-28 update from mcp/sdk and laravel/mcp, not from the MCP TypeScript SDK v2; there is no earlier PHP release to call v1.', + ], + ], + 'How it works' => [ + [ + 'typescript' => 'Values are validated: anything AgentCat did not issue is rejected rather than adopted, and the agent is told to re-send the ID it was given.', + 'php' => 'Values are validated: anything that is not a well-formed AgentCat session ID is rejected rather than adopted, and the agent is told to re-send the ID it was given.', + 'reason' => 'AgentCat checks the shape of a session ID, not where it came from: a well-formed ID that another server issued is adopted.', + ], + [ + 'typescript' => 'For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `_mcp_instructions`), so clients that only read structured results still receive them.', + 'php' => 'For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `mcp_session`), so clients that only read structured results still receive them.', + 'reason' => 'The mirror is the `mcp_session` key; `_mcp_instructions` is the name it had before TypeScript SDK 2.1.0.', + ], + ], + 'Identifying users' => [ + [ + 'typescript' => '`identify` (like every AgentCat hook) runs concurrently with your tool handler and never adds latency to tool calls: a slow lookup โ€” or a hook that fails outright โ€” costs analytics data for that event, never your tool\'s response time.', + 'php' => '`identify` (like every AgentCat hook) runs after the client has your tool\'s answer, so it never adds latency to that tool call: a slow lookup โ€” or a hook that fails outright โ€” costs analytics data for that event, never that call\'s response time.', + 'reason' => 'PHP runs every hook after the response (D04), not concurrently with the tool handler.', + ], + [ + 'typescript' => 'The callback receives the raw MCP `request` and the request context the SDK hands to handlers.', + 'php' => 'The callback receives a `ToolCall`: the tool name, the raw arguments and `_meta`, the HTTP headers, the client\'s name and version, and the request and context objects of your MCP library.', + 'reason' => 'A PHP hook receives one ToolCall snapshot (D04, D35), not the request and the context.', + ], + [ + 'typescript' => 'On MCP TypeScript SDK v2 that context is the SDK\'s `ServerContext` โ€” most identity signals live on its `http` property:', + 'php' => 'Most identity signals live in its `headers`, with lower-case names (empty on stdio):', + 'reason' => 'ServerContext belongs to the MCP TypeScript SDK; in PHP the HTTP headers are a ToolCall field.', + ], + [ + 'typescript' => 'For legacy MCP TypeScript SDK v1 servers, the structure looks like:', + 'php' => 'In Laravel, the `identify` key of `config/agentcat.php` names an invokable class that receives the same `ToolCall`; in Symfony, the `identify` key of the `agentcat` configuration names an invokable service.', + 'reason' => 'PHP has no legacy SDK form; the Laravel and Symfony bridges take each hook from their configuration (D52, D55).', + ], + ], + 'Redacting sensitive data' => [ + [ + 'typescript' => 'It may be sync or async, and can be combined with `redactSensitiveInformation`.', + 'php' => 'It can be combined with `redactSensitiveInformation`.', + 'reason' => 'A PHP hook is a plain closure: PHP has no async hooks.', + ], + ], + 'Internal diagnostics' => [ + [ + 'typescript' => 'To help us catch and fix broken installs, the SDK sends AgentCat a small, anonymized signal when setup or runtime errors occur โ€” never your tool calls, your responses, or anything about your users.', + 'php' => 'To help us catch and fix broken installs, the SDK sends AgentCat every line it logs, with its time and level. The lines can contain session IDs, actor IDs, tool names, error messages and the answers of exporters that failed.', + 'reason' => 'Diagnostics carry every log line of the SDK (D07), which can name sessions, actors and tools, so the TypeScript sentence is false for PHP; the section states it plainly, with no special notice (D33).', + ], + [ + 'typescript' => 'Records carry only operational metadata, such as your project ID (or an anonymous install ID when none is set).', + 'php' => 'Records also carry operational metadata, such as your project ID (or, when none is set, an install ID that hashes the host name and the install path), the OS, and the versions of PHP, the SDK and `mcp/sdk`.', + 'reason' => 'The log lines are more than operational metadata (D07), and the PHP install ID is a hash of the host name and the install path (D48).', + ], + [ + 'typescript' => 'Your local `~/agentcat.log` is unchanged.', + 'php' => 'Your local log is unchanged: `~/agentcat.log` (or the PSR-3 logger of your `mcp/sdk` Builder) in plain PHP, `storage/logs/agentcat.log` in Laravel and `%kernel.logs_dir%/agentcat.log` in Symfony.', + 'reason' => 'Plain PHP logs to the Builder\'s PSR-3 logger when it has one (D30), and the Laravel and Symfony bridges write their own files (D60, D61).', + ], + [ + 'typescript' => '- `track(server, projectId, { disableDiagnostics: true })`, or', + 'php' => '- `AgentCat::track($builder, $projectId, new Options(disableDiagnostics: true))`, or', + 'reason' => 'The PHP call syntax.', + ], + [ + 'typescript' => '- the `DISABLE_DIAGNOSTICS` environment variable.', + 'php' => '- the `AGENTCAT_DISABLE_DIAGNOSTICS` (or `DISABLE_DIAGNOSTICS`) environment variable.', + 'reason' => 'PHP reads AGENTCAT_DISABLE_DIAGNOSTICS, and DISABLE_DIAGNOSTICS only as a fallback that turns diagnostics off (D50).', + ], + ], + 'Community Cats ๐Ÿฑ' => [ + [ + 'typescript' => 'Add your cat to our community by submitting a PR with your cat\'s photo in the `docs/cats/` directory.', + 'php' => 'Add your cat to our community by submitting a PR with your cat\'s photo in the `docs/cats/` directory of the [TypeScript SDK](' . $typescriptCats . '), whose cats this README shows too.', + 'reason' => 'The cats here are compared byte for byte with the TypeScript repository\'s (D65), so a new cat goes there first.', + ], + [ + 'typescript' => '_Want to add your cat? Create a PR adding your cat\'s photo to_ `docs/cats/` _and update this section!_', + 'php' => '_Want to add your cat? Create a PR adding your cat\'s photo to_ [`docs/cats/` of the TypeScript SDK](' . $typescriptCats . ') _and update its README!_', + 'reason' => 'The cats here are compared byte for byte with the TypeScript repository\'s (D65), so a new cat goes there first.', + ], + ], +]; diff --git a/tests/Fixtures/branding/images.php b/tests/Fixtures/branding/images.php new file mode 100644 index 0000000..e2a88e2 --- /dev/null +++ b/tests/Fixtures/branding/images.php @@ -0,0 +1,15 @@ + '263651e7a63ecc3fe4bff08d5a6cdfbd41ff8e56', + 'docs/cats/void.jpg' => '11486d1ffb74367d5a135272ee0468bb85cad40b', + 'docs/cats/zelda.jpg' => 'cada64d8147a23e48218b6321493ddf0ef1f2002', + 'docs/static/architecture.png' => 'd89d36cd3104c333c77116855e2fe731c65f84f3', + 'docs/static/og-image.png' => 'a8fe6de105e42abcc9836f42153eb474825cf243', +]; diff --git a/tests/Fixtures/branding/typescript-readme.md b/tests/Fixtures/branding/typescript-readme.md new file mode 100644 index 0000000..d2a6867 --- /dev/null +++ b/tests/Fixtures/branding/typescript-readme.md @@ -0,0 +1,238 @@ + +
+ AgentCat โ€” see exactly how agents experience your product +
+

+ Getting Started + ยท + Features + ยท + Docs + ยท + Website + ยท + Open Source + ยท + Schedule a Demo +

+

+ npm version + npm downloads + License: MIT + TypeScript + GitHub issues + CI +

+ +> [!NOTE] +> AgentCat v2 introduces compatibility with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) and the coinciding [MCP TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk/releases#release-@modelcontextprotocol/server@2.0.0) release that puts it into effect. The stateless transition has a massive impact on analytics, as sessions were a built-in concept tying related tool calls together. AgentCat has now migrated its session tracking under guidance of the MCP core team's recommendations of using [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567). +> +> As a result AgentCat now injects a `session_id` on every MCP tool call to associate them under the same task umbrella. Our evals show much higher tool correlation accuracy at the cost of < 1% additional context pollution. + +> [!IMPORTANT] > **MCPcat is now AgentCat** ๐Ÿฑ โ€” same team, same product, new name. This package was previously published as [`mcpcat`](https://www.npmjs.com/package/mcpcat), which keeps working forever, but new features land here. Upgrading takes a few minutes โ€” see the [migration guide](./MIGRATION.md). + +AgentCat is an analytics platform for MCP server owners ๐Ÿฑ. It captures user intentions and behavior patterns to help you understand what AI users actually need from your tools โ€” eliminating guesswork and accelerating product development all with one-line of code. + +This SDK also provides a free and simple way to forward telemetry like logs, traces, and errors to any Open Telemetry collector or popular tools like Datadog, Sentry, and PostHog. + +```bash +npm install agentcat +``` + +To learn more about us, check us out [here](https://agentcat.com). For detailed guides visit our [documentation](https://docs.agentcat.com). + +## Why use AgentCat? ๐Ÿค” + +AgentCat helps builders of MCP servers, Claude Connectors, and ChatGPT Plugins learn how to improve them by capturing any agents goals and detecting when they get stuck. + +Use AgentCat for: + +- **Agent session replay** ๐ŸŽฌ. Follow alongside your users and their agents to understand why they're using your MCP servers, what functionality you're missing, and what clients they're coming from. +- **Trace debugging** ๐Ÿ”. See where your users are getting stuck, track and find when LLMs get confused by your API, and debug sessions across all deployments of your MCP server. +- **Existing platform support** ๐Ÿ“Š. Get logging and tracing out of the box for your existing observability platforms (OpenTelemetry, Datadog, Sentry) โ€” eliminating the tedious work of implementing telemetry yourself. + +AgentCat architecture โ€” the AgentCat SDK inside your MCP server sends analytics to your observability vendors and session replay to the AgentCat dashboard + +## How it works + +AgentCat works as a lightweight middleware inside your MCP server. When you call `track()`, it seamlessly modifies your registered tool schemas in place, following the MCP core team's [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) guidelines. Concretely, AgentCat adds the following to your server: + +- **`session_id`** โ€” a parameter injected into each tool's input schema. Agents echo it back on every call, letting AgentCat group related tool calls into one task even over stateless transports. Values are validated: anything AgentCat did not issue is rejected rather than adopted, and the agent is told to re-send the ID it was given. +- **`agent_id`** _(off by default)_ โ€” enabled with `enableAgentTracking: true`. Each agent self-generates its own ID, keeping parallel agents working the same task individually attributable. +- **`context`** โ€” a parameter asking the agent to explain, in one sentence, why it is making this call. This is where intent data comes from. +- **`get_more_tools`** โ€” an additional tool, prompt-engineered so that agents readily report the features and tools they looked for but couldn't find โ€” surfacing your missing functionality directly from real usage. + +Injected parameters are stripped from arguments before your tool handler runs, so your code never sees them. For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `_mcp_instructions`), so clients that only read structured results still receive them. + +## Getting Started + +To get started with AgentCat, first create an account and obtain your project ID by signing up at [agentcat.com](https://agentcat.com). For detailed setup instructions visit our [documentation](https://docs.agentcat.com). + +Once you have your project ID, integrate AgentCat into your MCP server: + +```ts +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import * as agentcat from "agentcat"; + +const server = new McpServer( + { name: "echo-mcp", version: "0.1.0" }, + { capabilities: { tools: {} } }, +); + +server.registerTool( + "echo", + { description: "Echo a message", inputSchema: z.object({ msg: z.string() }) }, + async (args) => ({ content: [{ type: "text", text: args.msg }] }), +); + +// Track the server with AgentCat +agentcat.track(server, "proj_0000000"); +``` + +Stateless servers built on [MCP 2026-07-28](https://blog.modelcontextprotocol.io/posts/2026-07-28/) create a fresh server instance per request (`createMcpHandler`) or per connection (`serveStdio`). Call `track()` inside the factory so every instance is tracked: + +```ts +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import * as agentcat from "agentcat"; + +const handler = createMcpHandler(() => { + const server = new McpServer( + { name: "echo-mcp", version: "0.1.0" }, + { capabilities: { tools: {} } }, + ); + // register tools... + return agentcat.track(server, "proj_0000000"); +}); +``` + +Calling `track()` per instance is cheap โ€” the event queue, telemetry exporters, and diagnostics are initialized once and shared across instances. + +### Identifying users + +We strongly encourage identifying every actor. If you can't resolve a real user, return a stable anonymized ID instead โ€” for example, a hash of the auth token or API key โ€” so that all events from the same end user still roll up to one actor in your dashboard rather than scattering into anonymous one-off sessions. + +`identify` (like every AgentCat hook) runs concurrently with your tool handler and never adds latency to tool calls: a slow lookup โ€” or a hook that fails outright โ€” costs analytics data for that event, never your tool's response time. + +The callback receives the raw MCP `request` and the request context the SDK hands to handlers. On MCP TypeScript SDK v2 that context is the SDK's `ServerContext` โ€” most identity signals live on its `http` property: + +```ts +identify: async (request, ctx) => { + const token = ctx?.http?.authInfo?.token; // auth info from the HTTP layer + const orgId = ctx?.http?.req?.headers.get("x-org-id"); // Web Headers โ€” use .get() + const user = await myapi.getUser(token); + return user ? { userId: user.id, userData: { orgId } } : null; +}, +``` + +For legacy MCP TypeScript SDK v1 servers, the structure looks like: + +```ts +agentcat.track(mcpServer, "proj_0000000", { + identify: async (request, extra) => { + const user = await myapi.getUser(request.params.arguments.token); + return { + userId: user.id, + userName: user.name, + userData: { favoriteColor: user.favoriteColor }, + }; + }, +}); +``` + +### Redacting sensitive data + +AgentCat redacts all data sent to its servers and encrypts at rest, but for additional security, it offers a hook to do your own redaction on all text data returned back to our servers. + +```ts +agentcat.track(mcpServer, "proj_0000000", { + redactSensitiveInformation: async (text) => await redact(text), + // or + redactSensitiveInformation: (text) => redact(text), +}); +``` + +For redaction decisions that need more context than a single string โ€” such as which tool was called or what type of event is being published โ€” use the event-level `redactEvent` hook. It receives the full event object and returns a modified event, or `null` to drop the event entirely. It may be sync or async, and can be combined with `redactSensitiveInformation`. + +```ts +agentcat.track(mcpServer, "proj_0000000", { + redactEvent: (event) => { + // Drop events from tools that handle secrets entirely + if (event.resourceName === "get_credentials") { + return null; + } + // Strip response payloads from a specific tool + if (event.resourceName === "export_report") { + return { ...event, response: undefined }; + } + return event; + }, +}); +``` + +### Vendor Support + +AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously: + +```typescript +agentcat.track(server, "proj_0000", { + // Project ID can optionally be "null" if you just want to forward telemetry + exporters: { + otlp: { + type: "otlp", + endpoint: "http://localhost:4318/v1/traces", + }, + datadog: { + type: "datadog", + apiKey: process.env.DD_API_KEY, + site: "datadoghq.com", + service: "my-mcp-server", + }, + sentry: { + type: "sentry", + dsn: process.env.SENTRY_DSN, + environment: "production", + }, + posthog: { + type: "posthog", + apiKey: process.env.POSTHOG_API_KEY, + host: "https://us.i.posthog.com", // Optional: defaults to US region + }, + }, +}); +``` + +Learn more about our free and open source [telemetry integrations](https://docs.agentcat.com/telemetry/integrations). + +### Internal diagnostics + +To help us catch and fix broken installs, the SDK sends AgentCat a small, anonymized +signal when setup or runtime errors occur โ€” never your tool calls, your responses, +or anything about your users. Records carry only operational metadata, such as your +project ID (or an anonymous install ID when none is set). Your local `~/agentcat.log` +is unchanged. + +Diagnostics are on by default and can be turned off completely with either: + +- `track(server, projectId, { disableDiagnostics: true })`, or +- the `DISABLE_DIAGNOSTICS` environment variable. + +## Free for open source + +AgentCat is free for qualified open source projects. We believe in supporting the ecosystem that makes MCP possible. If you maintain an open source MCP server, you can access our full analytics platform at no cost. + +**How to apply**: Email [hi@agentcat.com](mailto:hi@agentcat.com) with your repository link + +_Already using AgentCat? We'll upgrade your account immediately._ + +## Community Cats ๐Ÿฑ + +Meet the cats behind AgentCat! Add your cat to our community by submitting a PR with your cat's photo in the `docs/cats/` directory. + +
+ bibi + zelda + void +
+ +_Want to add your cat? Create a PR adding your cat's photo to_ `docs/cats/` _and update this section!_ diff --git a/tests/Fixtures/golden/SOURCE.md b/tests/Fixtures/golden/SOURCE.md index f2246c6..d8fdb91 100644 --- a/tests/Fixtures/golden/SOURCE.md +++ b/tests/Fixtures/golden/SOURCE.md @@ -1,4 +1,4 @@ -Generated by running agentcat-typescript-sdk@9b56e9e. Read-only contract: never edit; a mismatch is a PHP bug unless the audit marks a deliberate fix. +Generated by running agentcat-typescript-sdk@9b56e9e. Read-only contract: never edit; a mismatch is a PHP bug unless a decision (D-number) makes it a deliberate fix. 386879afa73e4316a549b1f5c8797415f4f8d6ded946cb9038525d68f8dc622d handles/derive-vectors.json bdefc7c958afe4f599a3e8f6c681b5f54ef025fded32f466795ef00141b866e2 handles/golden.txt diff --git a/tests/Fixtures/golden/wire/SOURCE.md b/tests/Fixtures/golden/wire/SOURCE.md index fa667bf..1d4292c 100644 --- a/tests/Fixtures/golden/wire/SOURCE.md +++ b/tests/Fixtures/golden/wire/SOURCE.md @@ -1,4 +1,4 @@ -Copied verbatim from docs/audit/07-api-client-and-backend.md ยง6.7 (G1-G4). Read-only; compared as decoded JSON (key order ignored). +The four golden wire events G1-G4, in the shape that the TypeScript SDK's eventQueue.ts (agentcat-typescript-sdk@9b56e9e) posts to the AgentCat API. Read-only; compared as decoded JSON (key order ignored). 2ea8de0b41b037fbf6af5c16e9c8603c73b9b99b6cf512a5df3e7e26bd20a5f3 wire/g1-tool-call-anonymous.json c87683c2eaa8016cc36f80176e48534adc7a2ea3718d01762cdf7f535b72f84d wire/g2-identified-no-arguments.json diff --git a/tests/Fixtures/hardened-host.php b/tests/Fixtures/hardened-host.php new file mode 100644 index 0000000..9a13383 --- /dev/null +++ b/tests/Fixtures/hardened-host.php @@ -0,0 +1,21 @@ + 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']]; +$builder = Server::builder() + ->setServerInfo('hardened-host', '1.0.0') + ->addTool(static fn(string $msg): string => 'echo: ' . $msg, 'echo', description: 'Echo a message', inputSchema: $schema); + +AgentCat::track($builder, 'proj_' . str_repeat('h', 27)); + +exit($builder->build()->run(new StdioTransport())); diff --git a/tests/Fixtures/hardened-shutdown.php b/tests/Fixtures/hardened-shutdown.php new file mode 100644 index 0000000..86f44f2 --- /dev/null +++ b/tests/Fixtures/hardened-shutdown.php @@ -0,0 +1,16 @@ +add(new QueuedEvent(new Event(projectId: 'proj_' . str_repeat('h', 27), eventType: 'mcp:tools/call', resourceName: 'hardened-shutdown'))); diff --git a/tests/Fixtures/package-probe/composer.json.tpl b/tests/Fixtures/package-probe/composer.json.tpl new file mode 100644 index 0000000..acf8ee9 --- /dev/null +++ b/tests/Fixtures/package-probe/composer.json.tpl @@ -0,0 +1,25 @@ +{ + "name": "agentcat/package-probe", + "description": "A fresh project that installs agentcat/sdk from its dist, as a customer does.", + "type": "project", + "license": "proprietary", + "repositories": [ + { + "type": "path", + "url": "__DIST__", + "options": { + "symlink": false + } + } + ], + "require": { + "agentcat/sdk": "*@dev", + "mcp/sdk": "__MCP_SDK__" + }, + "minimum-stability": "__STABILITY__", + "prefer-stable": true, + "config": { + "allow-plugins": {}, + "sort-packages": true + } +} diff --git a/tests/Fixtures/package-probe/probe.php b/tests/Fixtures/package-probe/probe.php new file mode 100644 index 0000000..8d58bd9 --- /dev/null +++ b/tests/Fixtures/package-probe/probe.php @@ -0,0 +1,158 @@ +`. + * + * Environment: AGENTCAT_PROBE_COLLECTOR_LOG, the file where the collector appends one JSON line per request. + */ + +use Composer\InstalledVersions; + +require __DIR__ . '/vendor/autoload.php'; + +const PROBE_TIMEOUT_SECONDS = 20.0; + +function fail(string $message): never +{ + fwrite(STDERR, "probe failed: {$message}\n"); + + exit(1); +} + +$logFile = getenv('AGENTCAT_PROBE_COLLECTOR_LOG'); +if (!is_string($logFile) || $logFile === '') { + fail('AGENTCAT_PROBE_COLLECTOR_LOG is not set'); +} + +$serverScript = __DIR__ . '/probe-server.php'; +file_put_contents($serverScript, <<<'PHP' + setServerInfo('package-probe-server', '1.0.0') + ->addTool( + static fn(string $msg): string => 'echo: ' . $msg, + 'echo', + description: 'Echo a message', + inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']], + ); + + AgentCat::track($builder, 'proj_' . str_repeat('p', 27)); + + exit($builder->build()->run(new StdioTransport())); + PHP); + +$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open([PHP_BINARY, $serverScript], $descriptors, $pipes, __DIR__); +if (!is_resource($process)) { + fail('the server did not start'); +} +[$stdin, $stdout, $stderrPipe] = [$pipes[0] ?? null, $pipes[1] ?? null, $pipes[2] ?? null]; +if (!is_resource($stdin) || !is_resource($stdout) || !is_resource($stderrPipe)) { + fail('the server pipes did not open'); +} + +$deadline = microtime(true) + PROBE_TIMEOUT_SECONDS; +$buffer = ''; + +/** Reads stdout until the answer to request $id arrives. */ +$receive = static function (int $id) use ($stdout, &$buffer, $deadline): stdClass { + while (true) { + while (($newline = strpos($buffer, "\n")) !== false) { + $line = substr($buffer, 0, $newline); + $buffer = substr($buffer, $newline + 1); + $message = json_decode($line); + if ($message instanceof stdClass && ($message->id ?? null) === $id) { + return $message; + } + } + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + fail("no answer to request {$id} before the deadline"); + } + $read = [$stdout]; + $write = null; + $except = null; + if (stream_select($read, $write, $except, 0, (int) min(500_000, $remaining * 1_000_000)) === 1) { + $chunk = fread($stdout, 65536); + if ($chunk === '' || $chunk === false) { + fail('the server closed stdout before it answered request ' . $id); + } + $buffer .= $chunk; + } + } +}; +/** @param array $message */ +$send = static function (array $message) use ($stdin): void { + fwrite($stdin, json_encode($message, JSON_THROW_ON_ERROR) . "\n"); +}; + +$send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'package-probe-client', 'version' => '1.0.0'], +]]); +$initialize = $receive(1); +if (!isset($initialize->result)) { + fail('initialize did not return a result: ' . json_encode($initialize)); +} +$send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); +$send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'probe']]]); +$call = $receive(2); +// The first call of a session also carries AgentCat's session_id block, so look for the tool's own block. +$result = $call->result ?? null; +$blocks = $result instanceof stdClass && is_array($result->content ?? null) ? $result->content : []; +$texts = []; +foreach ($blocks as $block) { + if ($block instanceof stdClass && is_string($block->text ?? null)) { + $texts[] = $block->text; + } +} +if (!in_array('echo: probe', $texts, true)) { + fail('tools/call did not answer "echo: probe": ' . json_encode($call)); +} + +// stdin EOF ends the server; AgentCat delivers the queued event at shutdown. +fclose($stdin); +while (proc_get_status($process)['running']) { + if (microtime(true) > $deadline) { + proc_terminate($process); + fail('the server did not exit after stdin closed'); + } + usleep(20_000); +} +$stderr = stream_get_contents($stderrPipe); +proc_close($process); + +$events = []; +$lines = is_file($logFile) ? file($logFile, FILE_IGNORE_NEW_LINES) : false; +foreach ($lines === false ? [] : $lines as $line) { + $request = json_decode($line, true); + $rawBody = is_array($request) ? ($request['body'] ?? null) : null; + $body = is_string($rawBody) ? json_decode($rawBody, true) : null; + if (is_array($body) && isset($body['resource_name'])) { + $events[] = $body; + } +} +if (count($events) !== 1) { + fail('the collector received ' . count($events) . " events, expected 1 (server stderr: {$stderr})"); +} +if ($events[0]['resource_name'] !== 'echo') { + fail('the event is not for the echo tool: ' . json_encode($events[0])); +} + +$version = InstalledVersions::getPrettyVersion('mcp/sdk') ?? 'unknown'; +echo "probe ok {$version}\n"; diff --git a/tests/Fixtures/schema/SOURCE.md b/tests/Fixtures/schema/SOURCE.md index d61b7dc..78059ab 100644 --- a/tests/Fixtures/schema/SOURCE.md +++ b/tests/Fixtures/schema/SOURCE.md @@ -1,7 +1,7 @@ # publish-event-request.json - Source: https://api.agentcat.com/v1/schema/public/?format=json (OpenAPI 3.0.3) -- Fetched: 2026-10-17 +- Fetched: 2026-10-20 - JSON pointer: `/paths/~1v1~1public~1events~1/post/requestBody/content/application~1json/schema` - Command: @@ -10,8 +10,8 @@ php -r '$d = json_decode(file_get_contents("https://api.agentcat.com/v1/schema/p sha256sum tests/Fixtures/schema/publish-event-request.json ``` -- Digest (6353 bytes): +- Digest (6388 bytes): ``` -9dac28d9ce0fed0f108ec28d2bd1c2b2e8b894a606bd6ff79a235d1876e5ab56 tests/Fixtures/schema/publish-event-request.json +ef2e2f996f1fbe55d81225869a129c754faa00c9d0cd3a98794305ceb4ec054d tests/Fixtures/schema/publish-event-request.json ``` diff --git a/tests/Fixtures/schema/publish-event-request.json b/tests/Fixtures/schema/publish-event-request.json index c4fe2c4..d1c6b8c 100644 --- a/tests/Fixtures/schema/publish-event-request.json +++ b/tests/Fixtures/schema/publish-event-request.json @@ -46,7 +46,8 @@ "mcp:tasks/result", "mcp:tasks/list", "mcp:tasks/cancel", - "agentcat:identify" + "agentcat:identify", + "agentcat:custom" ] }, "is_error": { diff --git a/tests/Golden/DeriveVectorsTest.php b/tests/Golden/DeriveVectorsTest.php index 46c9afe..14f3909 100644 --- a/tests/Golden/DeriveVectorsTest.php +++ b/tests/Golden/DeriveVectorsTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; #[Group('ac-04-6')] +#[Group('ac-03-22')] final class DeriveVectorsTest extends TestCase { /** @@ -28,8 +29,8 @@ public static function vectors(): iterable yield 'json #' . $index => [$row->id, $row->projectId, $row->sessionId]; } - yield 'audit 3.3 customer-correlation-7' => ['customer-correlation-7', 'proj_test', 'ses_2cVoolHakk4pa3LZQSgvJRkTwcK']; - yield 'audit 3.3 cafe' => ['cafรฉ โ˜•', 'proj_x', 'ses_2bVQK3nSTtccWONoNR4Z7z2HC1v']; + yield 'customer-correlation-7' => ['customer-correlation-7', 'proj_test', 'ses_2cVoolHakk4pa3LZQSgvJRkTwcK']; + yield 'cafe with an emoji' => ['cafรฉ โ˜•', 'proj_x', 'ses_2bVQK3nSTtccWONoNR4Z7z2HC1v']; } #[DataProvider('vectors')] diff --git a/tests/Integration/ClientDiagnosticsTest.php b/tests/Integration/ClientDiagnosticsTest.php index aa43115..cffe398 100644 --- a/tests/Integration/ClientDiagnosticsTest.php +++ b/tests/Integration/ClientDiagnosticsTest.php @@ -40,7 +40,7 @@ final class ClientDiagnosticsTest extends TestCase private Environment $environment; - /** `AGENTCAT_DIAGNOSTICS_ENDPOINT`: a closed local port (ruling P8). */ + /** `AGENTCAT_DIAGNOSTICS_ENDPOINT`: a closed local port, so a test fails closed. */ private string $endpoint = ''; private MemoryLogger $logger; @@ -129,7 +129,7 @@ public function testALaterTrackCannotDisableDiagnosticsAndWarnsOnce(): void $this->assertCount(1, array_keys($this->logger->messages(), self::IGNORED, true)); } - /** D44 and spec conflict 9: the enabled gate runs before the diagnostics setup. */ + /** D44: the enabled gate runs before the diagnostics setup, so a disabled client sends nothing. */ public function testADisabledClientNeverSetsDiagnosticsUp(): void { putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); @@ -168,7 +168,7 @@ public function testACopyWithAnHttpContextKeepsTheTelemetrySetting(): void $this->assertFalse(Diagnostics::enabled()); } - /** D07 over 06 AC-61 (spec conflict 2): the per-event line carries the actor ID; no other line carries user data. */ + /** D07 over ac-06-61: the per-event line carries the actor ID; no other line carries user data. */ #[Group('ac-06-61')] #[MaximumDuration(5000)] public function testOnlyTheSentEventLineCarriesTheActorId(): void diff --git a/tests/Integration/ClientExportersTest.php b/tests/Integration/ClientExportersTest.php index 2f706e0..d8d437a 100644 --- a/tests/Integration/ClientExportersTest.php +++ b/tests/Integration/ClientExportersTest.php @@ -114,7 +114,7 @@ public function testOnlyTheFirstExportersReceiveEvents(): void $this->assertContains('AgentCat setup complete | project ' . self::PROJECT . ' | tracing=true context=true reportMissing=true exporters=1', $this->logger->messages()); } - /** Review Focus 3: a new Builder per request in a long-running worker, the same exporters every time. */ + /** A new Builder per request in a long-running worker, the same exporters every time. */ public function testPerRequestBuildersWithTheSameExportersShareOneManager(): void { for ($request = 0; $request < 3; ++$request) { diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index f209c88..bf770c9 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -199,7 +199,7 @@ public function testTheCompleteLineShowsTheOptions(): void public static function unsupportedServers(): iterable { yield 'any object' => [static fn(): object => new \stdClass(), '(none)']; - yield 'a built mcp/sdk server' => [static fn(): object => Server::builder()->build(), 'mcpSdkServer']; + yield 'a built mcp/sdk server whose Builder is not tracked' => [static fn(): object => Server::builder()->build(), 'mcpSdkServer']; } /** @param \Closure(): object $make */ @@ -242,6 +242,24 @@ public function testABuiltBuilderIsLeftAlone(): void $this->assertSame(['echo'], $this->names($this->harness($builder)->listTools()), 'no AgentCat handler was added'); } + /** When `Builder::$parts` cannot be read, track() returns the Builder untracked, and the lines name the member. */ + public function testABuilderWhosePartsCannotBeReadIsReturnedUntracked(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + BuilderMembers::uninitialize('parts')($builder); + + $this->assertSame($builder, $this->client->track($builder, self::PROJECT)); + + $reason = sprintf('mcp/sdk %s internals changed (Error: Typed property Mcp\Server\Builder::$parts must not be accessed before initialization).', McpSdkAdapter::version()); + $this->assertSame([ + 'AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, + 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: ' . $reason . ' ' . McpSdkAdapter::SUPPORT_MATRIX, + ], $this->logger->messages()); + $this->assertNull(TrackedServers::get($builder), 'nothing is tracked'); + $this->assertNull(BuilderAccess::container($builder), 'nothing is installed'); + } + #[Group('ac-01-3')] public function testAnInternalFailureReturnsTheServerAndLogsIt(): void { @@ -472,6 +490,36 @@ public function testTrackingATrackedBuilderAfterItWasBuiltChangesNothing(): void $this->assertSame(['echo', GetMoreTools::NAME], $this->names($harness->listTools())); } + /** The Server that a tracked Builder built is tracked through it: track() on the Server changes nothing. */ + #[Group('ac-01-8')] + public function testTrackingTheServerOfATrackedBuilderChangesNothing(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT, new Options(identify: static fn(ToolCall $call): Identity => new Identity('first'))); + $server = $builder->build(); + $this->logger->records = []; + + $this->assertSame($server, $this->client->track($server, self::PROJECT, new Options( + enableReportMissing: false, + identify: static fn(ToolCall $call): Identity => new Identity('second'), + ))); + + $this->assertSame([ + 'AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/server | signals mcpSdkServer', + self::ALREADY_TRACKED, + ], $this->logger->messages()); + $this->assertNotContains(LogLevel::WARNING, array_column($this->logger->records, 'level')); + $this->assertFalse(TrackedServers::incomplete(), 'no failure is recorded'); + $this->assertSame(TrackedServers::get($builder), TrackedServers::get($server), 'the Builder keeps the one tracking'); + $harness = $this->initialized($server); + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($harness->listTools())); + $texts = $this->texts(HttpHarness::result($harness->callTool('echo', ['msg' => 'hi']))); + $this->assertCount(1, array_filter($texts, static fn(mixed $text): bool => is_string($text) && str_starts_with($text, Constants::MINT_BACK_HEADER_ISSUED))); + $this->assertSame('echo: hi', $texts[1] ?? null); + $this->assertSame('first', $this->event()->identifyActorGivenId); + } + #[Group('ac-01-9')] #[Group('ac-02-5')] public function testSeveralBuildersKeepTheirOwnStateAndShareOneQueue(): void diff --git a/tests/Integration/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php index d1e9e77..9b91412 100644 --- a/tests/Integration/Conformance/AdapterConformance.php +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\TestCase; /** - * The adapter conformance suite (README ยง8 M10): the same calls and the same expected events on every MCP library - * AgentCat supports. A library leg implements the factories; the library's own tests cover what only it can do. + * The adapter conformance suite: the same calls and the same expected events on every MCP library AgentCat supports. A + * library leg implements the factories; the library's own tests cover what only it can do. * * @phpstan-require-extends TestCase */ @@ -44,6 +44,12 @@ abstract protected function reusedObjectId(): ConformanceHarness; */ abstract protected function fullPage(): ConformanceHarness; + /** + * A server whose listing has two pages of 15: `echo` and `t01` to `t14` on page 1, then `own` (its own required + * `session_id`) alone on page 2. + */ + abstract protected function paged(): ConformanceHarness; + /** * The `parameters.extra.requestInfo` that the leg records for one `tools/call` of `echo` (a supplied session) that * a web server hands to PHP with these server variables. The mcp/sdk adapter reads the SAPI's variables, so its @@ -265,7 +271,7 @@ public function testConformanceRequestInfo(array $server): void $this->assertSame($server['HTTP_AUTHORIZATION'] ?? null, JsonAssert::at($info, 'headers', 'authorization')); } - /** Plan E1 header conflict 3: a reused object ID belongs to a dead server, so the new server's state replaces it. */ + /** A reused object ID belongs to a dead server, so the new server's state replaces it. */ #[Group('ac-02-5')] public function testConformanceReusedObjectId(): void { @@ -282,7 +288,7 @@ public function testConformanceReusedObjectId(): void $this->assertNotContains(self::ALREADY_TRACKED, $harness->lines()); } - /** Plan E1 header conflict 1: a full page has no `nextCursor`, and `get_more_tools` comes after its 15 tools. */ + /** A full page has no `nextCursor`, and `get_more_tools` comes after its 15 tools. */ #[Group('ac-02-8')] public function testConformanceOneFullPage(): void { @@ -295,6 +301,26 @@ public function testConformanceOneFullPage(): void $this->assertSame(array_fill(0, 14, ['session_id', 'context']), array_map(fn(mixed $tool): array => $this->memberNames(JsonAssert::at($tool, 'inputSchema', 'properties')), array_slice($tools, 1, 14))); } + /** + * D66 (B10 fixed): a client lists page 1 only, then calls `own` from page 2. A long-running leg now holds page 1's + * registries, which do not name `own`, so the call rebuilds to page 2: `own` keeps its own `session_id`. + */ + #[Group('ac-02-8')] + #[Group('ac-02-19')] + public function testConformanceSecondPage(): void + { + $harness = $this->paged(); + $first = $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'theirs']); + + $names = array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $this->listedTools($first)); + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $names); + $this->assertObjectHasProperty('nextCursor', $first); + $this->assertSame([['session_id' => 'theirs']], $harness->received('own')); + $this->assertSame('foreign', $this->onlyEvent($harness)->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + /** The library's validation of the tool's own arguments fails: the tool does not run, and the event records it. */ #[Group('ac-05-E6')] public function testConformanceValidationError(): void @@ -313,7 +339,6 @@ public function testConformanceValidationError(): void } /** Each call of a long-lived server sees and records only its own arguments. */ - #[Group('ac-02-51')] public function testConformanceConsecutiveCalls(): void { $harness = $this->conformance(); diff --git a/tests/Integration/Conformance/LaravelMcpConformanceTest.php b/tests/Integration/Conformance/LaravelMcpConformanceTest.php index 226f508..13dc0c4 100644 --- a/tests/Integration/Conformance/LaravelMcpConformanceTest.php +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -47,8 +47,7 @@ public function testWithAppDebugTheWireIsTheLibrarysAndTheEventKeepsTheException /** * Library difference: a laravel/mcp tool validates its own arguments (`$request->validate()`), and the library - * answers the `ValidationException` with an error result; the event keeps that exception (plan E1 header - * conflict 5). + * answers the `ValidationException` with an error result; the event keeps that exception. */ #[Group('ac-05-E6')] public function testAValidationErrorIsTheToolsExceptionAndAnErrorResult(): void @@ -68,7 +67,6 @@ public function testAValidationErrorIsTheToolsExceptionAndAnErrorResult(): void } /** laravel/mcp binds each call's `mcp.request` in the container: no call sees the arguments of the one before. */ - #[Group('ac-02-51')] #[Group('ac-05-E5')] public function testConsecutiveCallsSeeOnlyTheirOwnArgumentsThroughMcpRequest(): void { @@ -88,7 +86,7 @@ public function testConsecutiveCallsSeeOnlyTheirOwnArgumentsThroughMcpRequest(): /** * Library difference: laravel/mcp answers an uncaught tool exception with an error result, which the event keeps as - * its response next to the exception (plan E1 header conflict 5); mcp/sdk answers with a JSON-RPC error. + * its response next to the exception; mcp/sdk answers with a JSON-RPC error. */ #[Group('ac-02-24')] public function testAnUncaughtToolExceptionIsAnErrorResultThatTheEventKeeps(): void @@ -125,8 +123,8 @@ public function testAnUnknownToolIsTheLibrarysJsonRpcExceptionWhichTheEventKeeps } /** - * Library difference: laravel/mcp 1.x has no sessions, so `extra` has no transport session (plan E1 header conflict - * 8); over HTTP the event has the server's own request (header conflict 9), whatever the SAPI. + * Library difference: laravel/mcp 1.x has no sessions, so `extra` has no transport session; over HTTP the event + * has the server's own request, whatever the SAPI. */ #[Group('ac-02-43')] #[Group('ac-02-46')] @@ -165,6 +163,12 @@ protected function fullPage(): ConformanceHarness return LaravelConformanceHarness::fullPage(); } + #[\Override] + protected function paged(): ConformanceHarness + { + return LaravelConformanceHarness::paged(); + } + /** One HTTP request to a tracked server, built from the server variables like `Request::capture()` does. */ #[\Override] protected function requestInfoOf(array $server): \stdClass diff --git a/tests/Integration/Conformance/McpSdkConformanceTest.php b/tests/Integration/Conformance/McpSdkConformanceTest.php index d40b6ef..d9d7b00 100644 --- a/tests/Integration/Conformance/McpSdkConformanceTest.php +++ b/tests/Integration/Conformance/McpSdkConformanceTest.php @@ -100,8 +100,8 @@ public function testAnUnknownToolIsTheLibrarysErrorAndAnEventWithItsMessageOnly( /** * Library difference: a handshake-era call has mcp/sdk's transport session in `extra`, never as the event's session - * (ac-02-46); laravel/mcp 1.x has no sessions (plan E1 header conflict 8). The adapter reads the HTTP request from - * the SAPI (`$_SERVER`, plan C1 conflict 8), which a request in this process does not set: no `requestInfo`. + * (ac-02-46); laravel/mcp 1.x has no sessions. The adapter reads the HTTP request from the SAPI (`$_SERVER`), which + * a request in this process does not set: no `requestInfo`. */ #[Group('ac-02-46')] public function testExtraHasTheTransportSessionOfTheHandshakeAndNoRequestInfoInProcess(): void @@ -159,6 +159,12 @@ protected function fullPage(): ConformanceHarness return McpSdkConformanceHarness::fullPage(); } + #[\Override] + protected function paged(): ConformanceHarness + { + return McpSdkConformanceHarness::paged(); + } + /** The adapter's reader with what `HttpContext::capture()` gives under PHP-FPM for these server variables. */ #[\Override] protected function requestInfoOf(array $server): \stdClass diff --git a/tests/Integration/Conformance/SymfonyConformanceTest.php b/tests/Integration/Conformance/SymfonyConformanceTest.php index 83fd6ae..18bc17f 100644 --- a/tests/Integration/Conformance/SymfonyConformanceTest.php +++ b/tests/Integration/Conformance/SymfonyConformanceTest.php @@ -11,6 +11,7 @@ use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\Symfony\FullPage\FullPageTools; +use AgentCat\Tests\Support\Symfony\FullPage\SecondPageOwnTool; use AgentCat\Tests\Support\Symfony\SymfonyConformanceHarness; use AgentCat\Tests\Support\Symfony\SymfonyTestCase; use AgentCat\Tests\Support\Symfony\TestKernel; @@ -45,7 +46,7 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); if (!class_exists(McpBundle::class)) { - return; // the mcp/sdk main leg: every test skips + return; // the mcp/sdk 0.9 leg: every test skips } self::$apiUrl = MockCollector::closedPortUrl(); WarmUp::run(static function (): void { @@ -57,6 +58,7 @@ public static function setUpBeforeClass(): void $compile->conformanceKernel(new Options()); $compile->conformanceKernel(new Options(enableTracing: false)); $compile->fullPageKernel(); + $compile->pagedKernel(); $compiled = true; } finally { $compile->tearDown(); @@ -77,8 +79,8 @@ public static function tearDownAfterClass(): void /** * Library difference: the bundle serves mcp/sdk, so a handshake-era call has mcp/sdk's transport session (the * `Mcp-Session-Id`) in `extra`, never as the event's session (ac-02-46), as on the mcp/sdk leg. Unlike plain - * mcp/sdk, whose adapter reads the SAPI, the bundle gives the adapter the `RequestStack` (plan E2 header conflict - * 5): a request in this process has its `requestInfo`. + * mcp/sdk, whose adapter reads the SAPI, the bundle gives the adapter the `RequestStack`: a request in this process + * has its `requestInfo`. */ #[Group('ac-02-43')] #[Group('ac-02-46')] @@ -123,6 +125,12 @@ protected function fullPage(): ConformanceHarness return SymfonyConformanceHarness::of($this->fullPageKernel(...)); } + #[\Override] + protected function paged(): ConformanceHarness + { + return SymfonyConformanceHarness::of($this->pagedKernel(...)); + } + /** The adapter's reader with the bundle's request context: a `RequestStack` whose main request has these values. */ #[\Override] protected function requestInfoOf(array $server): \stdClass @@ -150,6 +158,12 @@ private function fullPageKernel(): TestKernel return $this->compiled($this->config(new Options()), TestKernel::FULL_PAGE, [FullPageTools::class => FullPageTools::class]); } + /** A kernel of `TestKernel::PAGED`. */ + private function pagedKernel(): TestKernel + { + return $this->compiled($this->config(new Options()), TestKernel::PAGED, [FullPageTools::class => FullPageTools::class, SecondPageOwnTool::class => SecondPageOwnTool::class]); + } + /** * A kernel whose container this class compiled for its configuration. * diff --git a/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index bbfeb5f..81271f7 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -154,15 +154,21 @@ public function testShutdownStopsWaitingAtItsBudget(): void } /** - * Review Focus 1: an exporter endpoint that hangs, on a server whose AgentCat API works. `pump()` returns at once, - * the event is delivered, and `shutdown()` waits for the export only until its budget, then abandons it. + * An exporter endpoint that hangs, on a server whose AgentCat API works. `pump()` returns at once, the event is + * delivered, and `shutdown()` waits for the export only until its budget, then abandons it. The export goes to a + * local server that never answers, not to the mock collector: a collector worker that took both connections would + * serve the event after the slow export. */ #[Group('ac-06-63')] #[MaximumDuration(5000)] public function testASlowExportEndpointNeverHoldsTheEventsBack(): void { $events = $this->collector()->scenario(); - $slow = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 3000]); + $silent = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); // the kernel accepts; nothing reads + $this->assertNotFalse($silent, 'no local server: ' . $errorMessage); + $address = stream_socket_get_name($silent, false); + $this->assertIsString($address); + $slow = 'http://' . $address; $channel = new CurlHttpChannel(); $exporter = new readonly class ($channel, $slow) implements ExporterSink { public function __construct(private HttpChannel $channel, private string $url) {} @@ -189,10 +195,11 @@ public function export(Event $event): void $this->assertLessThan(1.5, $elapsed, 'the shutdown budget (0.3 s here) bounds the slow export'); $this->assertCount(1, $this->collector()->requests($events), 'the event is delivered'); $this->assertSame(1, $channel->pending(), 'the slow export is abandoned'); + fclose($silent); } /** - * I2: a SIGTERM drain that runs inside a callback of the side channel (the seam stands in for the signal + * A SIGTERM drain that runs inside a callback of the side channel (the seam stands in for the signal * handler). It delivers the event on the transport's own multi handle and returns once the event is delivered: the * side channel cannot progress inside its own callback, so the drain does not wait for it. The side transfer then * completes in the next passes. diff --git a/tests/Integration/Delivery/MissingCurlTest.php b/tests/Integration/Delivery/MissingCurlTest.php index a647522..ca761a2 100644 --- a/tests/Integration/Delivery/MissingCurlTest.php +++ b/tests/Integration/Delivery/MissingCurlTest.php @@ -14,7 +14,7 @@ /** D32: a PHP without ext-curl's functions keeps the facade silent and harmless; events are dropped with one line. */ final class MissingCurlTest extends TestCase { - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled ('; + private const string NO_CURL = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; /** HOME of the script (its agentcat.log) and its result file. */ private string $dir = ''; diff --git a/tests/Integration/Delivery/RepeatedFailureTest.php b/tests/Integration/Delivery/RepeatedFailureTest.php new file mode 100644 index 0000000..95b667b --- /dev/null +++ b/tests/Integration/Delivery/RepeatedFailureTest.php @@ -0,0 +1,190 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + RepeatedLines::reset(); + } + + #[\Override] + protected function tearDown(): void + { + RepeatedLines::reset(); + Log::reset(); + } + + #[MaximumDuration(2000)] + public function testATransportThatThrowsOnEveryPassLogsTwoLinesPerDrain(): void + { + $transport = new class implements Transport { + public int $pumps = 0; + + #[\Override] + public function start(Delivery $delivery): void {} + + #[\Override] + public function pump(): void + { + ++$this->pumps; + + throw new \RuntimeException('boom'); + } + + #[\Override] + public function waitForActivity(float $deadline): bool + { + return true; + } + + #[\Override] + public function drain(float $deadline): bool + { + return false; + } + + #[\Override] + public function send(Delivery $delivery, float $deadline = INF): bool + { + return false; + } + + #[\Override] + public function inFlight(): int + { + return 1; + } + + #[\Override] + public function now(): float + { + return hrtime(true) / 1e9; + } + }; + $queue = new EventQueue(transport: $transport); + $queue->add($this->queued()); + + $this->assertFalse($queue->flush(self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, $transport->pumps, 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: boom', + sprintf('%sRuntimeException: boom (%d more times in this delivery)', self::GAVE_UP, $transport->pumps - 1), + ], $this->logger->messages()); + } + + /** The real transport: a curl call that throws on every pass (here the test seam of curl_multi_exec()). */ + #[MaximumDuration(2000)] + public function testCurlTransportWorkThatThrowsOnEveryPassLogsTwoLinesPerDrain(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + + throw new \RuntimeException('exec boom'); + } + + return $code; + }); + $queue = new EventQueue(transport: $transport); + $queue->configure(MockCollector::closedPortUrl()); + $queue->add($this->queued()); + + $this->assertFalse($queue->flush(self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 1), + ], $this->logger->messages()); + } + + /** The transport's own drain (`send()`) collapses its repeated failure too. */ + #[MaximumDuration(2000)] + public function testTheTransportsOwnDrainLogsTwoLines(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + + throw new \RuntimeException('exec boom'); + } + + return $code; + }); + $event = $this->queued()->event; + $delivery = Delivery::forEvent($event, ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), WireEncoder::encode($event)); + + $this->assertFalse($transport->send($delivery, $transport->now() + self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 1), + ], $this->logger->messages()); + } + + /** `drain()` on its own, after a `start()` whose first step went well, is one delivery as well. */ + #[MaximumDuration(2000)] + public function testTheTransportsDrainAloneLogsTwoLines(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + if (count($passes) > 1) { + throw new \RuntimeException('exec boom'); + } + } + + return $code; + }); + $event = $this->queued()->event; + $transport->start(Delivery::forEvent($event, ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), WireEncoder::encode($event))); + $this->assertSame([], $this->logger->messages(), 'the first step went well'); + + $this->assertFalse($transport->drain($transport->now() + self::FLUSH_SECONDS)); + + $this->assertGreaterThan(3, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 2), + ], $this->logger->messages()); + } + + private function queued(): QueuedEvent + { + return new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'repeated')); + } +} diff --git a/tests/Integration/Delivery/SignalAfterExecTest.php b/tests/Integration/Delivery/SignalAfterExecTest.php index 6711314..13b1cde 100644 --- a/tests/Integration/Delivery/SignalAfterExecTest.php +++ b/tests/Integration/Delivery/SignalAfterExecTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Process\Process; /** - * N1: PHP runs a SIGTERM that arrived during curl_multi_exec() right after that call returns (`signal-after-exec.php`). + * PHP runs a SIGTERM that arrived during curl_multi_exec() right after that call returns (`signal-after-exec.php`). * The drain runs there, with the default disposition after it, and must still deliver. */ #[RequiresPhpExtension('pcntl')] @@ -101,7 +101,7 @@ public function testADrainRightAfterCurlMultiExecDeliversTheEventAndItsExport(st } /** - * I1: an application's own handler that throws right after curl_multi_exec() returns (five times per process). On + * An application's own handler that throws right after curl_multi_exec() returns (five times per process). On * PHP 8.3 this corrupted the frame while the exec call was nested in another call's arguments: before the fix the * fixture crashed in most runs (see the report). Now the server goes on, delivers every event and exits 0. */ diff --git a/tests/Integration/Delivery/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index d896a21..0bcf8d1 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -8,6 +8,7 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\JsonAssert; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; @@ -115,6 +116,36 @@ public function testWithTheHandlersDisabledSigtermEndsTheProcessAtOnceAndNothing $this->assertSame([], $this->resourceNames($base)); } + /** @return iterable */ + public static function posixFunctions(): iterable + { + yield 'posix_getpid' => ['posix_getpid']; + yield 'posix_kill' => ['posix_kill']; + } + + /** A host can disable `posix_getpid()` or `posix_kill()`: the drain then ends the process with the shell's status for SIGTERM. */ + #[DataProvider('posixFunctions')] + #[MaximumDuration(10000)] + public function testWithoutAPosixFunctionTheDrainEndsTheProcessWithTheExitStatusOfTheSignal(string $disabled): void + { + $base = $this->collector()->scenario(); + $process = $this->fixture($base, 'outside', '', ['-d', 'disable_functions=' . $disabled]); + + $process->start(); + $ready = microtime(true) + self::SIGNAL_DEADLINE; + while ($process->isRunning() && $process->getOutput() === '' && microtime(true) < $ready) { + usleep(10_000); + } + $this->assertSame("ready\n", $process->getOutput(), $process->getErrorOutput()); + $process->signal(15); + $process->wait(); + + $this->assertFalse($process->hasBeenSignaled()); + $this->assertSame(128 + 15, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame(['signal-drain'], $this->resourceNames($base)); + } + #[MaximumDuration(10000)] public function testWithTheHandlersDisabledANormalExitStillDeliversThroughTheShutdownFunction(): void { @@ -222,10 +253,11 @@ public function testASigtermDuringTheShutdownFlushKeepsItsDeadline(): void fclose($hole); } - private function fixture(string $base, string $mode, string $marker = ''): Process + /** @param list $php options of the PHP binary, before the script */ + private function fixture(string $base, string $mode, string $marker = '', array $php = []): Process { $process = new Process( - [PHP_BINARY, __DIR__ . '/../Fixtures/signal-drain.php', $base, $mode, $marker], + [PHP_BINARY, ...$php, __DIR__ . '/../Fixtures/signal-drain.php', $base, $mode, $marker], null, ['HOME' => $this->dir], ); @@ -250,13 +282,23 @@ private function blackHole(): array return [$socket, 'http://' . $name]; } - /** Waits (within the process timeout) until the fixture prints `$line`; false when it ended first. */ + /** + * Waits (within the process timeout) until the fixture prints `$line`; false when it ended first. `start()` reads + * the pipes once, into the output buffer and not to a later `waitUntil()` callback, so a fixture that printed the + * line before then (the test process lost the CPU inside `start()` on a loaded host) printed it there: the buffer + * comes first. + */ private function waitForLine(Process $process, string $line): bool { - $output = ''; + $output = $process->getOutput(); + if (str_contains($output, $line . "\n")) { + return true; + } return $process->waitUntil(static function (string $type, string $chunk) use (&$output, $line): bool { - $output .= $chunk; + if ($type === Process::OUT) { + $output .= $chunk; + } return str_contains($output, $line . "\n"); }); diff --git a/tests/Integration/Delivery/SignalInCallbackTest.php b/tests/Integration/Delivery/SignalInCallbackTest.php index 7b6c0f4..e4ea3b0 100644 --- a/tests/Integration/Delivery/SignalInCallbackTest.php +++ b/tests/Integration/Delivery/SignalInCallbackTest.php @@ -11,7 +11,7 @@ use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; -/** I2: signal handlers inside the side channel's curl callbacks (`signal-in-callback.php`, `exit-in-callback.php`). */ +/** Signal handlers inside the side channel's curl callbacks (`signal-in-callback.php`, `exit-in-callback.php`). */ final class SignalInCallbackTest extends TestCase { private const int ROUNDS = 20; diff --git a/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php b/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php index d4031a3..cac0a90 100644 --- a/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php +++ b/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php @@ -13,7 +13,7 @@ use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\TestCase; -/** A9 (D49): a blocked collector holds the shutdown only for the short diagnostics timeouts. */ +/** D49: a blocked collector holds the shutdown only for the short diagnostics timeouts. */ final class DiagnosticsTimeoutsTest extends TestCase { private Environment $environment; diff --git a/tests/Integration/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index fa30d9d..a4dfd37 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -23,6 +23,8 @@ // sends itself SIGTERM. Nothing delivers; SIGTERM ends the process at once. // disabled-shutdown: like disabled, but it prints "ending" and ends normally: the shutdown function delivers the event. // shutdown: no signal of its own: it prints "ending" and ends, so that the shutdown function delivers the event. +// outside: it prints "ready" and waits for the SIGTERM of the test, so that it also runs where a host disabled +// posix_getpid() or posix_kill(). $baseUrl = $argv[1] ?? ''; $mode = $argv[2] ?? ''; $markerFile = $argv[3] ?? ''; @@ -50,7 +52,11 @@ if ($mode === 'chain-exit' || $mode === 'twice') { echo "signaled\n"; } -posix_kill(posix_getpid(), SIGTERM); +if ($mode === 'outside') { + echo "ready\n"; +} else { + posix_kill(posix_getpid(), SIGTERM); +} if ($mode === 'chain') { usleep(200_000); echo "done\n"; diff --git a/tests/Integration/Fixtures/signal-in-callback.php b/tests/Integration/Fixtures/signal-in-callback.php index 9bca6d8..f0e76ba 100644 --- a/tests/Integration/Fixtures/signal-in-callback.php +++ b/tests/Integration/Fixtures/signal-in-callback.php @@ -17,10 +17,10 @@ // A CLI server whose side channel downloads large answers while SIGTERM arrives every 200 ยตs (a forked child sends it). // A signal handler runs between two opcodes, so also inside a write callback of curl_multi_exec(). Each SIGTERM that // lands there runs AgentCat's drain (SignalDrain::onSignal()), then the server's own handler, which only counts and -// returns (a graceful-stop flag): the process goes on. Before the I2 fix, such a drain re-entered the -// multi handle, and PHP crashed when the callback returned (exit 139). A SIGTERM elsewhere calls only the server's -// handler: a drain there would send the side requests itself while the next signals wait, so few would land inside a -// callback. (AgentCat installs its drain once per process; the fixture runs it on every signal of the case under test.) +// returns (a graceful-stop flag): the process goes on. Before the fix, such a drain re-entered the multi handle, and +// PHP crashed when the callback returned (exit 139). A SIGTERM elsewhere calls only the server's handler: a drain there +// would send the side requests itself while the next signals wait, so few would land inside a callback. (AgentCat +// installs its drain once per process; the fixture runs it on every signal of the case under test.) // Usage: signal-in-callback.php // Prints: "survived signals= nested= done= errors=" $eventsUrl = $argv[1] ?? ''; diff --git a/tests/Integration/Fixtures/throw-after-exec.php b/tests/Integration/Fixtures/throw-after-exec.php index 8af9f8c..fd502aa 100644 --- a/tests/Integration/Fixtures/throw-after-exec.php +++ b/tests/Integration/Fixtures/throw-after-exec.php @@ -18,7 +18,7 @@ // flood from a forked child finds that moment in the event transport's run() (at the curl_multi_exec() line on PHP 8.3, // right below the returned call's frame on PHP 8.4+). There the handler queues one more event and throws, up to // times. On PHP 8.3 a throw at that moment corrupted the frame when the exec call was nested in another call's -// arguments (I1: a segfault, a hang or an out-of-memory error). The transport swallows the throwable (D32); the server +// arguments (a segfault, a hang or an out-of-memory error). The transport swallows the throwable (D32); the server // goes on, delivers every event and exits 0. // Usage: throw-after-exec.php // Prints: "throws= left=" diff --git a/tests/Integration/HardenedHostTest.php b/tests/Integration/HardenedHostTest.php new file mode 100644 index 0000000..56fed93 --- /dev/null +++ b/tests/Integration/HardenedHostTest.php @@ -0,0 +1,273 @@ +stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->home = getenv('HOME') . '/hardened-host-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->home, 0o700)); + } + + #[\Override] + protected function tearDown(): void + { + $this->client?->process()->stop(0); + $this->client = null; + FileTree::remove($this->home); + } + + /** @return iterable */ + public static function lists(): iterable + { + yield 'shared hosting' => ['exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,pcntl_fork,posix_kill,posix_mkfifo,set_time_limit,ignore_user_abort,putenv,getmypid,ini_set,parse_ini_file,show_source,highlight_file']; + yield 'no signals' => ['pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_async_signals,posix_getpid,posix_kill']; + yield 'no curl multi' => ['curl_multi_init,curl_multi_exec,curl_multi_add_handle,curl_multi_select']; + yield 'no clocks' => ['hrtime,getenv']; + yield 'shared hosting without curl multi or signals' => ['curl_multi_exec,pcntl_signal,set_time_limit,ignore_user_abort,putenv,getmypid']; + } + + #[DataProvider('lists')] + #[Group('hardened-host')] + #[Group('ac-03-37')] + #[MaximumDuration(10000)] + public function testTheServerAnswersAndEndsWithOneLinePerMissingFunction(string $functions): void + { + $client = $this->start($functions); + $initialize = $this->resultOf($client, 1, 'initialize', [ + 'protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'hardened-client', 'version' => '1.0.0'], + ]); + $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($initialize, 'protocolVersion')); + $client->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + $call = $this->resultOf($client, 2, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hardened']]); + $this->assertFalse(JsonAssert::at($call, 'isError')); + $this->assertContains('echo: hardened', $this->texts($call)); + + $client->closeInput(); + $code = $client->waitForExit(self::EOF_EXIT_SECONDS); + $stderr = $client->process()->getErrorOutput(); + $this->assertSame(0, $code, 'exit code at stdin EOF; stderr: ' . $stderr); + $this->assertSame('', $stderr, 'no error, warning or deprecation'); + foreach ($client->stdoutLines() as $line) { + $this->assertSame('2.0', JsonAssert::at(Json::decode($line), 'jsonrpc'), 'stdout carries JSON-RPC only: ' . $line); + } + + $posts = $this->collector()->requests($this->diagnostics); + if (str_contains($functions, 'curl_')) { + $this->assertSame([], $posts, 'without curl multi nothing is sent'); + } else { + $this->assertNotSame([], $posts, 'diagnostics reach the collector'); + $this->assertStringContainsString('AgentCat setup complete', MockCollector::body($posts[0])); + } + + $entries = $this->logEntries(); + $this->assertNotSame([], preg_grep('/AgentCat setup complete/', $entries), 'the log works: ' . implode('', $entries)); + $this->assertSame([], preg_grep('/Call to undefined function/', $entries), 'AgentCat calls no disabled function'); + // Without ext-pcntl no pcntl function exists to name: one line says to install the extension instead. + $pcntl = extension_loaded('pcntl'); + $listed = explode(',', $functions); + $signalLines = preg_grep('/' . self::SIGNAL_LINE . '/', $entries); + $this->assertIsArray($signalLines); + $this->assertCount($pcntl ? (array_intersect(self::DRAIN, $listed) === [] ? 0 : 1) : 1, $signalLines, 'the signal drain line: ' . implode('', $entries)); + foreach ($listed as $function) { + $naming = preg_grep('/\b' . preg_quote($function, '/') . '\b/', $entries); + $this->assertIsArray($naming); + if (in_array($function, self::DELIVERY, true) || (in_array($function, self::DRAIN, true) && $pcntl)) { + $this->assertCount(1, $naming, $function . ' is named in one line: ' . implode('', $entries)); + } elseif (in_array($function, self::DRAIN, true)) { + $this->assertCount(0, $naming, $function . ' does not exist without ext-pcntl: ' . implode('', $entries)); + } else { + $this->assertLessThanOrEqual(1, count($naming), $function . ' is named in one line at most: ' . implode('', $entries)); + } + } + } + + /** @return iterable the disabled functions, the failed attempts, and whether a line names them */ + public static function sleepLists(): iterable + { + // time_nanosleep() waits for the retries: attempts at 0, 1 and 3 s of the 5 s budget. + yield 'no usleep' => ['usleep', 3, false]; + // Nothing can wait: the delivery ends at the first retry, at once. + yield 'no sleep function' => ['usleep,time_nanosleep', 1, true]; + } + + /** + * The delivery at the end of a process, with the event API down: it waits for each retry with whatever sleep function + * exists, and without one it ends at once, with one line, instead of spinning through the 5 s budget. + */ + #[DataProvider('sleepLists')] + #[Group('hardened-host')] + #[MaximumDuration(10000)] + public function testTheShutdownDeliveryWaitsForARetryOnlyWithASleepFunction(string $functions, int $attempts, bool $named): void + { + $process = new Process( + [PHP_BINARY, '-d', 'disable_functions=' . $functions, '-d', 'display_errors=stderr', '-d', 'log_errors=0', __DIR__ . '/../Fixtures/hardened-shutdown.php'], + null, + ['HOME' => $this->home, 'AGENTCAT_ENABLED' => 'true', 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'true'], + ); + $process->setTimeout(self::EOF_EXIT_SECONDS + 2.0); + $started = hrtime(true); + + $process->run(); + + $elapsed = (hrtime(true) - $started) / 1e9; + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame('', $process->getErrorOutput(), 'no error, warning or deprecation'); + $entries = $this->logEntries(); + $log = implode('', $entries); + $this->assertSame([], preg_grep('/Call to undefined function/', $entries), 'AgentCat calls no disabled function: ' . $log); + $this->assertCount($attempts, $this->matching('/' . preg_quote(self::RETRYING, '/') . '/', $entries), 'the failed attempts: ' . $log); + $this->assertCount($named ? 1 : 0, $this->matching('/' . preg_quote(self::NO_SLEEP, '/') . '/', $entries), 'the line of a host that cannot wait: ' . $log); + $this->assertCount($named ? 1 : 0, $this->matching('/\btime_nanosleep\b/', $entries), 'time_nanosleep is named in that line only: ' . $log); + if ($named) { + $this->assertLessThan(2.0, $elapsed, 'no spin through the 5 s budget'); + } else { + $this->assertGreaterThan(2.5, $elapsed, 'the delivery waited for its retries'); + } + } + + /** + * Starts the fixture server with `$functions` disabled. The event API is a closed local port; diagnostics are on, as + * they are by default outside tests, and go to this test's collector scenario. + */ + private function start(string $functions): StdioMcpClient + { + $this->diagnostics = $this->collector()->scenario(); + $this->client = StdioMcpClient::command( + [PHP_BINARY, '-d', 'disable_functions=' . $functions, '-d', 'display_errors=stderr', '-d', 'log_errors=0', __DIR__ . '/../Fixtures/hardened-host.php'], + [ + 'HOME' => $this->home, + 'AGENTCAT_ENABLED' => 'true', + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->diagnostics, + 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', + 'DISABLE_DIAGNOSTICS' => 'false', + ], + ); + + return $this->client; + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } + + /** + * Sends one request and returns the `result` of its answer. + * + * @param array $params + */ + private function resultOf(StdioMcpClient $client, int $id, string $method, array $params): \stdClass + { + $client->send(['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]); + + return HttpHarness::result($client->receive($id)); + } + + /** @return list the `text` of each content block */ + private function texts(\stdClass $result): array + { + $content = JsonAssert::at($result, 'content'); + $this->assertIsArray($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), array_values($content)); + } + + /** + * @param list $entries + * + * @return array the entries that match `$pattern` + */ + private function matching(string $pattern, array $entries): array + { + $matching = preg_grep($pattern, $entries); + $this->assertIsArray($matching); + + return $matching; + } + + /** @return list the entries of the server's agentcat.log, each starting at its timestamp */ + private function logEntries(): array + { + $path = $this->home . '/agentcat.log'; + $log = is_file($path) ? (string) file_get_contents($path) : ''; + $entries = preg_split('/^(?=\[\d{4}-\d\d\-\d\dT)/m', $log); + $this->assertIsArray($entries); + + return array_values(array_filter($entries, static fn(string $entry): bool => $entry !== '')); + } +} diff --git a/tests/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php index d88607b..43e307c 100644 --- a/tests/Integration/Laravel/CachedConfigurationTest.php +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -27,8 +27,8 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; /** - * An application with `php artisan config:cache` (plan E1 header conflict 12): the configuration holds what `.env` - * gave when the cache was made, and the process holds none of it. + * An application with `php artisan config:cache`: the configuration holds what `.env` gave when the cache was made, and + * the process holds none of it. */ final class CachedConfigurationTest extends BridgeTestCase { @@ -44,7 +44,7 @@ final class CachedConfigurationTest extends BridgeTestCase /** The diagnostics variables of the process; a test turns diagnostics on as a platform variable can. */ private ?Environment $diagnosticsEnvironment = null; - /** The collector `.env` names for diagnostics: a closed local port (ruling P8). */ + /** The collector `.env` names for diagnostics: a closed local port, so a test fails closed. */ private string $cachedCollector = ''; #[\Override] @@ -224,7 +224,7 @@ public function testAnUnknownCachedWordLeavesTheProcessToDecide(): void $this->assertSame(self::SESSION, $this->deliveredField('session_id')); } - /** I2 (c): an mcp/sdk Builder tracked with the shared client and no project ID gets the cached one. */ + /** An mcp/sdk Builder tracked with the shared client and no project ID gets the cached one. */ #[DefineEnvironment('defineCachedProject')] public function testTheSharedClientTracksWithTheCachedProjectId(): void { diff --git a/tests/Integration/Laravel/CustomerTestSuiteTest.php b/tests/Integration/Laravel/CustomerTestSuiteTest.php index 6fc6423..841310d 100644 --- a/tests/Integration/Laravel/CustomerTestSuiteTest.php +++ b/tests/Integration/Laravel/CustomerTestSuiteTest.php @@ -12,18 +12,23 @@ use AgentCat\LaravelMcp\Internal\Access\ServerAccess; use AgentCat\Tests\Support\Laravel\BridgeTestCase; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; +use AgentCat\Tests\Support\LaravelMcp\PagedOwnServer; +use AgentCat\Tests\Support\LaravelMcp\PagedServer; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; +use AgentCat\Tests\Support\LaravelMcp\Tools\OwnTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\Methods\ListTools; use Laravel\Mcp\Server\Testing\TestListResponse; use Laravel\Mcp\Server\Transport\FakeTransporter; +use PHPUnit\Framework\Attributes\Group; /** - * A customer's Laravel test suite with AgentCat installed (plan E1 header conflict 18, D31, Review Focus 2): laravel/mcp's - * `Server::tool()` and `Server::tools()` helpers, with `agentcat.enabled` unset as in the published configuration. + * A customer's Laravel test suite with AgentCat installed (D31): laravel/mcp's `Server::tool()` and `Server::tools()` + * helpers, with `agentcat.enabled` unset as in the published configuration. */ final class CustomerTestSuiteTest extends BridgeTestCase { @@ -77,7 +82,7 @@ public function testAFakeRecordsTheHelpersCalls(): void $this->assertSame(['echo', 'structured'], array_map(static fn(Event $event): ?string => $event->resourceName, $fake->events())); } - /** D15: a tracked listing ends with AgentCat's tool; `tools()` follows `nextCursor`, so it shows one per page. */ + /** A tracked listing ends with AgentCat's tool: `tools()` follows `nextCursor`, and it comes once, after the last page (D66). */ public function testWithAFakeTheListingEndsWithTheGetMoreToolsTool(): void { AgentCat::fake(); @@ -85,6 +90,28 @@ public function testWithAFakeTheListingEndsWithTheGetMoreToolsTool(): void $this->assertSame([...self::TOOLS, GetMoreTools::NAME], $this->names(ConformanceServer::tools())); } + /** D66 (B10 fixed): `tools()` follows `nextCursor` on one server, and only the last page has AgentCat's tool. */ + #[Group('ac-02-8')] + public function testWithAFakeAPagedListingEndsWithOneGetMoreTools(): void + { + AgentCat::fake(); + + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 15)), GetMoreTools::NAME], $this->names(PagedServer::tools())); + } + + /** D66 (B10 fixed): each `tool()` builds a new server, whose rebuild reads up to page 2, where `own` is. */ + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testWithAFakeAToolOnTheSecondPageKeepsItsOwnSessionId(): void + { + $fake = AgentCat::fake(); + + PagedOwnServer::tool(OwnTool::class, ['session_id' => 'theirs'])->assertSee('ok'); + + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $fake->assertTrackedCount(1); + } + /** @return list */ private function names(TestListResponse $tools): array { diff --git a/tests/Integration/Laravel/DeliveryTest.php b/tests/Integration/Laravel/DeliveryTest.php index 090a704..253a37d 100644 --- a/tests/Integration/Laravel/DeliveryTest.php +++ b/tests/Integration/Laravel/DeliveryTest.php @@ -35,8 +35,8 @@ /** * Delivery after each request of a Laravel application whose process outlives the request, as in an Octane worker - * (plan E1 header conflict 13, D58, Review Focus 4). Each request goes through Laravel's HTTP kernel to `Mcp::web()`, - * and Laravel's test client runs `$kernel->terminate()` after it, as a server does after it sent the response. + * (D58). Each request goes through Laravel's HTTP kernel to `Mcp::web()`, and Laravel's test client runs + * `$kernel->terminate()` after it, as a server does after it sent the response. */ final class DeliveryTest extends BridgeTestCase { @@ -228,7 +228,7 @@ public function testARequestThatQueuedNoEventDoesNotWait(): void $this->assertSame($before, $this->transport->now()); } - /** Review Focus 4: three requests in one application container, each with its own server, headers and identity. */ + /** Three requests in one application container, each with its own server, headers and identity. */ public function testRequestsInOneContainerKeepTheirOwnContext(): void { config(['agentcat.identify' => HeaderIdentity::class]); @@ -276,9 +276,9 @@ public static function containerHooks(): iterable } /** - * Review Focus 4 with a hook that reads the container and an AgentCat API that backs up. The first five deliveries - * stay in flight, so the sixth event waits for a free slot past its own request. Its hook still runs before that - * request's wait returns, in that request: the payload alone waits for the transport. + * A hook that reads the container, and an AgentCat API that backs up. The first five deliveries stay in flight, so + * the sixth event waits for a free slot past its own request. Its hook still runs before that request's wait + * returns, in that request: the payload alone waits for the transport. * * @param class-string $hook */ @@ -301,7 +301,7 @@ public function testAContainerReadingHookRunsInItsOwnRequestWhileTheApiIsBackedU $this->assertSame($users, array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'identify_actor_given_id'), $bodies)); } - /** Review Focus 4: an AgentCat API that does not answer holds each request for the cap, not for the 5 s budget. */ + /** An AgentCat API that does not answer holds each request for the cap, not for the 5 s budget. */ #[Group('ac-03-1')] public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void { @@ -321,7 +321,7 @@ public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): } /** - * Review Focus 4 with the real curl transport. The collector answers after 4 s: a hook that waited for the 5 s + * The same with the real curl transport. The collector answers after 4 s: a hook that waited for the 5 s * shutdown budget would hold the request at least that long, and the worker's stop still has time to deliver. */ #[MaximumDuration(12000)] diff --git a/tests/Integration/Laravel/InstallCommandTest.php b/tests/Integration/Laravel/InstallCommandTest.php index 4e5d71b..51d323f 100644 --- a/tests/Integration/Laravel/InstallCommandTest.php +++ b/tests/Integration/Laravel/InstallCommandTest.php @@ -48,6 +48,7 @@ public function testItWritesTheProjectIdAndNothingElse(): void { $this->install('agentcat:install', ['project' => self::PROJECT]) ->expectsOutputToContain('AgentCat is set up: AGENTCAT_PROJECT_ID is in ' . $this->dir . '/.env.') + ->doesntExpectOutputToContain('Created') ->assertExitCode(0); $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=" . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); @@ -73,7 +74,9 @@ public function testItCreatesAMissingEnvAndLeavesAMissingExampleAlone(): void unlink($this->dir . '/.env'); unlink($this->dir . '/.env.example'); - $this->install('agentcat:install', ['project' => self::PROJECT])->assertExitCode(0); + $this->install('agentcat:install', ['project' => self::PROJECT]) + ->expectsOutputToContain('Created ' . $this->dir . '/.env with AGENTCAT_PROJECT_ID only.') + ->assertExitCode(0); $this->assertSame('AGENTCAT_PROJECT_ID=' . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); $this->assertFileDoesNotExist($this->dir . '/.env.example'); diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php index fe2cb1c..befd8cd 100644 --- a/tests/Integration/Laravel/ServiceProviderTest.php +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -51,14 +51,14 @@ protected function defineEnvironment(mixed $app): void } } - /** Review Focus 3: config/agentcat.php with missing and mistyped `env()` values, before the providers boot. */ + /** config/agentcat.php with missing and mistyped `env()` values, before the providers boot. */ public function defineBadConfiguration(): void { $this->ownConfiguration = true; config([ 'agentcat.enabled' => true, 'agentcat.project_id' => null, - 'agentcat.enable_tracing' => 'yes', // a D44 word since M3: tracing stays on, no line + 'agentcat.enable_tracing' => 'yes', // a D44 word: tracing stays on, no line 'agentcat.enable_report_missing' => 'maybe', 'agentcat.identify' => 'App\Missing\Identify', 'agentcat.exporters' => ['otlp' => null], @@ -66,7 +66,7 @@ public function defineBadConfiguration(): void ]); } - /** Review Focus 3 for the bridge's own keys: each of a wrong type, before the providers boot. */ + /** The bridge's own keys, each of a wrong type, before the providers boot. */ public function defineBadBridgeKeys(): void { $this->ownConfiguration = true; @@ -100,7 +100,7 @@ public function defineEnabledUnset(): void config(['agentcat.enabled' => null, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); } - /** Review Focus 3: `AGENTCAT_ENABLED=maybe`, which `env()` gives the configuration too, before the providers boot. */ + /** `AGENTCAT_ENABLED=maybe`, which `env()` gives the configuration too, before the providers boot. */ public function defineAnUnknownEnabledWord(): void { $this->ownConfiguration = true; @@ -127,7 +127,7 @@ public function testBoostIsExcludedByDefaultAndConfigExcludesMore(): void $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); } - /** M2: a string is a list of one class, so the server the customer meant to exclude is not tracked. */ + /** A string is a list of one class, so the server the customer meant to exclude is not tracked. */ public function testAStringExcludeIsAListOfOneClass(): void { config(['agentcat.exclude' => ConformanceServer::class]); @@ -135,7 +135,7 @@ public function testAStringExcludeIsAListOfOneClass(): void $this->assertUntouched(); } - /** M2: an entry that is no class name logs one line; the other entries still exclude, and a null one is unset. */ + /** An entry that is no class name logs one line; the other entries still exclude, and a null one is unset. */ public function testAnExcludeEntryOfAnotherTypeIsIgnoredWithOneLine(): void { $logger = new MemoryLogger(); @@ -152,7 +152,7 @@ public function testAnExcludeEntryOfAnotherTypeIsIgnoredWithOneLine(): void $this->assertSame(['WARN: AgentCat option "agentcat.exclude.1" must be string, got int; the entry is ignored'], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'WARN:')))); } - /** M2: the bridge's keys of a wrong type log one line each, once, and their defaults are used. */ + /** The bridge's keys of a wrong type log one line each, once, and their defaults are used. */ #[DefineEnvironment('defineBadBridgeKeys')] public function testBridgeKeysOfAWrongTypeLogOneLineEachAndFallBack(): void { @@ -174,7 +174,7 @@ public function testBridgeKeysOfAWrongTypeLogOneLineEachAndFallBack(): void $this->assertSame([LaravelTrackingFixture::PROJECT, LaravelTrackingFixture::PROJECT, self::OTHER_PROJECT], array_map(static fn(mixed $event): mixed => JsonAssert::at($event, 'project_id'), $events)); } - /** M2: `projects` that is not a map logs one line, and every server goes to `project_id`. */ + /** `projects` that is not a map logs one line, and every server goes to `project_id`. */ public function testProjectsOfAnotherTypeLogOneLineAndTheProjectIdIsUsed(): void { $logger = new MemoryLogger(); @@ -187,7 +187,7 @@ public function testProjectsOfAnotherTypeLogOneLineAndTheProjectIdIsUsed(): void $this->assertContains('WARN: AgentCat option "agentcat.projects" must be array or null, got string; the default is used', $logger->messages()); } - /** M2: a `project_id` that is not a string logs one line, and AGENTCAT_PROJECT_ID of the process decides. */ + /** A `project_id` that is not a string logs one line, and AGENTCAT_PROJECT_ID of the process decides. */ #[DefineEnvironment('defineANonStringProjectId')] public function testAProjectIdOfAnotherTypeLogsOneLineAndTheVariableDecides(): void { @@ -293,7 +293,7 @@ public function testALogChannelNameRoutesTheLinesIntoTheApplicationsChannel(): v } /** - * I3: laravel/mcp builds a server per HTTP request, so the setup lines, and an ERROR line for a tool that declares + * laravel/mcp builds a server per HTTP request, so the setup lines, and an ERROR line for a tool that declares * its own `session_id`, repeat per request. They stay out of the application's log and its error trackers. */ public function testRequestsWriteNoAgentCatEntryIntoTheApplicationsLog(): void @@ -396,7 +396,7 @@ public function testWithoutASwitchInATestAServerKeepsItsMethodsAndItsAnswers(): $this->assertUntouched(); } - /** Review Focus 3: boot, resolve, `tools/call` and flush with the bad values. */ + /** Boot, resolve, `tools/call` and flush with the bad values: nothing throws, and the call answers. */ #[DefineEnvironment('defineBadConfiguration')] public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void { @@ -429,7 +429,7 @@ public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void ], array_map(static fn(string $line): string => str_starts_with($line, $own) ? $own : $line, $logger->messages())); } - /** M3: `'enable_tracing' => env('AGENTCAT_ENABLE_TRACING')` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ + /** `'enable_tracing' => env('AGENTCAT_ENABLE_TRACING')` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ public function testAZeroFromEnvTurnsTracingOff(): void { config(['agentcat.enable_tracing' => '0']); @@ -440,7 +440,7 @@ public function testAZeroFromEnvTurnsTracingOff(): void $this->assertSame([], $this->deliveries()); } - /** Review Focus 3: boot, resolve, `tools/call` and flush with `AGENTCAT_ENABLED=maybe`. */ + /** Boot, resolve, `tools/call` and flush with `AGENTCAT_ENABLED=maybe`: nothing throws, and the call answers. */ #[DefineEnvironment('defineAnUnknownEnabledWord')] public function testAnUnknownEnabledWordLogsOneLineAndTracksNothing(): void { @@ -458,7 +458,7 @@ public function testAnUnknownEnabledWordLogsOneLineAndTracksNothing(): void $this->assertSame([], $this->transport->deliveries); } - /** Octane runs each request in a sandbox container: a hook comes from the container of its request (Review Focus 4). */ + /** Octane runs each request in a sandbox container: a hook comes from the container of its request. */ public function testAHookComesFromTheCurrentContainerWhenItRuns(): void { config(['agentcat.identify' => InvokableIdentify::class]); diff --git a/tests/Integration/Laravel/SrcErrorGuardInstallTest.php b/tests/Integration/Laravel/SrcErrorGuardInstallTest.php new file mode 100644 index 0000000..eadfdb2 --- /dev/null +++ b/tests/Integration/Laravel/SrcErrorGuardInstallTest.php @@ -0,0 +1,55 @@ + */ + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), GuardPositionProvider::class]; + } + + /** The providers register and boot behind the guard, after AgentCat's own. */ + public function testTheGuardIsInFrontWhileTheProvidersRegisterAndBoot(): void + { + $this->assertSame(['register' => true, 'boot' => true], GuardPositionProvider::$inFront); + } + + public function testOneGuardIsInFrontOfTheApplicationsHandler(): void + { + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + $this->get('/agentcat-guard'); // a request through the HTTP kernel installs no handler of its own + + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } + + /** A second application installs Laravel's handler again: a second guard goes in front of it, not two of one. */ + public function testARefreshedApplicationGetsAGuardOfItsOwn(): void + { + $this->refreshApplication(); + + $this->assertSame(2, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } +} diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 3b54588..7765efe 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -20,12 +20,16 @@ use AgentCat\Tests\Support\LaravelMcp\LaravelTestCase; use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; use AgentCat\Tests\Support\LaravelMcp\LineTransport; +use AgentCat\Tests\Support\LaravelMcp\ListingServer; +use AgentCat\Tests\Support\LaravelMcp\PagedGetMoreToolsServer; +use AgentCat\Tests\Support\LaravelMcp\PagedOwnServer; use AgentCat\Tests\Support\LaravelMcp\RecordingCallServer; use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; use AgentCat\Tests\Support\LaravelMcp\RequestEchoServer; use AgentCat\Tests\Support\LaravelMcp\StreamingServer; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\ToolCalls; use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; @@ -45,6 +49,10 @@ final class CallToolTest extends LaravelTestCase { private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + + private const string CANNED = 'Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.'; + #[Group('ac-02-16')] #[Group('ac-02-47')] public function testTheToolGetsItsOwnArgumentsAndTheEventRecordsTheCall(): void @@ -117,6 +125,121 @@ public function __construct(Transport $transport) $this->assertCount(1, $fixture->events()); } + /** @return iterable */ + public static function eras(): iterable + { + yield '2026-07-28' => [static fn(LaravelHarness $harness): \stdClass => $harness->callTool('own', ['session_id' => 'theirs', 'context' => 'why'])]; + yield 'legacy' => [static fn(LaravelHarness $harness): \stdClass => $harness->request('tools/call', ['name' => 'own', 'arguments' => ['session_id' => 'theirs', 'context' => 'why']])]; + } + + /** + * D66 (B10 fixed): a new server per request, as under PHP-FPM, rebuilds from page 1 to the page that names the + * tool, so `own`, alone on page 2, is stripped exactly and keeps its own `session_id`. + * + * @param \Closure(LaravelHarness): \stdClass $call + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testAToolOnTheSecondPageKeepsItsOwnSessionId(\Closure $call): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedOwnServer::class, $fixture->tracker()); + + $answer = $call($harness); + + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $this->assertEquals([(object) ['type' => 'text', 'text' => 'ok']], JsonAssert::at($answer, 'result', 'content')); // no "unrecognized" block + $event = $fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('why', $event->userIntent); + $this->assertSame([ListingLines::ownSessionId('own'), self::REBUILT], $fixture->lines()); + } + + /** D66 (B10 fixed): on a long-running server each listed page merges into the registries, so no call rebuilds. */ + #[Group('ac-02-8')] + public function testListedPagesMergeOnALongRunningServer(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(PagedOwnServer::class, $fixture->tracker()); + $first = JsonAssert::at($stdio->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => new \stdClass()]), 'result'); + $cursor = JsonAssert::at($first, 'nextCursor'); + $this->assertIsString($cursor); + $second = JsonAssert::at($stdio->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => ['cursor' => $cursor]]), 'result'); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']]]); + $stdio->send(['jsonrpc' => '2.0', 'id' => 4, 'method' => 'tools/call', 'params' => ['name' => 'own', 'arguments' => ['session_id' => 'theirs']]]); + + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $this->names($first)); + $this->assertSame(['own', GetMoreTools::NAME], $this->names($second)); + $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], ToolCalls::of('echo'), 'agent tracking is off: agent_id is not ours'); + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $this->assertSame([ListingLines::ownSessionId('own')], $fixture->lines(), 'both tools were listed: no rebuild'); + } + + /** + * D66 (B10 fixed): the rebuild stops at the first page that names the tool, or at a page it cannot read. No page + * that the rebuild could read names `echo`, and page 2 fails. Page 1 is kept, one line says where the rebuild + * stopped, and the call is served with the heuristic strip. + */ + #[Group('ac-02-21')] + public function testASecondPageThatFailsLeavesTheCallServed(): void + { + $list = new RecordingMethod(answer: static fn(JsonRpcRequest $request): JsonRpcResponse => isset($request->params['cursor']) + ? throw new \RuntimeException('page down') + : JsonRpcResponse::result($request->id, ['tools' => [['name' => 't01', 'inputSchema' => ['type' => 'object', 'properties' => new \stdClass()]]], 'nextCursor' => 'p2'])); + $this->app?->instance(RecordingMethod::class, $list); + $fixture = new LaravelTrackingFixture(); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start', 'context' => 'why']); + + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 1, 'text')); + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); + $this->assertSame([null, 'p2'], array_map(static fn(JsonRpcRequest $request): mixed => $request->params['cursor'] ?? null, $list->requests)); + $this->assertSame('minted', $fixture->event()->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame(['Warning: registry rebuild-on-demand stopped after page 1 - RuntimeException: page down'], $fixture->lines()); + } + + /** + * D66: a get_more_tools call rebuilds like any other tool. A new server per request (PHP-FPM), no listing: the + * rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the + * customer's tool), and the `session_id` that tool declares stays the customer's: no session is read from it and + * no "unrecognized" block is added. + */ + #[Group('ac-02-19')] + #[Group('ac-02-14')] + public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedGetMoreToolsServer::class, $fixture->tracker()); + + $answer = $harness->callTool(GetMoreTools::NAME, ['session_id' => 'theirs', 'context' => 'need X']); + + $this->assertEquals([(object) ['type' => 'text', 'text' => self::CANNED]], JsonAssert::at($answer, 'result', 'content')); + $this->assertSame([], ToolCalls::of(GetMoreTools::NAME), 'AgentCat answers its calls'); + $event = $fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('need X', $event->userIntent); + $this->assertSame(ListingLines::shadowedCall(6), $fixture->lines()); + } + + /** D66: with report-missing off, the customer's own get_more_tools on page 2 is found and gets its `session_id`. */ + #[Group('ac-02-19')] + #[Group('ac-02-41')] + public function testWithReportMissingOffACustomerGetMoreToolsOnPageTwoGetsItsOwnSessionId(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedGetMoreToolsServer::class, $fixture->tracker(new Options(enableReportMissing: false))); + + $answer = $harness->callTool(GetMoreTools::NAME, ['session_id' => 'theirs']); + + $this->assertSame('mine', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of(GetMoreTools::NAME)); + $this->assertSame('foreign', $fixture->event()->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + #[Group('ac-02-24')] #[Group('ac-05-E5')] public function testAThrownToolExceptionIsRecordedAsItself(): void @@ -216,7 +339,7 @@ public function testHeadersComeFromTheServersOwnRequest(): void $this->assertSame('http://localhost/mcp/conformance', JsonAssert::at($info, 'url')); } - /** Task 5 review minor 2: the client controls the headers; Symfony refuses an invalid method override. */ + /** The client controls the headers; Symfony refuses an invalid method override. */ public function testAnInvalidMethodOverrideCostsTheHttpContextOnly(): void { $fixture = new LaravelTrackingFixture(); @@ -232,7 +355,7 @@ public function testAnInvalidMethodOverrideCostsTheHttpContextOnly(): void $this->assertNotContains(LaravelMcpAdapter::COMPATIBILITY_LINE, $fixture->lines()); } - /** The URL is built like the SAPI reading's (Task 6 review): an invalid `Host` header is recorded as it was sent. */ + /** The URL is built like the SAPI reading's: an invalid `Host` header is recorded as it was sent. */ #[Group('ac-02-43')] public function testAnInvalidHostHeaderIsRecordedAsSent(): void { @@ -256,7 +379,7 @@ public static function callsWithoutAToolName(): iterable } /** - * TypeScript and mcp/sdk reject such a call before any handler, so none is recorded (D15, Task 5 review minor 4). + * TypeScript and mcp/sdk reject such a call before any handler, so none is recorded (D15). * * @param array $params */ @@ -331,7 +454,7 @@ public function testACustomToolsCallHandlerIsWrapped(): void $this->assertCount(1, $fixture->events()); } - /** Task 4 review M2: AgentCat builds the handler after laravel/mcp binds `mcp.request`, and after the strip. */ + /** AgentCat builds the handler after laravel/mcp binds `mcp.request`, and after the strip. */ public function testACustomHandlersConstructorRequestSeesTheStrippedArguments(): void { $fixture = new LaravelTrackingFixture(); @@ -374,7 +497,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): \Genera } /** - * M4: a customer's decorator of `CallTool` from `extend()`, here an authorization check, runs on a tracked server + * A customer's decorator of `CallTool` from `extend()`, here an authorization check, runs on a tracked server * as without AgentCat: the container builds the handler at each call, and AgentCat answers with what it built. */ public function testAnExtendDecoratorOfCallToolStillRuns(): void @@ -410,7 +533,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl } /** - * M4: a `resolving()` callback of `CallTool` runs at each call, as without AgentCat; the container still builds + * A `resolving()` callback of `CallTool` runs at each call, as without AgentCat; the container still builds * laravel/mcp's own `CallTool`, so AgentCat keeps the tool's exception. */ public function testAResolvingCallbackOfCallToolRunsAndTheToolExceptionIsKept(): void @@ -472,9 +595,8 @@ public function testAStreamedAnswerIsRecordedWhenItEnds(): void } /** - * Over HTTP laravel/mcp sends a stream from `StreamedResponse::sendContent()`, after `Server::handle()` returned - * (Review Focus 5): the SSE bytes are the library's, the event comes from the final result, and the hooks run later - * (D04). + * Over HTTP laravel/mcp sends a stream from `StreamedResponse::sendContent()`, after `Server::handle()` returned: + * the SSE bytes are the library's, the event comes from the final result, and the hooks run later (D04). */ #[Group('ac-02-43')] #[Group('ac-05-E5')] @@ -504,7 +626,7 @@ public function testAStreamedAnswerOverHttpIsTheLibrarysAndRecordedWhenItEnds(): $this->assertSame('DomainException', JsonAssert::at($failing->event()->error, 'type')); } - /** A stream the client abandons publishes nothing (plan E1 header conflict 6). */ + /** A stream the client abandons publishes nothing. */ public function testAStreamTheClientAbandonsPublishesNothing(): void { $fixture = new LaravelTrackingFixture(); @@ -538,7 +660,7 @@ public function testAStreamThatThrowsIsOneErrorEvent(): void $this->assertTrue(JsonAssert::at($stdio->lines()[1] ?? null, 'result', 'isError')); } - /** Controller ruling P9: a handler's own stream that throws midway reaches `later()`'s catch. */ + /** A handler's own stream that throws midway reaches `later()`'s catch. */ #[Group('ac-05-E5')] public function testARawStreamThatThrowsMidwayIsOneErrorEventAndTheLibrarysError(): void { @@ -661,6 +783,15 @@ private function httpStream(?\Closure $track, string $tool): string return implode('', $sent->getArrayCopy()); } + /** @return list the names of a listing's tools */ + private function names(mixed $result): array + { + $tools = JsonAssert::at($result, 'tools'); + $this->assertIsList($tools); + + return array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools); + } + private function text(mixed $value): string { $this->assertIsString($value); diff --git a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php index 5361dcb..6b86762 100644 --- a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -210,8 +210,8 @@ public function testTheServerInfoHoldsTheServerWeakly(): void } /** - * `createContext()` is public customer code, not a protected-member read (Task 5 review minor 3): what it throws - * leaves the name and version unknown and logs nothing; a deprecation it triggers costs nothing. + * `createContext()` is public customer code, not a protected-member read: what it throws leaves the name and + * version unknown and logs nothing; a deprecation it triggers costs nothing. */ public function testAServerInfoThatCannotBeReadIsUnknownWithoutALine(): void { @@ -256,7 +256,7 @@ public function testTheHttpContextIsTheRequestOfTheServersOwnTransport(): void /** * Symfony refuses an invalid method override, which the client controls: no HTTP context, and no compatibility line - * (Task 5 review minor 2). + * (the library did not drift). */ public function testARequestLaravelCannotReadHasNoHttpContextAndLogsNothing(): void { @@ -294,7 +294,7 @@ public static function unreadableTransports(): iterable } /** - * A drifted transport costs the HTTP context, never the event: one line per server (plan E1 Task 4 carry, D32). + * A drifted transport costs the HTTP context, never the event: one line per server (D32). * * @param \Closure(Server): void $drift */ diff --git a/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php b/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php index d89985d..a2629bb 100644 --- a/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php +++ b/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php @@ -31,7 +31,7 @@ public function testTheLibraryAnswersForALaravelMcpServer(): void $this->assertTrue($library->supports($server)); $this->assertFalse($library->supports(new \stdClass())); - $this->assertSame('laravel-mcp/server', $library->sdk()); + $this->assertSame('laravel-mcp/server', $library->sdk($server)); $this->assertSame(LaravelMcpAdapter::SUPPORT_MATRIX, $library->supportMatrix()); $this->assertSame(['laravelMcpServer'], $library->signals($server)); $this->assertSame([], $library->signals(new \stdClass())); diff --git a/tests/Integration/LaravelMcp/ListToolsTest.php b/tests/Integration/LaravelMcp/ListToolsTest.php index fece5c5..a6e98d0 100644 --- a/tests/Integration/LaravelMcp/ListToolsTest.php +++ b/tests/Integration/LaravelMcp/ListToolsTest.php @@ -54,8 +54,9 @@ public function testListedToolsGetTheInjectedParametersAndGetMoreToolsComesLast( $this->assertSame(GetMoreTools::descriptor()->description, JsonAssert::at($result, 'tools', 5, 'description')); } + /** D66 (B10 fixed): every page is injected, and only the last one, without a next cursor, gets get_more_tools. */ #[Group('ac-02-8')] - public function testEveryPageGetsGetMoreToolsAndKeepsItsCursor(): void + public function testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsItsCursor(): void { $fixture = new LaravelTrackingFixture(); $harness = new LaravelHarness(PagedServer::class, $fixture->tracker()); @@ -65,8 +66,7 @@ public function testEveryPageGetsGetMoreToolsAndKeepsItsCursor(): void $this->assertIsString($first->nextCursor); $second = $harness->listTools($first->nextCursor); - $this->assertCount(16, $this->names($first)); // 15 + get_more_tools (D15, B10) - $this->assertSame(GetMoreTools::NAME, $this->names($first)[15]); + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $this->names($first)); $this->assertSame($twin->nextCursor, $first->nextCursor); $this->assertSame(['t15', GetMoreTools::NAME], $this->names($second)); $this->assertObjectNotHasProperty('nextCursor', $second); @@ -133,7 +133,7 @@ public function testACustomerListHandlerIsWrapped(): void $result = JsonAssert::at($answer, 'result'); $this->assertInstanceOf(\stdClass::class, $twin); $this->assertInstanceOf(\stdClass::class, $result); - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['x'], $this->names($result)); // D66: a page with a next cursor has no get_more_tools $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); $this->assertSame($this->keys($twin), $this->keys($result)); $this->assertSame('c', $result->nextCursor); @@ -156,7 +156,7 @@ public function testAThrowingListHandlerGivesTheSameErrorAsWithoutAgentCat(): vo $this->assertSame([], $fixture->lines()); } - /** Task 4 review M1: PHP's own `Error`, not an AgentCat exception, reaches laravel/mcp's `report()`. */ + /** PHP's own `Error`, not an AgentCat exception, reaches laravel/mcp's `report()`. */ public function testAListHandlerTheContainerCannotResolveToAMethodFailsLikeWithoutAgentCat(): void { $this->app?->bind(RecordingMethod::class, static fn(): \stdClass => new \stdClass()); @@ -174,7 +174,7 @@ public function testAListHandlerTheContainerCannotResolveToAMethodFailsLikeWitho $this->assertSame('Call to undefined method stdClass::handle()', $error->getMessage()); } - /** Task 4 review M1: laravel/mcp calls `handle()` on whatever the container returns, so AgentCat does too. */ + /** laravel/mcp calls `handle()` on whatever the container returns, so AgentCat does too. */ public function testAListHandlerThatIsNoMethodButHasHandleIsCalledAndWrapped(): void { $this->app?->bind(RecordingMethod::class, static fn(): object => new class { diff --git a/tests/Integration/McpSdk/CallErrorsTest.php b/tests/Integration/McpSdk/CallErrorsTest.php index c9e01f6..2a0067b 100644 --- a/tests/Integration/McpSdk/CallErrorsTest.php +++ b/tests/Integration/McpSdk/CallErrorsTest.php @@ -15,7 +15,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -/** How mcp/sdk's error answers become error events (plan header conflict 3, D16). */ +/** How mcp/sdk's error answers become error events (D16); a JSON-RPC error is never decorated. */ final class CallErrorsTest extends TestCase { private TrackingFixture $fixture; @@ -52,6 +52,7 @@ public function testAToolCallExceptionIsAnErrorResultAndTheEventKeepsTheExceptio $this->assertNotSame([], JsonAssert::at($event->error, 'frames'), 'the stashed exception wins over the result text'); } + #[Group('ac-01-80')] public function testAnErrorResultWithoutAnExceptionIsAnErrorEvent(): void { $twin = HttpHarness::result($this->fixture->harness($this->builder())->callTool('refuses', [])); @@ -70,6 +71,8 @@ public function testAnErrorResultWithoutAnExceptionIsAnErrorEvent(): void #[Group('ac-02-24')] #[Group('ac-02-25')] #[Group('ac-05-E5')] + #[Group('ac-01-80')] + #[Group('ac-04-44')] public function testAnUncaughtToolExceptionGivesTheLibrarysErrorAndAnEventWithTheException(): void { $twin = $this->fixture->harness($this->builder())->callTool('boom', []); @@ -79,7 +82,7 @@ public function testAnUncaughtToolExceptionGivesTheLibrarysErrorAndAnEventWithTh $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $answer->error); $this->assertEquals($twin->error, $answer->error); - $this->assertObjectNotHasProperty('result', $answer, 'no mint-back on a JSON-RPC error (header conflict 3)'); + $this->assertObjectNotHasProperty('result', $answer, 'no mint-back on a JSON-RPC error'); $event = $this->fixture->event(); $this->assertTrue($event->isError); $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); @@ -120,6 +123,7 @@ public function testInvalidArgumentsGiveTheLibrarysErrorAndAnErrorEvent(): void } #[Group('ac-05-E7')] + #[Group('ac-01-80')] public function testASuccessfulCallHasNoError(): void { $harness = $this->tracked(); diff --git a/tests/Integration/McpSdk/CallToolTest.php b/tests/Integration/McpSdk/CallToolTest.php index 2f2a332..b40f24a 100644 --- a/tests/Integration/McpSdk/CallToolTest.php +++ b/tests/Integration/McpSdk/CallToolTest.php @@ -12,11 +12,13 @@ use AgentCat\Options; use AgentCat\Tests\Support\Golden; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\McpSdk\CustomerHandler; use AgentCat\Tests\Support\McpSdk\GreetingTool; use AgentCat\Tests\Support\McpSdk\HttpHarness; use AgentCat\Tests\Support\McpSdk\McpSdkVersion; use AgentCat\Tests\Support\McpSdk\NameService; +use AgentCat\Tests\Support\McpSdk\RecordingTool; use AgentCat\Tests\Support\McpSdk\Tools; use AgentCat\Tests\Support\McpSdk\TrackingFixture; use AgentCat\ToolCall; @@ -31,6 +33,8 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Tool; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -39,6 +43,8 @@ final class CallToolTest extends TestCase { private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + private TrackingFixture $fixture; #[\Override] @@ -171,6 +177,193 @@ public function testAFailedRebuildStripsHeuristicallyAndIsTriedAgain(): void $this->assertCount(2, $this->fixture->events()); } + /** @return iterable */ + public static function eras(): iterable + { + yield 'handshake' => [false]; + yield '2026-07-28' => [true]; + } + + /** + * D66 (B10 fixed): a server that never listed (every PHP-FPM request) rebuilds from page 1 to the page that names + * the tool, so the 51st tool, alone on page 2 of mcp/sdk's pages of 50, keeps its own required `session_id`. + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testTheFiftyFirstToolKeepsItsOwnSessionId(bool $modern): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $arguments = ['session_id' => 'theirs', 'agent_id' => 'a1']; + + $answer = $modern ? $harness->callToolModern('own', $arguments) : $harness->callTool('own', $arguments); + + $this->assertObjectNotHasProperty('error', $answer); // without D66: -32602, "Missing required properties" + $this->assertSame([['session_id' => 'theirs', 'agent_id' => 'a1']], $own->calls); + $this->assertSame('foreign', $this->tag($this->fixture->event(), Constants::TAG_SESSION_SOURCE)); + $this->assertSame([ListingLines::ownSessionId('own'), self::REBUILT], $this->fixture->lines()); + } + + /** D66 (B10 fixed): each listed page merges into the registries, so a tool of an earlier page is stripped exactly. */ + #[Group('ac-02-8')] + public function testListedPagesMergeIntoTheRegistries(): void + { + $builder = TrackingFixture::builder()->setPaginationLimit(1); + $echo = Tools::echo($builder); + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $cursor = JsonAssert::at($harness->listTools(), 'nextCursor'); + $this->assertIsString($cursor); + $harness->listTools($cursor); + + $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']); + $harness->callTool('own', ['session_id' => 'mine']); + + $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], $echo->calls, 'agent tracking is off: agent_id is not ours'); + $this->assertSame([['session_id' => 'mine']], $own->calls); + $this->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'both tools were listed: no rebuild'); + } + + /** + * D66 (B10 fixed): the rebuild stops at the first page that names the tool. A customer list handler that ignores + * the cursor gives the same next cursor again. The rebuild for a tool that no page names reads two pages and stops + * at that cursor, as at the last page; the call is served with the heuristic strip. + */ + #[Group('ac-02-20')] + #[Group('ac-04-30')] + public function testAListHandlerThatIgnoresTheCursorEndsTheRebuild(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $listed = new Tool(name: 'listed', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: 'Listed', annotations: null); + $list = new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$listed], 'again')); + $builder->addRequestHandler($list); + $harness = $this->fixture->harness($builder); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start', 'context' => 'why']); + + $this->assertSame(2, $list->calls); // page 1, then page "again", whose cursor was already followed + $this->assertSame([['msg' => 'hi']], $echo->calls); // no page names echo: the heuristic strip + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 1, 'text')); + $this->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** + * D66: the rebuild reads the cursor that the client gets. mcp/sdk sends no `nextCursor` for a falsy one, so a page + * whose next cursor is `'0'` is the last page for the rebuild too, as for the listing. + */ + #[Group('ac-02-20')] + public function testANextCursorTheLibraryDoesNotSendEndsTheRebuild(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $listed = new Tool(name: 'listed', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: 'Listed', annotations: null); + $list = new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$listed], '0')); + $builder->addRequestHandler($list); + $harness = $this->fixture->harness($builder); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(1, $list->calls); + $this->assertSame([['msg' => 'hi']], $echo->calls); + $this->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** + * D66: a get_more_tools call rebuilds like any other tool. A server that never listed (PHP-FPM): the rebuild finds + * the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the customer's + * tool), and the `session_id` that tool declares stays the customer's: no session is read from it. + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-14')] + public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(bool $modern): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + $customer = new RecordingTool(); + $schema = ['type' => 'object', 'properties' => ['session_id' => ['type' => 'string']], 'required' => ['session_id']]; + $builder->add(new Tool(name: GetMoreTools::NAME, title: null, inputSchema: $schema, description: 'Mine', annotations: null), $customer); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $arguments = ['session_id' => 'theirs', 'context' => 'need X']; + + $answer = $modern ? $harness->callToolModern(GetMoreTools::NAME, $arguments) : $harness->callTool(GetMoreTools::NAME, $arguments); + + $this->assertSame([$this->cannedText()], $this->texts(HttpHarness::result($answer))); + $this->assertSame([], $customer->calls, 'AgentCat answers its calls'); + $event = $this->fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertSame(ListingLines::shadowedCall(6), $this->fixture->lines()); + } + + /** + * D66 (B10 fixed): a server that never listed rebuilds up to the page that names the tool, so a page-2 tool with an + * `outputSchema` is in the output registry and gets the `mcp_session` mirror. Page 1 alone would leave it out. + */ + #[Group('ac-02-36')] + #[Group('ac-02-20')] + public function testAPageTwoToolWithAnOutputSchemaGetsTheSessionMirror(): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + Tools::structured($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $structured = JsonAssert::at($result, 'structuredContent'); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys($structured)); + $this->assertSame('issued', JsonAssert::at($structured, Constants::MCP_SESSION_KEY, 'status')); + $this->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** + * D66 (B10 fixed): on a long-running server a page listed again replaces only its own entries. After page 1, page 2 + * and page 1 again, the tools of page 2 keep their entry, their own `session_id` and their mirror, with no rebuild. + */ + #[Group('ac-02-8')] + #[Group('ac-02-19')] + public function testListingAnEarlierPageAgainKeepsTheLaterPagesEntries(): void + { + $builder = TrackingFixture::builder()->setPaginationLimit(2); + Tools::echo($builder); + Tools::named($builder, 't01'); + $own = Tools::own($builder); + Tools::structured($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $cursor = JsonAssert::at($harness->listTools(), 'nextCursor'); + $this->assertIsString($cursor); + $second = JsonAssert::at($harness->listTools($cursor), 'tools'); + $this->assertIsList($second); + $this->assertSame(['own', 'structured', GetMoreTools::NAME], array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $second)); + $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'mine', 'agent_id' => 'a1']); + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $this->assertSame([['session_id' => 'mine', 'agent_id' => 'a1']], $own->calls, 'agent tracking is off: agent_id is not ours'); + $this->assertSame('foreign', $this->tag($this->fixture->event(0, 2), Constants::TAG_SESSION_SOURCE)); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys(JsonAssert::at($result, 'structuredContent'))); + $this->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'no rebuild'); + } + /** @return iterable}> */ public static function sessionlessArguments(): iterable { @@ -262,8 +455,8 @@ public function testStructuredOutputEchoesTheAgentAndTheActiveSession(): void } /** - * Header conflict 4: mcp/sdk cannot tell `{}` from `[]`, so an empty structuredContent is an empty object to the - * mirror and to the event. On main (C3), mcp/sdk itself rejects `[]` against an object outputSchema first. + * mcp/sdk cannot tell `{}` from `[]`, so an empty structuredContent is an empty object to the mirror and to the + * event. On 0.9.0 and main, mcp/sdk itself rejects `[]` against an object outputSchema first. */ public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void { @@ -276,7 +469,7 @@ public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); $event = $this->fixture->event(); - if (McpSdkVersion::isMain()) { + if (McpSdkVersion::hasModernEra()) { $this->assertTrue(JsonAssert::at($result, 'isError')); $texts = $this->texts($result); $this->assertCount(2, $texts); @@ -370,7 +563,7 @@ public function testAgentTrackingWithoutAnAgentIdAddsNoAgentTags(): void $this->assertSame([Constants::TAG_SESSION_SOURCE => 'supplied'], $event->tags); } - /** Review Focus 2: integer-like keys and nested empty objects reach the tool as mcp/sdk decodes them. */ + /** Integer-like keys and nested empty objects reach the tool as mcp/sdk decodes them. */ public function testTheToolGetsTheSameArrayAsWithoutAgentCat(): void { $properties = ['0' => ['type' => 'string'], '123' => ['type' => 'string'], 'nested' => ['type' => 'object']]; @@ -391,7 +584,7 @@ public function testTheToolGetsTheSameArrayAsWithoutAgentCat(): void $this->assertSame(['msg', '0', '123', 'nested', 'session_id'], $this->keys(JsonAssert::at($this->fixture->event()->parameters, 'request', 'params', 'arguments'))); } - /** Review Focus 3: a reference handler set after track() replaces AgentCat's; only the stashed exception is lost. */ + /** A reference handler set after track() replaces AgentCat's; only the stashed exception is lost. */ public function testAReferenceHandlerSetAfterTrackStillGivesEvents(): void { $builder = TrackingFixture::builder(); @@ -430,7 +623,7 @@ public function testAReferenceHandlerSetBeforeTrackRunsTheToolsAndKeepsTheExcept $this->assertSame('RuntimeException', JsonAssert::at($this->fixture->event()->error, 'type')); } - /** Review Focus 3: a tools/call handler added after track() answers, and AgentCat records its answer. */ + /** A tools/call handler added after track() answers, and AgentCat records its answer. */ public function testACustomerCallHandlerAddedAfterTrackIsRecorded(): void { $builder = TrackingFixture::builder(); @@ -463,7 +656,7 @@ public function testACustomerCallHandlerAddedAfterTrackIsRecorded(): void public static function containerMoments(): iterable { yield 'set before track()' => [true]; - yield 'set after track() (Review Focus 3)' => [false]; + yield 'set after track()' => [false]; } /** The customer's container builds the tool handlers, whenever it was set. */ @@ -510,7 +703,7 @@ public function testAToolHandlerThatIsAlsoACompletionProviderHasOneInstance(): v $this->assertSame($tool, JsonAssert::at($completion, 'completion', 'values')); } - /** Review Focus 5: each event of a long-lived server keeps its own handles and identity. */ + /** Each event of a long-lived server keeps its own handles and identity. */ public function testInterleavedCallsKeepTheirOwnValues(): void { $builder = TrackingFixture::builder(); diff --git a/tests/Integration/McpSdk/HooksTest.php b/tests/Integration/McpSdk/HooksTest.php index d5b6729..c107956 100644 --- a/tests/Integration/McpSdk/HooksTest.php +++ b/tests/Integration/McpSdk/HooksTest.php @@ -8,6 +8,7 @@ use AgentCat\Identity; use AgentCat\Internal\Constants; use AgentCat\Internal\Id\SessionId; +use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; @@ -17,12 +18,15 @@ use AgentCat\ToolCall; use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** Customer hooks through a real server: what they see, and that they run after the response (D04). */ final class HooksTest extends TestCase { + private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private TrackingFixture $fixture; #[\Override] @@ -101,6 +105,64 @@ public function testAThrowingIdentifyLeavesTheCallAndTheEventAnonymous(): void $this->assertContains('Error: identify callback threw - RuntimeException: who?', $this->fixture->lines()); } + /** + * An `Identity` always has a userId, so an identity without one reaches AgentCat as an array or another object. + * + * @return iterable + */ + public static function identitiesWithoutAUserId(): iterable + { + yield 'an array' => [['userName' => 'Ada', 'userData' => ['plan' => 'pro']]]; + yield 'a plain object' => [(object) ['userName' => 'Ada', 'userData' => ['plan' => 'pro']]]; + } + + #[DataProvider('identitiesWithoutAUserId')] + #[Group('ac-01-36')] + public function testAnIdentityWithoutAUserIdLeavesTheEventAnonymous(mixed $identity): void + { + $harness = $this->tracked(Options::fromArray(['identify' => static fn(): mixed => $identity])); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 0, 'text')); + $this->fixture->event(); + $this->assertCount(1, $this->fixture->transport->deliveries); + $body = Json::decode($this->fixture->transport->deliveries[0]->body); + $this->assertInstanceOf(\stdClass::class, $body); + foreach (['actor_id', 'identify_actor_given_id', 'identify_actor_name'] as $key) { + $this->assertObjectNotHasProperty($key, $body); + } + $this->assertSame('{}', $this->json(JsonAssert::at($body, 'identify_data'))); + $this->assertContains(sprintf('WARN: identify returned %s, expected AgentCat\Identity or null; the event stays anonymous', get_debug_type($identity)), $this->fixture->lines()); + } + + /** + * What AgentCat keeps for a tracked server is its project ID, its options and its listing; the rest of its state + * is read-only. The calls it serves leave nothing there, an identity or a session no more than anything else: each + * event has its own call's identity. + */ + #[Group('ac-01-39')] + public function testTheStateOfAServerKeepsNothingOfItsCalls(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $options = new Options(identify: static fn(ToolCall $call): Identity => new Identity('user-' . (is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : ''))); + $state = $this->fixture->track($builder, $options); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + $listed = [$state->registry, $state->outputRegistry, $state->everyPageRead]; + $injection = clone $state->injection; + + $harness->callTool('echo', ['msg' => '1', 'session_id' => 'start']); + $harness->callTool('echo', ['msg' => '2', 'session_id' => self::SESSION]); + + $events = $this->fixture->events(); + $this->assertSame(['user-1', 'user-2'], array_map(static fn(Event $event): ?string => $event->identifyActorGivenId, $events)); + $this->assertSame([TrackingFixture::PROJECT, $options], [$state->projectId, $state->options]); + $this->assertSame($listed, [$state->registry, $state->outputRegistry, $state->everyPageRead], 'the calls changed nothing in the listing'); + $this->assertEquals($injection, $state->injection, 'nor in the injection state'); + } + #[Group('ac-01-30')] public function testAThrowingResolveSessionIdMintsASessionAndAnnouncesNothing(): void { diff --git a/tests/Integration/McpSdk/ListToolsTest.php b/tests/Integration/McpSdk/ListToolsTest.php index a9b28c0..6481df9 100644 --- a/tests/Integration/McpSdk/ListToolsTest.php +++ b/tests/Integration/McpSdk/ListToolsTest.php @@ -17,6 +17,7 @@ use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\McpSdk\CustomerHandler; use AgentCat\Tests\Support\McpSdk\HttpHarness; use AgentCat\Tests\Support\McpSdk\RecordingTool; @@ -114,8 +115,9 @@ public function testTheModernEraGetsTheSameInjectedListing(): void $this->assertSame('complete', $modern->resultType); } + /** D66 (B10 fixed): every page is injected, and only the last one, without a next cursor, gets get_more_tools. */ #[Group('ac-02-8')] - public function testEveryPageGetsGetMoreToolsAndKeepsTheLibrarysCursor(): void + public function testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsTheLibrarysCursor(): void { $build = function (bool $tracked): HttpHarness { $builder = $this->builder()->setPaginationLimit(1) @@ -134,12 +136,41 @@ public function testEveryPageGetsGetMoreToolsAndKeepsTheLibrarysCursor(): void $this->assertIsString($first->nextCursor); $second = $harness->listTools($first->nextCursor); - $this->assertSame(['a', GetMoreTools::NAME], $this->names($first)); + $this->assertSame(['a'], $this->names($first)); $this->assertSame($twin->nextCursor, $first->nextCursor); $this->assertSame(['b', GetMoreTools::NAME], $this->names($second)); $this->assertObjectNotHasProperty('nextCursor', $second); } + /** + * A customer's own get_more_tools on page 1: the process that served page 1 lists no second get_more_tools on the + * last page. A process that did not serve it (every request under PHP-FPM) lists AgentCat's there too. + */ + #[Group('ac-02-8')] + #[Group('ac-02-14')] + public function testACustomerGetMoreToolsOnAnEarlierPageIsTheOnlyOneTheProcessThatServedItLists(): void + { + $build = function (): HttpHarness { + $builder = $this->builder()->setPaginationLimit(1) + ->add(self::tool(GetMoreTools::NAME, 'Mine'), new RecordingTool()) + ->add(self::tool('b'), new RecordingTool()); + $this->track($builder); + + return $this->harness($builder); + }; + $harness = $build(); + + $first = $harness->listTools(); + $this->assertIsString($first->nextCursor); + $second = $harness->listTools($first->nextCursor); + $elsewhere = $build()->listTools($first->nextCursor); + + $this->assertSame([GetMoreTools::NAME], $this->names($first)); + $this->assertSame(['b'], $this->names($second)); + $this->assertSame(['b', GetMoreTools::NAME], $this->names($elsewhere)); + $this->assertSame([ListingLines::SHADOWED], array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'shadowed')))); + } + #[Group('ac-02-9')] public function testAToolAddedAfterInstallIsListedAndInjected(): void { @@ -373,17 +404,23 @@ public function testACustomerListHandlerAddedAfterInstallGetsTheInjection(): voi $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); } - /** @return iterable */ + /** + * The cursor a customer handler answers, and the names AgentCat lists. D66: a page with a next cursor on the wire + * has no get_more_tools. mcp/sdk sends no `nextCursor` for a falsy one (`''`, `'0'`), so such a page is the last. + * + * @return iterable}> + */ public static function cursors(): iterable { - yield 'none' => [null]; - yield 'empty' => ['']; - yield 'zero' => ['0']; - yield 'opaque' => ['c']; + yield 'none' => [null, ['x', GetMoreTools::NAME]]; + yield 'empty' => ['', ['x', GetMoreTools::NAME]]; + yield 'zero' => ['0', ['x', GetMoreTools::NAME]]; + yield 'opaque' => ['c', ['x']]; } + /** @param list $names */ #[DataProvider('cursors')] - public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor): void + public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor, array $names): void { $build = function (bool $tracked) use ($cursor): HttpHarness { $builder = $this->builder(); @@ -398,7 +435,7 @@ public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor): v $result = $build(true)->listTools(); - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame($names, $this->names($result)); $this->assertSame($this->keys($twin), $this->keys($result)); $this->assertSame($twin->nextCursor ?? null, $result->nextCursor ?? null); } @@ -410,7 +447,7 @@ public function testMembersALaterLibraryAddsToTheListingAreKept(): void $result = $this->customerListing(true, $listing); - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['x'], $this->names($result)); // D66: a page with a next cursor has no get_more_tools $this->assertSame(['tools', 'nextCursor', '_meta'], $this->keys($result)); $this->assertSame($twin->nextCursor, $result->nextCursor); $this->assertEquals($twin->_meta, $result->_meta); diff --git a/tests/Integration/McpSdk/ListingGoldenTest.php b/tests/Integration/McpSdk/ListingGoldenTest.php index 056f9eb..d2bb099 100644 --- a/tests/Integration/McpSdk/ListingGoldenTest.php +++ b/tests/Integration/McpSdk/ListingGoldenTest.php @@ -71,6 +71,7 @@ public static function configs(): iterable } #[DataProvider('configs')] + #[Group('ac-01-21')] public function testTheInjectedListingMatchesTypeScriptInBothEras(string $key, Options $options): void { $fixture = Golden::json('handles/listing-fixtures.json'); diff --git a/tests/Integration/McpSdk/McpSdkLibraryTest.php b/tests/Integration/McpSdk/McpSdkLibraryTest.php index b3cbe1d..3cf7bd4 100644 --- a/tests/Integration/McpSdk/McpSdkLibraryTest.php +++ b/tests/Integration/McpSdk/McpSdkLibraryTest.php @@ -26,7 +26,8 @@ public function testTheLibraryAnswersForTheBuilder(): void $this->assertTrue($library->supports($builder)); $this->assertFalse($library->supports(Server::builder()->build())); - $this->assertSame('mcp-sdk/builder', $library->sdk()); + $this->assertSame('mcp-sdk/builder', $library->sdk($builder)); + $this->assertSame('mcp-sdk/server', $library->sdk(Server::builder()->build())); $this->assertSame(McpSdkAdapter::SUPPORT_MATRIX, $library->supportMatrix()); $this->assertSame(['mcpSdkBuilder'], $library->signals($builder)); $this->assertSame([], $library->signals(new \stdClass())); diff --git a/tests/Integration/McpSdk/RequestContextSeamTest.php b/tests/Integration/McpSdk/RequestContextSeamTest.php index b1d6c96..441dc56 100644 --- a/tests/Integration/McpSdk/RequestContextSeamTest.php +++ b/tests/Integration/McpSdk/RequestContextSeamTest.php @@ -23,8 +23,8 @@ use Symfony\Component\HttpFoundation\RequestStack; /** - * The HTTP request of an mcp/sdk call from a framework bridge instead of `$_SERVER` (plan E2 header conflict 5): a - * worker that serves many requests in one process, where `$_SERVER` is stale, records each request's own. + * The HTTP request of an mcp/sdk call from a framework bridge instead of `$_SERVER`: a worker that serves many requests + * in one process, where `$_SERVER` is stale, records each request's own. */ final class RequestContextSeamTest extends TestCase { @@ -54,7 +54,7 @@ public function testTheClientsContextReplacesTheSapiGlobals(): void $this->assertSame('https://worker.test/mcp', JsonAssert::at($info, 'url')); } - /** Review Focus 4: one container, many requests, as in a Symfony Runtime worker under the CLI SAPI. */ + /** One container, many requests, as in a Symfony Runtime worker under the CLI SAPI. */ #[Group('ac-02-43')] public function testEachRequestOfAWorkerCarriesItsOwnHeaders(): void { diff --git a/tests/Integration/McpSdk/RequestContextTest.php b/tests/Integration/McpSdk/RequestContextTest.php index 502b159..1665c34 100644 --- a/tests/Integration/McpSdk/RequestContextTest.php +++ b/tests/Integration/McpSdk/RequestContextTest.php @@ -41,6 +41,7 @@ protected function tearDown(): void } #[Group('ac-02-44')] + #[Group('ac-01-81')] public function testClientInfoInMetaBeatsTheHandshakeFieldByField(): void { $harness = $this->tracked(); @@ -63,7 +64,7 @@ public function testTheProtocolTagComesFromTheCallNeverFromTheHandshake(): void { $harness = $this->tracked(); - // C2: mcp/sdk rejects a handshake call whose claim differs from its MCP-Protocol-Version header. + // mcp/sdk rejects a handshake call whose claim differs from its MCP-Protocol-Version header. $harness->callTool('echo', ['msg' => 'hi'], [Constants::META_PROTOCOL_VERSION_KEY => '2025-06-18']); $harness->callTool('echo', ['msg' => 'hi']); $harness->callToolModern('echo', ['msg' => 'hi']); @@ -212,7 +213,7 @@ public function testAProtocolClaimInMetaMustBeANonEmptyString(): void $this->assertNull($read(20250618)); } - /** N7: `authInfo` exists when the session holds an access token (mcp/sdk main), read by duck typing. */ + /** `authInfo` exists when the session holds an access token (mcp/sdk main), read by duck typing. */ public function testExtraCarriesTheAccessTokenAsAuthInfo(): void { $session = new Session(new InMemorySessionStore()); diff --git a/tests/Integration/McpSdk/ResponseEventTest.php b/tests/Integration/McpSdk/ResponseEventTest.php index 98afb08..5efcda4 100644 --- a/tests/Integration/McpSdk/ResponseEventTest.php +++ b/tests/Integration/McpSdk/ResponseEventTest.php @@ -131,11 +131,11 @@ public function testModernEraListenersSeeTheLibrarysTypes(): void $listing = $harness->listToolsModern(); $call = HttpHarness::result($harness->callToolModern('echo', ['msg' => 'hi'])); - // mcp/sdk 0.8.1 dispatches no ResponseEvent in the 2026-07-28 era; main does. + // mcp/sdk 0.8.1 dispatches no ResponseEvent in the 2026-07-28 era; 0.9.0 and main do. $lists = $this->dispatcher->results('tools/list'); $calls = $this->dispatcher->results('tools/call'); - $this->assertCount(McpSdkVersion::isMain() ? 1 : 0, $lists); - $this->assertCount(McpSdkVersion::isMain() ? 1 : 0, $calls); + $this->assertCount(McpSdkVersion::hasModernEra() ? 1 : 0, $lists); + $this->assertCount(McpSdkVersion::hasModernEra() ? 1 : 0, $calls); foreach ($lists as $result) { $this->assertInstanceOf(ListToolsResult::class, $result); $this->assertWireCarries($listing, $result); diff --git a/tests/Integration/PublishCustomEventTest.php b/tests/Integration/PublishCustomEventTest.php index 84d6d46..4c8d74b 100644 --- a/tests/Integration/PublishCustomEventTest.php +++ b/tests/Integration/PublishCustomEventTest.php @@ -41,6 +41,8 @@ final class PublishCustomEventTest extends TestCase private const string NOT_TRACKED = 'Server is not tracked. Please call agentcat.track() first or provide a session ID string.'; + private const string STATELESS = 'Server is not tracked: pass the Mcp\Server\Builder (or the Mcp\Server built from it) to AgentCat::publishCustomEvent(); Builder::buildStateless() returns a StatelessProtocol, which AgentCat cannot map to its Builder'; + private const string NO_SESSION = 'publishCustomEvent: no sessionId provided; event will be published without a session. Pass eventData.sessionId to attribute it to a task.'; private const string PUBLISHED_WITHOUT_SESSION = "Published custom event without a session with type 'agentcat:custom'"; @@ -116,6 +118,18 @@ public function testAFirstArgumentThatIsNeitherObjectNorStringIsATypeError(): vo $this->client->publishCustomEvent($bad, 'p'); // @phpstan-ignore argument.type (proves PHP's own type check) } + /** What `Builder::buildStateless()` returns cannot be mapped to its Builder, so the message says what to pass. */ + public function testAStatelessProtocolIsRejectedWithTheObjectToPass(): void + { + $protocol = TrackingFixture::builder()->buildStateless(); + + $error = Thrown::by(\InvalidArgumentException::class, fn() => $this->client->publishCustomEvent($protocol, self::PROJECT, new CustomEvent(sessionId: 'ses_1'))); + + $this->assertSame(self::STATELESS, $error->getMessage()); + $this->assertNothingQueued(); + $this->assertSame([], $this->logger->messages()); + } + /** @return iterable */ public static function untracked(): iterable { @@ -386,7 +400,7 @@ public function testUnderPhpUnitTheDefaultClientValidatesTheProjectAndQueuesNoth $this->assertSame(self::NO_PROJECT, $error->getMessage()); } - /** Review Focus 4: payloads that PHP cannot encode as they are. */ + /** Payloads that PHP cannot encode as they are never throw. */ public function testAPayloadPhpCannotEncodeNeverThrows(): void { $self = ['name' => 'loop']; @@ -401,7 +415,7 @@ public function testAPayloadPhpCannotEncodeNeverThrows(): void $this->assertSame(['Failed to sanitize event: RangeException: Maximum call stack size exceeded'], $failures); } - /** Review Focus 4: what remains encodable is delivered with the pipeline's markers and substitutions. */ + /** What remains encodable is delivered with the pipeline's markers and substitutions. */ public function testAPayloadWithoutACycleIsDeliveredWithMarkers(): void { $data = new CustomEvent(parameters: ['f' => static fn(): int => 1, 'r' => STDIN], response: NAN, message: "caf\xE9"); @@ -432,7 +446,7 @@ public function testAFailureAfterValidationIsLoggedAndSwallowed(): void $this->assertSame(['Warning: Failed to publish custom event - RuntimeException: boom'], $this->logger->messages()); } - /** Ruling R1: the event of a server goes out through the client that tracked it. */ + /** The event of a server goes out through the client that tracked it. */ public function testATrackedServerPublishesThroughTheClientThatTrackedIt(): void { $builder = $this->tracked(); @@ -446,7 +460,7 @@ public function testATrackedServerPublishesThroughTheClientThatTrackedIt(): void $this->assertSame('ses_1', JsonAssert::at($this->body(), 'session_id')); } - /** Ruling R3, `01` AC-69: the server built from a tracked Builder is accepted too. */ + /** ac-01-69: the server built from a tracked Builder is accepted too. */ #[Group('ac-01-69')] public function testTheServerBuiltFromATrackedBuilderIsAccepted(): void { @@ -481,8 +495,8 @@ public static function failedTracks(): iterable } /** - * Finding F1: after a track() that AgentCat could not complete (it logged why), a server it cannot find logs a - * warning instead of throwing into the customer's code, for example after an mcp/sdk upgrade. + * After a track() that AgentCat could not complete (it logged why), a server it cannot find logs a warning instead + * of throwing into the customer's code, for example after an mcp/sdk upgrade. * * @param \Closure(Client, Builder): void $fail */ diff --git a/tests/Integration/Symfony/BundleConfigurationTest.php b/tests/Integration/Symfony/BundleConfigurationTest.php index 7a1e68d..6f253c1 100644 --- a/tests/Integration/Symfony/BundleConfigurationTest.php +++ b/tests/Integration/Symfony/BundleConfigurationTest.php @@ -23,11 +23,10 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * Ruling P7: the shared client, which an application's own `AgentCat::track()` or `AgentCat::publishCustomEvent()` - * uses, follows the `agentcat` configuration before the process environment (plan E1 header conflict 12): a value can - * come from a literal in config/packages/agentcat.yaml, a parameter or a secret, which the environment does not hold. - * `AgentCatBundle::boot()` hands each value to the core. Each test boots a kernel: 0.2 to 0.3 s measured on PHP 8.3 - * and 8.5. + * The shared client, which an application's own `AgentCat::track()` or `AgentCat::publishCustomEvent()` uses, follows + * the `agentcat` configuration before the process environment: a value can come from a literal in + * config/packages/agentcat.yaml, a parameter or a secret, which the environment does not hold. `AgentCatBundle::boot()` + * hands each value to the core. Each test boots a kernel: 0.2 to 0.3 s measured on PHP 8.3 and 8.5. */ final class BundleConfigurationTest extends SymfonyTestCase { @@ -44,7 +43,7 @@ final class BundleConfigurationTest extends SymfonyTestCase /** The diagnostics variables of the process; a test turns diagnostics on as a platform variable can. */ private ?Environment $diagnosticsEnvironment = null; - /** The collector the configuration names for diagnostics: a closed local port (ruling P8 of Plan E1). */ + /** The collector the configuration names for diagnostics: a closed local port, so a test fails closed. */ private string $configuredCollector = ''; #[\Override] diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 026c1b5..3b42808 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -86,8 +86,8 @@ public function testEveryServerIsTrackedWithNoCode(): void } /** - * Header conflict 2 (Plan D carry): booting, and a console command that builds no MCP server, track nothing, so - * they get no diagnostics and no signal handlers, as Laravel's `artisan list`. + * Booting, and a console command that builds no MCP server, track nothing, so they get no diagnostics and no signal + * handlers, as Laravel's `artisan list`. */ #[MaximumDuration(1000)] public function testBootingAndACommandTrackNothing(): void @@ -110,6 +110,31 @@ public function testBootingAndACommandTrackNothing(): void $this->assertSame($handlers, $this->signalHandlers()); } + /** Every key of the `agentcat` configuration explains itself in `config:dump-reference`. */ + #[MaximumDuration(1000)] + public function testEveryKeyOfTheReferenceHasAnInfoText(): void + { + $console = new Application($this->kernel(agentcat: self::ON)); + $console->setAutoExit(false); + $output = new BufferedOutput(); + + $exit = $console->run(new ArrayInput(['command' => 'config:dump-reference', 'name' => 'agentcat']), $output); + $lines = explode("\n", $output->fetch()); + + $this->assertSame(0, $exit); + $bare = []; + foreach ($lines as $index => $line) { + if (1 === preg_match('/^ ([a-z_]+):/', $line, $key) && !str_starts_with($lines[$index - 1] ?? '', ' #')) { + $bare[] = $key[1]; + } + } + $this->assertSame([], $bare); + $dump = implode("\n", $lines); + $this->assertSame(5, substr_count($dump, 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'), 'every bool key states the accepted words'); + $this->assertStringContainsString('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. Takes true, false', $dump); + $this->assertStringContainsString('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. Takes true, false', $dump); + } + /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ #[MaximumDuration(1000)] public function testTheServerIsTrackedOncePerContainer(): void @@ -126,7 +151,7 @@ public function testTheServerIsTrackedOncePerContainer(): void $this->assertSame(['AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder'], array_values($started)); } - /** Review Focus 1, header conflict 1. */ + /** AgentCat's handler goes first and delegates to the customer's `tools/call` handler, which answers; one event. */ #[MaximumDuration(1000)] public function testACustomerToolsCallHandlerRunsAfterAgentCats(): void { @@ -155,9 +180,9 @@ public static function handlersCalls(): iterable } /** - * Header conflict 3, with a compiled container: an `addRequestHandlers` call of another shape stays where it is. The - * customer's handler still answers; it comes before AgentCat's, which still tracks the server (its listing), but - * no longer sees the calls that the customer's handler answers. + * With a compiled container, an `addRequestHandlers` call of another shape stays where it is. The customer's + * handler still answers; it comes before AgentCat's, which still tracks the server (its listing), but no longer + * sees the calls that the customer's handler answers. * * @param (\Closure(TaggedIteratorArgument): array)|null $shape */ @@ -232,9 +257,9 @@ public static function writtenNames(): iterable } /** - * The ruling of fix round 1: a name written in the configuration leaves the builder's definition as it is (no - * configurator), one from an environment variable reaches the configurator, which leaves the server untracked. An - * entry that is no name never fails the compile. + * A name written in the configuration leaves the builder's definition as it is (no configurator), one from an + * environment variable reaches the configurator, which leaves the server untracked. An entry that is no name never + * fails the compile. * * @param array $configurators */ @@ -325,7 +350,7 @@ public function testWithAgentCatOffNoLogDirectoryIsMade(): void $this->assertDirectoryDoesNotExist($kernel->getLogDir()); } - /** Review Focus 3, ruling P9: `AGENTCAT_ENABLED=maybe` gives one line, and the D31 default (off under PHPUnit). */ + /** `AGENTCAT_ENABLED=maybe` gives one line, and the D31 default (off under PHPUnit). */ #[MaximumDuration(2000)] public function testAnUnknownEnabledWordLogsOneLineAndLeavesTheServerAlone(): void { @@ -382,7 +407,7 @@ public function testAProjectsKeyWithAHyphenNamesTheServer(): void $this->assertSame([self::OTHER_PROJECT], $this->projects()); } - /** Review Focus 3 for the bundle's own keys: each of a wrong type logs one line, once, and its default is used. */ + /** The bundle's own keys: each of a wrong type logs one line, once, and its default is used. */ #[MaximumDuration(1000)] public function testBundleKeysOfAWrongTypeLogOneLineEachAndFallBack(): void { @@ -404,7 +429,7 @@ public function testBundleKeysOfAWrongTypeLogOneLineEachAndFallBack(): void $this->assertSame([self::PROJECT, self::OTHER_PROJECT], $this->projects()); } - /** M3 of Plan E1: `enable_tracing: '%env(AGENTCAT_ENABLE_TRACING)%'` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ + /** `enable_tracing: '%env(AGENTCAT_ENABLE_TRACING)%'` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ #[MaximumDuration(1000)] public function testAZeroFromTheEnvironmentTurnsTracingOff(): void { @@ -504,8 +529,8 @@ public function testAHookIdFromAVariableIsNotReadAndOneLineSaysSo(): void } /** - * Header conflict 5: the HTTP context of a call is the RequestStack's main request, not the SAPI globals, with the - * headers the client sent: not the `X-Php-Ob-Level` that Symfony's HttpKernel adds (Plan E1 Task 6 parity). + * The HTTP context of a call is the RequestStack's main request, not the SAPI globals, with the headers the client + * sent: not the `X-Php-Ob-Level` that Symfony's HttpKernel adds. */ #[MaximumDuration(1000)] public function testTheEventCarriesTheRequestOfTheKernel(): void @@ -552,7 +577,7 @@ public function testTheToolsOwnExceptionIsRecordedThroughTheBundlesReferenceHand $this->assertSame(['RuntimeException'], $this->fields('error', 'type')); } - /** Header conflict 6: a fake records the calls of every server, whatever the configuration says. */ + /** A fake records the calls of every server, whatever the configuration says. */ #[MaximumDuration(1000)] public function testAFakeRecordsTheCallsWhateverTheConfigurationSays(): void { @@ -618,7 +643,7 @@ public static function badLoggers(): iterable yield 'a service that is no logger' => ['request_stack']; } - /** D61, ruling P8: a `logger` that names no logger logs one line to the file and never breaks a request. */ + /** D61: a `logger` that names no logger logs one line to the file and never breaks a request. */ #[DataProvider('badLoggers')] #[MaximumDuration(1000)] public function testALoggerThatIsNoLoggerLogsOneLineToTheFile(string $id): void @@ -689,7 +714,7 @@ public function testWithoutAnApiUrlTheEventGoesToAClosedLocalPort(): void $this->assertStringStartsWith('http://127.0.0.1:', $urls[0]); } - /** Review Focus 3, ruling P9: mistyped and unset values; the container compiles and the request answers. */ + /** Mistyped and unset values; the container compiles and the request answers. */ #[MaximumDuration(1000)] public function testBadRuntimeValuesNeverBreakARequest(): void { diff --git a/tests/Integration/Symfony/CustomerTestSuiteTest.php b/tests/Integration/Symfony/CustomerTestSuiteTest.php index 00d2c50..e4ea618 100644 --- a/tests/Integration/Symfony/CustomerTestSuiteTest.php +++ b/tests/Integration/Symfony/CustomerTestSuiteTest.php @@ -18,11 +18,11 @@ use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; /** - * A customer's Symfony functional tests with AgentCat's bundle registered (plan E2 Review Focus 2, D31, ruling P9): a - * kernel booted by the test, requests sent to the MCP route, and the `agentcat` configuration empty, as the Flex recipe - * leaves it in the test environment. As a customer's `KernelBrowser` does, each request ends with the kernel's terminate - * phase, where AgentCat's `kernel.terminate` listener runs. Each test boots its kernels, which compile a container: - * about 0.3 s each, measured on PHP 8.3 and 8.5. + * A customer's Symfony functional tests with AgentCat's bundle registered (D31): a kernel booted by the test, requests + * sent to the MCP route, and the `agentcat` configuration empty, as the Flex recipe leaves it in the test environment. + * As a customer's `KernelBrowser` does, each request ends with the kernel's terminate phase, where AgentCat's + * `kernel.terminate` listener runs. Each test boots its kernels, which compile a container: about 0.3 s each, measured + * on PHP 8.3 and 8.5. */ final class CustomerTestSuiteTest extends SymfonyTestCase { @@ -45,7 +45,7 @@ public function testWithoutAFakeTheAnswersAreTheBundlesOwn(): void $this->assertFalse(EventQueue::global()->hasWork()); } - /** A fake before the kernel boots records each call: the bundle tracks with the fake's client (header conflict 6). */ + /** A fake before the kernel boots records each call: the bundle tracks with the fake's client. */ #[MaximumDuration(1000)] public function testAFakeBeforeTheKernelBootsRecordsTheCalls(): void { @@ -62,8 +62,8 @@ public function testAFakeBeforeTheKernelBootsRecordsTheCalls(): void } /** - * D41: the fake records what production sends, the HTTP request of each call included (header conflict 5): an - * `identify` service reads the call's `X-User` header, and the event carries the request's headers and URL. + * D41: the fake records what production sends, the HTTP request of each call included: an `identify` service reads + * the call's `X-User` header, and the event carries the request's headers and URL. */ #[MaximumDuration(2000)] public function testAFakeRecordsTheRequestOfEachCallAsProductionSendsIt(): void diff --git a/tests/Integration/Symfony/DeliveryTest.php b/tests/Integration/Symfony/DeliveryTest.php index 3a73170..331f326 100644 --- a/tests/Integration/Symfony/DeliveryTest.php +++ b/tests/Integration/Symfony/DeliveryTest.php @@ -21,10 +21,10 @@ use Symfony\Component\HttpKernel\KernelEvents; /** - * Delivery after each request of a Symfony application (plan E2 header conflict 7, D58): `TerminateListener` waits on - * `kernel.terminate`. PHPUnit runs on the `cli` SAPI, so the listener waits as in a Symfony Runtime worker, at most - * `RequestEndWait::WORKER_SECONDS`. The global queue records its deliveries, unless a test replaces it. Each test - * boots its own kernel, which compiles a container: 0.2 to 0.5 s measured on PHP 8.3 and 8.5. + * Delivery after each request of a Symfony application (D58): `TerminateListener` waits on `kernel.terminate`. PHPUnit + * runs on the `cli` SAPI, so the listener waits as in a Symfony Runtime worker, at most + * `RequestEndWait::WORKER_SECONDS`. The global queue records its deliveries, unless a test replaces it. Each test boots + * its own kernel, which compiles a container: 0.2 to 0.5 s measured on PHP 8.3 and 8.5. */ final class DeliveryTest extends SymfonyTestCase { @@ -104,7 +104,7 @@ public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): /** * The real curl transport. The collector answers after 4 s: a listener that waited for the 5 s shutdown budget - * would hold the terminate phase at least that long, and the worker's stop still has time to deliver (ruling P16). + * would hold the terminate phase at least that long, and the worker's stop still has time to deliver. */ #[MaximumDuration(12000)] public function testWithASlowApiTerminateReturnsAfterAboutOneSecondAndTheWorkerStopDelivers(): void @@ -132,11 +132,11 @@ public function testWithASlowApiTerminateReturnsAfterAboutOneSecondAndTheWorkerS } /** - * Ruling P10 (plan E1 final ruling I1, D04): one kernel, requests with different `X-User`, and an `identify` service - * that reads the `request_stack`, while the AgentCat API backs up. The first five deliveries stay in flight, so the - * later events wait for a free slot past their own request. Each hook still runs before its request's terminate - * phase ends, so it never reads a later request: it finds the stack empty, as `HttpKernel::handle()` pops the request - * before `kernel.terminate`. Hooks read the `ToolCall`, which carries each request's own headers. + * D04: one kernel, requests with different `X-User`, and an `identify` service that reads the `request_stack`, + * while the AgentCat API backs up. The first five deliveries stay in flight, so the later events wait for a free + * slot past their own request. Each hook still runs before its request's terminate phase ends, so it never reads a + * later request: it finds the stack empty, as `HttpKernel::handle()` pops the request before `kernel.terminate`. + * Hooks read the `ToolCall`, which carries each request's own headers. */ #[Group('ac-03-1')] #[MaximumDuration(2000)] diff --git a/tests/Integration/Symfony/SrcErrorGuardInstallTest.php b/tests/Integration/Symfony/SrcErrorGuardInstallTest.php new file mode 100644 index 0000000..d9fbdce --- /dev/null +++ b/tests/Integration/Symfony/SrcErrorGuardInstallTest.php @@ -0,0 +1,44 @@ +http($this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg')))->listTools(); + $this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg')); + + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } + + /** The bundles build the container behind the guard (AgentCat's compiler pass runs then). */ + #[MaximumDuration(1000)] + public function testTheGuardIsInFrontWhileTheContainerBuilds(): void + { + $probe = new class implements CompilerPassInterface { + public ?bool $inFront = null; + + #[\Override] + public function process(ContainerBuilder $container): void + { + $this->inFront = SrcErrorGuard::inFront(); + } + }; + + $this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg'), probe: $probe); + + $this->assertTrue($probe->inFront); + } +} diff --git a/tests/Integration/Symfony/WithoutMcpBundleTest.php b/tests/Integration/Symfony/WithoutMcpBundleTest.php index b5903c3..3172273 100644 --- a/tests/Integration/Symfony/WithoutMcpBundleTest.php +++ b/tests/Integration/Symfony/WithoutMcpBundleTest.php @@ -22,7 +22,7 @@ /** * AgentCatBundle in a Symfony application without symfony/mcp-bundle, FrameworkBundle and AgentCatBundle only: the Flex * recipe registers the bundle in every application that requires agentcat/sdk, a plain mcp/sdk one too. These tests - * need no symfony/mcp-bundle, so they run in the mcp/sdk main leg too (`tests/bin/mcp-sdk-main.sh`), which removes it. + * need no symfony/mcp-bundle, so they run in the mcp/sdk 0.9 leg too (`tests/bin/mcp-sdk-leg.sh`), which removes it. */ final class WithoutMcpBundleTest extends SymfonyTestCase { diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index f5914f9..e6f5b31 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -26,7 +26,7 @@ final class CurlHttpChannelTest extends TestCase private MemoryLogger $logger; - /** @var list<\Throwable> what the channels of this test swallowed (I2: the normal paths swallow nothing) */ + /** @var list<\Throwable> what the channels of this test swallowed (the normal paths swallow nothing) */ private array $swallowed = []; #[\Override] @@ -111,7 +111,7 @@ public function testARefusedConnectionAndATimeoutAreErrorsNotExceptions(): void $this->assertSame([], $this->logger->messages(), 'a failed request reaches only its callback'); } - /** Review Focus 5: no file is read and no redirect is followed. */ + /** No file is read and no redirect is followed. */ #[MaximumDuration(3000)] public function testOnlyHttpAndHttpsAreFetched(): void { @@ -223,7 +223,7 @@ public function testTheResponseBodyIsCappedAt64KiB(): void $this->assertSame(CurlHttpChannel::MAX_BODY_BYTES, strlen((string) $body)); } - /** I1: a background request in transfer is left out of `pending(false)` too. */ + /** A background request in transfer is left out of `pending(false)` too. */ #[MaximumDuration(3000)] public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): void { @@ -241,11 +241,11 @@ public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): } /** - * I2 and the N1 ruling: a signal handler (the SIGTERM drain) that runs inside a callback pumps, waits on and posts - * to the channel it interrupted. In the `header` callback the seam clears the busy flag first, as at PHP 8.4+'s - * callback entry: the nested pump's add and run are refused, the refusal marks the channel busy, and the request - * waits. In the `write` callback the channel is busy already. Either way: nothing pending, no wait, nothing failed, - * and every request completes. + * A signal handler (the SIGTERM drain) that runs inside a callback pumps, waits on and posts to the channel it + * interrupted. In the `header` callback the seam clears the busy flag first, as at PHP 8.4+'s callback entry: the + * nested pump's add and run are refused, the refusal marks the channel busy, and the request waits. In the `write` + * callback the channel is busy already. Either way: nothing pending, no wait, nothing failed, and every request + * completes. */ #[MaximumDuration(5000)] public function testAPumpFromInsideATransferCallbackLeavesTheChannelAlone(): void @@ -347,7 +347,7 @@ public function testAtMostTenTransfersRunAtOnce(): void $this->assertCount(12, $this->collector()->requests($url)); } - /** Ruling P11: a multi handle that cannot run fails its transfers, so a flush without a deadline cannot hang. */ + /** A multi handle that cannot run fails its transfers, so a flush without a deadline cannot hang. */ #[MaximumDuration(3000)] public function testAFailingExecFailsEveryTransferInsteadOfWaitingForever(): void { @@ -461,14 +461,18 @@ public function testARequestCurlCannotSetUpFailsThroughItsCallback(): void $this->assertSame(1, $channel->inTransfer(), 'the next request started in the same pass'); $this->assertTrue($this->drain($channel, 2.0)); $this->assertCount(3, $responses); - $nul = 'ValueError: curl_setopt_array(): cURL option must not contain any null bytes'; - $this->assertSame([$nul, $nul, null], array_map(static fn(HttpResponse $response): ?string => $response->error, $responses)); + $errors = array_map(static fn(HttpResponse $response): ?string => $response->error, $responses); + // PHP 8.6 names the option ("cURL option CURLOPT_URL must not โ€ฆ"); 8.3 to 8.5 do not. + $nul = '~^ValueError: curl_setopt_array\(\): cURL option .*must not contain any null bytes$~'; + $this->assertMatchesRegularExpression($nul, (string) $errors[0]); + $this->assertMatchesRegularExpression($nul, (string) $errors[1]); + $this->assertNull($errors[2]); $this->assertSame(202, $responses[2]->status); $this->assertCount(1, $this->collector()->requests($url)); } /** - * M2: curl_setopt_array() stops at the first option it rejects (libcurl takes no URL over 8 MB). Such a request + * curl_setopt_array() stops at the first option it rejects (libcurl takes no URL over 8 MB). Such a request * fails through its callback and is never added; the options that take the response come first, so nothing of * any response reaches STDOUT (the stdio MCP stream; PHPUnit fails a test that prints). */ @@ -494,7 +498,7 @@ public function testARequestWithARejectedOptionFailsAndPrintsNothing(): void $this->assertSame('/next', $requests[0]->path); } - /** I2: a throwable inside a pump never reaches the caller; the seam sees it, and the next pump goes on. */ + /** A throwable inside a pump never reaches the caller; the seam sees it, and the next pump goes on. */ #[MaximumDuration(3000)] public function testAThrowableInAPumpIsSwallowedAndTheTransfersGoOn(): void { @@ -579,9 +583,11 @@ public function testAWarningInACallbackStaysInsideTheChannel(): void } /** - * Ruling P7: only a wait with a transfer in progress reaches the NaN guard. Without the guard, PHP 8.4+ computes + * Only a wait with a transfer in progress reaches the NaN guard. Without the guard, PHP 8.4+ computes * a NAN bound (`max(0.0, NAN)`), which curl_multi_select() rejects with a ValueError: tearDown() sees it swallowed. - * CurlSupportTest pins the bound itself. + * CurlSupportTest pins the bound itself. The test counts waits instead of timing one: libcurl ends a wait early at a + * timer of its own (on libcurl 7.88, one due about 200 ms after the transfer starts), and until a pump() runs that + * timer, every later wait returns at once. */ #[MaximumDuration(3000)] public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): void @@ -611,12 +617,22 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): foreach ([NAN, -1.0] as $seconds) { $begin = hrtime(true); - $channel->waitForActivity($seconds); - $this->assertLessThan(0.5, (hrtime(true) - $begin) / 1e9); + for ($i = 0; $i < 10; ++$i) { + $channel->pump(); // runs libcurl's due timers, which would end a wait early + $channel->waitForActivity($seconds); + } + $this->assertLessThan(1.0, (hrtime(true) - $begin) / 1e9, 'ten waits return at once, not after a second each'); + } + // A positive bound does block: pump() and a wait of 0.2 s, for 0.6 s, run about three times. Each libcurl timer + // can end one wait early (one on libcurl 7.88); fewer than 8 passes means waits of more than about 80 ms. + $passes = 0; + $until = hrtime(true) + 600_000_000; + while (hrtime(true) < $until) { + $channel->pump(); + $channel->waitForActivity(0.2); + ++$passes; } - $begin = hrtime(true); - $channel->waitForActivity(0.2); - $this->assertGreaterThanOrEqual(0.15, (hrtime(true) - $begin) / 1e9, 'a positive bound does block'); + $this->assertLessThan(8, $passes, 'a positive bound does block'); fclose($connection); fclose($server); diff --git a/tests/Integration/Transport/CurlMultiTest.php b/tests/Integration/Transport/CurlMultiTest.php index 36e5e11..8cbb93a 100644 --- a/tests/Integration/Transport/CurlMultiTest.php +++ b/tests/Integration/Transport/CurlMultiTest.php @@ -28,11 +28,11 @@ public static function tearDownAfterClass(): void } /** - * I2 and the N1 ruling: a signal handler runs between two opcodes, so also inside a PHP write callback of - * `curl_multi_exec()`. There libcurl answers every multi call with `CURLM_RECURSIVE_API_CALL`, and the engine must - * leave every handle and transfer as it is: removing one there frees a handle that libcurl still uses (a segfault). - * A refused add waits for the next `run()`. The test keeps every handle alive, so that a regression fails an - * assertion instead of crashing PHPUnit. + * A signal handler runs between two opcodes, so also inside a PHP write callback of `curl_multi_exec()`. There + * libcurl answers every multi call with `CURLM_RECURSIVE_API_CALL`, and the engine must leave every handle and + * transfer as it is: removing one there frees a handle that libcurl still uses (a segfault). A refused add waits + * for the next `run()`. The test keeps every handle alive, so that a regression fails an assertion instead of + * crashing PHPUnit. */ #[MaximumDuration(5000)] public function testACallFromInsideATransferCallbackLeavesEveryTransferAsItIs(): void @@ -87,7 +87,7 @@ public function testACallFromInsideATransferCallbackLeavesEveryTransferAsItIs(): $this->assertSame(0, $multi->count()); } - /** N1: PHP runs a signal that arrived during curl_multi_exec() right after it returns; a call from there works as usual. */ + /** PHP runs a signal that arrived during curl_multi_exec() right after it returns; a call from there works as usual. */ #[MaximumDuration(5000)] public function testACallRightAfterCurlMultiExecReturnsWorksAsUsual(): void { diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index affe71e..650a081 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Transport\ApiBaseUrl; +use AgentCat\Internal\Transport\CurlSupport; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\RetryPolicy; @@ -17,6 +18,7 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Thrown; +use AgentCat\Tests\Support\UnreadTransfers; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -31,11 +33,17 @@ final class CurlTransportTest extends TestCase private const string GAVE_UP = 'Failed to deliver AgentCat event after retries: '; - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled ('; + private const string NO_CURL = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; /** Seconds; every blocking call in these tests has a deadline. */ private const float DEADLINE = 5.0; + /** The line of a host without `usleep()` and `time_nanosleep()`. */ + private const string NO_SLEEP = 'WARN: AgentCat cannot wait for the retry of a failed event: usleep and time_nanosleep are disabled; a delivery ends at the first retry that is not due yet; remove one of them from disable_functions'; + + /** The sleep functions of a waiting transport. */ + private const array SLEEP_FUNCTIONS = ['usleep', 'time_nanosleep']; + private static ?MockCollector $collector = null; private MemoryLogger $logger; @@ -58,12 +66,14 @@ protected function setUp(): void { $this->logger = new MemoryLogger(); Log::setLogger($this->logger); + CurlSupport::reset(); } #[\Override] protected function tearDown(): void { Log::reset(); + CurlSupport::reset(); } #[Group('ac-03-1')] @@ -410,9 +420,12 @@ public function testAnInvalidUrlDropsTheDeliveryWithOneLine(): void $transport->start(new Delivery("http://127.0.0.1/\0", '{}', 'evt_x', 'summary')); $this->assertSame(0, $transport->inFlight()); - $this->assertSame( - [self::GAVE_UP . 'ValueError: curl_setopt_array(): cURL option must not contain any null bytes'], - $this->logger->messages(), + $messages = $this->logger->messages(); + $this->assertCount(1, $messages); + // PHP 8.6 names the option ("cURL option CURLOPT_URL must not โ€ฆ"); 8.3 to 8.5 do not. + $this->assertMatchesRegularExpression( + '~^' . preg_quote(self::GAVE_UP . 'ValueError: curl_setopt_array(): cURL option ', '~') . '.*must not contain any null bytes$~', + $messages[0], ); } @@ -538,57 +551,62 @@ public function testAtMostFiveTransfersRunAtOnce(): void } /** - * Fix round 1: a transfer that finished while a slow logger held the loop is collected at once, not after a wait - * of up to 1 s on the socket of a slower transfer. evt_b fails at once (connection refused) and its line blocks the - * loop while evt_a's answer arrives. The time counts from the later of two moments, the slow logger letting go and - * the collector answering evt_a, so a loaded machine that delays either one does not count against the transport: - * with the fix it collects evt_a within milliseconds, without it about 1 s later. + * Fix round 1: every curl_multi_exec() is followed by a read of the transfers it finished, so a finished transfer is + * collected by the call that finished it, not after a wait of up to 1 s on the socket of a slower transfer. evt_b + * fails at once (connection refused) and its line blocks the loop (a slow customer PSR logger) while evt_a's answer + * arrives; evt_c goes to a local server that never answers, so its socket is the one a wait blocks on. The test + * asserts an order, not a time: after every start() and pump(), libcurl holds no finished transfer that the transport + * has not read, and evt_a is collected while evt_c is still in transfer. A time cannot tell the two apart with 1 s of + * slack: without the fix, evt_a waits one wait at most (`MAX_WAIT_SECONDS`, 1 s). */ #[MaximumDuration(2000)] public function testAFinishedTransferIsCollectedWithoutWaitingForOthers(): void { - $collector = self::collector(); - $slow = $collector->scenario([], ['status' => 202, 'delayMs' => 2000, 'body' => '']); - $fast = $collector->scenario([], ['status' => 202, 'delayMs' => 300, 'body' => '']); + $silent = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); // the kernel accepts; nothing reads + $this->assertNotFalse($silent, 'no local server: ' . $errorMessage); + $address = stream_socket_get_name($silent, false); + $this->assertIsString($address); + $unanswered = 'http://' . $address; + $fast = self::collector()->scenario([], ['status' => 202, 'delayMs' => 300, 'body' => '']); $logger = new class extends AbstractLogger { - /** @var array when each line was logged (wall clock) */ - public array $at = []; - - /** When the slow line was done (wall clock). */ - public float $released = 0.0; + /** @var list */ + public array $lines = []; /** @param mixed[] $context */ #[\Override] public function log($level, string|\Stringable $message, array $context = []): void { - $this->at[(string) $message] = microtime(true); + $this->lines[] = (string) $message; if (str_starts_with((string) $message, 'Failed to send event evt_b')) { usleep(700_000); // a slow customer PSR logger; evt_a's response arrives meanwhile - $this->released = microtime(true); } } }; Log::setLogger($logger); $transport = new CurlTransport(new RetryPolicy([10.0])); - $transport->start(new Delivery(ApiBaseUrl::endpoint($slow), '{}', 'evt_c', 'slow')); - $transport->start(new Delivery(ApiBaseUrl::endpoint($fast), '{}', 'evt_a', 'fast')); - $transport->start(new Delivery(ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), '{}', 'evt_b', 'refused')); + foreach ([[$unanswered, 'evt_c', 'slow'], [$fast, 'evt_a', 'fast'], [MockCollector::closedPortUrl(), 'evt_b', 'refused']] as [$url, $id, $summary]) { + $transport->start(new Delivery(ApiBaseUrl::endpoint($url), '{}', $id, $summary)); + $this->assertSame(0, UnreadTransfers::of($transport), 'start() left a finished transfer unread: ' . implode(' / ', $logger->lines)); + } $sent = 'Successfully sent event evt_a | fast'; - // drain() without its deadline: evt_c runs 2 s and evt_b retries in 10 s, so stop once evt_a is collected. + // drain() without its deadline: evt_c never ends and evt_b retries in 10 s, so stop once evt_a is collected (no + // wait after it: a wait would block on evt_c's socket for 1 s). $deadline = $transport->now() + self::DEADLINE; - while (!isset($logger->at[$sent]) && $transport->now() < $deadline) { + while (true) { $transport->pump(); + $this->assertSame(0, UnreadTransfers::of($transport), 'pump() left a finished transfer unread: ' . implode(' / ', $logger->lines)); + if (in_array($sent, $logger->lines, true) || $transport->now() >= $deadline) { + break; + } $transport->waitForActivity($deadline); } - $lines = implode(' / ', array_keys($logger->at)); - $this->assertArrayHasKey($sent, $logger->at, $lines); + $lines = implode(' / ', $logger->lines); $this->assertStringStartsWith('Failed to send event evt_b, retrying... [Error: cURL error 7: ', $lines); - $this->assertGreaterThan(0.0, $logger->released); - $answered = $collector->requests($fast)[0]->end ?? null; // the collector logs just before it answers - $this->assertIsNumeric($answered); - $this->assertLessThan(0.5, $logger->at[$sent] - max($logger->released, (float) $answered), $lines); + $this->assertContains($sent, $logger->lines, $lines); + $this->assertSame(2, $transport->inFlight(), 'evt_a is collected while evt_c is in transfer and evt_b waits for its retry'); + fclose($silent); } #[MaximumDuration(2000)] @@ -819,7 +837,6 @@ public function testDueRetriesStartInDueOrder(): void $this->assertSame(['a', 'b', 'a', 'a', 'b'], $order->getArrayCopy()); } - #[Group('review-focus-2')] #[MaximumDuration(1000)] public function testWarningsDuringAnAttemptDoNotReachTheHostHandler(): void { @@ -847,14 +864,80 @@ public function testWaitForActivityReturnsWhenARetryIsDue(): void $this->pumpUntil($transport, fn(): bool => $this->failures() === 1); $started = hrtime(true); - $transport->waitForActivity(INF); // the clock is frozen: the retry is due in 0.05 s of real sleep + $waited = $transport->waitForActivity(INF); // the clock is frozen: the retry is due in 0.05 s of real sleep $elapsed = (hrtime(true) - $started) / 1e9; + $this->assertTrue($waited); $this->assertGreaterThanOrEqual(0.045, $elapsed); $this->assertLessThan(0.5, $elapsed); $this->assertSame(1, $this->failures(), 'waitForActivity() does no work'); } + /** + * A host that disables `usleep()` and `time_nanosleep()`: a drain cannot wait for a retry, so it ends at once (one + * line per transport) instead of spinning until its deadline. The retry stays scheduled for a later pump. + */ + #[Group('hardened-host')] + #[MaximumDuration(2000)] + public function testWithoutASleepFunctionADrainEndsAtTheFirstRetryThatIsNotDue(): void + { + $transport = new CurlTransport(new RetryPolicy([5.0]), functionExists: static fn(string $function): bool => !in_array($function, self::SLEEP_FUNCTIONS, true)); + $started = hrtime(true); + + $settled = $transport->send($this->delivery(MockCollector::closedPortUrl()), $transport->now() + 1.0); + $again = $transport->drain($transport->now() + 1.0); + + $this->assertLessThan(0.5, (hrtime(true) - $started) / 1e9, 'no spin until the deadline'); + $this->assertFalse($settled); + $this->assertFalse($again); + $this->assertSame(1, $transport->inFlight(), 'the retry waits for a later pump'); + $this->assertSame(1, $this->failures()); + $this->assertSame([self::NO_SLEEP], array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'sleep')))); + } + + /** Without a sleep function, a retry that is due already needs no wait: the drain goes on and starts it. */ + #[Group('hardened-host')] + #[MaximumDuration(2000)] + public function testWithoutASleepFunctionAnOverdueRetryNeedsNoWait(): void + { + $now = 100.0; + $clock = static function () use (&$now): float { + return $now; + }; + $transport = new CurlTransport(new RetryPolicy([0.05]), clock: $clock, functionExists: static fn(string $function): bool => !in_array($function, self::SLEEP_FUNCTIONS, true)); + $transport->start($this->delivery(MockCollector::closedPortUrl())); + $this->pumpUntil($transport, fn(): bool => $this->failures() === 1); + $now = 101.0; // the retry was due at 100.05 + + $this->assertTrue($transport->waitForActivity(INF)); + $this->assertTrue($transport->waitForActivity($now - 1.0), 'a passed deadline needs no wait either'); + $this->assertSame(1, $this->failures(), 'waitForActivity() does no work'); + $this->assertCount(1, $this->logger->messages()); + } + + /** Without `usleep()`, a wait for a retry sleeps with `time_nanosleep()`. */ + #[Group('hardened-host')] + #[MaximumDuration(1000)] + public function testWithoutUsleepAWaitSleepsWithTimeNanosleep(): void + { + $now = 100.0; + $clock = static function () use (&$now): float { + return $now; + }; + $transport = new CurlTransport(new RetryPolicy([0.05]), clock: $clock, functionExists: static fn(string $function): bool => $function !== 'usleep'); + $transport->start($this->delivery(MockCollector::closedPortUrl())); + $this->pumpUntil($transport, fn(): bool => $this->failures() === 1); + $started = hrtime(true); + + $waited = $transport->waitForActivity(INF); + + $elapsed = (hrtime(true) - $started) / 1e9; + $this->assertTrue($waited); + $this->assertGreaterThanOrEqual(0.045, $elapsed); + $this->assertLessThan(0.5, $elapsed); + $this->assertCount(1, $this->logger->messages(), 'no line: the wait works'); + } + #[MaximumDuration(1000)] public function testWaitForActivityReturnsAtOnceWhenARetryIsOverdue(): void { @@ -901,7 +984,6 @@ public function testNowIsMonotonicSeconds(): void $this->assertEqualsWithDelta(hrtime(true) / 1e9, $second, 1.0); } - #[Group('review-focus-2')] #[MaximumDuration(2000)] public function testThrowingErrorHandlerSeesNoWarningAndIsRestored(): void { diff --git a/tests/Runtime/ApacheTest.php b/tests/Runtime/ApacheTest.php index 101ed17..3ad6d46 100644 --- a/tests/Runtime/ApacheTest.php +++ b/tests/Runtime/ApacheTest.php @@ -18,7 +18,7 @@ * pass sends PHP's output buffers before the hooks and the delivery (D57). The answer of an mcp/sdk server that sets * its own Content-Length, of a symfony/mcp-bundle server (D62: `AgentCatBundle::boot()` registers the listener that * sets it) and of a laravel/mcp server (D59) then reaches the client before the delivery. Each upper bound sits about - * 1 s above the measured time and about 1 s below the time a held answer takes, the collector's 2 s (ruling P16). + * 1 s above the measured time and about 1 s below the time a held answer takes, the collector's 2 s. */ #[Group('runtime')] final class ApacheTest extends RuntimeTestCase @@ -105,7 +105,7 @@ private function apache(string $events, string $diagnostics): array foreach ($files as $name => $contents) { $this->assertNotFalse(file_put_contents($out . '/' . $name, $contents . "\n")); } - $runtime = DockerRuntime::start(Images::APACHE, ['apache2-foreground'], $env, [ + $runtime = DockerRuntime::start(Images::apache(), ['apache2-foreground'], $env, [ $out . '/ports.conf' => '/etc/apache2/ports.conf', $out . '/site.conf' => '/etc/apache2/sites-enabled/000-default.conf', $out . '/php.ini' => '/usr/local/etc/php/conf.d/zz-agentcat.ini', diff --git a/tests/Runtime/CliTest.php b/tests/Runtime/CliTest.php index 714a70f..a3d6c90 100644 --- a/tests/Runtime/CliTest.php +++ b/tests/Runtime/CliTest.php @@ -18,8 +18,8 @@ /** * Stdio servers in the PHP CLI image (the official image plus ext-pcntl), the way an MCP host or an orchestrator runs - * them: `docker run --interactive --init`, so PHP is not PID 1 and a signal with the default disposition ends it - * (header conflict 11). And the signal guard under Swoole (header conflict 13). + * them: `docker run --interactive --init`, so PHP is not PID 1 and a signal with the default disposition ends it. + * And the signal guard under Swoole. */ #[Group('runtime')] final class CliTest extends RuntimeTestCase @@ -38,16 +38,19 @@ final class CliTest extends RuntimeTestCase private const int SIGTERM = 15; - /** @return iterable, string}> the server's command, and AgentCat's log in `/out` */ + /** @return iterable, string, int}> the server's command, AgentCat's log in `/out`, and the exit code after the signal */ public static function stdioServers(): iterable { - yield 'mcp/sdk' => [['php', self::repo() . '/tests/E2E/Fixtures/stdio-server.php'], 'agentcat.log']; - yield 'laravel/mcp artisan mcp:start' => [['php', self::repo() . '/tests/E2E/Laravel/artisan.php'], 'app/storage/logs/agentcat.log']; + yield 'mcp/sdk' => [['php', self::repo() . '/tests/E2E/Fixtures/stdio-server.php'], 'agentcat.log', 128 + self::SIGTERM]; + yield 'laravel/mcp artisan mcp:start' => [['php', self::repo() . '/tests/E2E/Laravel/artisan.php'], 'app/storage/logs/agentcat.log', 128 + self::SIGTERM]; + yield 'symfony/mcp-bundle mcp:server' => [['php', self::repo() . '/tests/Runtime/Fixtures/symfony-console.php', 'mcp:server'], 'symfony/log/agentcat.log', 0]; } /** - * `03` AC-10 and AC-37: the signal drain delivers the queued event, then raises SIGTERM again with the default - * disposition, so the container ends with 128 + 15; stdout carries only the JSON-RPC answers. + * ac-03-10 and ac-03-37: the signal drain delivers the queued event, then raises SIGTERM again with the default + * disposition, so the container ends with 128 + 15; stdout carries only the JSON-RPC answers. Under Symfony's + * `mcp:server` the drain chains to the handler that Symfony Console registered, which dispatches its signal event + * (exit code 0 by default) and calls `exit(0)`: the code is Console's own, 0, and AgentCat never raises the signal. * * @param list $server */ @@ -55,11 +58,11 @@ public static function stdioServers(): iterable #[Group('ac-03-10')] #[Group('ac-03-37')] #[MaximumDuration(60000)] - public function testSigtermDeliversTheQueueOnAStockBuildAndStdoutStaysJsonRpc(array $server, string $log): void + public function testSigtermDeliversTheQueueOnAStockBuildAndStdoutStaysJsonRpc(array $server, string $log, int $exitCode): void { $scenario = self::$collector->scenario(); $out = $this->out(); - $client = StdioMcpClient::command(DockerRuntime::command(Images::CLI, $server, $this->env($scenario), [$out => '/out']), timeout: self::CLIENT_SECONDS); + $client = StdioMcpClient::command(DockerRuntime::command(Images::cli(), $server, $this->env($scenario), [$out => '/out']), timeout: self::CLIENT_SECONDS); // The handshake era: mcp/sdk 0.8.1 serves 2026-07-28 over stdio only on main; laravel/mcp serves both. $client->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'cli', 'version' => '1']]]); $this->assertSame('2025-06-18', JsonAssert::at(HttpHarness::result($client->receive(1, self::ANSWER_SECONDS)), 'protocolVersion')); @@ -70,20 +73,20 @@ public function testSigtermDeliversTheQueueOnAStockBuildAndStdoutStaysJsonRpc(ar $client->signal(self::SIGTERM); // docker run passes it to the container's init, which passes it to PHP - $this->assertSame(128 + self::SIGTERM, $client->waitForExit(self::EXIT_SECONDS), $client->process()->getErrorOutput()); + $this->assertSame($exitCode, $client->waitForExit(self::EXIT_SECONDS), $client->process()->getErrorOutput()); $this->assertCount(1, self::$collector->requests($scenario)); $this->assertSame(1, substr_count($this->outFile($log), MockCollector::SENT_EVENT), 'the drain had the delivery\'s answer before PHP ended'); $lines = $client->stdoutLines(); $this->assertCount(2, $lines, 'the answers to initialize and tools/call'); foreach ($lines as $line) { - $this->assertInstanceOf(\stdClass::class, Json::decode($line), $line); + $this->assertSame('2.0', JsonAssert::at(Json::decode($line), 'jsonrpc'), $line); } } /** - * Plan E1 header conflict 14: ext-swoole stops its workers with C-level signal handlers that PHP cannot see or - * chain, so the first event installs no SIGTERM handler there; the same script in the CLI image installs one. Both - * images have ext-pcntl, so the guard, not a missing extension, keeps the handler out. + * ext-swoole stops its workers with C-level signal handlers that PHP cannot see or chain, so the first event + * installs no SIGTERM handler there; the same script in the CLI image installs one. Both images have ext-pcntl, so + * the guard, not a missing extension, keeps the handler out. */ #[MaximumDuration(60000)] public function testNoSignalHandlerIsInstalledUnderSwoole(): void @@ -91,8 +94,8 @@ public function testNoSignalHandlerIsInstalledUnderSwoole(): void $script = ['php', self::repo() . '/tests/Runtime/Fixtures/swoole-guard.php']; $out = $this->out(); - $swoole = DockerRuntime::run(Images::SWOOLE, $script, $this->env(), [$out => '/out']); - $plain = DockerRuntime::run(Images::CLI, $script, $this->env(), [$out => '/out']); + $swoole = DockerRuntime::run(Images::swoole(), $script, $this->env(), [$out => '/out']); + $plain = DockerRuntime::run(Images::cli(), $script, $this->env(), [$out => '/out']); $this->assertSame(0, $swoole['exitCode'], $swoole['stderr']); $this->assertEquals((object) ['swoole' => true, 'pcntl' => true, 'agentcatHandler' => false], Json::decode($swoole['stdout']), $swoole['stderr']); diff --git a/tests/Runtime/FastCgiClientTest.php b/tests/Runtime/FastCgiClientTest.php index 2c60385..34d283f 100644 --- a/tests/Runtime/FastCgiClientTest.php +++ b/tests/Runtime/FastCgiClientTest.php @@ -66,7 +66,7 @@ private function fpm(): int $out = $this->out(); $port = $this->freePort(); file_put_contents($out . '/pool.conf', "[www]\nlisten = 127.0.0.1:{$port}\npm = static\npm.max_children = 1\n"); - DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env(), [$out . '/pool.conf' => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf'], port: $port); + DockerRuntime::start(Images::fpm(), ['php-fpm', '-F'], $this->env(), [$out . '/pool.conf' => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf'], port: $port); return $port; } diff --git a/tests/Runtime/Fixtures/frankenphp/scenarios.php b/tests/Runtime/Fixtures/frankenphp/scenarios.php new file mode 100644 index 0000000..0bbdd54 --- /dev/null +++ b/tests/Runtime/Fixtures/frankenphp/scenarios.php @@ -0,0 +1,39 @@ +options(is_string($collector) && $collector !== '' ? $collector : null)); +$server = $builder->build(); +$factory = new Psr17Factory(); + +$handler = static function () use ($server, $factory): void { + $response = $server->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); + http_response_code($response->getStatusCode()); + foreach ($response->getHeaders() as $name => $values) { + header($name . ': ' . implode(', ', $values)); + } + echo $response->getBody(); +}; + +while (frankenphp_handle_request($handler)) { + AgentCat::flush(RequestEndWait::WORKER_SECONDS); +} diff --git a/tests/Runtime/Fixtures/frankenphp/worker.php b/tests/Runtime/Fixtures/frankenphp/worker.php index f00adcc..8c09aed 100644 --- a/tests/Runtime/Fixtures/frankenphp/worker.php +++ b/tests/Runtime/Fixtures/frankenphp/worker.php @@ -15,7 +15,7 @@ require dirname(__DIR__, 4) . '/vendor/autoload.php'; -// The worker loop Plan F documents: one server per request, AgentCat::flush() after each response. With +// The worker loop that the README documents: one server per request, AgentCat::flush() after each response. With // AGENTCAT_E2E_BOOT_SERVER=1, one server is tracked and built at worker boot, outside any request, and serves every // request; its AgentCat API and diagnostics then come from the container's environment. Each answer carries what the // SDK detects in the request, read through its internal classes (hence AgentCat's namespace). diff --git a/tests/Runtime/Fixtures/swoole-guard.php b/tests/Runtime/Fixtures/swoole-guard.php index 1d340c0..aca10b7 100644 --- a/tests/Runtime/Fixtures/swoole-guard.php +++ b/tests/Runtime/Fixtures/swoole-guard.php @@ -10,9 +10,9 @@ require dirname(__DIR__, 3) . '/vendor/autoload.php'; -// CliTest: whether the first event on the global queue installs AgentCat's SIGTERM handler (plan E1 header conflict -// 14: not when ext-swoole or ext-openswoole is loaded). The queue is internal, hence AgentCat's namespace. An event -// without a project is only exported, and no exporter is set, so the shutdown sends nothing. +// CliTest: whether the first event on the global queue installs AgentCat's SIGTERM handler (not when ext-swoole or +// ext-openswoole is loaded). The queue is internal, hence AgentCat's namespace. An event without a project is only +// exported, and no exporter is set, so the shutdown sends nothing. $pcntl = function_exists('pcntl_signal_get_handler'); $before = $pcntl ? pcntl_signal_get_handler(SIGTERM) : null; EventQueue::global()->add(new QueuedEvent(new Event(projectId: ''))); diff --git a/tests/Runtime/Fixtures/symfony-console.php b/tests/Runtime/Fixtures/symfony-console.php new file mode 100644 index 0000000..d0b73c6 --- /dev/null +++ b/tests/Runtime/Fixtures/symfony-console.php @@ -0,0 +1,22 @@ + 'true', 'project_id' => 'proj_' . str_repeat('e', 27)], + mcp: ['servers' => ['conformance' => [...TestKernel::CONFORMANCE, 'transports' => ['stdio' => true]]]], +); +$application = new Application($kernel); +$application->setAutoExit(false); + +exit($application->run()); diff --git a/tests/Runtime/Fixtures/unit.php b/tests/Runtime/Fixtures/unit.php index 4f40aab..9c6c8d7 100644 --- a/tests/Runtime/Fixtures/unit.php +++ b/tests/Runtime/Fixtures/unit.php @@ -4,7 +4,21 @@ // UnitTest: NGINX Unit runs every request through this script. It names the SAPI that Unit reports, then runs the // FrankenPHP classic fixture (what the SDK detects, then the buffered echo server), with the answer's Content-Length, -// or without it when the request sends `X-E2E-Unsized: 1`. +// or without it when the request sends `X-E2E-Unsized: 1`. The paths of the two bridges' servers run their front +// controllers instead (the Laravel application's `/mcp/e2e`, the Symfony application's `/mcp/conformance`). header('X-E2E-Sapi: ' . PHP_SAPI); +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$path = is_string($uri) ? parse_url($uri, PHP_URL_PATH) : null; +if ($path === '/mcp/e2e') { + require __DIR__ . '/laravel-front.php'; + + return; +} +if ($path === '/mcp/conformance') { + require __DIR__ . '/symfony-front.php'; + + return; +} + require __DIR__ . '/frankenphp/' . (($_SERVER['HTTP_X_E2E_UNSIZED'] ?? null) === '1' ? 'unsized.php' : 'classic.php'); diff --git a/tests/Runtime/FpmTest.php b/tests/Runtime/FpmTest.php index 53b6da0..b7d10d4 100644 --- a/tests/Runtime/FpmTest.php +++ b/tests/Runtime/FpmTest.php @@ -17,9 +17,9 @@ /** * PHP-FPM, talked to over FastCGI the way nginx does: the client has its answer when FPM ends the request - * (`FCGI_END_REQUEST`), and only then does AgentCat's shutdown function run the hooks and deliver (D47, plan E1 header - * conflicts 13 and 14). Each upper bound sits about 1 s above the measured time and about 1 s below the time the step it - * guards would add (a 3 s hook or a 3 s collector), ruling P16. + * (`FCGI_END_REQUEST`), and only then does AgentCat's shutdown function run the hooks and deliver (D47). Each upper + * bound sits about 1 s above the measured time and about 1 s below the time the step it guards would add (a 3 s hook + * or a 3 s collector). */ #[Group('runtime')] final class FpmTest extends RuntimeTestCase @@ -35,6 +35,14 @@ final class FpmTest extends RuntimeTestCase private const string HTTP_SERVER = '/tests/E2E/Fixtures/http-server.php'; + /** A shared host's `disable_functions`: delivery (curl multi), the signal drain, time limits, the environment, the pid. */ + private const string HARDENED = 'curl_multi_exec,pcntl_signal,set_time_limit,ignore_user_abort,putenv,getmypid'; + + private const string HANDSHAKE_PROTOCOL_VERSION = '2025-06-18'; + + /** The container of the last `fpm()`. */ + private ?DockerRuntime $runtime = null; + #[Group('ac-03-1')] #[Group('ac-02-48')] #[MaximumDuration(30000)] @@ -62,9 +70,9 @@ public function testTheResponseReachesTheClientBeforeTheHooksAndTheDelivery(): v } /** - * Review Focus 5: AgentCat writes and closes the native session before its drain, so a second request of the same - * session gets the lock while the first one still waits 3 s for the AgentCat API. Without that, PHP would write the - * session after the shutdown functions, after the drain. + * AgentCat writes and closes the native session before its drain, so a second request of the same session gets the + * lock while the first one still waits 3 s for the AgentCat API. Without that, PHP would write the session after + * the shutdown functions, after the drain. */ #[MaximumDuration(30000)] public function testTheSessionLockIsReleasedBeforeTheDrain(): void @@ -131,9 +139,8 @@ public function testDiagnosticsArePostedOncePerRequestAfterTheResponse(): void } /** - * Plan E1 header conflict 6: a streamed laravel/mcp answer (two progress notifications 50 ms apart, then the result) - * reaches the client as server-sent events, and its event comes when the stream ends, with a `duration` that covers - * the stream. + * A streamed laravel/mcp answer (two progress notifications 50 ms apart, then the result) reaches the client as + * server-sent events, and its event comes when the stream ends, with a `duration` that covers the stream. */ #[Group('ac-02-54')] #[MaximumDuration(40000)] @@ -154,9 +161,9 @@ public function testALaravelStreamIsRecordedAfterTheStreamEnds(): void } /** - * Header conflict 7 (D58): under PHP-FPM `Response::send()` finishes the request, `kernel.terminate` makes one - * non-blocking pass, and AgentCat's shutdown function delivers, so the client has its answer before the collector - * answers 3 s later. An untimed first request compiles the container. + * D58: under PHP-FPM `Response::send()` finishes the request, `kernel.terminate` makes one non-blocking pass, and + * AgentCat's shutdown function delivers, so the client has its answer before the collector answers 3 s later. An + * untimed first request compiles the container. */ #[MaximumDuration(40000)] public function testSymfonyAnswersBeforeTheShutdownDelivery(): void @@ -180,12 +187,65 @@ public function testSymfonyAnswersBeforeTheShutdownDelivery(): void $this->assertGreaterThan($answer['endedAt'] + 1.0, $this->time($delivery, 'end'), 'the collector answered the delivery after the client had its answer'); } + /** + * A pool whose `disable_functions` is a shared host's: one MCP session (`initialize`, `notifications/initialized`, + * `tools/call`) answers as without AgentCat, no PHP error text reaches an answer or the workers' output, and AgentCat + * calls no disabled function. Each request of the pool's one worker logs each disabled function in one line at most + * (PHP-FPM keeps no state between requests): the curl line, once per request that queued an event, and nothing else. + */ + #[Group('hardened-host')] + #[MaximumDuration(30000)] + public function testAHardenedPoolAnswersAndLogsEachMissingFunctionOncePerRequest(): void + { + $scenario = self::$collector->scenario(); + $fpm = $this->fpm($scenario, workers: 1, disableFunctions: self::HARDENED); + $script = self::repo() . self::HTTP_SERVER; + $headers = ['Accept' => 'application/json, text/event-stream', 'X-E2E-Collector' => $scenario]; + + $initialize = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => HttpHarness::initializeParams('fpm-client', '1.0.0', self::HANDSHAKE_PROTOCOL_VERSION)], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $initialize['status'], $initialize['body']); + $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($this->answerResult($initialize), 'protocolVersion')); + $session = $initialize['headers']['mcp-session-id'] ?? ''; + $this->assertNotSame('', $session, 'initialize issued an Mcp-Session-Id'); + $headers = [...$headers, 'Mcp-Session-Id' => $session, 'MCP-Protocol-Version' => self::HANDSHAKE_PROTOCOL_VERSION]; + $initialized = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(HttpHarness::INITIALIZED, JSON_THROW_ON_ERROR)); + HttpHarness::assertInitialized($initialized['status'], $initialized['body']); + $call = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hardened']]], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $call['status'], $call['body']); + $result = $this->answerResult($call); + $this->assertFalse(JsonAssert::at($result, 'isError')); + $content = JsonAssert::at($result, 'content'); + $this->assertIsArray($content); + $this->assertContains('echo: hardened', array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content)); + $last = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'ping'], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $last['status'], 'the one worker serves the ping only after the call\'s script, its shutdown included, ended: ' . $last['body']); + + foreach ([$initialize, $initialized, $call, $last] as $answer) { + $this->assertDoesNotMatchRegularExpression('/Warning|Notice|Deprecated|Fatal error/', $answer['body']); + } + $this->assertInstanceOf(DockerRuntime::class, $this->runtime); + $this->assertNoPhpErrors($this->runtime); + $log = $this->outFile('agentcat.log'); + $this->assertStringNotContainsString('Call to undefined function', $log); + $parts = preg_split('/^(?=\[[^\]]+\] \[[^\]]+\] AgentCat setup started)/m', $log); + $this->assertIsArray($parts); + $requests = array_values(array_filter($parts, static fn(string $part): bool => trim($part) !== '')); + $this->assertCount(4, $requests, 'each request tracks the server once: ' . $log); + foreach ($requests as $request) { + foreach (explode(',', self::HARDENED) as $function) { + $this->assertLessThanOrEqual(1, preg_match_all('/\b' . preg_quote($function, '/') . '\b/', $request), $function . ' in one line at most per request: ' . $request); + } + } + $this->assertSame(1, preg_match_all('/WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled \(curl_multi_exec\)/', $requests[2]), 'the call\'s request logs that its event cannot be sent: ' . $requests[2]); + $this->assertSame([], self::$collector->requests($scenario), 'without curl_multi_exec nothing is sent'); + } + /** * PHP-FPM on 127.0.0.1 with `$workers` workers (two by default: one can drain while the other answers), which keep * the container's environment (`RuntimeTestCase::env()` with `$collector` as the AgentCat API) and get `HOME=/out`, - * as FPM gives its workers `/var/www` otherwise. + * as FPM gives its workers `/var/www` otherwise. `$disableFunctions` becomes the pool's `disable_functions`. */ - private function fpm(?string $collector = null, int $workers = 2): FastCgiClient + private function fpm(?string $collector = null, int $workers = 2, ?string $disableFunctions = null): FastCgiClient { $out = $this->out(); $port = $this->freePort(); @@ -199,9 +259,10 @@ private function fpm(?string $collector = null, int $workers = 2): FastCgiClient 'pm.max_children = ' . $workers, 'catch_workers_output = yes', 'decorate_workers_output = no', + ...($disableFunctions === null ? [] : ['php_admin_value[disable_functions] = ' . $disableFunctions]), '', ])); - DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); + $this->runtime = DockerRuntime::start(Images::fpm(), ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); return new FastCgiClient('127.0.0.1', $port); } diff --git a/tests/Runtime/FrankenPhpTest.php b/tests/Runtime/FrankenPhpTest.php index d70670a..179fd87 100644 --- a/tests/Runtime/FrankenPhpTest.php +++ b/tests/Runtime/FrankenPhpTest.php @@ -16,13 +16,12 @@ use PHPUnit\Framework\Attributes\Group; /** - * FrankenPHP (thread-safe PHP inside a Go server, `max_execution_time` 2), in classic mode and in worker mode (header - * conflict 12). Classic mode is a per-request SAPI with `frankenphp_finish_request()`: the client has its answer before - * AgentCat's shutdown function delivers, and the drain gets its own time limit, which under ZTS counts wall time (plan - * E1 header conflict 14). In worker mode, shutdown functions run only when the worker stops: the raw - * `frankenphp_handle_request()` loop calls `AgentCat::flush()` after each request, which waits at most 1 s (D58), and - * the worker's stop delivers the rest. Each upper bound sits about 1 s above the measured time and about 1 s below the - * time its failure mode takes (ruling P16). + * FrankenPHP (thread-safe PHP inside a Go server, `max_execution_time` 2), in classic mode and in worker mode. Classic + * mode is a per-request SAPI with `frankenphp_finish_request()`: the client has its answer before AgentCat's shutdown + * function delivers, and the drain gets its own time limit, which under ZTS counts wall time. In worker mode, shutdown + * functions run only when the worker stops: the raw `frankenphp_handle_request()` loop calls `AgentCat::flush()` after + * each request, which waits at most 1 s (D58), and the worker's stop delivers the rest. Each upper bound sits about 1 s + * above the measured time and about 1 s below the time its failure mode takes. */ #[Group('runtime')] final class FrankenPhpTest extends RuntimeTestCase @@ -40,7 +39,7 @@ final class FrankenPhpTest extends RuntimeTestCase /** The `max_execution_time` of every request: the drain of `slow.php` ends after it. */ private const float TIME_LIMIT_SECONDS = 2.0; - /** How long the next request may wait for the loop's flush: the 1 s cap, not the 4 s collector (ruling P16). */ + /** How long the next request may wait for the loop's flush: the 1 s cap, not the 4 s collector. */ private const float WORKER_WAIT_SECONDS = RequestEndWait::WORKER_SECONDS + 1.5; /** The 2 s window of a process that outlives its requests: its first diagnostics POST comes this late at the earliest. */ @@ -93,10 +92,10 @@ public function testClassicModeEndsAnAnswerWithoutContentLengthBeforeTheDelivery } /** - * Plan E1 header conflict 14: under ZTS, `max_execution_time` counts wall time, so a request that used most of its - * 2 s would be killed during the drain. The drain sets its own limit: it waits for the collector past the 2 s, - * logs the delivery and then posts its diagnostics, the last step of the shutdown. The collector records a request - * after its delay also when the drain dies, so the diagnostics POST is the proof. + * Under ZTS, `max_execution_time` counts wall time, so a request that used most of its 2 s would be killed during + * the drain. The drain sets its own limit: it waits for the collector past the 2 s, logs the delivery and then + * posts its diagnostics, the last step of the shutdown. The collector records a request after its delay also when + * the drain dies, so the diagnostics POST is the proof. */ #[MaximumDuration(40000)] public function testTheDrainHasItsOwnTimeLimitUnderZts(): void @@ -106,8 +105,8 @@ public function testTheDrainHasItsOwnTimeLimitUnderZts(): void $client = new McpHttpClient($this->frankenphp(worker: false, env: $this->diagnosticsTo($diagnostics)) . '/slow.php'); $started = microtime(true); - // slow.php works 1 s of wall time of its 2 s limit, then answers; the delivery waits 2 s more (ruling P16: about - // 1 s of slack before the limit, and the drain ends about 1 s after it). + // slow.php works 1 s of wall time of its 2 s limit, then answers; the delivery waits 2 s more (about 1 s of + // slack before the limit, and the drain ends about 1 s after it). $answer = $client->post(self::echoCall(1, 'hi'), self::echoHeaders($events)); $this->assertEchoed('hi', $answer, 1); @@ -165,10 +164,10 @@ public static function workerServers(): iterable } /** - * Plan E2 Task 8 carry: the first `track()` decides the diagnostics window, at worker boot (outside any request: - * FrankenPHP sets `FRANKENPHP_WORKER` in `$_SERVER` and the environment) or in the first request (only the - * environment has it). The worker is detected either way, so its queue passes post the lines once they are 2 s - * old, while the worker still runs. A worker detected as classic would post nothing until it stops. + * The first `track()` decides the diagnostics window, at worker boot (outside any request: FrankenPHP sets + * `FRANKENPHP_WORKER` in `$_SERVER` and the environment) or in the first request (only the environment has it). The + * worker is detected either way, so its queue passes post the lines once they are 2 s old, while the worker still + * runs. A worker detected as classic would post nothing until it stops. * * @param array $env */ @@ -238,7 +237,7 @@ private function frankenphp(bool $worker, ?string $collector = null, array $env '', ])); $this->runtime = DockerRuntime::start( - Images::FRANKENPHP, + Images::frankenphp(), ['frankenphp', 'run', '--config', self::CADDYFILE], [...$this->env($collector), ...$env], [$caddyfile => self::CADDYFILE, $out => '/out'], @@ -252,7 +251,7 @@ private function frankenphp(bool $worker, ?string $collector = null, array $env /** * FrankenPHP in classic mode, after one untimed request to `$script` with its own scenario, whose delivery the - * collector has received: the timed request then meets a warm OPcache and no drain of the warm-up (ruling P16). + * collector has received: the timed request then meets a warm OPcache and no drain of the warm-up. */ private function warmClassicClient(string $script): McpHttpClient { diff --git a/tests/Runtime/HarnessTest.php b/tests/Runtime/HarnessTest.php index 027caeb..d012658 100644 --- a/tests/Runtime/HarnessTest.php +++ b/tests/Runtime/HarnessTest.php @@ -95,7 +95,7 @@ public function testAStartedContainerEndsOnItsOwnAfterItsLifetime(): void $prefix = 'agentcat-rt-' . getmypid() . '-'; $started = microtime(true); - DockerRuntime::start(Images::CLI, ['php', '-r', 'sleep(120);'], $this->env(), [], lifetime: 2.0); + DockerRuntime::start(Images::cli(), ['php', '-r', 'sleep(120);'], $this->env(), [], lifetime: 2.0); $this->assertNotSame('', $this->containerIds($prefix)); $deadline = $started + 20.0; @@ -119,28 +119,28 @@ public function testOnlyTheOutputDirectoryCanBeMounted(): void $this->assertTrue(mkdir($elsewhere)); try { - $refused = Thrown::by(\InvalidArgumentException::class, fn(): array => DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$elsewhere => '/kernel'])); + $refused = Thrown::by(\InvalidArgumentException::class, fn(): array => DockerRuntime::command(Images::cli(), ['true'], $this->env(), [$elsewhere => '/kernel'])); } finally { rmdir($elsewhere); } - $command = DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$out => '/out', $out . '/pool.conf' => '/etc/pool.conf']); + $command = DockerRuntime::command(Images::cli(), ['true'], $this->env(), [$out => '/out', $out . '/pool.conf' => '/etc/pool.conf']); $this->assertStringContainsString('output directory', $refused->getMessage()); $this->assertContains('type=bind,source=' . realpath($out) . ',target=/out', $command); $this->assertContains('type=bind,source=' . realpath($out) . '/pool.conf,target=/etc/pool.conf', $command); } - /** PHP must not be PID 1 in a PHP CLI image (header conflict 11): its signal drain's re-raise would not end it. */ + /** PHP must not be PID 1 in a PHP CLI image: its signal drain's re-raise would not end it. */ #[MaximumDuration(60000)] public function testPhpCliImagesGetAnInitWhateverTheCallerSays(): void { $out = $this->out(); - DockerRuntime::start(Images::CLI, ['php', '-r', 'file_put_contents("/out/pid", (string) getmypid()); sleep(30);'], $this->env(), [$out => '/out'], init: false); + DockerRuntime::start(Images::cli(), ['php', '-r', 'file_put_contents("/out/pid", (string) getmypid()); sleep(30);'], $this->env(), [$out => '/out'], init: false); - $this->assertContains('--init', DockerRuntime::command(Images::CLI, ['true'], $this->env(), [], init: false)); - $this->assertContains('--init', DockerRuntime::command(Images::SWOOLE, ['true'], $this->env(), [], init: false)); - $this->assertNotContains('--init', DockerRuntime::command(Images::FPM, ['true'], $this->env(), [], init: false)); + $this->assertContains('--init', DockerRuntime::command(Images::cli(), ['true'], $this->env(), [], init: false)); + $this->assertContains('--init', DockerRuntime::command(Images::swoole(), ['true'], $this->env(), [], init: false)); + $this->assertNotContains('--init', DockerRuntime::command(Images::fpm(), ['true'], $this->env(), [], init: false)); $deadline = microtime(true) + 20.0; while (!is_file($out . '/pid') && microtime(true) < $deadline) { usleep(50_000); @@ -159,12 +159,12 @@ public function testAgentCatsDefaultHostsResolveToTheLocalHostInEveryContainer() $hosts = [parse_url(Constants::DEFAULT_API_BASE_URL, PHP_URL_HOST), parse_url(Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, PHP_URL_HOST)]; $code = sprintf('echo gethostbyname(%s), " ", gethostbyname(%s);', var_export($hosts[0], true), var_export($hosts[1], true)); - $run = DockerRuntime::run(Images::CLI, ['env', '-i', '/usr/local/bin/php', '-r', $code], $this->env(), []); // an empty environment + $run = DockerRuntime::run(Images::cli(), ['env', '-i', '/usr/local/bin/php', '-r', $code], $this->env(), []); // an empty environment $this->assertSame(0, $run['exitCode'], $run['stderr']); $this->assertSame('127.0.0.1 127.0.0.1', $run['stdout']); $env = ['AGENTCAT_API_URL' => 'http://127.0.0.1:@example.com/'] + $this->env(); // user information, host example.com - $this->assertStringContainsString('AGENTCAT_API_URL', Thrown::by(\InvalidArgumentException::class, static fn(): array => DockerRuntime::command(Images::CLI, ['true'], $env, []))->getMessage()); + $this->assertStringContainsString('AGENTCAT_API_URL', Thrown::by(\InvalidArgumentException::class, static fn(): array => DockerRuntime::command(Images::cli(), ['true'], $env, []))->getMessage()); } /** @@ -183,7 +183,7 @@ private function smokeRun(array $env): Process /** Starts a container under `$name` that sleeps 60 s and is removed when it ends. */ private function detachedSleep(string $name): string { - $run = new Process(['docker', 'run', '--detach', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', Images::CLI, 'sleep', '60'], null, null, null, 30.0); + $run = new Process(['docker', 'run', '--detach', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', Images::cli(), 'sleep', '60'], null, null, null, 30.0); $run->run(); $this->assertTrue($run->isSuccessful(), $run->getErrorOutput()); diff --git a/tests/Runtime/LongLivedScenariosTest.php b/tests/Runtime/LongLivedScenariosTest.php new file mode 100644 index 0000000..3612aa6 --- /dev/null +++ b/tests/Runtime/LongLivedScenariosTest.php @@ -0,0 +1,141 @@ +open(Scenario::SessionContinuityStateful); + $httpSessionId = $client->sessionId(); + $this->assertIsString($httpSessionId); + $this->assertNotSame('', $httpSessionId); + $client->listTools(); + $sessionId = $this->issuedSessionId($client->call('echo', ['msg' => 'one', 'context' => 'session continuity 1', 'session_id' => 'start'])); + + $second = $client->call('echo', ['msg' => 'two', 'context' => 'session continuity 2', 'session_id' => $sessionId]); + + $this->assertNull($this->mintBack($second)); + $this->assertSame($httpSessionId, $client->sessionId(), 'one Mcp-Session-Id for every request'); + [$one, $two] = $this->twoEvents(); + foreach ([$one, $two] as $event) { + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + $this->assertSame($httpSessionId, JsonAssert::at($event, 'parameters', 'extra', 'sessionId'), 'the transport session stays in parameters.extra'); + } + $this->assertSame('minted', $this->tag($one, Constants::TAG_SESSION_SOURCE)); + $this->assertSame('supplied', $this->tag($two, Constants::TAG_SESSION_SOURCE)); + $this->assertStringNotContainsString(self::REBUILT, $this->outFile('agentcat.log'), 'one instance listed and served the calls'); + } + + /** + * FrankenPHP on 127.0.0.1 only, with `scenarios.php` as a worker of one thread, from a Caddyfile in `out()`. The + * container gets `RuntimeTestCase::env()` with this test's collector scenario as the AgentCat API, the scenario + * and the sink file; `HOME` is `/out`, where AgentCat writes its log. + */ + #[\Override] + protected function open(Scenario $scenario): ScenarioClient + { + $this->scenario = self::$collector->scenario(); + $out = $this->out(); + $port = $this->freePort(); + $fixtures = self::repo() . '/tests/Runtime/Fixtures/frankenphp'; + $caddyfile = $out . '/Caddyfile'; + file_put_contents($caddyfile, implode("\n", [ + '{', + "\tauto_https off", + "\tadmin off", + "\tfrankenphp {", + "\t\tphp_ini log_errors 1", // the image has no php.ini: without it, PHP errors go only to the response + "\t\tworker " . $fixtures . '/scenarios.php 1', + "\t}", + '}', + '', + 'http://127.0.0.1:' . $port . ' {', + "\tbind 127.0.0.1", // the site address alone names a host to match, and Caddy would listen on every interface + "\troot * " . $fixtures, + "\tphp_server", + '}', + '', + ])); + DockerRuntime::start( + Images::frankenphp(), + ['frankenphp', 'run', '--config', self::CADDYFILE], + [...$this->env($this->scenario), 'AGENTCAT_E2E_SCENARIO' => $scenario->value, 'AGENTCAT_E2E_SINK' => '/out/' . self::SINK], + [$caddyfile => self::CADDYFILE, $out => '/out'], + port: $port, + ); + $this->assertFalse(DockerRuntime::accepts($port, '127.0.0.2'), 'FrankenPHP listens beyond 127.0.0.1'); + + return ScenarioClient::connect('http://127.0.0.1:' . $port . '/scenarios.php', false); + } + + #[\Override] + protected function modern(): bool + { + return false; + } + + #[\Override] + protected function deliveries(int $count): array + { + return self::$collector->waitFor($this->scenario, static fn(array $requests): bool => count($requests) >= $count, self::COLLECTOR_DEADLINE_SECONDS); + } + + /** + * The worker serves one request at a time and flushes AgentCat's queue after each one, so once a later request has + * its answer, every event of the earlier ones has reached the collector. + */ + #[\Override] + protected function assertNothingDelivered(ScenarioClient $client): void + { + $client->listTools(); + $this->assertSame([], self::$collector->requests($this->scenario), 'no event'); + } + + #[\Override] + protected function sink(): Sink + { + return new Sink($this->out() . '/' . self::SINK); + } +} diff --git a/tests/Runtime/SmokeTest.php b/tests/Runtime/SmokeTest.php index 00465cb..3b6f2e2 100644 --- a/tests/Runtime/SmokeTest.php +++ b/tests/Runtime/SmokeTest.php @@ -18,12 +18,12 @@ final class SmokeTest extends RuntimeTestCase /** @return iterable */ public static function images(): iterable { - yield 'fpm' => [Images::FPM]; - yield 'apache' => [Images::APACHE]; - yield 'cli' => [Images::CLI]; - yield 'frankenphp' => [Images::FRANKENPHP]; - yield 'swoole' => [Images::SWOOLE]; - yield 'unit' => [Images::UNIT]; + yield 'fpm' => [Images::fpm()]; + yield 'apache' => [Images::apache()]; + yield 'cli' => [Images::cli()]; + yield 'frankenphp' => [Images::frankenphp()]; + yield 'swoole' => [Images::swoole()]; + yield 'unit' => [Images::unit()]; } #[DataProvider('images')] @@ -32,17 +32,17 @@ public function testTheImageRunsTheRepositorysPhp(string $image): void { $run = DockerRuntime::run($image, ['php', self::repo() . '/tests/Runtime/Fixtures/smoke.php'], $this->env(), []); - $this->assertSame(0, $run['exitCode'], $run['stderr']); - $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout']); + $this->assertSame(0, $run['exitCode'], $image . ': ' . $run['stderr']); + $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout'], $image); } /** The Swoole guard test needs ext-pcntl next to ext-swoole: `docker/swoole/Dockerfile` adds it to the image. */ #[MaximumDuration(60000)] public function testTheSwooleImageLoadsSwooleAndPcntl(): void { - $run = DockerRuntime::run(Images::SWOOLE, ['php', '-m'], $this->env(), []); + $run = DockerRuntime::run(Images::swoole(), ['php', '-m'], $this->env(), []); - $this->assertSame(0, $run['exitCode'], $run['stderr']); + $this->assertSame(0, $run['exitCode'], Images::swoole() . ': ' . $run['stderr']); $modules = preg_split('/\R/', $run['stdout']); $this->assertIsArray($modules); $this->assertContains('swoole', $modules); diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index 1e045b3..03f8491 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -8,8 +8,7 @@ use Symfony\Component\Process\Process; /** - * A container of the runtime tests, and the `docker run` lines that start one (header conflicts 10 and 11, rulings - * P14 and P15). + * A container of the runtime tests, and the `docker run` lines that start one. * * Every container uses host networking and reaches only 127.0.0.1: its environment must point `AGENTCAT_API_URL` and * `AGENTCAT_DIAGNOSTICS_ENDPOINT` there (`RuntimeTestCase::env()`), so a fixture that forgets its collector fails @@ -43,9 +42,6 @@ final class DockerRuntime private const string NAME_PREFIX = 'agentcat-rt-'; - /** The images whose main process is PHP CLI: PHP must not be PID 1 there (header conflict 11). */ - private const array INIT_IMAGES = [Images::CLI, Images::SWOOLE]; - /** The test's output directory is `/runtime-` (`RuntimeTestCase::out()`). */ public const string OUTPUT_DIRECTORY_PREFIX = 'runtime-'; @@ -62,8 +58,27 @@ final class DockerRuntime private static ?string $problem = null; + /** @var array the images of the containers that this process started since `forgetImages()` */ + private static array $images = []; + private function __construct(private readonly string $name) {} + /** + * The images that this process started a container from since `forgetImages()`, in start order: a failing test + * names them (`RuntimeTestCase::onNotSuccessfulTest()`). + * + * @return list + */ + public static function imagesUsed(): array + { + return array_keys(self::$images); + } + + public static function forgetImages(): void + { + self::$images = []; + } + /** `AGENTCAT_RUNTIME_TESTS=1`: the runtime tests run, and a Docker that does not answer fails them. */ public static function requested(): bool { @@ -108,9 +123,9 @@ public static function repo(): string * @param non-empty-list $command the command, after the image's entrypoint * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own * @param array $mounts host path (in the output directory) => container path - * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it + * @param bool $init `--init`; `Images::cli()` and `Images::swoole()` always get it * @param float $timeout how long the port may take to accept a connection - * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) + * @param bool $remove false keeps a stopped container for `logs()` until `stop()` * @param float $lifetime how long the container may run at most: longer than any runtime test */ public static function start( @@ -162,7 +177,7 @@ public static function run(string $image, array $command, array $env, array $mou * @param list $command * @param array $env * @param array $mounts host path (in the output directory) => container path - * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it + * @param bool $init `--init`; `Images::cli()` and `Images::swoole()` always get it * * @return list */ @@ -201,14 +216,14 @@ public static function removeStale(): void /** * Makes everything under `$dir` writable for everyone, so the test can remove what a container wrote there as - * root or www-data (ruling P14). The container sees only `$dir`, at `/out`, and no network. + * root or www-data. The container sees only `$dir`, at `/out`, and no network. */ public static function makeRemovable(string $dir): void { $name = self::newName(); - $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::inOutputDirectory($dir), '/out'), Images::CLI, 'chmod', '-R', 'a+rwX', '/out']); + $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::inOutputDirectory($dir), '/out'), Images::cli(), 'chmod', '-R', 'a+rwX', '/out']); if (!$run->isSuccessful()) { - throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::CLI, $run->getErrorOutput())); + throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::cli(), $run->getErrorOutput())); } } @@ -371,6 +386,7 @@ private static function options(string $image, string $name, array $env, array $ throw new \InvalidArgumentException(sprintf('A container needs %s on http://127.0.0.1:, the collector of the test or a closed port: pass RuntimeTestCase::env()', $required)); } } + self::$images[$image] = true; $repo = self::repo(); $options = ['--pull', 'never', '--name', $name, '--network', 'host', '--mount', self::bind($repo, $repo, readonly: true), '--workdir', $repo]; foreach ([Constants::DEFAULT_API_BASE_URL, Constants::DEFAULT_DIAGNOSTICS_ENDPOINT] as $default) { @@ -380,7 +396,7 @@ private static function options(string $image, string $name, array $env, array $ if ($remove) { $options[] = '--rm'; } - if ($init || in_array($image, self::INIT_IMAGES, true)) { + if ($init || in_array($image, Images::initImages(), true)) { $options[] = '--init'; } foreach ($mounts as $host => $container) { diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php index e088181..f0a617a 100644 --- a/tests/Runtime/Support/Images.php +++ b/tests/Runtime/Support/Images.php @@ -5,32 +5,89 @@ namespace AgentCat\Tests\Runtime\Support; /** - * The Docker images of the runtime tests, pinned by tag (header conflict 10). The CI job pulls them before the suite, - * and builds `CLI` and `SWOOLE`; a test never pulls (`docker run --pull never`). + * The Docker images of the runtime tests, pinned by tag. The CI job pulls them before the suite, and builds `cli()` and + * `swoole()`; a test never pulls (`docker run --pull never`). + * + * With `AGENTCAT_RUNTIME_IMAGES=latest` (the nightly `runtime-latest` job) every method returns the floating tag of + * the same line instead: the latest patch of PHP 8.3, FrankenPHP 1, Swoole 6 and Unit. The two local images are then + * built with `--build-arg BASE=` and tagged `:latest`. */ final class Images { + public const string ENVIRONMENT_VARIABLE = 'AGENTCAT_RUNTIME_IMAGES'; + + /** The `--build-arg BASE` of `tests/Runtime/docker/Dockerfile` in latest mode. */ + public const string CLI_BASE_LATEST = 'php:8.3-cli-bookworm'; + + /** + * The `--build-arg BASE` of `tests/Runtime/docker/swoole/Dockerfile` in latest mode: the line of the pinned tag. + * The image has no `6-php8.3` tag, and `php8.3` is the newest Swoole, release candidates included. + */ + public const string SWOOLE_BASE_LATEST = 'phpswoole/swoole:6.2-php8.3'; + /** Its stop signal is SIGQUIT: a graceful stop. */ - public const string FPM = 'php:8.3.35-fpm-bookworm'; + public static function fpm(): string + { + return self::pick('php:8.3.35-fpm-bookworm', 'php:8.3-fpm-bookworm'); + } /** Its stop signal is SIGWINCH: a graceful stop. */ - public const string APACHE = 'php:8.3.35-apache-bookworm'; + public static function apache(): string + { + return self::pick('php:8.3.35-apache-bookworm', 'php:8.3-apache-bookworm'); + } /** * Built locally: `docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker` (the official image plus - * ext-pcntl). Its stop signal is SIGTERM, as for `FRANKENPHP`, `SWOOLE` and `UNIT`. + * ext-pcntl). Its stop signal is SIGTERM, as for `frankenphp()`, `swoole()` and `unit()`. */ - public const string CLI = 'agentcat-runtime-cli:8.3.35'; + public static function cli(): string + { + return self::pick('agentcat-runtime-cli:8.3.35', 'agentcat-runtime-cli:latest'); + } - public const string FRANKENPHP = 'dunglas/frankenphp:1.13.1-php8.3.35-bookworm'; + public static function frankenphp(): string + { + return self::pick('dunglas/frankenphp:1.13.1-php8.3.35-bookworm', 'dunglas/frankenphp:1-php8.3-bookworm'); + } /** * Built locally: `docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole` * (`phpswoole/swoole:6.2.3-php8.3`, PHP 8.3.33, plus ext-pcntl). Its entrypoint splits the command at spaces * (`exec $@`): run a script, not `php -r`. */ - public const string SWOOLE = 'agentcat-runtime-swoole:6.2.3-php8.3'; + public static function swoole(): string + { + return self::pick('agentcat-runtime-swoole:6.2.3-php8.3', 'agentcat-runtime-swoole:latest'); + } + + /** NGINX Unit with PHP 8.3.26. */ + public static function unit(): string + { + return self::pick('unit:1.34.2-php8.3', 'unit:php8.3'); + } + + /** + * The images whose main process is PHP CLI: PHP must not be PID 1 there, or its signal drain's re-raise would not + * end it. + * + * @return list + */ + public static function initImages(): array + { + return [self::cli(), self::swoole()]; + } + + private static function pick(string $pinned, string $latest): string + { + $mode = getenv(self::ENVIRONMENT_VARIABLE); + if ($mode === false || $mode === '') { + return $pinned; + } + if ($mode === 'latest') { + return $latest; + } - /** NGINX Unit with PHP 8.3.26 (ruling P19). */ - public const string UNIT = 'unit:1.34.2-php8.3'; + throw new \InvalidArgumentException(sprintf('%s must be empty or "latest", got "%s".', self::ENVIRONMENT_VARIABLE, $mode)); + } } diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index abb4aeb..a491a54 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -10,17 +10,18 @@ use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\HttpHarness; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; /** - * The base of the Docker runtime tests (header conflict 10): they run only with `AGENTCAT_RUNTIME_TESTS=1` + * The base of the Docker runtime tests: they run only with `AGENTCAT_RUNTIME_TESTS=1` * (`AGENTCAT_RUNTIME_TESTS=1 vendor/bin/phpunit --testsuite Runtime --group runtime`) and skip otherwise. With the * variable, a `docker info` that fails or times out fails every test with its output, so the runtime CI job cannot * pass without running them. * * Each class has one mock collector on 127.0.0.1 (`self::$collector`). After each test and each class, every container - * of this process is removed, also when the test failed (ruling P14). A test's output directory (`out()`, mounted at - * `/out`) lives under the test HOME and is removed after the test. + * of this process is removed, also when the test failed. A test's output directory (`out()`, mounted at `/out`) lives + * under the test HOME and is removed after the test. * * PHPUnit does not inherit groups: every subclass carries `#[Group('runtime')]` itself, or the class fails in * `setUpBeforeClass()`. @@ -32,7 +33,7 @@ abstract class RuntimeTestCase extends TestCase private const string GROUP = 'runtime'; - /** The output directories under `out()` that the fixtures write to: mcp/sdk sessions and the Laravel application (ruling P15). */ + /** The output directories under `out()` that the fixtures write to: mcp/sdk sessions and the Laravel application. */ private const array OUT_DIRECTORIES = [ 'sessions', 'app', @@ -84,6 +85,7 @@ static function (): void { #[\Override] protected function setUp(): void { + DockerRuntime::forgetImages(); if (!DockerRuntime::requested()) { self::markTestSkipped('The runtime tests run with AGENTCAT_RUNTIME_TESTS=1'); } @@ -118,6 +120,30 @@ static function () use ($out): void { ]); } + /** + * A failing runtime test names the images of the containers it started (`AGENTCAT_RUNTIME_IMAGES=latest` runs + * floating tags, so a nightly failure must say which image broke). The original throwable stays as `previous`. + */ + #[\Override] + protected function invokeTestMethod(string $methodName, array $testArguments): mixed + { + try { + return parent::invokeTestMethod($methodName, $testArguments); + } catch (\Throwable $t) { + $images = DockerRuntime::imagesUsed(); + $isExpectation = $t instanceof ExpectationFailedException; + if ($images === [] || (!$isExpectation && str_starts_with($t::class, 'PHPUnit\\'))) { // a skip, an incomplete test or fail() + throw $t; + } + $message = $t->getMessage() . "\nRuntime images: " . implode(', ', $images); + if ($isExpectation) { + throw new ExpectationFailedException($message, $t->getComparisonFailure(), $t); + } + + throw new \RuntimeException(sprintf('%s: %s', $t::class, $message), 0, $t); + } + } + /** The repository's absolute path, which every container mounts read-only at the same path. */ final protected static function repo(): string { @@ -165,7 +191,7 @@ final protected function freePort(): int } /** - * The environment of every container (ruling P15): AgentCat on, with `HOME=/out`; the API at `$collector`, or at a + * The environment of every container: AgentCat on, with `HOME=/out`; the API at `$collector`, or at a * closed local port so that a fixture which forgets its collector fails closed; diagnostics off and at a closed * port (a fixture turns them on for one request with `X-E2E-Diagnostics`); the fixtures' sessions, markers and * Laravel application under `/out`. @@ -237,7 +263,7 @@ final protected function assertEchoed(string $msg, array $answer, int $id): void /** * Sends the untimed warm-up call (`echoCall(1, 'warm')` with `echoHeaders($scenario)` and `$headers`) and waits until - * `$scenario` has its delivery, so the timed request meets a warm runtime and no drain of the warm-up (ruling P16). + * `$scenario` has its delivery, so the timed request meets a warm runtime and no drain of the warm-up. * * @param array $headers */ diff --git a/tests/Runtime/UnitTest.php b/tests/Runtime/UnitTest.php index b311fc7..b15e38b 100644 --- a/tests/Runtime/UnitTest.php +++ b/tests/Runtime/UnitTest.php @@ -13,10 +13,10 @@ use PHPUnit\Framework\Attributes\Group; /** - * NGINX Unit 1.34.2 with PHP 8.3.26 (ruling P19). Unit's PHP module reports the SAPI of `php -S` (`cli-server`), runs + * NGINX Unit 1.34.2 with PHP 8.3.26. Unit's PHP module reports the SAPI of `php -S` (`cli-server`), runs * each request as a PHP request of its own, and has `fastcgi_finish_request()`, which ends the answer also when it * carries no Content-Length. Each upper bound sits about 1 s above the measured time and about 1 s below the time a - * held answer takes, the collector's 2 s (ruling P16). + * held answer takes, the collector's 2 s. */ #[Group('runtime')] final class UnitTest extends RuntimeTestCase @@ -51,7 +51,7 @@ public function testTheAnswerReachesTheClientBeforeTheDelivery(array $headers, b { $events = self::$collector->scenario([['status' => 202, 'delayMs' => 2000]]); $diagnostics = self::$collector->scenario(); - [$runtime, $base] = $this->unit($diagnostics); + [$runtime, $base] = $this->unit($diagnostics, null, 1); $client = new McpHttpClient($base . '/mcp'); $this->warmUp($client, self::$collector->scenario(), self::COLLECTOR_DEADLINE_SECONDS, $headers); $started = microtime(true); @@ -69,35 +69,75 @@ public function testTheAnswerReachesTheClientBeforeTheDelivery(array $headers, b $this->assertNoPhpErrors($runtime); } + /** @return iterable the MCP endpoint that `Fixtures/unit.php` hands to a bridge's front controller */ + public static function bridges(): iterable + { + yield 'symfony/mcp-bundle' => ['/mcp/conformance']; + yield 'laravel/mcp' => ['/mcp/e2e']; + } + + /** + * The bridges under Unit: the answer carries no Content-Length (`fastcgi_finish_request()` ends it, so neither + * bridge adds one) and still reaches the client before the delayed delivery. An untimed first request compiles + * the Symfony container and fills Laravel's caches, and its delivery takes the collector's undelayed step; the + * timed request's delivery waits 2 s. + */ + #[DataProvider('bridges')] + #[Group('ac-03-1')] + #[MaximumDuration(60000)] + public function testTheBridgesAnswerBeforeTheDelivery(string $path): void + { + $events = self::$collector->scenario([['status' => 202], ['status' => 202, 'delayMs' => 2000]]); + $diagnostics = self::$collector->scenario(); + [$runtime, $base] = $this->unit($diagnostics, $events, 1); + $client = new McpHttpClient($base . $path); + $this->warmUp($client, $events, self::COLLECTOR_DEADLINE_SECONDS); + $started = microtime(true); + + $answer = $client->post(self::echoCall(2, 'hi'), self::echoHeaders($events)); + + $this->assertLessThan(self::ANSWER_SECONDS, $answer['receivedAt'] - $started, 'Unit held the answer for the delivery'); + $this->assertEchoed('hi', $answer, 2); + $this->assertArrayNotHasKey('content-length', $answer['headers'], 'the bridge added a length that Unit does not need'); + $this->assertSame('cli-server', $answer['headers']['x-e2e-sapi'] ?? null, 'Unit reports the SAPI of php -S'); + $this->assertDeliveredAfter($answer, $events, self::COLLECTOR_DEADLINE_SECONDS, 2); + $this->waitForSentEventPosts($diagnostics, 2, self::COLLECTOR_DEADLINE_SECONDS); + $this->assertNoPhpErrors($runtime); + } + /** * NGINX Unit on 127.0.0.1 only (checked: 127.0.0.2 is refused), running `Fixtures/unit.php` for every request in - * two application processes. The configuration is `conf.json` in a state directory in `out()`, so Unit starts - * with it once: the image's entrypoint configures an empty state directory with a Unit that it stops again, and - * the port would accept connections before that restart. The application gets `RuntimeTestCase::env()` with - * diagnostics on and sent to `$diagnostics`, and logs PHP's errors to Unit's log, the container's output. `HOME` is + * `$processes` application processes. Unit cannot route a request to a chosen process, so a case with one warm-up + * request uses one process: the timed request never meets a cold one. The configuration is `conf.json` in a state + * directory in `out()`, so Unit starts with it once: the image's entrypoint configures an empty state directory + * with a Unit that it stops again, and the port would accept connections before that restart. + * + * The application gets `RuntimeTestCase::env()` with diagnostics on and sent to `$diagnostics`. Its AgentCat API is + * `$events` for a bridge's fixture, which reads it from the environment, and a closed port otherwise (the echo + * fixtures read the request's `X-E2E-Collector`). PHP's errors go to Unit's log, the container's output. `HOME` is * `/out`, where AgentCat writes its log. * * @return array{DockerRuntime, string} the container, and its base URL */ - private function unit(string $diagnostics): array + private function unit(string $diagnostics, ?string $events, int $processes): array { $out = $this->out(); $port = $this->freePort(); - $env = [...$this->env(), ...$this->diagnosticsTo($diagnostics)]; + $env = [...$this->env($events), ...$this->diagnosticsTo($diagnostics)]; $state = $out . '/unit'; $this->assertTrue(mkdir($state, 0o777) && chmod($state, 0o777), 'Cannot create ' . $state); $this->assertNotFalse(file_put_contents($state . '/conf.json', json_encode([ 'listeners' => ['127.0.0.1:' . $port => ['pass' => 'applications/agentcat']], 'applications' => ['agentcat' => [ 'type' => 'php', - 'processes' => 2, + 'processes' => $processes, 'root' => self::repo() . '/tests/Runtime/Fixtures', 'script' => 'unit.php', 'environment' => $env, 'options' => ['admin' => ['log_errors' => '1']], ]], ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR))); - $runtime = DockerRuntime::start(Images::UNIT, ['unitd', '--no-daemon', '--control', 'unix:/var/run/control.unit.sock'], $env, [$state => self::STATE_DIRECTORY, $out => '/out'], port: $port); + $runtime = DockerRuntime::start(Images::unit(), ['unitd', '--no-daemon', '--control', 'unix:/var/run/control.unit.sock'], $env, [$state => self::STATE_DIRECTORY, $out => '/out'], port: $port); $this->assertFalse(DockerRuntime::accepts($port, '127.0.0.2'), 'Unit listens beyond 127.0.0.1'); return [$runtime, 'http://127.0.0.1:' . $port]; diff --git a/tests/Runtime/docker/Dockerfile b/tests/Runtime/docker/Dockerfile index 66cb64a..559b5f7 100644 --- a/tests/Runtime/docker/Dockerfile +++ b/tests/Runtime/docker/Dockerfile @@ -1,3 +1,4 @@ # The CLI image of the runtime tests: the official image plus ext-pcntl for the signal drain (D39). -FROM php:8.3.35-cli-bookworm +ARG BASE=php:8.3.35-cli-bookworm +FROM ${BASE} RUN docker-php-ext-install pcntl diff --git a/tests/Runtime/docker/swoole/Dockerfile b/tests/Runtime/docker/swoole/Dockerfile index 2598c1c..9830c86 100644 --- a/tests/Runtime/docker/swoole/Dockerfile +++ b/tests/Runtime/docker/swoole/Dockerfile @@ -1,3 +1,4 @@ # The Swoole image of the runtime tests: phpswoole/swoole plus ext-pcntl, so the Swoole guard test has a signal handler to guard. -FROM phpswoole/swoole:6.2.3-php8.3 +ARG BASE=phpswoole/swoole:6.2.3-php8.3 +FROM ${BASE} RUN docker-php-ext-install pcntl diff --git a/tests/Support/Branding.php b/tests/Support/Branding.php new file mode 100644 index 0000000..4e10a64 --- /dev/null +++ b/tests/Support/Branding.php @@ -0,0 +1,435 @@ +'; + + /** The shared sections under a heading of their own, by heading text. Getting Started shares its first paragraph only. */ + private const array HEADED = [ + 'Why use AgentCat? ๐Ÿค”', + 'How it works', + 'Getting Started', + 'Identifying users', + 'Redacting sensitive data', + 'Vendor Support', + 'Internal diagnostics', + 'Free for open source', + 'Community Cats ๐Ÿฑ', + ]; + + /** The section of which only the first paragraph is shared. */ + private const string FIRST_PARAGRAPH_ONLY = 'Getting Started'; + + /** A line that starts a Markdown block of its own instead of continuing a paragraph. */ + private const string BLOCK_START = '/\A(?:[\s\-*+>|<#]|\d+\.)/'; + + private const string CODE = 'code'; + + private const string HEADING = 'heading'; + + private const string QUOTE = 'quote'; + + private const string TEXT = 'text'; + + /** + * The TypeScript README of a copy that starts with FIXTURE_HEADER. + * + * @throws \UnexpectedValueException when the file cannot be read or does not start with the header line + */ + public static function typescriptReadme(string $path): string + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false || !str_starts_with($contents, self::FIXTURE_HEADER . "\n")) { + throw new \UnexpectedValueException(sprintf('%s must start with the line %s', $path, self::FIXTURE_HEADER)); + } + + return substr($contents, strlen(self::FIXTURE_HEADER) + 1); + } + + /** + * Each shared section's prose, by section name: `header` (the logo, the links and the badges), `note` (the NOTE + * callout), `tagline` (the paragraphs after the callouts), `learn more` (the paragraph after the install command) + * and the headed sections by their heading text. + * + * @return array null for a section the README lacks + */ + public static function sections(string $markdown): array + { + $blocks = self::blocks($markdown); + $preamble = []; + foreach ($blocks as $block) { + if ($block['kind'] === self::HEADING) { + break; + } + $preamble[] = $block; + } + $sections = self::preamble($preamble); + foreach (self::HEADED as $title) { + $sections[$title] = self::headed($blocks, $title); + } + + return $sections; + } + + /** + * The bodies of the fenced code blocks whose info string is `$language`. + * + * @return list + */ + public static function codeBlocks(string $markdown, string $language): array + { + $bodies = []; + foreach (self::blocks($markdown) as $block) { + if ($block['kind'] === self::CODE && $block['info'] === $language) { + $bodies[] = $block['text']; + } + } + + return $bodies; + } + + /** The README without its fenced code blocks, line for line: what renders as text, links and images. */ + public static function withoutCode(string $markdown): string + { + $texts = []; + foreach (self::blocks($markdown) as $block) { + if ($block['kind'] !== self::CODE) { + $texts[] = $block['raw']; + } + } + + return implode("\n\n", $texts); + } + + /** + * The order of the README's parts: the kind of each block before the first heading (`text`, `quote` or `code`), + * then every heading line. + * + * @return list + */ + public static function outline(string $markdown): array + { + $outline = []; + $preamble = true; + foreach (self::blocks($markdown) as $block) { + if ($block['kind'] === self::HEADING) { + $preamble = false; + $outline[] = $block['text']; + } elseif ($preamble) { + $outline[] = $block['kind']; + } + } + + return $outline; + } + + /** + * The heading lines of the shared sections, in the order of the README. + * + * @return list + */ + public static function sharedHeadings(string $markdown): array + { + $headings = []; + foreach (self::outline($markdown) as $line) { + if (str_starts_with($line, '#') && in_array(self::headingText($line), self::HEADED, true)) { + $headings[] = $line; + } + } + + return $headings; + } + + /** + * The text of every heading, without its `#` marks. + * + * @return list + */ + public static function headings(string $markdown): array + { + $headings = []; + foreach (self::blocks($markdown) as $block) { + if ($block['kind'] === self::HEADING) { + $headings[] = self::headingText($block['text']); + } + } + + return $headings; + } + + /** GitHub's anchor for a heading: lower case, punctuation and emoji removed, each space a hyphen. */ + public static function slug(string $heading): string + { + $kept = preg_replace('/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/u', '', mb_strtolower($heading)); + + return str_replace(' ', '-', $kept ?? ''); + } + + /** + * What changed on the TypeScript main branch since the copy: one line for a changed outline (a callout or a section + * added, removed or moved), then one line per shared section or image that differs, by name. + * + * @param array $images path => git blob SHA-1 of the copied image + * @param array $upstreamImages path => git blob SHA-1 on the main branch + * + * @return list + */ + public static function drift(string $copy, string $upstream, array $images, array $upstreamImages): array + { + $changes = []; + $outline = self::firstDifference(implode("\n", self::outline($copy)), implode("\n", self::outline($upstream))); + if ($outline !== '') { + $changes[] = 'outline (the blocks before the first heading, then the headings): ' . $outline; + } + $now = self::sections($upstream); + foreach (self::sections($copy) as $name => $prose) { + $current = $now[$name] ?? null; + if ($current === null) { + $changes[] = sprintf('section "%s": not found on main', $name); + } elseif ($current !== $prose) { + $changes[] = sprintf('section "%s": %s', $name, self::firstDifference($prose ?? '', $current)); + } + } + foreach ($images as $path => $hash) { + $current = $upstreamImages[$path] ?? null; + if ($current === null) { + $changes[] = sprintf('image %s: not found on main', $path); + } elseif ($current !== $hash) { + $changes[] = sprintf('image %s: blob %s on main, %s here', $path, $current, $hash); + } + } + + return $changes; + } + + /** + * The git blob SHA-1 of each file under `docs/static/` and `docs/cats/` in the JSON of GitHub's recursive tree API. + * + * @return array path => blob SHA-1 + * + * @throws \JsonException when the answer is not JSON + * @throws \UnexpectedValueException when the answer holds no tree, or a truncated one + */ + public static function treeHashes(string $json): array + { + $tree = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $entries = is_array($tree) ? ($tree['tree'] ?? null) : null; + if (!is_array($entries)) { + throw new \UnexpectedValueException('the GitHub answer holds no tree'); + } + if (($tree['truncated'] ?? false) !== false) { + throw new \UnexpectedValueException('GitHub truncated the tree'); + } + $hashes = []; + foreach ($entries as $entry) { + $path = is_array($entry) ? ($entry['path'] ?? null) : null; + $sha = is_array($entry) ? ($entry['sha'] ?? null) : null; + if (is_array($entry) && ($entry['type'] ?? null) === 'blob' && is_string($path) && is_string($sha) && preg_match('#\Adocs/(?:static|cats)/#', $path) === 1) { + $hashes[$path] = $sha; + } + } + + return $hashes; + } + + /** The first line where two texts differ, as `line N: "" became ""`; '' when they are equal. */ + public static function firstDifference(string $before, string $after): string + { + if ($before === $after) { + return ''; + } + $old = explode("\n", $before); + $new = explode("\n", $after); + $line = 0; + while (($old[$line] ?? null) === ($new[$line] ?? null)) { + ++$line; + } + + return sprintf('line %d: "%s" became "%s"', $line + 1, $old[$line] ?? '(end)', $new[$line] ?? '(end)'); + } + + /** + * The sections before the first heading. + * + * @param list $blocks + * + * @return array + */ + private static function preamble(array $blocks): array + { + $firstOther = count($blocks); + $lastQuote = -1; + $firstCode = count($blocks); + $note = null; + foreach ($blocks as $index => $block) { + if ($block['kind'] !== self::TEXT && $firstOther === count($blocks)) { + $firstOther = $index; + } + if ($block['kind'] === self::QUOTE) { + $lastQuote = $index; + $note ??= str_starts_with($block['text'], '> [!NOTE]') ? $block['text'] : null; + } + if ($block['kind'] === self::CODE && $firstCode === count($blocks)) { + $firstCode = $index; + } + } + + return [ + 'header' => self::prose(array_slice($blocks, 0, $firstOther)), + 'note' => $note, + 'tagline' => self::prose(array_slice($blocks, $lastQuote + 1, max(0, $firstCode - $lastQuote - 1))), + 'learn more' => self::prose(array_slice($blocks, $firstCode + 1)), + ]; + } + + /** + * The section under the heading `$title`, its heading line first, up to the next heading. + * + * @param list $blocks + */ + private static function headed(array $blocks, string $title): ?string + { + foreach ($blocks as $index => $block) { + if ($block['kind'] !== self::HEADING || self::headingText($block['text']) !== $title) { + continue; + } + $section = [$block]; + foreach (array_slice($blocks, $index + 1) as $next) { + if ($next['kind'] === self::HEADING) { + break; + } + if ($next['kind'] === self::CODE) { + continue; + } + $section[] = $next; + if ($title === self::FIRST_PARAGRAPH_ONLY) { + break; + } + } + + return self::prose($section); + } + + return null; + } + + /** @param list $blocks */ + private static function prose(array $blocks): ?string + { + $texts = []; + foreach ($blocks as $block) { + if ($block['kind'] !== self::CODE) { + $texts[] = $block['text']; + } + } + + return $texts === [] ? null : str_replace('src="' . self::IMAGE_BASE, 'src="', implode("\n\n", $texts)); + } + + private static function headingText(string $line): string + { + return trim((string) preg_replace('/\A#{1,6}\s+/', '', $line)); + } + + /** + * The Markdown blocks: fenced code (its body and info string), headings, block quotes and other text. + * + * @return list + */ + private static function blocks(string $markdown): array + { + $blocks = []; + $lines = []; + $fence = null; + $info = ''; + $split = preg_split('/\r\n|\n|\r/', $markdown); + foreach (is_array($split) ? $split : [] as $line) { + if ($fence !== null) { + if (preg_match('/\A {0,3}' . preg_quote($fence[0], '/') . '{' . strlen($fence) . ',}\s*\z/', $line) === 1) { + $blocks[] = ['kind' => self::CODE, 'text' => implode("\n", $lines), 'info' => $info, 'raw' => implode("\n", $lines)]; + $lines = []; + $fence = null; + } else { + $lines[] = $line; + } + + continue; + } + if (preg_match('/\A {0,3}(`{3,}|~{3,})\s*([^\s`]*)/', $line, $opening) === 1) { + self::endParagraph($blocks, $lines); + $fence = $opening[1]; + $info = $opening[2]; + + continue; + } + if (trim($line) === '') { + self::endParagraph($blocks, $lines); + } elseif (preg_match('/\A#{1,6}\s/', $line) === 1) { + self::endParagraph($blocks, $lines); + $blocks[] = ['kind' => self::HEADING, 'text' => rtrim($line), 'info' => '', 'raw' => $line]; + } else { + $lines[] = $line; + } + } + if ($fence !== null) { + $blocks[] = ['kind' => self::CODE, 'text' => implode("\n", $lines), 'info' => $info, 'raw' => implode("\n", $lines)]; // an unclosed fence runs to the end + } else { + self::endParagraph($blocks, $lines); + } + + return $blocks; + } + + /** + * Adds the lines read so far as a block quote or a text block, and starts the next block. + * + * @param list $blocks + * @param list $lines + */ + private static function endParagraph(array &$blocks, array &$lines): void + { + if ($lines === []) { + return; + } + $blocks[] = ['kind' => str_starts_with(ltrim($lines[0]), '>') ? self::QUOTE : self::TEXT, 'text' => self::unwrap($lines), 'info' => '', 'raw' => implode("\n", $lines)]; + $lines = []; + } + + /** + * The lines of a block, each soft line break of a paragraph a space. + * + * @param non-empty-list $lines + */ + private static function unwrap(array $lines): string + { + $joined = []; + foreach ($lines as $line) { + $line = rtrim($line); + $last = array_key_last($joined); + if ($last !== null && preg_match(self::BLOCK_START, $line) !== 1) { + $joined[$last] .= ' ' . $line; + } else { + $joined[] = $line; + } + } + + return implode("\n", $joined); + } +} diff --git a/tests/Support/ExporterFixtures.php b/tests/Support/ExporterFixtures.php index f74bb99..8b46570 100644 --- a/tests/Support/ExporterFixtures.php +++ b/tests/Support/ExporterFixtures.php @@ -7,7 +7,7 @@ use AgentCat\Event; use AgentCat\Internal\Json\Json; -/** The shared golden events of 06 ยง9.0 (golden.ts) and golden2.ts. */ +/** The shared golden events of the TypeScript SDK's golden.ts and golden2.ts. */ final class ExporterFixtures { public const string SESSION = 'ses_2yXlShd4ODDcHLYliGW9VgH3m1R'; diff --git a/tests/Support/FakeCallSeam.php b/tests/Support/FakeCallSeam.php index 87797a6..c046f65 100644 --- a/tests/Support/FakeCallSeam.php +++ b/tests/Support/FakeCallSeam.php @@ -30,6 +30,18 @@ final class FakeCallSeam implements CallSeam /** @var list|\Throwable|null the library's first `tools/list` page, or what listing it throws */ public array|\Throwable|null $listing = null; + /** The first page's next cursor. */ + public ?string $nextCursor = null; + + /** + * @var array|\Throwable|null, ?string}> the later pages by the cursor that asks for them: + * tools (as `$listing`) and next cursor + */ + public array $pages = []; + + /** @var list the cursor of each listing, in order */ + public array $cursors = []; + public ?\Throwable $replaceFailure = null; /** @@ -118,14 +130,16 @@ public function answer(\stdClass $tree): \stdClass } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { ++$this->listCalls; - if ($this->listing instanceof \Throwable) { - throw $this->listing; + $this->cursors[] = $cursor; + [$tools, $next] = $cursor === null ? [$this->listing, $this->nextCursor] : ($this->pages[$cursor] ?? [null, null]); + if ($tools instanceof \Throwable) { + throw $tools; } - return $this->listing; + return $tools === null ? null : [$tools, $next]; } #[\Override] diff --git a/tests/Support/FileTree.php b/tests/Support/FileTree.php index 4dd7315..99c2442 100644 --- a/tests/Support/FileTree.php +++ b/tests/Support/FileTree.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support; -/** Removes the directories that tests make under the test HOME: the bootstrap removes only plain files there (Ruling R8). */ +/** Removes the directories that tests make under the test HOME: the bootstrap removes only plain files there. */ final class FileTree { /** Removes a file, a link, or a directory and everything in it. A path that does not exist, or `''`, is left alone. */ diff --git a/tests/Support/Laravel/GuardPositionProvider.php b/tests/Support/Laravel/GuardPositionProvider.php new file mode 100644 index 0000000..ea163db --- /dev/null +++ b/tests/Support/Laravel/GuardPositionProvider.php @@ -0,0 +1,26 @@ + `register` and `boot`: whether the guard was in front */ + public static array $inFront = []; + + #[\Override] + public function register(): void + { + self::$inFront['register'] = SrcErrorGuard::inFront(); + } + + public function boot(): void + { + self::$inFront['boot'] = SrcErrorGuard::inFront(); + } +} diff --git a/tests/Support/LaravelMcp/LaravelConformanceHarness.php b/tests/Support/LaravelMcp/LaravelConformanceHarness.php index babeeb3..a003250 100644 --- a/tests/Support/LaravelMcp/LaravelConformanceHarness.php +++ b/tests/Support/LaravelMcp/LaravelConformanceHarness.php @@ -57,10 +57,18 @@ public static function fullPage(): self return new self($fixture, new LaravelStdioHarness(FullPageServer::class, $fixture->tracker())); } + /** `PagedOwnServer`: a full page, then `own` alone on page 2. */ + public static function paged(): self + { + $fixture = new LaravelTrackingFixture(); + + return new self($fixture, new LaravelStdioHarness(PagedOwnServer::class, $fixture->tracker())); + } + /** * The conformance server, tracked by `Client::track()` after an earlier one, which the client had tracked with * `enableTracing: false`, was freed. Asserts that the new server has the old one's object ID, so the same - * container keys, and that its binding replaced the old one's (plan E1 header conflict 3). + * container keys, and that its binding replaced the old one's. */ public static function reusedObjectId(): self { diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index d344890..24827fa 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -13,17 +13,23 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; +use AgentCat\Tests\Support\SrcErrorGuard; use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; use Illuminate\Foundation\Application; +use Illuminate\Foundation\Bootstrap\RegisterProviders; use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\After; /** * A Laravel application with laravel/mcp, for the adapter and bridge tests. `app.debug` is false, like production. * Every file Laravel writes (the package and service manifests, the caches, storage, the log, the environment file * and published configuration) goes to a directory of this test under the test HOME, removed after the test: none - * goes into vendor/ (Ruling R8). + * goes into vendor/. A notice, warning or deprecation from a file of `src/` that Laravel's error handler + * takes (it logs a deprecation and turns any other issue into an exception, which a request answers with an error) + * fails the test (`SrcErrorGuard`). */ abstract class LaravelTestCase extends TestCase { @@ -45,6 +51,9 @@ abstract class LaravelTestCase extends TestCase private ?Environment $environment = null; + /** The error and exception handler stacks before the application, put back after the test. */ + private ?GlobalHandlers $handlers = null; + /** * The first Laravel application of a PHP process is slow: PHP compiles the framework's files then (about 0.07 s on * PHP 8.5, up to 0.3 s on a loaded host, against 0.01 s for each later one). So, once per process and before the @@ -78,6 +87,7 @@ public function testWarmUp(): void $warmUp->testWarmUp(); } finally { $warmUp->tearDown(); + $warmUp->assertNoSrcIssueWasHidden(); $warmUp::tearDownAfterClass(); } }); @@ -86,6 +96,8 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + SrcErrorGuard::forget(); + $this->handlers = GlobalHandlers::save(); $home = getenv('HOME'); if (!is_string($home) || $home === '') { throw new \LogicException('tests/bootstrap.php sets HOME to the test HOME'); @@ -105,6 +117,30 @@ protected function setUp(): void parent::setUp(); } + /** + * Each new application (`setUp()`, or a test's own refresh) installs Laravel's error handler, then registers and + * boots the providers, AgentCat's among them. The guard goes in front of that handler before the providers register + * (Testbench resolves `RegisterProviders` right after `HandleExceptions`), and again after the application if a + * handler came in front of it since. + * + * @param Application $app + */ + #[\Override] + protected function resolveApplicationResolvingCallback(mixed $app): void + { + parent::resolveApplicationResolvingCallback($app); + $app->afterResolving(RegisterProviders::class, static function (): void { + SrcErrorGuard::install(); + }); + } + + #[\Override] + protected function refreshApplication(): void + { + parent::refreshApplication(); + SrcErrorGuard::install(); + } + /** @return list */ #[\Override] protected function getPackageProviders(mixed $app): array @@ -144,8 +180,22 @@ protected function tearDown(): void TrackedServers::resetIncomplete(); TrackedServers::resetSkipped(); - parent::tearDown(); - $this->environment?->restore(); - FileTree::remove($this->laravelDirectory); + parent::tearDown(); // Laravel's flushState() empties both stacks and puts PHPUnit's handler back, also before a throw + try { + $this->environment?->restore(); + FileTree::remove($this->laravelDirectory); + } finally { + $this->handlers?->restore(); + } + } + + /** + * Fails with each `src/` issue that a guard recorded. It runs after every `tearDown()` of the test, the subclasses' + * too, so its failure skips none of their cleanup; `setUp()` forgets what a test that threw earlier left. + */ + #[After(-100)] + public function assertNoSrcIssueWasHidden(): void + { + SrcErrorGuard::assertNone($this); } } diff --git a/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php b/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php new file mode 100644 index 0000000..6cf09b7 --- /dev/null +++ b/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php @@ -0,0 +1,29 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 14)), CustomerGetMoreTools::class]; + } +} diff --git a/tests/Support/LaravelMcp/PagedOwnServer.php b/tests/Support/LaravelMcp/PagedOwnServer.php new file mode 100644 index 0000000..0599a34 --- /dev/null +++ b/tests/Support/LaravelMcp/PagedOwnServer.php @@ -0,0 +1,29 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 14)), OwnTool::class]; + } +} diff --git a/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php b/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php new file mode 100644 index 0000000..8d298e3 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php @@ -0,0 +1,36 @@ + */ + #[\Override] + public function schema(JsonSchema $schema): array + { + return ['session_id' => $schema->string()->required()]; + } + + public function handle(Request $request): Response + { + ToolCalls::record('get_more_tools', $request->all()); + + return Response::text('mine'); + } +} diff --git a/tests/Support/ListingLines.php b/tests/Support/ListingLines.php new file mode 100644 index 0000000..9ddae5d --- /dev/null +++ b/tests/Support/ListingLines.php @@ -0,0 +1,32 @@ + + */ + public static function shadowedCall(int $contextLength): array + { + return [self::SHADOWED, self::ownSessionId('get_more_tools'), self::REBUILT, sprintf(self::MISSING_TOOL_REPORTED, $contextLength)]; + } +} diff --git a/tests/Support/McpSdk/McpSdkConformanceHarness.php b/tests/Support/McpSdk/McpSdkConformanceHarness.php index 8b43d98..bc7a0f8 100644 --- a/tests/Support/McpSdk/McpSdkConformanceHarness.php +++ b/tests/Support/McpSdk/McpSdkConformanceHarness.php @@ -58,16 +58,13 @@ public static function untracked(): self /** `echo`, then `t01` to `t14`, with a page size of 15: laravel/mcp's default page, one full page here too. */ public static function fullPage(): self { - $harness = new self(new Options()); - $builder = self::builder()->setPaginationLimit(15); - $tools = ['echo' => Tools::echo($builder)]; - foreach (range(1, 14) as $i) { - $tools[sprintf('t%02d', $i)] = Tools::named($builder, sprintf('t%02d', $i)); - } - $harness->tools[] = $tools; - $harness->server = $harness->fixture->harness($harness->tracked($builder)); + return self::pagesOf15(withOwn: false); + } - return $harness; + /** `fullPage()`'s 15 tools, then `own` alone on page 2. */ + public static function paged(): self + { + return self::pagesOf15(withOwn: true); } /** @@ -139,6 +136,24 @@ private static function builder(): Builder return Server::builder()->setServerInfo('conformance-server', '2.0.0'); } + /** `echo` and `t01` to `t14` with a page size of 15, then `own` on page 2 when `$withOwn`, tracked with defaults. */ + private static function pagesOf15(bool $withOwn): self + { + $harness = new self(new Options()); + $builder = self::builder()->setPaginationLimit(15); + $tools = ['echo' => Tools::echo($builder)]; + foreach (range(1, 14) as $i) { + $tools[sprintf('t%02d', $i)] = Tools::named($builder, sprintf('t%02d', $i)); + } + if ($withOwn) { + $tools['own'] = Tools::own($builder); + } + $harness->tools[] = $tools; + $harness->server = $harness->fixture->harness($harness->tracked($builder)); + + return $harness; + } + /** Adds the five conformance tools, which `received()` reads. */ private function conformanceTools(Builder $builder): Builder { diff --git a/tests/Support/McpSdk/McpSdkVersion.php b/tests/Support/McpSdk/McpSdkVersion.php index 47c1e46..87b05da 100644 --- a/tests/Support/McpSdk/McpSdkVersion.php +++ b/tests/Support/McpSdk/McpSdkVersion.php @@ -6,11 +6,14 @@ use AgentCat\Internal\SdkVersion; -/** Which mcp/sdk revision the suite runs against: the locked release, or main through tests/bin/mcp-sdk-main.sh. */ +/** Which mcp/sdk revision the suite runs against: the locked release, or another one through tests/bin/mcp-sdk-leg.sh. */ final class McpSdkVersion { - public static function isMain(): bool + /** Whether the installed mcp/sdk serves the 2026-07-28 era over stdio: 0.9.0 and later, or dev-main. */ + public static function hasModernEra(): bool { - return SdkVersion::installed('mcp/sdk') === 'dev-main'; + $installed = SdkVersion::installed('mcp/sdk'); + + return $installed === 'dev-main' || ($installed !== null && version_compare($installed, '0.9.0', '>=')); } } diff --git a/tests/Support/RecordingTransport.php b/tests/Support/RecordingTransport.php index af59958..c3cf50a 100644 --- a/tests/Support/RecordingTransport.php +++ b/tests/Support/RecordingTransport.php @@ -12,13 +12,18 @@ * * `start()` records and holds a delivery; `pump()` completes everything held and runs each `beforeAttempt` once. * With `$complete = false` deliveries stay held. Time is simulated: `waitForActivity()` jumps to its deadline, - * so no test ever sleeps. With `$failure` set, every method throws it. + * so no test ever sleeps. With `$canWait = false` it cannot block, as on a host without a sleep function: it returns + * false, and the clock moves on by `SPIN_SECONDS`, the time of a pass of a loop that spins. With `$failure` set, every + * method throws it. */ final class RecordingTransport implements Transport { /** @var list every delivery passed to start(), in order */ public array $deliveries = []; + /** What one waitForActivity() costs on the simulated clock when it cannot block. */ + public const float SPIN_SECONDS = 0.001; + /** Number of waitForActivity() calls. */ public int $waits = 0; @@ -30,6 +35,7 @@ final class RecordingTransport implements Transport public function __construct( public bool $complete = true, public ?\Throwable $failure = null, + public bool $canWait = true, ) {} #[\Override] @@ -56,14 +62,21 @@ public function pump(): void } #[\Override] - public function waitForActivity(float $deadline): void + public function waitForActivity(float $deadline): bool { $this->fail(); if (is_nan($deadline)) { throw new \LogicException('waitForActivity() got a NaN deadline'); // a deadline that never passes } ++$this->waits; + if (!$this->canWait) { + $this->clock += self::SPIN_SECONDS; + + return false; + } $this->clock = max($this->clock, $deadline); + + return true; } #[\Override] diff --git a/tests/Support/SrcErrorGuard.php b/tests/Support/SrcErrorGuard.php new file mode 100644 index 0000000..f761822 --- /dev/null +++ b/tests/Support/SrcErrorGuard.php @@ -0,0 +1,141 @@ + 'E_NOTICE', + E_USER_NOTICE => 'E_USER_NOTICE', + E_WARNING => 'E_WARNING', + E_USER_WARNING => 'E_USER_WARNING', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + ]; + + /** @var list each recorded issue: its level, message and `file:line` */ + private static array $recorded = []; + + /** @var list<\Closure(int, string, string, int): bool> the guards installed since the last `assertNone()` */ + private static array $guards = []; + + /** + * Puts a guard in front of the error handler in place, unless a guard is already in front. The guard calls that + * handler itself: a handler that returns false hands the issue to PHP's own handling, not to the one below it. + * + * @param string|null $source the directory whose files' issues are recorded; null: the repository's `src/` + */ + public static function install(?string $source = null): void + { + if (self::inFront()) { + return; // a second kernel of the same test, or after the boot: no handler came in front of the guard + } + $inPlace = self::inPlace(); + $source ??= self::source(); + $guard = static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { + if (self::fromSource($level, $file, $source)) { + self::$recorded[] = sprintf('%s: %s at %s:%d', self::LEVELS[$level] ?? (string) $level, $message, $file, $line); + } + + return self::forward($inPlace, $level, $message, $file, $line); + }; + self::$guards[] = $guard; + set_error_handler($guard); + } + + /** Fails with every recorded issue; counts as one assertion. It forgets the issues and the guards either way. */ + public static function assertNone(TestCase $test): void + { + $recorded = self::$recorded; + self::$recorded = []; + self::$guards = []; + $test::assertCount(0, $recorded, sprintf("A file of src/ raised %d issue(s) that a framework's error handler took:\n%s", count($recorded), implode("\n", $recorded))); + } + + /** + * Forgets the issues and the guards of a test that ended before its `assertNone()`: its `tearDown()` threw, so it + * failed already. A bridge test case calls it first in `setUp()`, so no other test reports them. + */ + public static function forget(): void + { + self::$recorded = []; + self::$guards = []; + } + + /** The guards installed since the last `assertNone()`: one for each application or kernel handler they front. */ + public static function installed(): int + { + return count(self::$guards); + } + + /** Whether a guard is the error handler in place. */ + public static function inFront(): bool + { + return in_array(self::inPlace(), self::$guards, true); + } + + /** The repository's `src/`, with a trailing slash. */ + public static function source(): string + { + return dirname(__DIR__, 2) . '/src/'; + } + + /** + * Whether an issue is a notice, warning or deprecation, not suppressed with `@`, from a file under `$source` (both + * compared by their real paths). + */ + public static function fromSource(int $level, string $file, string $source): bool + { + if (($level & self::ISSUES) === 0 || (error_reporting() & ~self::INSUPPRESSIBLE) === 0) { + return false; + } + $path = realpath($file); + $directory = realpath($source); + + return str_starts_with($path === false ? $file : $path, ($directory === false ? rtrim($source, '/') : $directory) . '/'); + } + + /** + * The error handler in place (PHP 8.3 has no `get_error_handler()`). + * + * @return (callable(int, string, string, int): bool)|null + */ + public static function inPlace(): ?callable + { + $inPlace = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + return $inPlace; + } + + /** + * Hands an issue to the handler that was in place, as PHP would have: true when it took the issue, false (PHP's own + * handling) when it declined it or there was none. Every level goes to it: PHP does not tell which levels a handler + * was registered for (`set_error_handler()`'s second argument), and no framework in use registers one. + * + * @param (callable(int, string, string, int): bool)|null $inPlace + */ + public static function forward(?callable $inPlace, int $level, string $message, string $file, int $line): bool + { + return $inPlace !== null && $inPlace($level, $message, $file, $line) !== false; + } +} diff --git a/tests/Support/Symfony/FullPage/SecondPageOwnTool.php b/tests/Support/Symfony/FullPage/SecondPageOwnTool.php new file mode 100644 index 0000000..7d7a225 --- /dev/null +++ b/tests/Support/Symfony/FullPage/SecondPageOwnTool.php @@ -0,0 +1,24 @@ + $session_id]); + + return 'ok'; + } +} diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 86a69c6..c2d2055 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -15,10 +15,13 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\RecordingTransport; +use AgentCat\Tests\Support\SrcErrorGuard; use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; +use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -27,9 +30,10 @@ /** * Symfony applications with symfony/mcp-bundle, for the bundle tests. Skipped where the bundle is not installed (the - * mcp/sdk main leg removes it), unless a test class needs no symfony/mcp-bundle (`needsTheMcpBundle()`). Each kernel compiles its container into a directory of this test under the test HOME, - * removed after the test (Ruling R8). The AGENTCAT_* variables of the process that runs the tests are cleared, so the - * configuration decides, and the global event queue records its deliveries. + * mcp/sdk 0.9 leg removes it), unless a test class needs no symfony/mcp-bundle (`needsTheMcpBundle()`). Each kernel compiles its container into a directory of this test under the test HOME, + * removed after the test. The AGENTCAT_* variables of the process that runs the tests are cleared, so the + * configuration decides, and the global event queue records its deliveries. A notice, warning or deprecation from a + * file of `src/` that an error handler of the kernel takes fails the test (`SrcErrorGuard`). */ abstract class SymfonyTestCase extends TestCase { @@ -46,6 +50,9 @@ abstract class SymfonyTestCase extends TestCase private ?Environment $environment = null; + /** The error and exception handler stacks before the first kernel, put back after the test. */ + private ?GlobalHandlers $handlers = null; + /** Whether this process ran its warm-up kernel (`setUpBeforeClass()`). */ private static bool $warm = false; @@ -84,6 +91,7 @@ public function testWarmUp(): void $warmUp->testWarmUp(); } finally { $warmUp->tearDown(); + $warmUp->assertNoSrcIssueWasHidden(); $warmUp::tearDownAfterClass(); } }); @@ -92,6 +100,8 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + SrcErrorGuard::forget(); + $this->handlers = GlobalHandlers::save(); if ($this->needsTheMcpBundle() && !class_exists(McpBundle::class)) { self::markTestSkipped('symfony/mcp-bundle is not installed'); } @@ -117,22 +127,36 @@ protected function needsTheMcpBundle(): bool #[\Override] protected function tearDown(): void { - foreach ($this->kernels as $kernel) { - $kernel->shutdown(); + try { + foreach ($this->kernels as $kernel) { + $kernel->shutdown(); + } + $this->kernels = []; + ToolCalls::reset(); + TestMode::reset(); + BridgeConfig::reset(); + Log::reset(); + Diagnostics::reset(); + EventQueue::setGlobal(null); + AgentCat::setClient(null); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + + $this->environment?->restore(); + FileTree::remove($this->directory); + } finally { // a throw above leaves no guard to the next test + $this->handlers?->restore(); // the guard goes, and any handler that a kernel left } - $this->kernels = []; - ToolCalls::reset(); - TestMode::reset(); - BridgeConfig::reset(); - Log::reset(); - Diagnostics::reset(); - EventQueue::setGlobal(null); - AgentCat::setClient(null); - TrackedServers::resetIncomplete(); - TrackedServers::resetSkipped(); - - $this->environment?->restore(); - FileTree::remove($this->directory); + } + + /** + * Fails with each `src/` issue that a guard recorded. It runs after every `tearDown()` of the test, the subclasses' + * too, so its failure skips none of their cleanup; `setUp()` forgets what a test that threw earlier left. + */ + #[After(-100)] + public function assertNoSrcIssueWasHidden(): void + { + SrcErrorGuard::assertNone($this); } /** @@ -155,15 +179,20 @@ protected function kernel(array $agentcat = [], array $services = [], bool $with return $kernel; } - /** Boots a kernel of this test, which shuts it down after the test. */ + /** + * Boots a kernel of this test, which shuts it down after the test. The guard is in front while the bundles build the + * container and boot (AgentCat's among them), and again after the boot if a handler came in front of it. + */ protected function boot(KernelInterface $kernel): void { $this->kernels[] = $kernel; $handler = $this->exceptionHandler(); + SrcErrorGuard::install(); $kernel->boot(); if ($this->exceptionHandler() !== $handler) { restore_exception_handler(); // FrameworkBundle::boot() leaves Symfony's handler with symfony/error-handler 7.3 } + SrcErrorGuard::install(); } /** This test's directory under the test HOME, removed after the test. */ diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index 7e2be6c..698d82e 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -57,6 +57,12 @@ final class TestKernel extends Kernel */ public const array FULL_PAGE = ['servers' => ['conformance' => ['registry' => [Tools\EchoTool::class, FullPage\FullPageTools::class], 'pagination_limit' => 15] + self::CONFORMANCE]]; + /** + * Two pages of 15: `FULL_PAGE`'s tools, then `own` (`FullPage\SecondPageOwnTool`) alone on page 2. The kernel needs + * both tool classes as services, in this order. + */ + public const array PAGED = ['servers' => ['conformance' => ['registry' => [Tools\EchoTool::class, FullPage\FullPageTools::class, FullPage\SecondPageOwnTool::class], 'pagination_limit' => 15] + self::CONFORMANCE]]; + /** * @param string $directory the directory of the cache and log directories * @param array $agentcat the agentcat configuration diff --git a/tests/Support/UnreadTransfers.php b/tests/Support/UnreadTransfers.php new file mode 100644 index 0000000..a210320 --- /dev/null +++ b/tests/Support/UnreadTransfers.php @@ -0,0 +1,39 @@ +getValue($transport); + if (!$multi instanceof CurlMulti) { + throw new \LogicException('CurlTransport::$multi is ' . get_debug_type($multi) . ', not a CurlMulti'); + } + $handle = (new \ReflectionProperty(CurlMulti::class, 'multi'))->getValue($multi); + if (!$handle instanceof \CurlMultiHandle) { + throw new \LogicException('CurlMulti::$multi is ' . get_debug_type($handle) . ', not a CurlMultiHandle: no delivery started, or the member changed'); + } + $unread = 0; + while (curl_multi_info_read($handle) !== false) { + ++$unread; + } + + return $unread; + } +} diff --git a/tests/Support/WarmUp.php b/tests/Support/WarmUp.php index 0762cb4..c6f05c8 100644 --- a/tests/Support/WarmUp.php +++ b/tests/Support/WarmUp.php @@ -10,32 +10,27 @@ * `src/`, so a notice, warning or deprecation that PHP raises once per file (when it includes or compiles it) would * reach no test. Such an issue from a file under the source directory is thrown instead, out of `setUpBeforeClass()`: * every test of the class errors, and the suite fails. Any other issue (from vendor/, or suppressed with `@`) goes to - * the handler in place, as without the guard. The handler stacks are put back afterwards, also after a throw. + * the handler in place, as without the guard. An issue that reaches a framework's own handler is recorded by the + * warm-up's `SrcErrorGuard` and fails its `tearDown()` the same way. The handler stacks are put back afterwards, also + * after a throw. */ final class WarmUp { - /** The issues that fail the warm-up. */ - private const int ISSUES = E_NOTICE | E_USER_NOTICE | E_WARNING | E_USER_WARNING | E_DEPRECATED | E_USER_DEPRECATED; - - /** What `@` leaves of `error_reporting()`: with no other level, the issue is suppressed (PHPUnit's own test). */ - private const int INSUPPRESSIBLE = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR; - /** * @param \Closure(): void $warmUp the test case's lifecycle * @param string|null $source the directory whose files' issues fail the warm-up; null: the repository's `src/` */ public static function run(\Closure $warmUp, ?string $source = null): void { - $source ??= dirname(__DIR__, 2) . '/src/'; + $source ??= SrcErrorGuard::source(); $handlers = GlobalHandlers::save(); // the frameworks change them, and no test puts them back - $inPlace = set_error_handler(static fn(): bool => false); - restore_error_handler(); + $inPlace = SrcErrorGuard::inPlace(); set_error_handler(static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { - if (($level & self::ISSUES) !== 0 && (error_reporting() & ~self::INSUPPRESSIBLE) !== 0 && str_starts_with($file, $source)) { + if (SrcErrorGuard::fromSource($level, $file, $source)) { throw new \ErrorException(sprintf('%s (raised from %s:%d during a test case warm-up)', $message, $file, $line), 0, $level, $file, $line); } - return $inPlace !== null && $inPlace($level, $message, $file, $line) !== false; // false: PHP's own handling + return SrcErrorGuard::forward($inPlace, $level, $message, $file, $line); // false: PHP's own handling }); try { $warmUp(); diff --git a/tests/Support/WireEvents.php b/tests/Support/WireEvents.php index 0d600bf..06a3648 100644 --- a/tests/Support/WireEvents.php +++ b/tests/Support/WireEvents.php @@ -8,7 +8,7 @@ use AgentCat\Internal\Constants; use AgentCat\Internal\Json\Json; -/** The four golden events of `07` ยง6.7 as `Event` objects. */ +/** The four golden wire events of `tests/Fixtures/golden/wire` as `Event` objects. */ final class WireEvents { private const string PROJECT = 'proj_2mZ9yX8wV7uT6sR5qP4oN3mL2kJ'; diff --git a/tests/Traceability/criteria.php b/tests/Traceability/criteria.php new file mode 100644 index 0000000..99baca3 --- /dev/null +++ b/tests/Traceability/criteria.php @@ -0,0 +1,433 @@ + 'track() returns the same server instance it was given.', + 'ac-01-2' => 'track() on an unsupported object throws nothing, returns it, and logs the compatibility or unrecognized-shape line.', + 'ac-01-3' => 'An internal failure in track() returns the input server and logs `Warning: Failed to track server - โ€ฆ`.', + 'ac-01-4' => 'A successful track() logs one `AgentCat setup started` and one `AgentCat setup complete` line with the options.', + 'ac-01-5' => 'Without a projectId and exporters, tracking is still installed and the no-projectId warning is logged.', + 'ac-01-6' => 'Without a projectId, the setup lines show `project (telemetry-only)`.', + 'ac-01-7' => 'A second track() of a tracked server changes nothing: one event, one block, one get_more_tools, the first hooks.', + 'ac-01-8' => 'track() on the low-level server of a tracked high-level server treats it as already tracked.', + 'ac-01-9' => 'Two servers tracked with different identify hooks each stamp their own identity on their events.', + 'ac-01-10' => 'Only the exporters of the first track() receive events.', + 'ac-01-11' => 'Diagnostics that the first track() disables stay off after a later track().', + 'ac-01-12' => 'The apiBaseUrl of the latest track() is used for later sends.', + 'ac-01-13' => 'By default report-missing, tracing and tool-call context are on, and agent tracking is off.', + 'ac-01-14' => 'With tracing off, no session_id or agent_id is listed, no event is published and no result is decorated.', + 'ac-01-15' => 'With tracing off and report-missing on, get_more_tools returns the canned answer and publishes no event.', + 'ac-01-16' => 'With report-missing off, get_more_tools is not listed, and session_id and context are still injected.', + 'ac-01-17' => 'With tool-call context off, no context parameter is listed, session_id still is, and events have no userIntent.', + 'ac-01-18' => 'customContextDescription replaces the context description of every tool except get_more_tools.', + 'ac-01-19' => 'An empty or absent customContextDescription gives the default context description, byte for byte.', + 'ac-01-20' => 'With tool-call context on, the `context` argument of a call becomes the event\'s userIntent.', + 'ac-01-21' => 'With agent tracking on, every listed tool, get_more_tools included, has a required string agent_id.', + 'ac-01-22' => 'With agent tracking on, a supplied agent_id is tagged agentcat_agent_id, with agentcat_agent_id_source supplied.', + 'ac-01-23' => 'With agent tracking on and no agent_id, the call succeeds and the event has no agent tags.', + 'ac-01-24' => 'With agent tracking off, no tool has agent_id and no agent tags appear.', + 'ac-01-25' => 'The agentcat_agent_id tag turns CR and LF into spaces and keeps 200 characters; the request keeps the raw value.', + 'ac-01-26' => 'In hook mode (resolveSessionId), no listed tool has a session_id parameter.', + 'ac-01-27' => 'In hook mode, one hook value derives the same golden ses_ ID on every server instance, tagged hook.', + 'ac-01-28' => 'Two different hook values give two different session IDs.', + 'ac-01-29' => 'A hook that returns null or an empty string, throws or never settles mints a session and adds no session data.', + 'ac-01-30' => 'In hook mode, a tool response never gets an issuance or unrecognized block.', + 'ac-01-31' => 'identify runs once per call with the raw request and the context, and its identity is stamped on the event.', + 'ac-01-32' => 'identify runs on every call (no cache), and each event is stamped.', + 'ac-01-33' => 'identify returning a different identity per call stamps each event with the identity of its own call.', + 'ac-01-34' => 'identify returning null leaves no actor ID or name, and identify_data is `{}`.', + 'ac-01-35' => 'A throwing identify leaves the result unchanged and the event anonymous, and logs `Error: identify callback threw`.', + 'ac-01-36' => 'identify returning an identity without a userId leaves no actor ID or name, and the data is `{}`.', + 'ac-01-37' => 'A slow identify does not delay the tool response, and the event duration measures the handler only.', + 'ac-01-38' => 'Serving tools/list runs no hook and publishes no event.', + 'ac-01-39' => 'The state kept per server is its project ID and options: no identify cache or other data of its calls.', + 'ac-01-40' => 'The keys that eventTags returns are the event\'s customer tags, next to the SDK tags.', + 'ac-01-41' => 'An SDK tag wins over a customer tag of the same name.', + 'ac-01-42' => 'An eventTags key that is not valid is dropped with a `Dropping invalid tag` line; the valid keys stay.', + 'ac-01-43' => 'A tag with a 33-character key, a non-string value, a 201-character value or a newline in its value is dropped.', + 'ac-01-44' => 'Of 60 valid customer tags the first 50 stay, `Dropping 10 tag(s)` is logged, and the SDK tags are added on top.', + 'ac-01-45' => 'A throwing or null eventTags or eventProperties leaves the call working, with SDK tags only and no properties.', + 'ac-01-46' => 'The map that eventProperties returns is the event\'s properties.', + 'ac-01-47' => 'redactSensitiveInformation leaves tags and properties unchanged.', + 'ac-01-48' => 'redactSensitiveInformation gets the strings of parameters, response, userIntent and error, never protected fields.', + 'ac-01-49' => 'redactEvent returning null drops that event with `Event dropped by redactEvent hook`; other events still go out.', + 'ac-01-50' => 'A throwing redactEvent drops that event and logs the error.', + 'ac-01-51' => 'Changes that redactEvent makes to id, sessionId, projectId, eventType or timestamp are undone.', + 'ac-01-52' => 'When redactEvent removes response, the event is sent without a response.', + 'ac-01-53' => 'redactEvent runs after identity, tags, properties and session are resolved, and before redactSensitiveInformation.', + 'ac-01-54' => 'redactEvent applies to the custom event of a tracked server, and not to a custom event for a session ID string.', + 'ac-01-55' => 'The apiBaseUrl option wins over both environment variables.', + 'ac-01-56' => 'AGENTCAT_API_URL wins over MCPCAT_API_URL.', + 'ac-01-57' => 'MCPCAT_API_URL alone is used.', + 'ac-01-58' => 'With no option and no environment variable, events go to https://api.agentcat.com.', + 'ac-01-59' => 'An empty apiBaseUrl option or environment variable is skipped, as if unset.', + 'ac-01-60' => 'With no option and no test-environment signal, diagnostics are on.', + 'ac-01-61' => 'disableDiagnostics: true turns diagnostics off.', + 'ac-01-62' => 'DISABLE_DIAGNOSTICS set to 1, true, yes, on or any other non-empty value turns diagnostics off.', + 'ac-01-63' => 'DISABLE_DIAGNOSTICS set to false, 0, no or off keeps diagnostics on, even in a test environment.', + 'ac-01-64' => 'A whitespace-only DISABLE_DIAGNOSTICS is treated as unset.', + 'ac-01-65' => 'A PHP test-environment signal turns diagnostics off by default.', + 'ac-01-66' => 'publishCustomEvent() with an empty or null projectId throws `projectId is required for publishCustomEvent`.', + 'ac-01-67' => 'publishCustomEvent() with a first argument that is neither an object nor a string throws.', + 'ac-01-68' => 'publishCustomEvent() for an untracked server object throws `Server is not tracked. โ€ฆ`.', + 'ac-01-69' => 'publishCustomEvent() accepts a high-level server whose low-level server is tracked.', + 'ac-01-70' => 'publishCustomEvent() with a session ID string makes that string the event\'s session.', + 'ac-01-71' => 'A sessionId in the event data wins over the session ID string argument.', + 'ac-01-72' => 'The custom event of a tracked server without a sessionId has no session and logs `no sessionId provided`.', + 'ac-01-73' => 'A custom event has the type agentcat:custom, the call time as timestamp, and its message as user_intent.', + 'ac-01-74' => 'A custom event sends only its valid tags, and no SDK tags.', + 'ac-01-75' => 'A custom event with empty properties leaves properties out.', + 'ac-01-76' => 'A custom event with absent or empty event data has the core fields only.', + 'ac-01-77' => 'The custom event of a tracked server has the server, client and SDK fields; one for a session string has none.', + 'ac-01-78' => 'The custom event of a tracked server goes to the tracked project ID, or the SDK documents a deliberate fix.', + 'ac-01-79' => 'A tool call publishes one mcp:tools/call event with the raw request, extra, original result, duration and start.', + 'ac-01-80' => 'is_error is absent on success; an isError result or a throw sets is_error and error, and the throw is rethrown.', + 'ac-01-81' => 'client_name and client_version come from the envelope, _meta or handshake, the first one there, strings only.', + 'ac-01-82' => 'The _meta protocolVersion 2026-07-28 gives the tag agentcat_protocol_version=2026-07-28.', + 'ac-01-83' => 'No mcp:initialize, mcp:tools/list or agentcat:identify event is ever published.', + 'ac-01-84' => 'An event with an empty session has `session_id: null` on the wire.', + 'ac-01-85' => 'Every sent id matches ^evt_[0-9A-Za-z]{27}$, and actor_id equals identify_actor_given_id when that is set.', + 'ac-01-86' => 'sdk_language is a fixed PHP value, and agentcat_version is the installed agentcat/sdk version.', + 'ac-01-87' => 'The SDK writes nothing to STDOUT or STDERR: it logs to /agentcat.log, and silently without a home.', + 'ac-01-88' => 'The PHP constants, tag names and get_more_tools descriptor are byte-identical to those of the TypeScript SDK.', + 'ac-01-89' => 'AgentCatIDPrefixes has the cases Session = ses, Event = evt and Agent = agt.', + 'ac-01-90' => 'composer require agentcat/sdk autoloads the entry point, with no hard dependency on one PHP MCP SDK.', + + // The engine: tracking, tool listing, tool calls, sessions and hooks. + 'ac-02-1' => 'track() on each supported server flavor returns it, logs the setup lines with its sdk and signals, and intercepts.', + 'ac-02-2' => 'track() on a value that is no supported server returns it, logs the unrecognized-shape line and installs nothing.', + 'ac-02-3' => 'track() on a server whose name cannot be read fails safe, with a compatibility line that names the server info.', + 'ac-02-4' => 'A second track() with report-missing off is a no-op: get_more_tools stays listed and each call gives one event.', + 'ac-02-5' => 'A factory that tracks a server per request builds exporters and the queue once and keeps each server\'s state apart.', + 'ac-02-6' => 'With defaults, echo lists msg, session_id and context, all required, with the session_id pattern; get_more_tools last.', + 'ac-02-7' => 'With agent tracking, the order is msg, session_id, agent_id, context, all required, get_more_tools included.', + 'ac-02-8' => 'A wrapped listing keeps its nextCursor and its result _meta unchanged.', + 'ac-02-9' => 'A tool or tools handler registered after track() is injected like the others, and each call gives one event.', + 'ac-02-10' => 'A throwing list handler gives the client the same error, and no event.', + 'ac-02-11' => 'An empty tool list passes through unchanged, logs `No tools found in the original listโ€ฆ`, and sets no registry.', + 'ac-02-12' => 'A tool with an unprocessable schema (true, self-referencing, unencodable) is listed unmodified; others are injected.', + 'ac-02-13' => 'A total listing failure returns the original listing and logs `โ€ฆserving the original tool list unmodifiedโ€ฆ`.', + 'ac-02-14' => 'A customer get_more_tools with report-missing on is listed once, shadow-warned once, and answered by AgentCat.', + 'ac-02-15' => 'A listing publishes no event and runs no identify, tags or properties hook.', + 'ac-02-16' => 'A call with a supplied session gets its own arguments only, and its one event records the raw call and the result.', + 'ac-02-17' => 'A strict tool accepts the injected parameters: they are stripped before the library validates the arguments.', + 'ac-02-18' => 'A loose tool sees a customer\'s extra key, and never session_id, agent_id or context.', + 'ac-02-19' => 'A tool that declares its own session_id or context gets its own value; the event\'s session is empty, tag foreign.', + 'ac-02-20' => 'The first tools/call on a fresh instance rebuilds the registries from the list handler, logs it, and strips exactly.', + 'ac-02-21' => 'A failed rebuild serves the call with the heuristic strip, logs `registry rebuild-on-demand failed`, and retries.', + 'ac-02-22' => 'When the runtime allows a timeout, a hanging list handler delays a tools/call by about 5 s at most.', + 'ac-02-23' => 'An isError result gives an isError event whose error is the original result\'s text and whose response is the result.', + 'ac-02-24' => 'An exception that the library turns into an isError result is recorded with its class, message, frames and chain.', + 'ac-02-25' => 'An exception that becomes a JSON-RPC error is recorded in one isError event and rethrown unchanged.', + 'ac-02-26' => 'An unknown tool gives one isError event named after it, and the library\'s own answer.', + 'ac-02-27' => 'A failure in AgentCat\'s post-handler decoration returns the customer\'s result untouched and logs a warning.', + 'ac-02-28' => 'A failure in AgentCat\'s pre-handler setup runs the original handler unstripped, with no event, and logs it.', + 'ac-02-29' => 'After a failed call, the next call on the same instance succeeds normally.', + 'ac-02-30' => 'A call without session_id, or with start, gets the issuance block first, and the event has the minted session.', + 'ac-02-31' => 'An unrecognized session_id gets the unrecognized block first, an empty event session and the tag invalid.', + 'ac-02-32' => 'A valid supplied session, a foreign tool or hook mode adds no text block.', + 'ac-02-33' => 'An isError result still gets the block prepended.', + 'ac-02-34' => 'A result whose content is not a list gets nothing prepended.', + 'ac-02-35' => 'An input_required round passes unchanged, tagged agentcat_mrtr=input_required; a continuation is tagged too.', + 'ac-02-36' => 'A tool with an outputSchema gets an optional mcp_session, first in structuredContent; the event keeps the original.', + 'ac-02-37' => 'The mcp_session mirror holds session, agent and status per state, and is skipped for a customer key or composition.', + 'ac-02-38' => 'Neither event.response nor event.error contains the text block or mcp_session.', + 'ac-02-39' => 'With defaults, the get_more_tools descriptor is exact, plus session_id (and agent_id with agent tracking).', + 'ac-02-40' => 'A get_more_tools call runs no customer code, gets the canned answer, and gives one event with the context as intent.', + 'ac-02-41' => 'With report-missing off, get_more_tools is not listed, and a call of that name goes to the customer or the library.', + 'ac-02-42' => 'With tracing off, get_more_tools is still listed and answered, with no block and no event.', + 'ac-02-43' => 'An HTTP request\'s lowercased headers, method and URL are recorded as strings in the event\'s parameters.', + 'ac-02-44' => 'Client name and version come from the envelope, then _meta, then the handshake; a non-string field becomes null.', + 'ac-02-45' => 'A protocolVersion claim in the envelope or _meta is tagged agentcat_protocol_version; a negotiated version is not.', + 'ac-02-46' => 'A transport session ID never becomes the event\'s session; at most it appears in parameters.extra.', + 'ac-02-47' => 'The event carries the server\'s name and version.', + 'ac-02-48' => 'A sleeping hook does not delay the tool response; under PHP-FPM the response is flushed first.', + 'ac-02-49' => 'identify\'s result is stamped on the event of that call, and no separate identify event exists.', + 'ac-02-50' => 'A throwing hook leaves the call working and the event published; in hook mode the session is minted silently.', + 'ac-02-51' => 'In an async runtime, three interleaved calls each carry only their own session, agent and actor.', + 'ac-02-52' => 'With tracing off, no handle parameter is listed, no event is published and no block is added.', + 'ac-02-53' => 'A real-HTTP matrix of a long-lived and a per-request server passes the end-to-end scenarios of each lane.', + 'ac-02-54' => 'SSE-streamed responses pass the baseline-defaults scenario.', + 'ac-02-55' => 'The cross-SDK health harness receives a PHP server\'s successful and errored tool-call events, sentinel included.', + + // The event pipeline: queue, delivery, shutdown, IDs, wire body and logging. + 'ac-03-1' => 'A tool call sends one JSON POST to /v1/public/events/, and the client gets the result before that POST completes.', + 'ac-03-2' => 'With 10 000 events waiting, a new event drops the oldest with `Event queue full, dropping oldest event`.', + 'ac-03-3' => 'A draining queue has at most 5 events in the pipeline at once.', + 'ac-03-4' => 'An API that always fails gets 4 attempts, 1 s, 2 s and 4 s apart, with the same id, then a failure line.', + 'ac-03-5' => 'An API that accepts the second attempt gets 2 attempts and a `Successfully sent event` line with no payload.', + 'ac-03-6' => 'With a failing API, each custom exporter receives the event exactly once.', + 'ac-03-7' => 'Without a projectId, each exporter receives the event once, and nothing is sent to AgentCat.', + 'ac-03-8' => 'Without a projectId and exporters, track() logs the no-projectId warning.', + 'ac-03-9' => 'A shutdown drain waits at most 5 s, and an event added after it starts is dropped with a log line.', + 'ac-03-10' => 'SIGTERM without a customer handler drains the queue in 5 s at most, then ends the process by the default action.', + 'ac-03-11' => 'SIGTERM with a customer handler drains, then calls that handler, and does not end the process itself.', + 'ac-03-12' => 'The shutdown function delivers the pending events within the drain budget after a normal script end.', + 'ac-03-13' => 'A background-task registrar is not used for telemetry-only events; when it throws, the event is still delivered.', + 'ac-03-14' => 'The tool result does not wait for the identify, tags, properties and session hooks; the event has their results.', + 'ac-03-15' => 'A hook that never settles is left out after 30 s, with `Warning: