diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 9b255b3..d301237 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -125,11 +125,10 @@ jobs: name: Open issue on failure runs-on: ubuntu-latest needs: [server, client] - if: failure() + if: failure() && github.event_name == 'schedule' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - EVENT_NAME: ${{ github.event_name }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: File or comment tracking issue @@ -141,13 +140,12 @@ jobs: gh issue create \ --title '[conformance] Weekly conformance run failed' \ --label conformance-weekly \ - --body "Conformance against the newest published \`@modelcontextprotocol/conformance\` failed. + --body "Weekly conformance against \`@modelcontextprotocol/conformance@latest\` failed. - Run: $RUN_URL - - Trigger: $EVENT_NAME - - Date: $(date -u +%FT%TZ) + - Triggered: $(date -u +%FT%TZ) - Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to the matching \`tests/Conformance/conformance-baseline-.yml\`." + Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to \`tests/Conformance/conformance-baseline-2025-11-25.yml\`." fi publish: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7a399f1..a3ef63c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: uses: "ramsey/composer-install@v4" - name: Install uv - uses: astral-sh/setup-uv@v10.1.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md index e77f062..f085872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,6 @@ All notable changes to `mcp/sdk` will be documented in this file. -0.9.0 ------ - -* [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. -* Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. -* Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). - 0.8.0 ----- diff --git a/README.md b/README.md index 231c4fe..04965f8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # MCP PHP SDK +
+ [![Latest Version](https://img.shields.io/packagist/v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) [![CI](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml) [![PHP Version](https://img.shields.io/packagist/php-v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) [![License](https://img.shields.io/packagist/l/mcp/sdk.svg)](LICENSE) +[![Server Conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) +[![Client Conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) +[![Server Conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) +[![Client Conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) + +
+ The official PHP SDK for the Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers and clients in PHP — tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and both protocol eras (the `initialize` handshake and the stateless `2026-07-28` revision). @@ -16,17 +25,6 @@ development practices and standards from the Symfony project, including [Coding Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html), please see the [roadmap](./ROADMAP.md) for planned next steps and features. -## Protocol Conformance - -Measured weekly against the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance), -as passing checks out of total. The `2026-07-28` scores run against the framework's `alpha` releases, so they move as -upstream publishes new scenarios. - -| Revision | Server | Client | -|:--------:|:------:|:------:| -| `2026-07-28` | [![Server conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | [![Client conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | -| `2025-11-25` | [![Server conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | [![Client conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | - ## Installation ```bash diff --git a/docs/advanced/events.md b/docs/advanced/events.md index d6d7b44..590ad6e 100644 --- a/docs/advanced/events.md +++ b/docs/advanced/events.md @@ -35,7 +35,6 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When any request is received from the client, before it's processed by handlers. **Properties**: - - `getRequest(): Request` - The incoming request - `setRequest(Request $request): void` - Modify the request before processing - `getSession(): SessionInterface` - The current session @@ -46,7 +45,6 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When a successful response is ready to be sent to the client, after handler execution. **Properties**: - - `getResponse(): Response` - The response being sent - `setResponse(Response $response): void` - Modify the response before sending - `getRequest(): Request` - The original request @@ -58,7 +56,6 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When an error occurs during request processing. **Properties**: - - `getError(): Error` - The error being sent - `setError(Error $error): void` - Modify the error before sending - `getRequest(): Request` - The original request. Messages that fail to parse are rejected before this event, so a listener never sees them. @@ -70,7 +67,6 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When a notification is received from the client, before it's processed by handlers. **Properties**: - - `getNotification(): Notification` - The incoming notification - `setNotification(Notification $notification): void` - Modify the notification before processing - `getSession(): SessionInterface` - The current session diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 1fe01e1..b060916 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -169,7 +169,6 @@ $client->connect($transport); ``` The `connect()` method performs the MCP initialization handshake: - 1. Opens the transport connection 2. Sends InitializeRequest with client capabilities 3. Waits for InitializeResult from server diff --git a/docs/client/transports.md b/docs/client/transports.md index 84e1500..dd27166 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -18,7 +18,6 @@ $transport = new StdioTransport( ``` **Parameters:** - - `command` (string): The command to execute - `args` (array): Command arguments - `cwd` (string|null): Working directory for the process @@ -40,7 +39,6 @@ $transport = new HttpTransport( ``` **Parameters:** - - `endpoint` (string): The MCP server URL - `headers` (array): Additional HTTP headers - `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) @@ -52,7 +50,6 @@ $transport = new HttpTransport( **PSR-18 Auto-Discovery:** The transport automatically discovers PSR-18 HTTP clients from: - - `php-http/guzzle7-adapter` - `php-http/curl-client` - `symfony/http-client` diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 017d4a8..52d27e7 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -54,7 +54,6 @@ $server = Server::builder() ``` **Parameters:** - - `$name` (string): The server name - `$version` (string): Version string (semantic versioning recommended) - `$description` (string|null): Optional description @@ -153,7 +152,6 @@ $server = Server::builder() ``` **Parameters:** - - `$basePath` (string): Base directory for discovery (typically `__DIR__`) - `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) - `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning @@ -191,7 +189,6 @@ $server = Server::builder() ``` **How `excludeDirs` works:** - - If scanning `src/` and there's `src/vendor/`, it will be excluded - If scanning `lib/` and there's `lib/tests/`, it will be excluded - But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) @@ -220,7 +217,6 @@ $server = Server::builder() ``` **Basic Container Features:** - - Supports constructor auto-wiring for classes with parameterless constructors - Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container - Supports parameters with default values diff --git a/docs/run/sessions.md b/docs/run/sessions.md index 93f01c7..abdac60 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -74,7 +74,6 @@ $server = Server::builder() ``` **Parameters:** - - `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC. - `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1. @@ -82,7 +81,6 @@ $server = Server::builder() > the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation. **Available Session Stores:** - - `InMemorySessionStore`: Fast in-memory storage (default) - `FileSessionStore`: Persistent file-based storage - `Psr16SessionStore`: PSR-16 compliant cache-based storage diff --git a/docs/servers/completions.md b/docs/servers/completions.md index e1f6086..c422620 100644 --- a/docs/servers/completions.md +++ b/docs/servers/completions.md @@ -87,7 +87,6 @@ public function getUserProfile( ``` **Provider Resolution:** - - **Class strings** (`Provider::class`) → Resolved from PSR-11 container - **Instances** (`new Provider()`) → Used directly - **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index bb46ea5..33da3c7 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -100,7 +100,6 @@ makes the prompt handler throw. ### Error Handling Prompt handlers can throw any exception, but the type determines how it's handled: - - **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message - **Any other exception**: Converted to JSON-RPC error response, but with a generic error message diff --git a/docs/servers/registration.md b/docs/servers/registration.md index 896333b..e2dc563 100644 --- a/docs/servers/registration.md +++ b/docs/servers/registration.md @@ -6,7 +6,6 @@ registry somehow. There are three ways to get it there, and they mix freely. ## Attribute-Based Discovery **Advantages:** - - Declarative and readable - Automatic parameter inference - DocBlock integration @@ -26,7 +25,6 @@ Register MCP elements programmatically without using attributes. The handler is any PHP callable. **Advantages:** - - Fine-grained control - Runtime configuration - Conditional registration diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index a21963c..9e8dc80 100644 --- a/src/Capability/Attribute/CompletionProvider.php +++ b/src/Capability/Attribute/CompletionProvider.php @@ -24,9 +24,9 @@ class CompletionProvider * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved * from the container at the point of use * @param ?array $values a list of values to use for completion - * @param class-string|null $enum an enum whose cases are the completions */ public function __construct( + public ?string $providerClass = null, public string|ProviderInterface|null $provider = null, public ?array $values = null, public ?string $enum = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index c70559f..5b7e476 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -325,6 +325,8 @@ final class Discoverer implements DiscovererInterface if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->providerClass) { + $completionProviders[$param->getName()] = $attributeInstance->provider; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index 209644c..d1e849d 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -287,7 +287,7 @@ final class ReflectedElementLoader implements LoaderInterface } /** - * @return array|ProviderInterface> + * @return array */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { @@ -307,6 +307,8 @@ final class ReflectedElementLoader implements LoaderInterface if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->providerClass) { + $completionProviders[$param->getName()] = $attributeInstance->providerClass; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 3c12ea7..b5499e8 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -121,22 +121,6 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa $this->headerCallback = $callback; } - /** - * The session ID minted by the server for this connection, if any. - * - * Captured from the Mcp-Session-Id response header of a handshake-era - * server; always null on 2026-07-28, which removed protocol-level sessions - * (SEP-2567). A caller that cannot keep the transport alive between - * interactions can persist it and pass it back through the constructor's - * $headers on a later transport, where it wins over the tracked value. - * - * @return string|null null until the server mints a session, and again after close() - */ - public function getSessionId(): ?string - { - return $this->sessionId; - } - public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php index 3ec0883..bfd37f1 100644 --- a/src/Schema/JsonRpc/Notification.php +++ b/src/Schema/JsonRpc/Notification.php @@ -74,7 +74,7 @@ abstract class Notification implements HasMethodInterface, MessageInterface $array['params'] = $params; } - if (null !== $this->meta && !isset($params['_meta'])) { + if (null !== $this->meta && !isset($params['meta'])) { $array['params']['_meta'] = $this->meta; } diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index afdf9e9..cb8ed83 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -122,7 +122,7 @@ abstract class Request implements HasMethodInterface, MessageInterface $array['params'] = $params; } - if (null !== $this->meta && !isset($params['_meta'])) { + if (null !== $this->meta && !isset($params['meta'])) { $array['params']['_meta'] = $this->meta; } diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index f460c3c..4d6e5f5 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -303,11 +303,9 @@ class Protocol $notification = $result['notification']; $this->sendNotification($notification, $session); } elseif ('request' === $result['type']) { - // Keep $request untouched: it is the inbound request the catch - // blocks below answer under, not this outbound one. - $outboundRequest = $result['request']; + $request = $result['request']; $timeout = $result['timeout'] ?? 120; - $this->sendRequest($outboundRequest, $timeout, $session); + $this->sendRequest($request, $timeout, $session); } } diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 8d08bae..e218e75 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -14,8 +14,6 @@ namespace Mcp\Server\Session; use Mcp\Exception\RuntimeException; use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; /** @@ -28,13 +26,9 @@ class FileSessionStore implements SessionStoreInterface private readonly string $directory, private readonly int $ttl = 3600, private readonly ClockInterface $clock = new NativeClock(), - private readonly LoggerInterface $logger = new NullLogger(), ) { - if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) { - $this->logger->warning('Failed to create session directory.', [ - 'directory' => $this->directory, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); + if (!is_dir($this->directory)) { + @mkdir($this->directory, 0775, true); } if (!is_dir($this->directory) || !is_writable($this->directory)) { @@ -65,23 +59,13 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { - if (!@unlink($path) && is_file($path)) { - $this->logger->warning('Failed to delete expired session file.', [ - 'path' => $path, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); - } + @unlink($path); return false; } $data = @file_get_contents($path); if (false === $data) { - $this->logger->warning('Failed to read session file.', [ - 'path' => $path, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); - return false; } @@ -94,11 +78,6 @@ class FileSessionStore implements SessionStoreInterface $tmp = $path.'.tmp'; if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { - $this->logger->warning('Failed to write session file.', [ - 'path' => $tmp, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); - return false; } @@ -106,10 +85,6 @@ class FileSessionStore implements SessionStoreInterface if (!@rename($tmp, $path)) { // Fallback if rename fails cross-device if (false === @copy($tmp, $path)) { - $this->logger->warning('Failed to move session file into place.', [ - 'path' => $path, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); @unlink($tmp); return false; @@ -126,11 +101,8 @@ class FileSessionStore implements SessionStoreInterface { $path = $this->pathFor($id); - if (is_file($path) && !@unlink($path) && is_file($path)) { - $this->logger->warning('Failed to delete session file.', [ - 'path' => $path, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); + if (is_file($path)) { + @unlink($path); } return true; @@ -147,11 +119,6 @@ class FileSessionStore implements SessionStoreInterface $dir = @opendir($this->directory); if (false === $dir) { - $this->logger->warning('Failed to open session directory for garbage collection.', [ - 'directory' => $this->directory, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); - return $deleted; } @@ -173,15 +140,7 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($now - $mtime) > $this->ttl) { - if (!@unlink($path) && is_file($path)) { - $this->logger->warning('Failed to delete expired session file.', [ - 'path' => $path, - 'error' => error_get_last()['message'] ?? 'unknown', - ]); - - continue; - } - + @unlink($path); $deleted[] = Uuid::fromString($entry); } } diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 4db11f7..6eee72d 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -110,12 +110,15 @@ class Session implements SessionInterface while (\count($segments) > 1) { $segment = array_shift($segments); if (!isset($data[$segment]) || !\is_array($data[$segment])) { - return; + $data[$segment] = []; } $data = &$data[$segment]; } - unset($data[array_shift($segments)]); + $lastKey = array_shift($segments); + if (isset($data[$lastKey])) { + unset($data[$lastKey]); + } } public function clear(): void diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php index a0c0cd6..2f24685 100644 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -146,10 +146,8 @@ class OidcDiscovery implements OidcDiscoveryInterface */ private function fetchMetadata(string $issuer): array { - // The trailing slash is dropped to build discovery URLs (RFC 8414 §3.1), - // but the issuer is matched verbatim (RFC 8414 §3.3, OIDC Discovery §4.3). - $discoveryIssuer = rtrim($issuer, '/'); - $parsed = parse_url($discoveryIssuer); + $issuer = rtrim($issuer, '/'); + $parsed = parse_url($issuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); @@ -172,7 +170,7 @@ class OidcDiscovery implements OidcDiscoveryInterface // 2. OIDC path insertion $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration'; + $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index c01503e..2bf088b 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -15,7 +15,6 @@ use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; -use Mcp\Server\NativeClock; use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; @@ -23,7 +22,6 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\Http\MiddlewareRequestHandler; use Mcp\Server\Transport\Http\StatelessResponder; use Mcp\Server\Wire\InboundClassifier; -use Psr\Clock\ClockInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -81,13 +79,12 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware */ public function __construct( - private readonly ServerRequestInterface $request, + private ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, ?iterable $middleware = null, private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, - private readonly ClockInterface $clock = new NativeClock(), ) { parent::__construct($logger); @@ -188,17 +185,12 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra { $this->handleMessage($body, $this->sessionId); - // Consume the immediate response exactly once, so a transport instance - // reused for a later POST does not replay it. - $immediateResponse = $this->immediateResponse; - $immediateStatusCode = $this->immediateStatusCode; - $this->immediateResponse = null; - $this->immediateStatusCode = null; - - if (null !== $immediateResponse) { - return $this->responseFactory->createResponse($immediateStatusCode ?? 200) + if (null !== $this->immediateResponse) { + $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($immediateResponse)); + ->withBody($this->streamFactory->createStream($this->immediateResponse)); + + return $response; } if (null !== $this->sessionFiber) { @@ -276,7 +268,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra break; } - if ($this->clock->now()->getTimestamp() - $timestamp >= $timeout) { + if (time() - $timestamp >= $timeout) { $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); @@ -384,6 +376,8 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra private function handleRequest(ServerRequestInterface $request): ResponseInterface { + $this->request = $request; + if ('OPTIONS' === $request->getMethod()) { return $this->handleOptionsRequest(); } @@ -411,7 +405,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra } if ($classification->modern) { - return $this->handleModernRequest($request, $body ?? '', $classification->claimedVersion ?? ''); + return $this->handleModernRequest($body ?? '', $classification->claimedVersion ?? ''); } // The version-header rule only reaches the traffic it is about. Running @@ -449,7 +443,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra /** * Answers a request that claimed the modern era's per-request envelope. */ - private function handleModernRequest(ServerRequestInterface $request, string $body, string $claimedVersion): ResponseInterface + private function handleModernRequest(string $body, string $claimedVersion): ResponseInterface { if (null === $this->stateless) { return $this->responder->error( @@ -458,7 +452,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra ); } - return $this->responder->respond($this->stateless->handle($body, self::headers($request))); + return $this->responder->respond($this->stateless->handle($body, self::headers($this->request))); } /** diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php index 37ebcab..06c3409 100644 --- a/tests/Conformance/score.php +++ b/tests/Conformance/score.php @@ -16,12 +16,11 @@ * php score.php [results-dir] * * The conformance CLI writes one `checks.json` per scenario into - * `[results-dir]` (default `results`), relative to this file. Every check with - * a SUCCESS or FAILURE status counts towards the score; the badge message is - * "/ (%)" and is written to + * `[results-dir]` (default `results`), relative to this file. A scenario + * counts as passing when none of its checks has a FAILURE status; the badge + * message is "/ (%)" and is written to * `-conformance-.json`, so each revision gets its own - * badge. The badge is labelled "checks" only: the README renders the badges in - * a table whose row and column headers already name the revision and suite. + * badge. */ use Symfony\Component\Console\Command\Command; @@ -93,7 +92,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $badge = [ 'schemaVersion' => 1, - 'label' => 'checks', + 'label' => sprintf('%s %s', $suite, $specVersion), 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', 'color' => match (true) { 0 === $total => 'lightgrey', @@ -117,7 +116,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $io->listing($failures); } - $io->success(sprintf('%s %s: %s', $suite, $specVersion, $badge['message'])); + $io->success(sprintf('%s: %s', $badge['label'], $badge['message'])); return Command::SUCCESS; }) diff --git a/tests/Integration/CompletionTest.php b/tests/Integration/CompletionTest.php deleted file mode 100644 index 0da021a..0000000 --- a/tests/Integration/CompletionTest.php +++ /dev/null @@ -1,57 +0,0 @@ -connect('completion'); - - $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '12']); - - $this->assertSame(['12A', '12B'], $result->values); - } - - #[TestDox('an empty value offers every completion the provider knows')] - public function testEmptyValueOffersEveryCompletion(): void - { - $client = $this->connect('completion'); - - $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '']); - - $this->assertSame(['12A', '12B', '14C'], $result->values); - } - - #[TestDox('an argument the prompt does not declare completes to nothing')] - public function testUnknownArgumentCompletesToNothing(): void - { - $client = $this->connect('completion'); - - $result = $client->complete(new PromptReference('book_seat'), ['name' => 'unknown', 'value' => '1']); - - $this->assertSame([], $result->values); - } -} diff --git a/tests/Integration/Fixture/Completion/BookingElements.php b/tests/Integration/Fixture/Completion/BookingElements.php deleted file mode 100644 index 2a64e5d..0000000 --- a/tests/Integration/Fixture/Completion/BookingElements.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -final class BookingElements -{ - /** - * Confirms a seat booking. - * - * @param string $seat the seat to book - * - * @return array the prompt messages - */ - #[McpPrompt(name: 'book_seat')] - public function bookSeat( - #[CompletionProvider(provider: SeatCompletionProvider::class)] - string $seat, - ): array { - return [ - ['role' => 'user', 'content' => \sprintf('Book seat %s for me.', $seat)], - ]; - } -} diff --git a/tests/Integration/Fixture/Completion/SeatCompletionProvider.php b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php deleted file mode 100644 index a749fcf..0000000 --- a/tests/Integration/Fixture/Completion/SeatCompletionProvider.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ -final class SeatCompletionProvider implements ProviderInterface -{ - /** - * @param list $seats - */ - public function __construct( - private readonly array $seats, - ) { - } - - public function getCompletions(string $currentValue): array - { - return array_values(array_filter( - $this->seats, - static fn (string $seat): bool => str_starts_with($seat, $currentValue), - )); - } -} diff --git a/tests/Integration/Fixture/completion.php b/tests/Integration/Fixture/completion.php deleted file mode 100644 index f33e951..0000000 --- a/tests/Integration/Fixture/completion.php +++ /dev/null @@ -1,31 +0,0 @@ -set(SeatCompletionProvider::class, new SeatCompletionProvider(['12A', '12B', '14C'])); - -Server::builder() - ->setServerInfo('integration-server', '1.0.0') - ->setContainer($container) - ->setDiscovery(__DIR__, ['Completion']) - ->build() - ->run(new StdioTransport()); diff --git a/tests/Unit/Capability/Attribute/CompletionProviderTest.php b/tests/Unit/Capability/Attribute/CompletionProviderTest.php index 4b5269d..19a7875 100644 --- a/tests/Unit/Capability/Attribute/CompletionProviderTest.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderTest.php @@ -26,15 +26,6 @@ class CompletionProviderTest extends TestCase $this->assertNull($attribute->enum); } - public function testCanBeConstructedWithAPositionalProviderClass(): void - { - $attribute = new CompletionProvider(CompletionProviderFixture::class); - - $this->assertSame(CompletionProviderFixture::class, $attribute->provider); - $this->assertNull($attribute->values); - $this->assertNull($attribute->enum); - } - public function testCanBeConstructedWithProviderInstance(): void { $instance = new CompletionProviderFixture(); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 628aaa6..5c716d7 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -94,7 +94,7 @@ class DiscoveryTest extends TestCase $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); $this->assertArrayHasKey('content_creator', $prompts); - $this->assertCount(4, $prompts['content_creator']->completionProviders); + $this->assertCount(3, $prompts['content_creator']->completionProviders); $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); @@ -165,7 +165,7 @@ class DiscoveryTest extends TestCase $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); - $this->assertCount(4, $prompts['content_creator']->completionProviders); + $this->assertCount(3, $prompts['content_creator']->completionProviders); $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); @@ -182,14 +182,4 @@ class DiscoveryTest extends TestCase $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); } - - public function testDiscoversPositionalCompletionProviderAsClassString(): void - { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - - $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); - - // Kept as a class-string so the container resolves it at the point of use. - $this->assertEquals(CompletionProviderFixture::class, $prompts['content_creator']->completionProviders['author']); - } } diff --git a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php index 9975f9a..5ac67dd 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -14,14 +14,13 @@ namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; use Mcp\Capability\Attribute\McpResourceTemplate; -use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; class EnhancedCompletionHandler { /** - * Create content with list, enum and positional provider completion providers. + * Create content with list and enum completion providers. */ #[McpPrompt(name: 'content_creator')] public function createContent( @@ -31,11 +30,9 @@ class EnhancedCompletionHandler string $status, #[CompletionProvider(enum: PriorityEnum::class)] string $priority, - #[CompletionProvider(CompletionProviderFixture::class)] - string $author, ): array { return [ - ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority} for {$author}"], + ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"], ]; } diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 83a77e0..6c6119e 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -94,53 +94,6 @@ final class HttpTransportTest extends TestCase $this->assertSame('test-server', $client->getServerInfo()?->name); } - #[TestDox('the server-minted session ID is exposed while connected and cleared on close')] - public function testSessionIdIsExposedAndClearedOnClose(): void - { - $httpClient = new class implements ClientInterface { - public function sendRequest(RequestInterface $request): ResponseInterface - { - $decoded = json_decode((string) $request->getBody(), true); - - if ('initialize' !== ($decoded['method'] ?? null)) { - return new Response(202); - } - - $payload = json_encode([ - 'jsonrpc' => '2.0', - 'id' => $decoded['id'], - 'result' => [ - 'protocolVersion' => '2025-11-25', - 'capabilities' => ['tools' => ['listChanged' => false]], - 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], - ], - ]); - - return new Response(200, [ - 'Content-Type' => 'application/json', - 'Mcp-Session-Id' => 'session-abc123', - ], $payload); - } - }; - - $transport = new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory); - - $this->assertNull($transport->getSessionId()); - - $client = Client::builder() - ->setClientInfo('test-client', '1.0.0') - ->setInitTimeout(1) - ->build(); - - $client->connect($transport); - - $this->assertSame('session-abc123', $transport->getSessionId()); - - $client->disconnect(); - - $this->assertNull($transport->getSessionId()); - } - #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] public function testSseBufferIsBoundedByConfiguredCap(): void { diff --git a/tests/Unit/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php index b2580de..0d7d7c4 100644 --- a/tests/Unit/Schema/JsonRpc/NotificationTest.php +++ b/tests/Unit/Schema/JsonRpc/NotificationTest.php @@ -53,44 +53,4 @@ final class NotificationTest extends TestCase $this->assertSame($expectedMeta, $notification->jsonSerialize()); } - - public function testMetaDoesNotOverrideMetaFromParams(): void - { - $notificationImplementation = new class extends Notification { - public static function getMethod(): string - { - return 'notifications/dummy'; - } - - public static function fromParams(?array $params): self - { - return new self(); - } - - protected function getParams(): array - { - return [ - '_meta' => ['key' => 'from-params'], - ]; - } - }; - - $notification = $notificationImplementation::fromArray([ - 'jsonrpc' => '2.0', - 'method' => 'notifications/dummy', - 'params' => [ - '_meta' => ['key' => 'from-meta'], - ], - ]); - - $expected = [ - 'jsonrpc' => '2.0', - 'method' => 'notifications/dummy', - 'params' => [ - '_meta' => ['key' => 'from-params'], - ], - ]; - - $this->assertSame($expected, $notification->jsonSerialize()); - } } diff --git a/tests/Unit/Schema/JsonRpc/RequestTest.php b/tests/Unit/Schema/JsonRpc/RequestTest.php index d23b6a9..f400a90 100644 --- a/tests/Unit/Schema/JsonRpc/RequestTest.php +++ b/tests/Unit/Schema/JsonRpc/RequestTest.php @@ -55,41 +55,4 @@ final class RequestTest extends TestCase $this->assertSame($expectedMeta, $notification->jsonSerialize()); } - - public function testMetaDoesNotOverrideMetaFromParams(): void - { - $requestImplementation = new class extends Request { - public static function getMethod(): string - { - return 'foo/bar'; - } - - public static function fromParams(?array $params): static - { - return new self(); - } - - protected function getParams(): array - { - return [ - '_meta' => ['key' => 'from-params'], - ]; - } - }; - - $request = $requestImplementation::fromParams(null) - ->withId('12345') - ->withMeta(['key' => 'from-meta']); - - $expected = [ - 'jsonrpc' => '2.0', - 'id' => '12345', - 'method' => 'foo/bar', - 'params' => [ - '_meta' => ['key' => 'from-params'], - ], - ]; - - $this->assertSame($expected, $request->jsonSerialize()); - } } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 175db74..997c39d 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -25,9 +25,7 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; -use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use Mcp\Tests\Unit\Fixtures\ThrowingRequest; @@ -795,63 +793,6 @@ final class ProtocolTest extends TestCase $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); } - #[TestDox('Failure while dispatching an outbound request is answered under the inbound request id')] - public function testOutboundRequestFailureIsAnsweredUnderInboundRequestId(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->method('supports')->willReturn(true); - $handler->method('handle')->willReturnCallback(static function (): Response { - // Suspend with an outbound, id-less request, as sampling/elicitation handlers do. - \Fiber::suspend(['type' => 'request', 'request' => new PingRequest(), 'timeout' => 5]); - - return new Response(1, []); - }); - - $exception = new \RuntimeException('Transport unavailable'); - $this->transport->method('attachFiberToSession')->willThrowException($exception); - - $sessionManager = new SessionManager(new InMemorySessionStore()); - - $events = []; - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use (&$events): object { - $events[] = $event; - - return $event; - }); - - $protocol = new Protocol( - requestHandlers: [$handler], - notificationHandlers: [], - messageFactory: MessageFactory::make(), - sessionManager: $sessionManager, - eventDispatcher: $dispatcher, - ); - - $session = $sessionManager->create(); - $session->save(); - $sessionId = $session->getId(); - $protocol->processInput( - $this->transport, - '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', - $sessionId - ); - - $errorEvents = array_values(array_filter($events, static fn (object $event): bool => $event instanceof ErrorEvent)); - $this->assertCount(1, $errorEvents); - $this->assertSame($exception, $errorEvents[0]->getThrowable()); - $this->assertSame(1, $errorEvents[0]->getError()->getId()); - - $outgoing = $protocol->consumeOutgoingMessages($sessionId); - $errors = array_values(array_filter( - array_map(static fn (array $outgoingMessage): array => json_decode($outgoingMessage['message'], true), $outgoing), - static fn (array $message): bool => isset($message['error']), - )); - $this->assertCount(1, $errors); - $this->assertSame(1, $errors[0]['id']); - $this->assertSame(Error::INTERNAL_ERROR, $errors[0]['error']['code']); - } - #[TestDox('Notification handler exceptions are caught and logged')] public function testNotificationHandlerExceptionsAreCaught(): void { diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php index ed22e86..a2b8729 100644 --- a/tests/Unit/Server/Session/FileSessionStoreTest.php +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -16,8 +16,6 @@ use Mcp\Exception\RuntimeException; use Mcp\Server\Session\FileSessionStore; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger; -use Psr\Log\LogLevel; use Symfony\Component\Uid\UuidV4; class FileSessionStoreTest extends TestCase @@ -111,121 +109,4 @@ class FileSessionStoreTest extends TestCase throw $e; } } - - #[TestDox('logs a warning when a session file cannot be read')] - public function testUnreadableSessionFileLogsWarning(): void - { - $logger = new WarningCollectingLogger(); - $store = new FileSessionStore($this->directory, logger: $logger); - $id = new UuidV4(); - - $store->write($id, 'payload'); - - $path = $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); - chmod($path, 0000); - clearstatcache(true, $path); - - if (is_readable($path)) { - $this->markTestSkipped('Permission bits do not restrict reads here (running as root, or a filesystem that ignores them).'); - } - - $this->assertFalse($store->read($id)); - $this->assertCount(1, $logger->warnings); - $this->assertSame('Failed to read session file.', $logger->warnings[0]['message']); - $this->assertSame($path, $logger->warnings[0]['context']['path']); - } - - #[TestDox('logs a warning when a session file cannot be written')] - public function testUnwritableSessionFileLogsWarning(): void - { - $logger = new WarningCollectingLogger(); - $store = new FileSessionStore($this->directory, logger: $logger); - - chmod($this->directory, 0555); - clearstatcache(true, $this->directory); - - if (is_writable($this->directory)) { - $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); - } - - $this->assertFalse($store->write(new UuidV4(), 'payload')); - $this->assertCount(1, $logger->warnings); - $this->assertSame('Failed to write session file.', $logger->warnings[0]['message']); - } - - #[TestDox('logs a warning when the session directory cannot be opened for garbage collection')] - public function testGcLogsWarningWhenDirectoryVanished(): void - { - $logger = new WarningCollectingLogger(); - $store = new FileSessionStore($this->directory, logger: $logger); - - rmdir($this->directory); - - $this->assertSame([], $store->gc()); - $this->assertCount(1, $logger->warnings); - $this->assertSame('Failed to open session directory for garbage collection.', $logger->warnings[0]['message']); - $this->assertSame($this->directory, $logger->warnings[0]['context']['directory']); - } - - #[TestDox('gc() warns and does not report a session whose file survived deletion')] - public function testGcLogsWarningWhenExpiredFileCannotBeDeleted(): void - { - $logger = new WarningCollectingLogger(); - $store = new FileSessionStore($this->directory, ttl: 60, logger: $logger); - $id = new UuidV4(); - $store->write($id, 'payload'); - - $path = $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); - touch($path, time() - 120); - - chmod($this->directory, 0555); - clearstatcache(true, $this->directory); - - if (is_writable($this->directory)) { - $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); - } - - // The file is still there, so its id must not be reported as deleted. - $this->assertSame([], $store->gc()); - $this->assertFileExists($path); - $this->assertCount(1, $logger->warnings); - $this->assertSame('Failed to delete expired session file.', $logger->warnings[0]['message']); - $this->assertSame($path, $logger->warnings[0]['context']['path']); - } - - #[TestDox('stays silent on the happy path')] - public function testHappyPathLogsNothing(): void - { - $logger = new WarningCollectingLogger(); - $store = new FileSessionStore($this->directory, logger: $logger); - $id = new UuidV4(); - - $store->write($id, 'payload'); - $store->read($id); - $store->destroy($id); - $store->gc(); - - $this->assertSame([], $logger->warnings); - } -} - -/** - * Keeps every warning with its context, so a silent failure can be told apart - * from one the operator was told about. - */ -final class WarningCollectingLogger extends AbstractLogger -{ - /** @var list}> */ - public array $warnings = []; - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function log($level, $message, array $context = []): void - { - if (LogLevel::WARNING === $level) { - $this->warnings[] = ['message' => (string) $message, 'context' => $context]; - } - } } diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index a6b1697..40483d3 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -245,21 +245,6 @@ class SessionTest extends TestCase $this->assertFalse($this->session->has('nonexistent')); } - public function testForgetDoesNotCreateIntermediateSegments(): void - { - $this->session->forget('a.b'); - - $this->assertSame([], $this->session->all()); - } - - public function testForgetDoesNotOverwriteNonArrayIntermediateValue(): void - { - $this->session->set('key', 'string_value'); - $this->session->forget('key.nested'); - - $this->assertSame('string_value', $this->session->get('key')); - } - public function testSessionCanStoreVariousDataTypes(): void { $this->session->set('string', 'value'); diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index fd86d02..29c86d6 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -293,88 +293,6 @@ class OidcDiscoveryTest extends TestCase $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); } - #[TestDox('issuer with path and trailing slash is discovered and matched verbatim')] - public function testIssuerWithPathAndTrailingSlash(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $requestedUrls = []; - $issuer = 'https://auth.example.com/application/o/mcp/'; - $validMetadata = [ - 'issuer' => $issuer, - 'authorization_endpoint' => 'https://auth.example.com/application/o/authorize/', - 'token_endpoint' => 'https://auth.example.com/application/o/token/', - 'jwks_uri' => 'https://auth.example.com/application/o/mcp/jwks/', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(3)) - ->method('sendRequest') - ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { - $requestedUrls[] = (string) $request->getUri(); - - if (3 !== \count($requestedUrls)) { - return $factory->createResponse(404); - } - - return $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), - ); - }); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $metadata = $discovery->discover($issuer); - - $this->assertSame($issuer, $metadata['issuer']); - $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server/application/o/mcp', $requestedUrls[0]); - $this->assertSame('https://auth.example.com/.well-known/openid-configuration/application/o/mcp', $requestedUrls[1]); - $this->assertSame('https://auth.example.com/application/o/mcp/.well-known/openid-configuration', $requestedUrls[2]); - } - - #[TestDox('issuer without path but with trailing slash is discovered and matched verbatim')] - public function testIssuerWithoutPathWithTrailingSlash(): void - { - $this->skipIfPsrHttpClientIsMissing(); - - $factory = new Psr17Factory(); - $requestedUrls = []; - $issuer = 'https://auth.example.com/'; - $validMetadata = [ - 'issuer' => $issuer, - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/oauth/token', - 'jwks_uri' => 'https://auth.example.com/.well-known/jwks.json', - 'code_challenge_methods_supported' => ['S256'], - ]; - - $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->once()) - ->method('sendRequest') - ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { - $requestedUrls[] = (string) $request->getUri(); - - return $factory->createResponse(200)->withBody( - $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), - ); - }); - - $discovery = new OidcDiscovery( - httpClient: $httpClient, - requestFactory: $factory, - ); - - $metadata = $discovery->discover($issuer); - - $this->assertSame($issuer, $metadata['issuer']); - $this->assertSame(['https://auth.example.com/.well-known/oauth-authorization-server'], $requestedUrls); - } - private function skipIfPsrHttpClientIsMissing(): void { if (!interface_exists(ClientInterface::class)) { diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 186432f..932f5c7 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -12,23 +12,19 @@ namespace Mcp\Tests\Unit\Server\Transport; use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; -use Mcp\Server\Transport\TransportInterface; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; -use Psr\Clock\ClockInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; final class StreamableHttpTransportTest extends TestCase { @@ -351,92 +347,6 @@ final class StreamableHttpTransportTest extends TestCase new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); } - #[TestDox('an immediate response is consumed once and not replayed on a later POST')] - public function testImmediateResponseIsNotReplayedOnSecondPost(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withBody($this->factory->createStream('{"jsonrpc":"2.0","id":1,"method":"ping"}')); - - $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - - $calls = 0; - $transport->onMessage(static function (TransportInterface $transport, string $payload) use (&$calls): void { - if (1 === ++$calls) { - $transport->send('{"jsonrpc":"2.0","id":1,"result":{}}', ['status_code' => 200]); - } - }); - - $first = $transport->listen(); - - $this->assertSame(200, $first->getStatusCode()); - $this->assertSame('{"jsonrpc":"2.0","id":1,"result":{}}', (string) $first->getBody()); - - $second = $transport->listen(); - - $this->assertSame(2, $calls); - $this->assertSame(202, $second->getStatusCode()); - $this->assertSame('', (string) $second->getBody()); - } - - #[TestDox('the polling loop times out a pending request via the injected clock')] - public function testPollingLoopTimesOutPendingRequestViaInjectedClock(): void - { - $request = $this->factory - ->createServerRequest('POST', 'http://localhost/') - ->withHeader('Host', 'localhost') - ->withBody($this->factory->createStream('{"jsonrpc":"2.0","id":1,"method":"ping"}')); - - $requestedAt = 1_000_000; - - // Frozen 121s after the pending request was issued — past its 120s timeout. - $clock = new class($requestedAt + 121) implements ClockInterface { - public function __construct(private readonly int $timestamp) - { - } - - public function now(): \DateTimeImmutable - { - return (new \DateTimeImmutable())->setTimestamp($this->timestamp); - } - }; - - $transport = new StreamableHttpTransport( - $request, - $this->factory, - $this->factory, - clock: $clock, - ); - - $received = null; - $fiber = new \Fiber(static function () use (&$received) { - $received = \Fiber::suspend(); - - return null; - }); - $fiber->start(); - - $transport->onMessage(static function (TransportInterface $transport) use ($fiber): void { - $transport->attachFiberToSession($fiber, Uuid::v4()); - }); - $transport->setOutgoingMessagesProvider(static fn (): array => []); - $transport->setResponseFinder(static fn () => null); - $transport->setPendingRequestsProvider(static fn (): array => [ - ['request_id' => 1, 'timestamp' => $requestedAt, 'timeout' => 120], - ]); - - $response = $transport->listen(); - - $this->assertSame('text/event-stream', $response->getHeaderLine('Content-Type')); - - $this->expectOutputString(''); - $response->getBody()->getContents(); - - $this->assertTrue($fiber->isTerminated()); - $this->assertInstanceOf(Error::class, $received); - } - private function stubAuth401(): MiddlewareInterface { return new class($this->factory) implements MiddlewareInterface {