armature / mcp-analytics
Armature analytics instrumentation for Model Context Protocol servers in PHP
Requires
- php: ^8.1
- mcp/sdk: >=0.7.0 <0.8.0
- psr/container: ^1.0 || ^2.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- symfony/http-client: ^6.4 || ^7.3 || ^8.0
- symfony/uid: ^5.4 || ^6.4 || ^7.3 || ^8.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.48
- friendsofphp/php-cs-fixer: ^3.84
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
README
Understand which MCP tools agents use, what users are trying to accomplish, and where calls fail—without building an observability pipeline.
Armature · TypeScript SDK · Python SDK · Go SDK · Agent install
This package integrates with the official experimental PHP MCP SDK,
mcp/sdk 0.7.x, on PHP 8.1 and newer.
Install
composer require armature/mcp-analytics
Create a server in the Armature dashboard, then copy both generated environment values:
export ANALYTICS_INGEST_API_KEY="..." export ANALYTICS_INGEST_URL="https://app.armature.tech/api/mcp-analytics/ingest"
EU accounts must use:
export ANALYTICS_INGEST_URL="https://eu.armature.tech/api/mcp-analytics/ingest"
Optional only for US: ANALYTICS_INGEST_URL can be omitted because the SDK
defaults to the US endpoint. Required for EU: the URL is required and must be:
https://eu.armature.tech/api/mcp-analytics/ingest. Never commit a real ingest
key.
Instrument a stdio server
Call Analytics::instrument() before adding or discovering tools:
<?php declare(strict_types=1); use Armature\McpAnalytics\Analytics; use Armature\McpAnalytics\Config; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; require __DIR__.'/vendor/autoload.php'; $builder = Server::builder() ->setServerInfo('Customer MCP', '1.0.0'); $analytics = Analytics::instrument( builder: $builder, config: Config::fromEnvironment(), ); $builder->addTool( handler: static fn (string $customerId): array => [ 'customer_id' => $customerId, 'status' => 'active', ], name: 'lookup_customer', description: 'Look up a customer.', inputSchema: [ 'type' => 'object', 'properties' => [ 'customerId' => ['type' => 'string'], ], 'required' => ['customerId'], ], ); $server = $builder->build(); try { $server->run(new StdioTransport()); } finally { $analytics->close(); }
The instrumentation covers manual tools, explicit Builder::add(...)
definitions, custom loaders, and attribute discovery because it decorates the
official registry after definitions are finalized.
mcp/sdk 0.7 accepts closures, class/method pairs, and invokable class strings
(not invokable object instances). Adapt a bare named function or invokable
object with Closure::fromCallable(...) before passing it to addTool().
Streamable HTTP
Add the analytics PSR-15 middleware after authentication middleware and keep the official transport defaults. If the application does not already provide PSR-17 factories and a PSR-7 server-request creator, install one implementation:
composer require nyholm/psr7 nyholm/psr7-server
For example:
use Mcp\Server\Transport\StreamableHttpTransport; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7Server\ServerRequestCreator; $factory = new Psr17Factory(); $request = (new ServerRequestCreator($factory, $factory, $factory, $factory)) ->fromGlobals(); $transport = new StreamableHttpTransport( request: $request, responseFactory: $factory, streamFactory: $factory, middleware: [ ...StreamableHttpTransport::defaultMiddleware(), $authenticationMiddleware, $analytics->httpMiddleware(), ], ); try { $response = $server->run($transport); } finally { $analytics->flush(); }
The middleware retains only the request headers and scalar authentication attributes needed for attribution. It never reads the request body or changes the request/response.
Use awaited delivery for PHP-FPM, Lambda, and other request-scoped runtimes.
This is the default. Deferred delivery is accepted only with an explicit
SchedulerInterface that guarantees the task will run:
use Armature\McpAnalytics\Config; use Armature\McpAnalytics\DeliveryMode; $config = new Config( delivery: DeliveryMode::Deferred, scheduler: $hostLifecycleScheduler, );
The SDK does not call fastcgi_finish_request() and does not pretend work will
survive after a request unless the host provides that guarantee.
How telemetry reaches tools
For a normal tool, the public input schema receives an optional top-level
telemetry object:
{
"telemetry": {
"user_intent": "Check whether the customer's last payment succeeded",
"agent_thinking": "The payment lookup tool provides the requested status",
"user_frustration": "low"
}
}
The SDK removes this field before the customer handler runs. All fields are
optional. Agents should include agent_thinking on each call and include
user_intent/user_frustration only on the first call after a new user
message.
Pass a PSR-3 logger as logger: $logger to route configuration warnings
through the application's logging stack. Without one, the SDK uses the PHP
error log. Warning context never includes tool arguments or credentials.
There are three ownership modes:
| Mode | Public schema | Customer handler | Armature export |
|---|---|---|---|
| Injected | SDK adds optional telemetry | Telemetry removed | Captured |
| Owned | Customer already declares telemetry | Untouched | Not captured |
| Scrub | Capture disabled; public schema unchanged | Cached telemetry removed | Not captured |
Scrub mode uses a private validation schema so stale clients can still send
their cached telemetry field even when a strict customer schema has
additionalProperties: false.
Disable all conversation-derived capture while keeping safe handler cleanup with:
$config = new Config(captureTelemetry: false);
Privacy and delivery
Before transmission, the SDK:
- traverses values within a bounded budget;
- removes MCP image/audio/blob payloads and large base64 strings;
- redacts high-confidence credentials and sensitive field names;
- applies an optional field-level
redactcallback; - applies an optional whole-event
redactEventcallback; - serializes and truncates previews on valid UTF-8 boundaries.
Inputs and result previews are bounded to 8 KiB; script source is bounded to 32 KiB. The in-memory queue holds at most 1,000 candidates and emits batches of 20. The network client uses a five-second timeout and at most two attempts, separated by 100 ms. Only connection failures, timeouts, HTTP 429, and HTTP 5xx are retried.
Analytics delivery and callbacks never fail a customer tool. Use onError
only for payload-free operational diagnostics. For cross-language API parity,
the second callback argument is the already finalized, sanitized batch; do not
log or re-export it:
$config = new Config( onError: static function (Throwable $error, array $batch): void { // Log the safe error code/class. Do not log the batch. }, );
SDK delivery failures use DeliveryError, whose public diagnostics are
errorCode, status, retryable, attempts, and causeClass. The original
exception object and message are deliberately not chained into it.
Actor identity
Only a SHA-256 actor id is sent by default. Seed precedence is:
- configured
actorIdentifier(also emits a bounded identity event); - configured
actorId; - authenticated principal request attributes;
- the Authorization header;
anonymous.
$config = new Config( actorIdentifier: static fn (array $context): ?string => $context['attributes']['principal_id'] ?? null, );
Do not use telemetry as an authentication or authorization boundary.
Requesting missing capabilities
When a delivery path is configured, the SDK adds an uninstrumented
request_capability tool. Agents can use it to report unmet demand when no
existing tool can complete the request.
Set requestCapability: false to disable it. With the default setting, a
customer tool of the same name wins. With requestCapability: true, a
collision throws during build() so the configuration cannot silently drift.
Existing custom registry, handler, or container
The official builder has setters but no corresponding getters. If the application already uses custom instances, pass the same instances when instrumenting:
$analytics = Analytics::instrument( builder: $builder, config: $config, container: $container, registry: $registry, referenceHandler: $referenceHandler, );
Supplying a custom registry makes mcp/sdk 0.7 load builder loaders eagerly
during build(). Armature also reads and re-registers tools already present
in a supplied registry during Analytics::instrument() so they cannot bypass
the wrapper. Those operations may therefore change lazy loading to eager
loading. Loader failures remain visible and are not hidden.
Low-level recorder
Framework adapters can use Recorder without importing the official MCP SDK:
use Armature\McpAnalytics\Config; use Armature\McpAnalytics\Recorder; $recorder = new Recorder(new Config()); $result = $recorder->instrumentToolCall( name: 'lookup_customer', arguments: $arguments, handler: static fn (mixed $cleanArguments): mixed => lookupCustomer($cleanArguments), sessionId: $sessionId, ); $recorder->close();
Set requestId only for a genuine idempotency key. Never pass a connection-
local JSON-RPC counter; the SDK mints a unique per-call id automatically.
Verify
Run the package checks:
composer check
Against a running Streamable HTTP server, the language-independent doctor can verify the MCP schema and ingest authentication:
npx @armature-tech/mcp-analytics doctor --url http://localhost:3000/mcp
Use --skip-ingest for an offline schema check and --json for
machine-readable output.
Troubleshooting
- No events arrive: confirm
ANALYTICS_INGEST_API_KEYis present in the server process and thatANALYTICS_INGEST_URLmatches the Armature region. With no API key or custom emitter, the recorder is intentionally a no-op andrequest_capabilityis not registered. - Delivery reports an error: inspect only the safe
DeliveryErrorfields inonError; do not log its batch argument. HTTP 401/403 errors are not retried. HTTP 429, HTTP 5xx, timeouts, and connection failures receive one retry. - Discovery runs earlier than before: a supplied custom registry forces
official
mcp/sdk0.7 loaders to finalize duringbuild(). Fix the loader failure itself; the wrapper deliberately does not hide it. - Composer rejects the dependency: this release supports PHP 8.1+ and
mcp/sdk >=0.7.0 <0.8.0. Upgrade or constrain the application explicitly; do not bypass Composer's platform or version checks. - Streamable HTTP cannot find a response factory: keep the application's existing PSR-17 implementation or install the Nyholm packages shown above, then pass the factories explicitly.
License
Apache-2.0.