Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
995ad65
test: move ToolCalls to tests/Support so the Symfony test tools can r…
naji247 Oct 19, 2026
76e9077
build: add symfony/mcp-bundle as an optional dependency with a contra…
naji247 Oct 19, 2026
892f0f6
fix: bound each pump pass to the events queued at its start
naji247 Oct 19, 2026
1fa4398
feat: let a framework bridge give mcp/sdk calls their request context
naji247 Oct 19, 2026
4c0b2fa
fix: stop a pump or prepare pass when a signal drain empties the queu…
naji247 Oct 19, 2026
9d79855
test: name the no-context seam test after what it proves on the comma…
naji247 Oct 19, 2026
84eac83
refactor: move the bridge's typed key reads into core so the Symfony …
naji247 Oct 19, 2026
bffa8a1
build: raise the symfony/clock dev floor so an MCP request on the PHP…
naji247 Oct 19, 2026
719bd34
feat: track every symfony/mcp-bundle server through AgentCatBundle
naji247 Oct 19, 2026
7bfb568
test: give each test kernel its own container class so it never reads…
naji247 Oct 19, 2026
4f647cd
refactor: create the bundle's log directory with mkdir's default mode
naji247 Oct 19, 2026
a7f24d1
fix: leave out the X-Php-Ob-Level header that Symfony's HttpKernel ad…
naji247 Oct 19, 2026
36a1fc2
refactor: hand a bridge's agentcat configuration to the process in on…
naji247 Oct 19, 2026
d9ac197
test: share the handshake and tools/call params of the two HTTP harne…
naji247 Oct 19, 2026
cfab293
fix: read the bundle's environment-variable exclusions and service ID…
naji247 Oct 19, 2026
b0864fc
test: give each Laravel Content-Length test a maximum duration, as th…
naji247 Oct 19, 2026
707eb6b
feat: deliver Symfony events on kernel.terminate, waiting at most 1 s…
naji247 Oct 19, 2026
0352863
refactor: move the Laravel bridge's Content-Length rule into one core…
naji247 Oct 19, 2026
21e8927
fix: give no Content-Length to an answer without a body (1xx, 204, 304)
naji247 Oct 19, 2026
a3e8cb7
feat: give a tracked symfony/mcp-bundle server's JSON answers their C…
naji247 Oct 19, 2026
2ea8ec3
test: read a kernel event's listeners the same way on Symfony 7 and 8
naji247 Oct 19, 2026
b61e026
fix: give no Content-Length to an answer to a HEAD request
naji247 Oct 19, 2026
ca342a2
test: share the Symfony tests' AgentCat-on configuration, streaming k…
naji247 Oct 19, 2026
9bfc571
test: end each request of the Symfony customer test suites with the k…
naji247 Oct 19, 2026
0b54805
test: warm up one Laravel application and one Symfony kernel per proc…
naji247 Oct 19, 2026
b046791
test: fail a bridge test class when its warm-up meets a notice, warni…
naji247 Oct 19, 2026
7908029
test: run the adapter conformance suite on symfony/mcp-bundle
naji247 Oct 19, 2026
bec1348
feat: add the Symfony Flex recipe for AgentCatBundle
naji247 Oct 19, 2026
f524808
test: make the recipe test fail when the recipe YAML or the export-ig…
naji247 Oct 19, 2026
2edb597
test: add a Docker harness for runtime delivery tests
naji247 Oct 19, 2026
8cfaa25
test: build the Swoole image of the runtime tests with ext-pcntl
naji247 Oct 19, 2026
48749ff
test: fail the runtime tests when Docker does not answer, and run eve…
naji247 Oct 19, 2026
89cd48d
test: remove the runtime containers of dead test processes, and end e…
naji247 Oct 19, 2026
d675d1e
test: mount only the output directory, give PHP CLI images an init, a…
naji247 Oct 19, 2026
2c2dc38
test: cover FastCGI 4-byte lengths, parameters in several records and…
naji247 Oct 19, 2026
688749c
test: document which runtime images always get an init
naji247 Oct 19, 2026
17d107e
fix: post diagnostics once per request, at shutdown, in per-request r…
naji247 Oct 19, 2026
004cb2e
test: prove delivery after the response under PHP-FPM
naji247 Oct 19, 2026
88e1958
test: check that AgentCatBundle extends Bundle, which Symfony 8.1 doe…
naji247 Oct 20, 2026
bd79c8c
test: cover FrankenPHP worker mode, which keeps the 2 s diagnostics w…
naji247 Oct 20, 2026
cb65738
test: share one collector poll, the sent-event filter and the body ac…
naji247 Oct 20, 2026
0a13c05
test: prove FrankenPHP delivery in classic and worker mode
naji247 Oct 20, 2026
f84a075
test: bind FrankenPHP to 127.0.0.1 and prove the finish call on an an…
naji247 Oct 20, 2026
6ac7170
fix: end the answer with fastcgi_finish_request() under NGINX Unit
naji247 Oct 20, 2026
c7ef09c
test: prove CLI, Swoole, mod_php and NGINX Unit delivery in Docker
naji247 Oct 20, 2026
babcef3
test: pin the php -S server software on the output-buffer path
naji247 Oct 20, 2026
cb706a9
test: share the runtime delivery checks and read the Apache and Unit …
naji247 Oct 20, 2026
51ab339
fix: record the request of each call under AgentCat::fake() in the Sy…
naji247 Oct 20, 2026
e425236
fix: log instead of throwing for a custom event of a server that a br…
naji247 Oct 20, 2026
bd66ccd
fix: give the recipe's projects example a default for its variable
naji247 Oct 20, 2026
614d532
fix: move the bundle's addRequestHandlers call only in the shape it h…
naji247 Oct 20, 2026
9e64b1b
test: run AgentCatBundle in an application without symfony/mcp-bundle
naji247 Oct 20, 2026
d736aa8
test: pin the single argument of the bundle's addRequestHandlers call
naji247 Oct 20, 2026
1ac0127
test: send the events of a test without its own URL to a closed local…
naji247 Oct 20, 2026
7b76244
fix: type the bundle's configuration tree for symfony/config 7.3
naji247 Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
/.gitignore export-ignore
/.editorconfig export-ignore
/phpstan.neon.dist export-ignore
/phpstan.no-bundle.neon export-ignore
/.php-cs-fixer.dist.php export-ignore
/rector.php export-ignore
/phpunit.xml.dist export-ignore
/infection.json5 export-ignore
/docs export-ignore
/recipe export-ignore
/composer-dependency-analyser.php export-ignore
30 changes: 28 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,41 @@ jobs:
- 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 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"
- run: vendor/bin/phpstan analyse --no-progress --error-format=github
- run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon
- run: vendor/bin/phpunit --testsuite Contract,Integration,E2E

runtime:
name: Runtime delivery (Docker, PHP 8.3)
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 and pull the others
run: |
docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker
docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole
for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm unit:1.34.2-php8.3; do docker pull "$image"; done
- run: vendor/bin/phpunit --testsuite Runtime --group runtime
env:
AGENTCAT_RUNTIME_TESTS: '1'

all-green:
name: All green
if: always()
needs: [static, tests, coverage, mutation, mcp-sdk-main]
needs: [static, tests, coverage, mutation, mcp-sdk-main, runtime]
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0
Expand Down
18 changes: 15 additions & 3 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/McpSdk', [ErrorType::SHADOW_DEPENDENCY])
// Psr\EventDispatcher\EventDispatcherInterface arrives with mcp/sdk; the test double stands in for a customer's dispatcher.
->ignoreErrorsOnPackageAndPath('psr/event-dispatcher', __DIR__ . '/tests/Support/McpSdk/RecordingDispatcher.php', [ErrorType::SHADOW_DEPENDENCY])
// Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixture builds its request from the globals with it, like a customer.
->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/Fixtures', [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.
->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD])
->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD])
Expand All @@ -28,7 +28,19 @@
// 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.
->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])
// Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it.
->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY])
// Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle adds its kernel.response listener to the kernel's dispatcher, and the bundle tests read the listeners from it.
->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Support/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY])
// Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it.
->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY])
// Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel.
->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]);
->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY])
// Symfony\Component\Console arrives with symfony/framework-bundle; a bundle test runs a console command as an application does.
->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/Integration/Symfony/BundleTest.php', [ErrorType::SHADOW_DEPENDENCY])
// Symfony\Component\Routing arrives with symfony/framework-bundle (and symfony/mcp-bundle); the test kernel imports the bundle's MCP routes with it, the kernel without symfony/mcp-bundle adds its own route, and a contract test reads the routes.
->ignoreErrorsOnPackageAndPaths('symfony/routing', [__DIR__ . '/tests/Support/Symfony/TestKernel.php', __DIR__ . '/tests/Support/Symfony/WithoutMcpBundleKernel.php', __DIR__ . '/tests/Contract/Symfony/BundleServicesTest.php'], [ErrorType::SHADOW_DEPENDENCY]);
14 changes: 13 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,21 @@
"rector/rector": "2.7.0",
"shipmonk/composer-dependency-analyser": "^1.8.4",
"spaze/phpstan-disallowed-calls": "^4.15",
"symfony/cache-contracts": "^2.5.3 || ^3.4.2",
"symfony/clock": "^6.4.3 || ^7.0.3 || ^8.0",
"symfony/config": "^7.3.4 || ^8.0",
"symfony/dependency-injection": "^7.3.4 || ^8.0",
"symfony/event-dispatcher-contracts": "^2.5.3 || ^3.4.2",
"symfony/framework-bundle": "^7.3 || ^8.0",
"symfony/http-foundation": "^7.3 || ^8.0",
"symfony/http-kernel": "^7.3 || ^8.0",
"symfony/mcp-bundle": "^0.13 || ^0.14",
"symfony/polyfill-uuid": "^1.20",
"symfony/process": "^7.4 || ^8.1",
"symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0",
"symfony/translation-contracts": "^2.5.3 || ^3.4.2",
"symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0",
"symfony/yaml": "^7.3 || ^8.0",
"tomasvotruba/type-coverage": "^2.5.1"
},
"conflict": {
Expand All @@ -71,7 +82,8 @@
"ext-pcntl": "Delivers the queued events when a CLI server (an MCP stdio server) gets SIGTERM or SIGINT",
"ext-posix": "Ends the process with the signal after that delivery, and finds the home directory for agentcat.log when HOME is not set",
"laravel/mcp": "Tracks every laravel/mcp server of a Laravel application, through the auto-discovered service provider",
"mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)"
"mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)",
"symfony/mcp-bundle": "Tracks every symfony/mcp-bundle server through AgentCat\\Symfony\\AgentCatBundle"
},
"minimum-stability": "stable",
"autoload": {
Expand Down
11 changes: 11 additions & 0 deletions phpstan.no-bundle.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# PHPStan for the mcp/sdk main leg, which removes symfony/mcp-bundle (it requires mcp/sdk ^0.8.1).
includes:
- phpstan.neon.dist
parameters:
tmpDir: .cache/phpstan-no-bundle
excludePaths:
- tests/Support/Symfony
- tests/Integration/Symfony
- tests/Contract/Symfony
- tests/Integration/Conformance/SymfonyConformanceTest.php
- tests/Runtime/Fixtures/symfony-front.php
4 changes: 4 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<testsuite name="E2E">
<directory>tests/E2E</directory>
</testsuite>
<testsuite name="Runtime">
<directory>tests/Runtime</directory>
</testsuite>
<testsuite name="Architecture">
<directory>tests/Architecture</directory>
<!-- PHPat rules run inside PHPStan, not PHPUnit -->
Expand All @@ -35,6 +38,7 @@
<groups>
<exclude>
<group>network</group>
<group>runtime</group>
</exclude>
</groups>
<source>
Expand Down
7 changes: 7 additions & 0 deletions recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# AgentCat tracks every symfony/mcp-bundle server of this application.
# Set AGENTCAT_PROJECT_ID (proj_…) in .env.local. Reference: bin/console config:dump-reference agentcat
agentcat:
project_id: '%env(default::AGENTCAT_PROJECT_ID)%'
# exclude_servers: [internal]
# projects:
# billing: '%env(default::AGENTCAT_BILLING_PROJECT_ID)%'
11 changes: 11 additions & 0 deletions recipe/agentcat/sdk/1.0/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"bundles": {
"AgentCat\\Symfony\\AgentCatBundle": ["all"]
},
"copy-from-recipe": {
"config/": "%CONFIG_DIR%/"
},
"env": {
"AGENTCAT_PROJECT_ID": ""
}
}
2 changes: 2 additions & 0 deletions recipe/agentcat/sdk/1.0/post-install.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Set <info>AGENTCAT_PROJECT_ID</> in <comment>.env.local</> to your AgentCat project ID (proj_…).
* AgentCat is off while your tests run; call <comment>AgentCat::fake()</> in a test to record its events.
24 changes: 22 additions & 2 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use AgentCat\Internal\Delivery\EventQueue;
use AgentCat\Internal\Diagnostics\Diagnostics;
use AgentCat\Internal\Engine\CallInterceptor;
use AgentCat\Internal\Engine\HttpContext;
use AgentCat\Internal\Engine\Libraries;
use AgentCat\Internal\Engine\Library;
use AgentCat\Internal\Engine\Publisher;
Expand Down Expand Up @@ -45,18 +46,37 @@

private bool $telemetry;

/** @var (\Closure(): ?HttpContext)|null the HTTP request of a call, for every server this client tracks */
private ?\Closure $httpContext;

/**
* Creates a client that sends through the process-wide event queue. Call it without arguments. A
* dependency-injection container that autowires it gets the same client.
*
* @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate.
* @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate, and
* a framework bridge gives the HTTP request of a call.
*/
public function __construct(?ClientSeams $seams = null)
{
$this->publisher = new Publisher($seams->queue ?? EventQueue::global());
$this->interceptor = new CallInterceptor($this->publisher);
$this->enabled = $seams->enabled ?? TestMode::sdkEnabled(...);
$this->telemetry = $seams->telemetry ?? true;
$this->httpContext = $seams?->httpContext;
}

/**
* Internal: this client with the HTTP request of a call from `$httpContext`, for every server the copy tracks. The
* copy keeps the event queue, the enabled gate and the telemetry setting, so the copy of a fake's client records
* into the fake. The Symfony bundle gives a fake's client its `RequestStack` this way (D41).
*
* @internal
*
* @param \Closure(): ?HttpContext $httpContext
*/
public function withHttpContext(\Closure $httpContext): self
{
return new self(ClientSeams::of($this->publisher->queue(), $this->enabled, $this->telemetry, $httpContext));
}

/**
Expand Down Expand Up @@ -129,7 +149,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio
} elseif ($projectId !== '' && preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) {
Log::write(sprintf('WARN: Project ID "%s" does not match proj_ followed by 27 letters or digits; the AgentCat API drops its events', $projectId));
}
$state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher);
$state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher, $this->httpContext);
$library->install($server, $state, $this->publisher, $this->interceptor);
TrackedServers::add($server, $state);
Log::write(sprintf(
Expand Down
25 changes: 17 additions & 8 deletions src/Internal/ClientSeams.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,38 @@
namespace AgentCat\Internal;

use AgentCat\Internal\Delivery\EventQueue;
use AgentCat\Internal\Engine\HttpContext;

/**
* What tests and `AgentCat::fake()` replace in a `Client`: its event queue, its enabled gate and whether it sets up telemetry. The constructor is
* private, so a dependency-injection container cannot build one, and an autowired `Client` always gets the
* process-wide queue and the default gate.
* What tests, `AgentCat::fake()` and the framework bridges replace in a `Client`: its event queue, its enabled gate,
* whether it sets up telemetry, and where the HTTP request of a call comes from. The constructor is private, so a
* dependency-injection container cannot build one, and an autowired `Client` always gets the process-wide queue, the
* default gate and the SAPI globals.
*
* @internal
*/
final readonly class ClientSeams
{
/** @param (\Closure(): bool)|null $enabled */
/**
* @param (\Closure(): bool)|null $enabled
* @param (\Closure(): ?HttpContext)|null $httpContext
*/
private function __construct(
public EventQueue $queue,
public ?\Closure $enabled,
public bool $telemetry,
public ?\Closure $httpContext,
) {}

/**
* @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44)
* @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`)
* @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44)
* @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)
*/
public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true): self
public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true, ?\Closure $httpContext = null): self
{
return new self($queue, $enabled, $telemetry);
return new self($queue, $enabled, $telemetry, $httpContext);
}
}
66 changes: 66 additions & 0 deletions src/Internal/Delivery/ContentLength.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace AgentCat\Internal\Delivery;

/**
* D59, D62: mcp/sdk and laravel/mcp send their JSON answers without `Content-Length`. On a SAPI that sends a response
* only when the script ends (mod_php, php-cgi, `php -S`: `FinishRequest::responseEndsWithTheScript()`), the client of
* such an answer then waits for AgentCat's hooks and delivery. So there, the framework bridges give the plain answer of
* a tracked server its length, and with D57 the client has all of it before the shutdown function delivers. This is
* their one rule; each bridge hands it its framework's response as plain values.
*
* @internal
*/
final readonly class ContentLength
{
/** @var \Closure(): bool */
private \Closure $headersSent;

/** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */
public function __construct(?\Closure $headersSent = null)
{
$this->headersSent = $headersSent ?? static fn(): bool => headers_sent();
}

/**
* The length in bytes to give an answer, or null when it goes as it is: an answer to a HEAD request (its length
* would be the GET answer's, RFC 9110 §8.6, but `Response::prepare()` has emptied the body), an answer without a body
* (informational, 204, 304, as `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length`
* or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in
* an output buffer, which go out first and would make the length short).
*
* @param string $method the request's method (`Request::getMethod()`, upper case)
* @param array<array-key, mixed> $headers the answer's headers by name, in any case (`HeaderBag::all()`)
* @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`)
*/
public function of(string $method, int $status, array $headers, string|false $content): ?string
{
if ($content === false || $method === 'HEAD' || $status < 200 || $status === 204 || $status === 304) {
return null;
}
$headers = array_change_key_case($headers);
if (array_key_exists('content-length', $headers) || array_key_exists('transfer-encoding', $headers)) {
return null;
}
if (($this->headersSent)() || $this->outputWaits()) {
return null; // stray output goes out before the answer: a length of the answer alone would cut its end
}

return (string) strlen($content);
}

/** Some output buffer level holds bytes, which PHP sends before the answer. */
private function outputWaits(): bool
{
foreach (ob_get_status(true) as $status) {
$used = is_array($status) ? $status['buffer_used'] ?? null : null;
if (is_int($used) && $used > 0) {
return true;
}
}

return false;
}
}
Loading
Loading