Repository navigation
Symfony bundle and runtime delivery tests in Docker - #7
Merged
Merged
Conversation
…ecord into it ToolCalls no longer resets the laravel/mcp CustomCallTool; LaravelTestCase resets it next to ToolCalls, so the shared recorder names no library.
…ct test of its builder services symfony/mcp-bundle and the Symfony parts it builds on are dev dependencies and a suggestion, never a requirement. BundleServicesTest pins the bundle internals AgentCat's bundle will read at compile time (the builder service ID and tag, the addRequestHandlers call with the tagged handlers, no configurator); PrivateMemberManifest::BUNDLE_SERVICES lists them. TestKernel, SymfonyTestCase and the five test tools are the shared Symfony test support: every kernel compiles into its own directory under the test HOME, logs to a MemoryLogger, and makes the mcp.server.* services public. On PHP 8.5 the lowest set raised deprecations inside symfony/config and symfony/dependency-injection 7.3.0 and in the 2.5.0 cache, event-dispatcher and translation contracts, so those packages get dev floors. symfony/error-handler 7.3 leaves its exception handler behind when FrameworkBundle boots; SymfonyTestCase restores it. The mcp/sdk main leg removes the bundle, which requires mcp/sdk ^0.8.1, runs PHPStan with phpstan.no-bundle.neon, and the Symfony tests skip.
A telemetry-only event, an event whose encoding fails and an event a hook drops start no transfer, so the delivery slots stay free. A redactEvent hook that publishes a custom event on every run then kept one pump() pass, the handler-entry pump of a request included, from ever returning. Each pass now starts only the events queued when it began, as 70d1812 did for the prepare pass; an event a hook queues meanwhile waits for the next pass, and a flush still ends at its deadline.
ClientSeams::of() takes an httpContext closure; a client built with it hands it to every server it tracks (TrackedServer::$httpContext), and the mcp/sdk adapter's request reader reads the call's method, URL and headers through it instead of $_SERVER. A worker that serves many requests in one process (Symfony Runtime, FrankenPHP worker mode, RoadRunner, Swoole) then records each request's own headers. Without the closure the SAPI globals still count. RequestStackContext::of() gives that closure for a Symfony RequestStack: the main request when a call is recorded, null without one (the mcp:server console command) and null, with no line, when Symfony cannot read the request. The rules of LaravelHttpContext move into HttpContext::fromRequest(), which both bridges call with plain values: the URL as sent (HTTPS, the Host header and the raw request URI), repeated header values joined with ", ", and Symfony's made-up php-auth-* headers left out.
…e during a hook A SIGTERM or SIGINT that arrives while a hook runs, with an earlier handler that keeps the process running, calls finalShutdown(), which delivers the whole queue in the middle of the pass. The bounded loops of pumpOnce() (892f0f6) and prepareWaiting() (70d1812) then dequeued from an empty queue: the queue logged a false "Failed to deliver AgentCat event after retries" line and that pass skipped the transport pump. Both loops now also stop when the queue is empty.
…bundle can share them
… 8.5 lowest set triggers no deprecation
… the directory of a removed kernel
A mode literal gave Infection a mutant (0o1000) that makes a directory its own cleanup cannot read under Infection's include interceptor, so mutation runs left directories in the test HOME. The default mode is the same 0777 less the umask. The FileTree chmod added for that case is dropped: the interceptor's url_stat reports such a directory as missing before the chmod runs.
…ds to each request
…s at runtime - exclude_servers: a name written in the configuration is still removed when the container compiles; a value that holds an environment variable (any type, csv and json included) now compiles and is read by the tracker when it builds a server, which then leaves that server untracked. A string is a list of one, and a wrong type logs one line, as Laravel's exclude does. - A logger or hook service ID from an environment variable is not in the service locator: one line now says that the key takes a literal service ID, instead of the missing-logger line or a hook-guard line per event. - The log directory is made at the first line (LogFile), never while AgentCat logs nothing. - The info() texts say that MCPCAT_API_URL and DISABLE_DIAGNOSTICS are read too, and when exclude_servers names are read. - ServerNames holds the name matching of the pass and the tracker. - A console command (list) tracks nothing: no line, no diagnostics, no signal handler.
…e first Laravel application of a PHP 8.5 process takes about 0.1 s
… helper for both bridges
…ontent-Length where responses end with the script (D62)
…ernel and two-server configuration
…ernel's terminate phase, as KernelBrowser does
…ess before the first measured test
…ng or deprecation from src/
…ry cleanup step after a failed one
…very started container after its lifetime
…nd send AgentCat's default hosts to 127.0.0.1 in runtime containers
… a body above one record against PHP-FPM
…untimes Under PHP-FPM, mod_php and php -S, a drain longer than 2 s (a slow hook or a slow AgentCat API) let the 2 s window post the buffered lines, and the shutdown posted the rest: two posts for one request (D47 asks for one). Only a process that outlives its requests (the command line, FrankenPHP worker mode) keeps the TypeScript window now.
…indow through $_SERVER
…cessor MockCollector::waitFor() is the one poll loop over a scenario, with a condition; RuntimeTestCase::waitUntil() asserts it, and waitForRequests() and FpmTest use it. MockCollector::withASentEvent() and body() replace the copies in FpmTest, HttpEndToEndTest and StdioEndToEndTest.
Classic mode answers before the shutdown delivery and gives the drain its own time limit, which under ZTS counts wall time. In worker mode the raw frankenphp_handle_request() loop flushes after each request, waits at most 1 s with a slow API (D58), and its stop delivers the rest. A server tracked at worker boot or in the first request keeps the 2 s diagnostics window. PHP logs its errors in these containers (log_errors 1): the image has no php.ini, so a time-limit fatal after the answer went only to the closed response.
…swer without Content-Length The site address alone is only a host matcher, so Caddy listened on every interface of the host network. The Caddyfile now binds 127.0.0.1, and each test checks that 127.0.0.2 is refused. A classic answer without Content-Length reaches the client before the delivery only through the shutdown function's frankenphp_finish_request() call. Both classic tests send an untimed warm-up request first.
Unit's PHP module reports the SAPI of php -S (cli-server), so the SDK treated it as a SAPI without a finish-request function. Unit has fastcgi_finish_request(), and without the call an answer without a Content-Length reached the client only when the delivery ended. Unit names itself in SERVER_SOFTWARE (Unit/<version>); php -S does not, so php -S keeps the output-buffer path (D57).
CliTest: an mcp/sdk and a laravel/mcp stdio server in the CLI image (docker run --init) deliver the queued event at SIGTERM and end with 143; stdout carries only JSON-RPC. The first event installs no SIGTERM handler under ext-swoole, and one in the CLI image, both with pcntl. ApacheTest: mod_php with output_buffering=4096 answers an mcp/sdk server with its own Content-Length (D57), a symfony/mcp-bundle server (D62) and a laravel/mcp server (D59) before the delivery. UnitTest: NGINX Unit reports cli-server; the answer reaches the client before the delivery with and without a Content-Length. RuntimeTestCase now holds the echo call, its headers and checks, the collector times, the diagnostics switch and the /out file reader that the FPM, FrankenPHP, Apache and Unit tests share.
php -S names itself "PHP/8.3.6 (Development Server)" in SERVER_SOFTWARE (recorded from a real run). With that value and a fastcgi_finish_request() of its own, php -S still sends its answer when the script ends, so the answer-length listeners and the buffer close (D57, D59, D62) stay on.
…logs RuntimeTestCase now holds the warm-up request, the delivered-after check, the wait for diagnostics posts with a sent event and the PHP log check; the FrankenPHP, Apache and Unit tests call them instead of copies. Apache serves only the two fixture directories and forbids the rest of tests/, which the test checks. Its log check and Unit's new one match PHP warnings, notices, deprecations and fatal errors; Apache logs mod_php's info level, where PHP puts deprecations.
…mfony bundle Under a fake, the bundle tracked with the fake's client, which reads the SAPI globals: on the CLI they hold no request, so recorded events had no requestInfo and hooks got no headers. An identify service that reads a header named the actor differently under the fake than in production. The tracker now gives a fake's client the RequestStack through an internal Client::withHttpContext(), a copy that keeps the queue, the enabled gate and the telemetry setting, so it still records into the fake (D41). The Laravel bridge reads the request from laravel/mcp's transport and records the same under a fake; a test now pins that.
…idge left untracked When the Symfony bundle could not read its configuration (a bare %env()% whose variable is unset), or the Laravel bridge failed before Client::track(), the tracker logged the failure but did not mark the process. publishCustomEvent($server, ...) for that server then threw "Server is not tracked" into the customer's tool (D32). Both trackers now call TrackedServers::markIncomplete() in their catch, as Client::track() does on its own failures, so the custom event logs the "see the earlier AgentCat log line" warning instead.
The commented projects example read '%env(AGENTCAT_BILLING_PROJECT_ID)%'. Unlike Laravel's env(), Symfony's bare %env()% throws when the variable is not set, so a customer who uncommented the example without setting the variable in every environment made the whole agentcat configuration unreadable: every server answered untracked. The example now reads '%env(default::AGENTCAT_BILLING_PROJECT_ID)%'; an unset variable gives a null entry, which is project_id. RecipeTest checks that every variable of the recipe has a default and that the uncommented examples are valid for the bundle.
…as today TrackBuildersPass removed every addRequestHandlers call of a server builder and handed its first argument to the configurator. A call of another shape, for example a named $handlers argument in a later symfony/mcp-bundle release, handed [] on, and the customer's tagged request handlers disappeared without a line. The pass now moves the call only when its single argument is a TaggedIteratorArgument. Any other call stays where it is, untouched: its handlers then come before AgentCat's, which still tracks the builder (header conflict 3's fallback). A test with a compiled container shows the customer's handler answering in each shape.
The Flex recipe registers AgentCatBundle in every Symfony application that requires agentcat/sdk, plain mcp/sdk ones too, but every bundle test booted symfony/mcp-bundle and skipped without it. WithoutMcpBundleTest boots FrameworkBundle and AgentCatBundle only: the container compiles with no server to track, a request is answered, the event its controller publishes goes out at terminate(), and the D62 listener, forced on, leaves the answer as it is. SymfonyTestCase lets a class run without symfony/mcp-bundle, so the test also runs in the mcp/sdk main leg, which removes the bundle.
TrackBuildersPass moves the call only when the tagged request handlers are its single argument. The contract test checked the first argument only, so a bundle release that added a second argument would keep the contract leg green while AgentCat fell back, without a word, to tracking the builder after the customer's handlers. It now asserts that the call has exactly one argument.
… port tests/bootstrap.php pointed only the diagnostics endpoint at a closed port. An event of a test that named no collector of its own, through a queue with the real transport, went to https://api.agentcat.com. The bootstrap now sets MCPCAT_API_URL, the last name the SDK reads, to a closed port on 127.0.0.1 (EventApiGuard), so a test's own option, AGENTCAT_API_URL or MCPCAT_API_URL still comes first, and child processes inherit it. Every setUp that clears MCPCAT_API_URL sets the guard again; the three tests of the default URL remove it and their tearDown puts it back. New tests check the guard after the setUp of the Symfony and Laravel bases and of ClientDiagnosticsTest, and send one event through EventQueue::global() that fails at the closed port.
symfony/config 7.3 types DefinitionConfigurator::rootNode() as any node and a prototype's end() as any parent, so PHPStan on the lowest dependency set could not follow the tree. The root is now narrowed from a native type, and the projects node keeps its definition in a variable. The tree is unchanged.
kashishhora
approved these changes
Oct 22, 2026
Base automatically changed from
feat/plan-e1-laravel
to
feat/plan-a-foundation
October 22, 2026 22:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seventh part of the PHP AgentCat SDK: the Symfony bundle and runtime delivery proofs in Docker. Stacked on #6; review #1 to #6 first.
What is in it
AgentCat\Symfony\AgentCatBundletracks everysymfony/mcp-bundleserver (0.13 and 0.14, Symfony 7.3 and 8.x) when the container builds it. A compiler pass runs AgentCat'stools/call,tools/listandinitializehandlers before the customer's taggedmcp.request_handlerservices, so a customer handler that answerstools/callitself is still recorded and still answers. If a future bundle release changes the shape of that call, the pass leaves it untouched, and a contract test fails by name.agentcatconfiguration (bin/console config:dump-reference agentcat):enabled,project_id,exclude_serversandprojectsbymcp.serversname,logger(a PSR-3 service ID),api_url, the TypeScript options in snake_case, hooks as service IDs of invokable services,exporters, and the diagnostics keys. Every value reads an%env(default::…)%by default. A wrong value logs one line at runtime and never breaks the container compile or a request.recipe/agentcat/sdk/1.0: it registers the bundle, copiesconfig/packages/agentcat.yamland addsAGENTCAT_PROJECT_ID=to.env. The bundle also works in an application that hasmcp/sdkwithoutsymfony/mcp-bundle: it compiles and tracks nothing.AgentCat::fake()records the events of a kernel booted after it, with each call's HTTP request, so a test of anidentifyhook that reads a header records the same actor as production.kernel.terminatelistener waits at most 1 s in a long-running worker and makes one non-blocking pass elsewhere. Each event reads the headers of its own request from theRequestStack, so a worker that serves many requests never mixes them up.php -S), both bridges give a tracked JSON answer itsContent-Length, so the client does not wait for delivery. HEAD requests, 1xx, 204 and 304 answers, SSE streams and answers that already have a length are left alone.%kernel.logs_dir%/agentcat.logby default;loggerroutes them to a service.agentcatconfiguration cannot be read (for example an unset%env()%withoutdefault::), each server stays untracked with one log line, andAgentCat::publishCustomEvent()logs instead of throwing into the tool. The Laravel bridge gets the same fix.SDK fixes found by the runtime tests
fastcgi_finish_request(); long-running runtimes keep the 2 s window.php -S(cli-server), so the SDK did not call itsfastcgi_finish_request(), and an answer withoutContent-Lengthreached the client only after delivery (2.0 s with a slow API). The SDK now finds Unit bySERVER_SOFTWARE(Unit/…);php -Skeeps the output-buffer path.Runtime delivery tests (Docker,
runtimeCI job)Each test runs a real server in a pinned image against a local collector that answers slowly, and asserts that the client gets its answer before the delivery ends:
Content-Length), the drain's own time limit under ZTS, worker mode with the loop'sAgentCat::flush(1.0), a slow API in worker mode, and diagnostics while a worker runs.mcp/sdkandphp artisan mcp:starton a stock PHP build, and stdout stays JSON-RPC. Under Swoole the signal drain does not install.mcp/sdkserver, Symfony and Laravel answer before the delivery.Content-Lengthbefore the delivery.Every test server listens on 127.0.0.1 only. The suite also fails closed for the event API: a test without its own collector sends to a closed local port, never to the AgentCat API.
Testing
3140 tests pass (
composer check; two expected skips). The covered mutation score is 98 % on the full set (5500 mutants) and 99 % on the lines changed since #6 (337 mutants). Themcp-sdk-mainjob passes with 771 tests againstmcp/sdkmain at82e19d1, without symfony/mcp-bundle (which requiresmcp/sdk^0.8.1); the bundle's no-mcp-bundle test runs there. PHP 8.5 with the highest (PHPUnit 13.4.3, Symfony 8.1, symfony/mcp-bundle 0.14.1) and the lowest (PHPUnit 12.5.40, Symfony 7.3, symfony/mcp-bundle 0.13.0) dependency sets passed locally in Docker: both PHPStan configs and 3136 tests each. The runtime suite passes with 37 tests in Docker.