Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `AgentCat::track()` takes the `Mcp\Server` built from a Builder, and a Builder after `build()` or `buildStateless()`, so `track()` can come before or after them.
A server tracked after `build()` gives no event for a call to an unknown tool, a call whose arguments fail the tool's schema, or a call that a `tools/call` handler of your own answers; with a reference handler of your own (`setReferenceHandler()`), track the Builder before `build()`.

### Changed

- AgentCat's request handlers come before every handler of the Builder, wherever `track()` is in your code.
A `tools/call` or `tools/list` handler that you add with `addRequestHandler()` before `track()`, or in a compiler pass of your own in Symfony, is now tracked: its listing gets AgentCat's parameters and `get_more_tools`, and each call gives an event.
- With `setReferenceHandler()` after `track()`, the error event of a failing tool now carries the tool's exception.

## [1.0.0] - 2026-10-22

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Once you have your project ID, set up AgentCat for your MCP library below; the [

### 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()`:
Install AgentCat next to `mcp/sdk` (`composer require agentcat/sdk mcp/sdk`), then track the server with one call. Pass the `Mcp\Server\Builder` or the `Mcp\Server` built from it, before or after your tools:

```php
<?php
Expand Down
4 changes: 2 additions & 2 deletions src/AgentCat.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ final class AgentCat
private function __construct() {}

/**
* Adds AgentCat analytics to an MCP server. Call it before the server is built: for mcp/sdk, pass the
* `Mcp\Server\Builder` before `build()`.
* Adds AgentCat analytics to an MCP server. For mcp/sdk, pass the `Mcp\Server\Builder` or the `Mcp\Server` built
* from it, before or after `build()`, and before or after your tools and request handlers.
*
* It never throws. When the server cannot be tracked, it logs why and returns the server unchanged. Tracking
* the same server again, from any client, changes nothing.
Expand Down
6 changes: 3 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ public function withHttpContext(\Closure $httpContext): self
}

/**
* Adds AgentCat analytics to an MCP server. Call it before the server is built: for mcp/sdk, pass the
* `Mcp\Server\Builder` before `build()`.
* Adds AgentCat analytics to an MCP server. For mcp/sdk, pass the `Mcp\Server\Builder` or the `Mcp\Server` built
* from it, before or after `build()`, and before or after your tools and request handlers.
*
* It never throws. When the server cannot be tracked, it logs why and returns the server unchanged. Tracking
* the same server again, from any client, changes nothing.
Expand Down Expand Up @@ -117,7 +117,7 @@ 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;
$tracked = TrackedServers::get($server) instanceof TrackedServer || $library?->trackedServer($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);
Expand Down
50 changes: 47 additions & 3 deletions src/McpSdk/Internal/Access/BuilderAccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,48 @@
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Schema\Implementation;
use Mcp\Server\Builder;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

/**
* Every private member of mcp/sdk that AgentCat reads (D21). Reads only: AgentCat changes the Builder through its
* public setters. Each closure is bound to Builder, so PHPStan checks the member in Builder's scope. The accessors
* are `@phpstan-impure`: they return the Builder's current state, which its setters and build() change.
* Every private member of the mcp/sdk Builder that AgentCat touches (D21). AgentCat writes one: the list of added
* request handlers, to put its own first. Each closure is bound to Builder, so PHPStan checks the member in Builder's
* scope. The accessors are `@phpstan-impure`: they return the Builder's current state, which its setters and build()
* change.
*
* @internal
*/
final class BuilderAccess
{
/**
* @return array<mixed> the request handlers added with addRequestHandler() and addRequestHandlers(), in order
*
* @phpstan-impure
*/
#[TouchesPrivateMember(class: Builder::class, member: 'requestHandlers', type: 'array')]
private static function addedRequestHandlers(Builder $builder): array
{
$read = \Closure::bind(static fn(Builder $b): array => $b->requestHandlers, null, Builder::class);

return $read($builder);
}

/**
* Puts `$handlers` before every request handler added so far. A handler added later comes after them, as
* addRequestHandler() appends, so AgentCat's handlers come first wherever track() runs.
*
* @param list<RequestHandlerInterface<mixed>> $handlers
*/
#[TouchesPrivateMember(class: Builder::class, member: 'requestHandlers', type: 'array')]
public static function prependRequestHandlers(Builder $builder, array $handlers): void
{
$write = \Closure::bind(static function (Builder $b) use ($handlers): void {
$b->requestHandlers = [...$handlers, ...$b->requestHandlers];
}, null, Builder::class);
$write($builder);
}

/**
* @return array<mixed>|null the handlers build() assembled, AgentCat's included; the entries are not checked here
*
Expand All @@ -34,6 +64,19 @@ public static function requestHandlers(Builder $builder): ?array
return $read($builder);
}

/**
* @return mixed the registry that build() assembled and gave the server, or null before build()
*
* @phpstan-impure
*/
#[TouchesPrivateMember(class: Builder::class, member: 'parts', type: '?array')]
public static function registry(Builder $builder): mixed
{
$read = \Closure::bind(static fn(Builder $b): mixed => $b->parts === null ? null : $b->parts['registry'], null, Builder::class);

return $read($builder);
}

/** @phpstan-impure */
#[TouchesPrivateMember(class: Builder::class, member: 'parts', type: '?array')]
public static function serverInfo(Builder $builder): ?Implementation
Expand Down Expand Up @@ -86,6 +129,7 @@ public static function logger(Builder $builder): ?LoggerInterface
public static function probe(Builder $builder): void
{
StrictErrors::run(static function () use ($builder): void {
self::addedRequestHandlers($builder);
self::requestHandlers($builder);
self::serverInfo($builder);
self::referenceHandler($builder);
Expand Down
65 changes: 65 additions & 0 deletions src/McpSdk/Internal/Access/CallToolHandlerAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace AgentCat\McpSdk\Internal\Access;

use AgentCat\Internal\Access\TouchesPrivateMember;
use Mcp\Capability\Discovery\SchemaValidator;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Server\Handler\Request\CallToolHandler;
use Psr\Log\LoggerInterface;

/**
* The private members of mcp/sdk's own `tools/call` handler that AgentCat touches (D21): AgentCat makes a copy of the
* handler with the same members, whose reference handler shows it a failing tool's exception, and, for a server
* tracked after build(), replaces the schema validator. Each closure is bound to CallToolHandler, so PHPStan checks the
* member in that class's scope.
*
* @internal
*/
final class CallToolHandlerAccess
{
#[TouchesPrivateMember(class: CallToolHandler::class, member: 'registry', type: RegistryInterface::class, readonly: true)]
public static function registry(CallToolHandler $handler): RegistryInterface
{
$read = \Closure::bind(static fn(CallToolHandler $h): RegistryInterface => $h->registry, null, CallToolHandler::class);

return $read($handler);
}

#[TouchesPrivateMember(class: CallToolHandler::class, member: 'referenceHandler', type: ReferenceHandlerInterface::class, readonly: true)]
public static function referenceHandler(CallToolHandler $handler): ReferenceHandlerInterface
{
$read = \Closure::bind(static fn(CallToolHandler $h): ReferenceHandlerInterface => $h->referenceHandler, null, CallToolHandler::class);

return $read($handler);
}

#[TouchesPrivateMember(class: CallToolHandler::class, member: 'logger', type: LoggerInterface::class, readonly: true)]
public static function logger(CallToolHandler $handler): LoggerInterface
{
$read = \Closure::bind(static fn(CallToolHandler $h): LoggerInterface => $h->logger, null, CallToolHandler::class);

return $read($handler);
}

/** @phpstan-impure */
#[TouchesPrivateMember(class: CallToolHandler::class, member: 'schemaValidator', type: SchemaValidator::class)]
public static function schemaValidator(CallToolHandler $handler): SchemaValidator
{
$read = \Closure::bind(static fn(CallToolHandler $h): SchemaValidator => $h->schemaValidator, null, CallToolHandler::class);

return $read($handler);
}

#[TouchesPrivateMember(class: CallToolHandler::class, member: 'schemaValidator', type: SchemaValidator::class)]
public static function setSchemaValidator(CallToolHandler $handler, SchemaValidator $validator): void
{
$write = \Closure::bind(static function (CallToolHandler $h) use ($validator): void {
$h->schemaValidator = $validator;
}, null, CallToolHandler::class);
$write($handler);
}
}
43 changes: 43 additions & 0 deletions src/McpSdk/Internal/Access/RegistryAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace AgentCat\McpSdk\Internal\Access;

use AgentCat\Internal\Access\TouchesPrivateMember;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\ToolReference;

/**
* The tools of mcp/sdk's registry (D21). For a server tracked after build(), AgentCat replaces each tool with one that
* advertises its parameters and runs the tool through AgentCat. It writes the member, not registerTool(), which would
* send the clients a `notifications/tools/list_changed` for each tool. The closures are bound to Registry, so PHPStan
* checks the member in its scope.
*
* @internal
*/
final class RegistryAccess
{
/**
* @return array<mixed> the tools by name, in the order they were registered; the entries are not checked here
*
* @phpstan-impure
*/
#[TouchesPrivateMember(class: Registry::class, member: 'tools', type: 'array')]
public static function tools(Registry $registry): array
{
$read = \Closure::bind(static fn(Registry $r): array => $r->tools, null, Registry::class);

return $read($registry);
}

/** @param array<string, ToolReference> $tools */
#[TouchesPrivateMember(class: Registry::class, member: 'tools', type: 'array')]
public static function setTools(Registry $registry, array $tools): void
{
$write = \Closure::bind(static function (Registry $r) use ($tools): void {
$r->tools = $tools;
}, null, Registry::class);
$write($registry);
}
}
Loading
Loading