Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1e68e77
feat: release the session, the time limit and the output buffers befo…
naji247 Oct 18, 2026
0a05768
fix: send the output buffers only when PHP ignores an aborted client
naji247 Oct 18, 2026
8a30b32
refactor: track servers through a registry of MCP libraries
naji247 Oct 18, 2026
55c2ac2
test: run the default output-buffer close in a PHP process, not over …
naji247 Oct 18, 2026
90a7070
build: add laravel/mcp as an optional adapter dependency with private…
naji247 Oct 18, 2026
47b97fd
feat: inject AgentCat's parameters into laravel/mcp tool listings
naji247 Oct 18, 2026
623bef2
feat: track laravel/mcp tool calls, their exceptions and their stream…
naji247 Oct 18, 2026
4bc9cf2
fix: share the once-per-object warning and keep customer and client e…
naji247 Oct 18, 2026
61ed20c
test: run one adapter conformance suite on mcp/sdk and laravel/mcp
naji247 Oct 18, 2026
401577c
test: tag the conformance validation scenario only with what it proves
naji247 Oct 18, 2026
13a6d64
fix: record the laravel/mcp request URL as sent and leave out the hea…
naji247 Oct 18, 2026
fcde847
feat: track every laravel/mcp server through an auto-discovered servi…
naji247 Oct 18, 2026
b3ea4bf
fix: let the cached Laravel configuration decide the project and the …
naji247 Oct 18, 2026
47d697b
feat: wait at most 1 s for delivery after each Laravel worker request
naji247 Oct 18, 2026
43ec642
fix: read Laravel's runningInConsole() by its truthiness in the reque…
naji247 Oct 18, 2026
2e418ed
feat: add agentcat:install to write the project ID into .env
naji247 Oct 18, 2026
e7d3363
fix: refuse an unreadable .env, keep export prefixes, and test consol…
naji247 Oct 18, 2026
01e757b
test: prove Laravel tracking end to end over HTTP and stdio
naji247 Oct 18, 2026
e92d93d
test: read a collector log that has no record yet as no requests
naji247 Oct 19, 2026
584a071
feat: give a tracked laravel/mcp answer its Content-Length where the …
naji247 Oct 19, 2026
88b3a3c
test: read only this request's setup line in the Laravel HTTP end-to-…
naji247 Oct 19, 2026
7777e31
fix: leave a laravel/mcp answer without Content-Length after stray ou…
naji247 Oct 19, 2026
f6bd433
fix: run the hooks of every waiting event before a flush returns, so …
naji247 Oct 19, 2026
632b4ec
fix: route every AgentCat environment name through config/agentcat.ph…
naji247 Oct 19, 2026
53873d4
feat: log the Laravel bridge's lines to storage/logs/agentcat.log by …
naji247 Oct 19, 2026
4d6fc08
fix: read the Laravel bridge's own keys with their types and log a ba…
naji247 Oct 19, 2026
bae996f
test: reset diagnostics before each cached-configuration test
naji247 Oct 19, 2026
7719339
fix: let the Laravel bridge's bool keys take the D44 words and name a…
naji247 Oct 19, 2026
4abb20e
fix: build laravel/mcp's CallTool through the container on a tracked …
naji247 Oct 19, 2026
70d1812
fix: keep a 0 s flush one non-blocking pass and bound each prepare pa…
naji247 Oct 19, 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: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

$finder = Finder::create()
->in([__DIR__ . '/src', __DIR__ . '/tests'])
->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __FILE__]);
->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __DIR__ . '/config/agentcat.php', __FILE__]);

return (new Config())
->setRiskyAllowed(true)
Expand Down
18 changes: 15 additions & 3 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;

return (new Configuration())
// ext-pcntl and ext-posix are optional: FileLogger and SignalDrain guard their use with function_exists().
->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix'], [ErrorType::SHADOW_DEPENDENCY])
// 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])
// 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.
Expand All @@ -19,4 +19,16 @@
// 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]);
->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/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])
->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD])
->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD])
// 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])
// 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]);
14 changes: 14 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@
"ergebnis/composer-normalize": "^2.54",
"ergebnis/phpunit-slow-test-detector": "^2.26",
"infection/infection": "^0.35.8",
"larastan/larastan": "^3.13",
"laravel/framework": "^12.41.1 || ^13.0",
"laravel/mcp": "^1.0",
"mcp/sdk": "^0.8.1",
"nyholm/psr7": "^1.8.2",
"opis/json-schema": "^2.6",
"orchestra/testbench": "^10.8 || ^11.3",
"php-cs-fixer/shim": "3.95.27",
"phpat/phpat": "^0.12.5",
"phpdocumentor/type-resolver": "^1.10 || ^2.0",
Expand All @@ -55,15 +59,18 @@
"spaze/phpstan-disallowed-calls": "^4.15",
"symfony/polyfill-uuid": "^1.20",
"symfony/process": "^7.4 || ^8.1",
"symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0",
"symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0",
"tomasvotruba/type-coverage": "^2.5.1"
},
"conflict": {
"laravel/mcp": "<1.0",
"mcp/sdk": "<0.8.1"
},
"suggest": {
"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)"
},
"minimum-stability": "stable",
Expand All @@ -86,6 +93,13 @@
},
"sort-packages": true
},
"extra": {
"laravel": {
"providers": [
"AgentCat\\Laravel\\AgentCatServiceProvider"
]
}
},
"scripts": {
"check": [
"@composer validate --strict",
Expand Down
58 changes: 58 additions & 0 deletions config/agentcat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

// AgentCat analytics for every laravel/mcp server of this application.
// Publish this file with: php artisan vendor:publish --tag=agentcat-config

return [
// On or off for every MCP server and every AgentCat call: true or false. Unset: on, except while your tests run.
'enabled' => env('AGENTCAT_ENABLED'),

// Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run.
'project_id' => env('AGENTCAT_PROJECT_ID'),

// Servers AgentCat never tracks: server classes. A class also excludes its subclasses.
'exclude' => [
'Laravel\Boost\Mcp\Boost',
],

// Another project for some servers: server class => project ID. The first class the server is an instance of wins.
'projects' => [
// App\Mcp\Servers\BillingServer::class => env('AGENTCAT_BILLING_PROJECT_ID'),
],

// The log channel for AgentCat's own lines, for example stack. Unset: their own file, storage/logs/agentcat.log.
'log_channel' => env('AGENTCAT_LOG_CHANNEL'),

// The AgentCat API. Unset: AgentCat's. MCPCAT_API_URL is the TypeScript SDK's name.
'api_url' => env('AGENTCAT_API_URL', env('MCPCAT_API_URL')),

'enable_report_missing' => true,
'enable_tracing' => true,
'enable_tool_call_context' => true,
'custom_context_description' => null,
'enable_agent_tracking' => false,

// Hooks: the class name of an invokable class, resolved from the container when the hook runs.
// Closures cannot go here: php artisan config:cache cannot store them.
'identify' => null,
'resolve_session_id' => null,
'event_tags' => null,
'event_properties' => null,
'redact_sensitive_information' => null,
'redact_event' => null,

// Your own telemetry tools: name => ['type' => 'otlp'|'datadog'|'sentry'|'posthog', …]. A null entry is skipped.
'exporters' => null,

// 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 is the TypeScript SDK's name.
'disable_diagnostics' => [env('AGENTCAT_DISABLE_DIAGNOSTICS'), env('DISABLE_DIAGNOSTICS')],

// Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs) and its bearer token. Unset: AgentCat's.
'diagnostics_endpoint' => env('AGENTCAT_DIAGNOSTICS_ENDPOINT'),
'diagnostics_token' => env('AGENTCAT_DIAGNOSTICS_TOKEN'),

'disable_signal_handlers' => false,
];
4 changes: 4 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ parameters:
reportPossiblyNonexistentGeneralArrayOffset: true
reportUnmatchedIgnoredErrors: true
reportIgnoresWithoutComments: true
# Larastan: the SDK reads Env::get(), never env() (the only env() calls are in config/agentcat.php, which is not
# analysed), and it has no Eloquent models.
noEnvCallsOutsideOfConfig: false
checkModelProperties: false
type_coverage:
return: 100
param: 100
Expand Down
8 changes: 6 additions & 2 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Rector\Config\RectorConfig;
use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector;
use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector;

return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
Expand All @@ -18,7 +19,10 @@
phpunitCodeQuality: true,
)
->withSkip([
// Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31).
StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php'],
// Keeps #[\Override] on setUp() and tearDown() that call the parent: PHPStan's checkMissingOverrideMethodAttribute requires it.
NoSetupWithParentCallOverrideRector::class,
// Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). AfterRequest
// names the Octane event class, which is not a dependency either.
StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Laravel/Internal/AfterRequest.php'],
])
->withCache(__DIR__ . '/.cache/rector');
2 changes: 1 addition & 1 deletion src/AgentCat.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ private function __construct() {}
*
* `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with
* `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV`
* `testing` or `test`).
* `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable.
*
* @template T of object
*
Expand Down
31 changes: 17 additions & 14 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@
use AgentCat\Internal\Delivery\EventQueue;
use AgentCat\Internal\Diagnostics\Diagnostics;
use AgentCat\Internal\Engine\CallInterceptor;
use AgentCat\Internal\Engine\Libraries;
use AgentCat\Internal\Engine\Library;
use AgentCat\Internal\Engine\Publisher;
use AgentCat\Internal\Engine\TrackedServer;
use AgentCat\Internal\Engine\TrackedServers;
use AgentCat\Internal\Logging\Log;
use AgentCat\Internal\Pipeline\QueuedEvent;
use AgentCat\Internal\Support\BridgeConfig;
use AgentCat\Internal\Support\Env;
use AgentCat\Internal\Support\ErrorText;
use AgentCat\Internal\Support\TestMode;
use AgentCat\Internal\Support\Values;
use AgentCat\Internal\Tags\TagValidator;
use AgentCat\Internal\Telemetry\TelemetryManager;
use AgentCat\McpSdk\McpSdkAdapter;
use Psr\Log\LoggerInterface;

/**
Expand Down Expand Up @@ -66,7 +68,7 @@ public function __construct(?ClientSeams $seams = null)
*
* `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with
* `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV`
* `testing` or `test`).
* `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable.
*
* @template T of object
*
Expand All @@ -86,33 +88,34 @@ public function track(object $server, ?string $projectId = null, ?Options $optio
return $server; // D44 AGENTCAT_ENABLED, else D31: nothing in test environments unless fake()
}
$options ??= new Options();
$projectId ??= Env::get('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only
$projectId ??= BridgeConfig::env('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only; a bridge's configuration first
if ($this->telemetry) {
// TS: the first statement of track(), before the server is checked; the first call in the process decides.
Diagnostics::init($projectId, $options->disableDiagnostics, $this->publisher->queue());
}
if ($options->disableSignalHandlers && !$this->publisher->queue()->disableSignalHandlers() && $this->publisher->queue()->claimSignalHandlersWarning()) {
Log::write('WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored');
}
if (!McpSdkAdapter::supports($server)) {
$signals = McpSdkAdapter::fingerprint($server);
Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, McpSdkAdapter::SUPPORT_MATRIX));
$library = Libraries::for($server);
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()));

throw new \UnexpectedValueException('AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . McpSdkAdapter::SUPPORT_MATRIX);
throw new \UnexpectedValueException('AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . Libraries::supportMatrix());
}
$logger = McpSdkAdapter::logger($server);
$logger = $library->logger($server);
if ($logger instanceof LoggerInterface) {
Log::adopt($logger); // D30, D38
}
$tracked = TrackedServers::get($server) instanceof TrackedServer;
if (!$tracked) {
McpSdkAdapter::inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing
$library->inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing
}
if (($options->apiBaseUrl ?? '') !== '' || Env::get('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) {
if (($options->apiBaseUrl ?? '') !== '' || BridgeConfig::env('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) {
$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 mcp-sdk/builder | signals %s', $project, McpSdkAdapter::fingerprint($server)));
Log::write(sprintf('AgentCat setup started | project %s | sdk %s | signals %s', $project, $library->sdk(), implode(',', $library->signals($server))));
if ($tracked) {
Log::write('[SESSION DEBUG] track() - Server already being tracked, skipping initialization');

Expand All @@ -126,8 +129,8 @@ 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, McpSdkAdapter::serverInfo($server), $this->publisher);
McpSdkAdapter::install($server, $state, $this->publisher, $this->interceptor);
$state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher);
$library->install($server, $state, $this->publisher, $this->interceptor);
TrackedServers::add($server, $state);
Log::write(sprintf(
'AgentCat setup complete | project %s | tracing=%s context=%s reportMissing=%s exporters=%d',
Expand Down Expand Up @@ -188,7 +191,7 @@ public function publishCustomEvent(object|string $serverOrSessionId, ?string $pr
if (is_string($serverOrSessionId)) {
$sessionId = $sessionId !== '' ? $sessionId : $serverOrSessionId; // verbatim: no derivation, '' is sessionless
} else {
$server = TrackedServers::get($serverOrSessionId) ?? McpSdkAdapter::trackedServer($serverOrSessionId);
$server = TrackedServers::get($serverOrSessionId) ?? Libraries::trackedServer($serverOrSessionId);
if (!$server instanceof TrackedServer) {
if (TrackedServers::incomplete()) {
// AgentCat's own failure, logged before: it must not throw into the customer's code
Expand Down
Loading
Loading