From a98e01a4bec4f00c51d65f89f254257ecb8e725d Mon Sep 17 00:00:00 2001 From: Jonathan Renard <1273438+fox-john@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:54:03 +0100 Subject: [PATCH 001/143] chore: remove final keyword on attributes classes (#240) Co-authored-by: Jonathan Renard --- src/Capability/Attribute/McpPrompt.php | 2 +- src/Capability/Attribute/McpResource.php | 2 +- src/Capability/Attribute/McpResourceTemplate.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 0737c18..edb163b 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -20,7 +20,7 @@ use Mcp\Schema\Icon; * @author Kyrian Obikwelu */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] -final class McpPrompt +class McpPrompt { /** * @param ?string $name overrides the prompt name (defaults to method name) diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 2dcf85e..80f309c 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -21,7 +21,7 @@ use Mcp\Schema\Icon; * @author Kyrian Obikwelu */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] -final class McpResource +class McpResource { /** * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 14e66c5..0269f93 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -20,7 +20,7 @@ use Mcp\Schema\Annotations; * @author Kyrian Obikwelu */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] -final class McpResourceTemplate +class McpResourceTemplate { /** * @param string $uriTemplate the URI template string (RFC 6570) From 61c20f1450e326bcb2519d76f2658a3bee20af7d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 5 Mar 2026 00:07:49 +0100 Subject: [PATCH 002/143] fix(session): initialize data lazily in save() to prevent uninitialized property access (#252) --- src/Server/Session/Session.php | 2 +- tests/Unit/Server/Session/SessionTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 3ca8d80..f777c22 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -51,7 +51,7 @@ class Session implements SessionInterface public function save(): bool { - return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); + return $this->store->write($this->id, json_encode($this->readData(), \JSON_THROW_ON_ERROR)); } public function get(string $key, mixed $default = null): mixed diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index 01e88bc..4d16e1a 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -34,6 +34,15 @@ class SessionTest extends TestCase $this->assertEquals(['foo' => 'bar'], $result); } + public function testSaveBeforeReadInitializesData() + { + $store = new InMemorySessionStore(); + $session = new Session($store); + + // save() before any get()/set() should not crash + $this->assertTrue($session->save()); + } + public function testAllReturnsEmptyArrayForNullPayload() { $store = $this->getMockBuilder(InMemorySessionStore::class) From e3f4b8876878ff744c59e9da66995420719e0cd7 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Tue, 10 Mar 2026 17:10:07 +0100 Subject: [PATCH 003/143] [Server] Oauth2 based on middleware (#221) * Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware * Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware Signed-off-by: Volodymyr Panivko * Address PR review feedback for OAuth middleware * Apply php-cs-fixer to satisfy QA * Use Mcp exception classes in OAuth middleware * Refactor OAuth HTTP stack and add Microsoft example policies * Fix QA PHPStan findings in OAuth examples and auth middleware * Require OIDC discovery in JWKS provider and refine OAuth proxy behavior * Remove local development docker artifacts from VCS * Revert .gitignore changes for local docker files * Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion Signed-off-by: Volodymyr Panivko * Address OAuth PR review feedback and align example configs * Fix QA style issues and add authorization docs * Fix PHPStan typing issues in OAuth middleware * Remove duplicate Middleware namespace, consolidate under Http\Middleware Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs to reference the correct namespaces. * Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR - JwtTokenValidator: replace catch(\Throwable) with explicit \InvalidArgumentException|\UnexpectedValueException|\DomainException, letting RuntimeException from JwksProvider propagate as infrastructure errors - OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response - Update tests to expect RuntimeException for JWKS infrastructure failures --------- Signed-off-by: Volodymyr Panivko Co-authored-by: Volodymyr Panivko --- composer.json | 6 + docs/authorization.md | 430 +++++++++++++ examples/server/oauth-keycloak/Dockerfile | 23 + .../server/oauth-keycloak/McpElements.php | 134 ++++ examples/server/oauth-keycloak/README.md | 135 ++++ .../server/oauth-keycloak/docker-compose.yml | 69 ++ .../oauth-keycloak/keycloak/mcp-realm.json | 128 ++++ .../server/oauth-keycloak/nginx/default.conf | 25 + examples/server/oauth-keycloak/server.php | 68 ++ examples/server/oauth-microsoft/.env.dist | 3 + examples/server/oauth-microsoft/Dockerfile | 23 + .../server/oauth-microsoft/McpElements.php | 154 +++++ .../MicrosoftJwtTokenValidator.php | 187 ++++++ .../MicrosoftOidcMetadataPolicy.php | 38 ++ examples/server/oauth-microsoft/README.md | 222 +++++++ .../server/oauth-microsoft/docker-compose.yml | 43 ++ examples/server/oauth-microsoft/env.example | 18 + .../server/oauth-microsoft/nginx/default.conf | 25 + examples/server/oauth-microsoft/server.php | 91 +++ .../Unit/MicrosoftJwtTokenValidatorTest.php | 300 +++++++++ .../Unit/MicrosoftOidcMetadataPolicyTest.php | 64 ++ phpunit.xml.dist | 3 + .../Middleware/AuthorizationMiddleware.php | 182 ++++++ .../Http/Middleware/OAuthProxyMiddleware.php | 273 ++++++++ .../Middleware/OAuthRequestMetaMiddleware.php | 149 +++++ .../ProtectedResourceMetadataMiddleware.php | 64 ++ .../Http/OAuth/AuthorizationResult.php | 135 ++++ .../AuthorizationTokenValidatorInterface.php | 32 + .../Transport/Http/OAuth/JwksProvider.php | 122 ++++ .../Http/OAuth/JwksProviderInterface.php | 28 + .../Http/OAuth/JwtTokenValidator.php | 205 ++++++ .../Transport/Http/OAuth/OidcDiscovery.php | 236 +++++++ .../Http/OAuth/OidcDiscoveryInterface.php | 31 + .../OidcDiscoveryMetadataPolicyInterface.php | 22 + .../Http/OAuth/ProtectedResourceMetadata.php | 245 ++++++++ .../StrictOidcDiscoveryMetadataPolicy.php | 48 ++ .../AuthorizationMiddlewareTest.php | 284 +++++++++ .../Middleware/OAuthProxyMiddlewareTest.php | 299 +++++++++ .../OAuthRequestMetaMiddlewareTest.php | 179 ++++++ ...rotectedResourceMetadataMiddlewareTest.php | 125 ++++ .../Transport/Http/OAuth/JwksProviderTest.php | 168 +++++ .../Http/OAuth/JwtTokenValidatorTest.php | 589 ++++++++++++++++++ .../Http/OAuth/OidcDiscoveryTest.php | 268 ++++++++ .../OAuth/ProtectedResourceMetadataTest.php | 86 +++ .../StrictOidcDiscoveryMetadataPolicyTest.php | 79 +++ 45 files changed, 6038 insertions(+) create mode 100644 docs/authorization.md create mode 100644 examples/server/oauth-keycloak/Dockerfile create mode 100644 examples/server/oauth-keycloak/McpElements.php create mode 100644 examples/server/oauth-keycloak/README.md create mode 100644 examples/server/oauth-keycloak/docker-compose.yml create mode 100644 examples/server/oauth-keycloak/keycloak/mcp-realm.json create mode 100644 examples/server/oauth-keycloak/nginx/default.conf create mode 100644 examples/server/oauth-keycloak/server.php create mode 100644 examples/server/oauth-microsoft/.env.dist create mode 100644 examples/server/oauth-microsoft/Dockerfile create mode 100644 examples/server/oauth-microsoft/McpElements.php create mode 100644 examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php create mode 100644 examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php create mode 100644 examples/server/oauth-microsoft/README.md create mode 100644 examples/server/oauth-microsoft/docker-compose.yml create mode 100644 examples/server/oauth-microsoft/env.example create mode 100644 examples/server/oauth-microsoft/nginx/default.conf create mode 100644 examples/server/oauth-microsoft/server.php create mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php create mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php create mode 100644 src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php create mode 100644 src/Server/Transport/Http/OAuth/AuthorizationResult.php create mode 100644 src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php create mode 100644 src/Server/Transport/Http/OAuth/JwksProvider.php create mode 100644 src/Server/Transport/Http/OAuth/JwksProviderInterface.php create mode 100644 src/Server/Transport/Http/OAuth/JwtTokenValidator.php create mode 100644 src/Server/Transport/Http/OAuth/OidcDiscovery.php create mode 100644 src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php create mode 100644 src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php create mode 100644 src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php create mode 100644 src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php diff --git a/composer.json b/composer.json index e41b98b..992f8d0 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", @@ -35,6 +36,8 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -46,6 +49,7 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "autoload": { @@ -68,6 +72,8 @@ "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", + "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" } diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 0000000..5b77b37 --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,430 @@ +# Authorization + +The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the +[MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Components](#components) +- [JWT Token Validation](#jwt-token-validation) +- [Protected Resource Metadata](#protected-resource-metadata) +- [Custom Token Validators](#custom-token-validators) +- [Scope-Based Access Control](#scope-based-access-control) +- [Examples](#examples) + +## Overview + +Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides: + +- **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication +- **ProtectedResourceMetadataMiddleware** - Serves RFC 9728 metadata at well-known endpoints +- **OAuthProxyMiddleware** - Proxies OAuth flows to upstream authorization servers +- **OAuthRequestMetaMiddleware** - Bridges HTTP OAuth attributes to JSON-RPC request meta +- **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers +- **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints + +``` +┌─────────────┐ ┌────────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ AuthorizationMiddleware │────▶│ MCP Handlers │ +└─────────────┘ └────────────────────┘ └─────────────────┘ + │ │ + │ │ Validate JWT + ▼ ▼ +┌─────────────┐ ┌─────────────────┐ +│ Auth Server │◀────│ JwtTokenValidator│ +│ (Keycloak, │ │ + JWKS │ +│ Entra ID) │ └─────────────────┘ +└─────────────┘ +``` + +## Quick Start + +```php +use Mcp\Server; +use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; +use Mcp\Server\Transport\Http\OAuth\JwksProvider; +use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\OidcDiscovery; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\StreamableHttpTransport; + +// 1. Set up OIDC discovery and JWKS provider +$discovery = new OidcDiscovery(); +$jwksProvider = new JwksProvider($discovery); + +// 2. Create JWT validator for your OAuth provider +$validator = new JwtTokenValidator( + issuer: 'https://auth.example.com/realms/mcp', + audience: 'mcp-server', + jwksProvider: $jwksProvider, +); + +// 3. Create Protected Resource Metadata (RFC 9728) +$metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com/realms/mcp'], + scopesSupported: ['mcp:read', 'mcp:write'], +); + +// 4. Create middleware stack +$authMiddleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $metadata, +); + +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, +); + +// 5. Create transport with middleware +$transport = new StreamableHttpTransport( + $request, + middlewares: [$metadataMiddleware, $authMiddleware], +); + +// 6. Run server +$server = Server::builder() + ->setServerInfo('Protected MCP Server', '1.0.0') + ->setDiscovery(__DIR__) + ->build(); + +$response = $server->run($transport); +``` + +## Components + +### AuthorizationMiddleware + +The main middleware that enforces authentication: + +```php +$middleware = new AuthorizationMiddleware( + validator: $validator, // AuthorizationTokenValidatorInterface + resourceMetadata: $metadata, // ProtectedResourceMetadata instance + responseFactory: null, // PSR-17 (auto-discovered) +); +``` + +**Behavior:** + +| Request | Response | +|---------|----------| +| Missing Authorization header | 401 with `WWW-Authenticate: Bearer resource_metadata="..."` | +| Invalid/expired token | 401 with error details | +| Valid token | Passes to next handler with OAuth attributes on request | + +### ProtectedResourceMetadataMiddleware + +Serves Protected Resource Metadata at configured well-known paths: + +```php +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, // ProtectedResourceMetadata instance + responseFactory: null, // PSR-17 (auto-discovered) + streamFactory: null, // PSR-17 (auto-discovered) +); +``` + +### JwtTokenValidator + +Validates JWT access tokens: + +```php +$validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', // Expected issuer claim + audience: 'mcp-server', // Expected audience (string or array) + jwksProvider: $jwksProvider, // JwksProviderInterface + jwksUri: null, // Explicit JWKS URI (auto-discovered) + algorithms: ['RS256', 'RS384'], // Allowed algorithms + scopeClaim: 'scope', // Claim name for scopes +); +``` + +**Request Attributes:** + +After successful validation, these attributes are added to the request: + +| Attribute | Description | +|-----------|-------------| +| `oauth.claims` | All JWT claims as array | +| `oauth.scopes` | Extracted scopes as array | +| `oauth.subject` | The `sub` claim | +| `oauth.client_id` | The `client_id` claim (if present) | +| `oauth.authorized_party` | The `azp` claim (if present) | + +### ProtectedResourceMetadata + +Represents RFC 9728 Protected Resource Metadata: + +```php +$metadata = new ProtectedResourceMetadata( + authorizationServers: [ // Required: authorization server URLs + 'https://auth.example.com', + ], + scopesSupported: [ // Optional: supported scopes + 'mcp:read', + 'mcp:write', + ], + resource: 'https://mcp.example.com', // Optional: resource identifier + resourceName: 'My MCP Server', // Optional: human-readable name + metadataPaths: [ // Paths to serve metadata (default: /.well-known/oauth-protected-resource) + '/.well-known/oauth-protected-resource', + ], + extra: [ // Optional: additional fields + 'custom_field' => 'value', + ], +); +``` + +### OidcDiscovery + +Discovers OAuth/OIDC server metadata: + +```php +$discovery = new OidcDiscovery( + httpClient: null, // PSR-18 (auto-discovered) + requestFactory: null, // PSR-17 (auto-discovered) + cache: $cache, // PSR-16 cache (optional) + cacheTtl: 3600, // Cache TTL +); + +// Discover metadata +$metadata = $discovery->discover('https://auth.example.com/realms/mcp'); + +// Get specific endpoints +$jwksUri = $discovery->getJwksUri($issuer); +$tokenEndpoint = $discovery->getTokenEndpoint($issuer); +$authEndpoint = $discovery->getAuthorizationEndpoint($issuer); +``` + +### JwksProvider + +Fetches and caches JWKS key sets: + +```php +$jwksProvider = new JwksProvider( + discovery: $discovery, // OidcDiscoveryInterface + httpClient: null, // PSR-18 (auto-discovered) + requestFactory: null, // PSR-17 (auto-discovered) + cache: $cache, // PSR-16 cache (optional) + cacheTtl: 3600, // JWKS cache TTL +); +``` + +## JWT Token Validation + +### Keycloak + +```php +$validator = new JwtTokenValidator( + issuer: 'https://keycloak.example.com/realms/mcp', + audience: 'mcp-server', + jwksProvider: $jwksProvider, +); +``` + +### Microsoft Entra ID (Azure AD) + +```php +$tenantId = 'your-tenant-id'; +$clientId = 'your-client-id'; + +$validator = new JwtTokenValidator( + issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0", + audience: $clientId, + jwksProvider: $jwksProvider, +); +``` + +### Auth0 + +```php +$validator = new JwtTokenValidator( + issuer: 'https://your-tenant.auth0.com/', + audience: 'https://api.example.com', + jwksProvider: $jwksProvider, +); +``` + +### Okta + +```php +$validator = new JwtTokenValidator( + issuer: 'https://your-org.okta.com/oauth2/default', + audience: 'api://default', + jwksProvider: $jwksProvider, +); +``` + +## Protected Resource Metadata + +The `ProtectedResourceMetadataMiddleware` serves Protected Resource Metadata at configured paths, enabling clients to discover the authorization server: + +```json +{ + "authorization_servers": ["https://auth.example.com/realms/mcp"], + "scopes_supported": ["mcp:read", "mcp:write"], + "resource": "https://mcp.example.com/mcp" +} +``` + +Clients request this from `/.well-known/oauth-protected-resource` before authenticating. + +### WWW-Authenticate Header + +On 401 responses, the middleware includes: + +``` +WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", + scope="mcp:read mcp:write" +``` + +## Custom Token Validators + +Implement `AuthorizationTokenValidatorInterface` for custom validation: + +```php +use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface; +use Mcp\Server\Transport\Http\OAuth\AuthorizationResult; + +final class ApiKeyValidator implements AuthorizationTokenValidatorInterface +{ + public function __construct( + private array $validKeys, + ) {} + + public function validate(string $accessToken): AuthorizationResult + { + if (!isset($this->validKeys[$accessToken])) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Unknown API key' + ); + } + + $keyInfo = $this->validKeys[$accessToken]; + + return AuthorizationResult::allow([ + 'api_key.name' => $keyInfo['name'], + 'api_key.scopes' => $keyInfo['scopes'], + ]); + } +} + +// Usage +$validator = new ApiKeyValidator([ + 'sk_live_abc123' => ['name' => 'Production', 'scopes' => ['read', 'write']], +]); +``` + +### AuthorizationResult + +Factory methods for different outcomes: + +```php +// Allow access with attributes +AuthorizationResult::allow(['user_id' => '123']); + +// Deny - missing/invalid token (401) +AuthorizationResult::unauthorized('invalid_token', 'Token expired'); + +// Deny - valid token but insufficient permissions (403) +AuthorizationResult::forbidden('insufficient_scope', 'Requires admin scope', ['admin']); + +// Deny - malformed request (400) +AuthorizationResult::badRequest('invalid_request', 'Malformed header'); +``` + +## Scope-Based Access Control + +### Checking Scopes in Handlers + +```php +#[McpTool(name: 'admin_action')] +public function adminAction(RequestContext $context): array +{ + $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + + if (!in_array('mcp:admin', $scopes, true)) { + throw new \RuntimeException('Admin scope required'); + } + + // Perform admin action + return ['status' => 'success']; +} +``` + +### Using JwtTokenValidator::requireScopes + +```php +// In a custom middleware or handler +$result = $validator->validate($token); + +if ($result->isAllowed()) { + // Check for specific scopes + $result = $validator->requireScopes($result, ['mcp:write']); +} + +if (!$result->isAllowed()) { + // Handle insufficient scope (returns 403) +} +``` + +## Examples + +Complete working examples are available in the `examples/server/` directory: + +### Keycloak Example + +```bash +cd examples/server/oauth-keycloak +docker-compose up -d + +# Test credentials: demo / demo123 +``` + +See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) + +### Microsoft Entra ID Example + +```bash +cd examples/server/oauth-microsoft +cp env.example .env +# Edit .env with your Azure credentials +docker-compose up -d +``` + +See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) + +## Security Considerations + +1. **Always use HTTPS** in production for token transmission +2. **Validate audience claims** to prevent token confusion attacks +3. **Use short-lived tokens** and implement token refresh +4. **Cache JWKS** to reduce latency but allow for key rotation +5. **Never log tokens** - log only non-sensitive claims like subject +6. **Validate scopes** before performing sensitive operations + +## Troubleshooting + +### "Invalid issuer" error + +The `iss` claim in the token must exactly match the configured issuer URL, including trailing slashes. + +### "Invalid audience" error + +Check the `aud` claim matches your configured audience. Some providers use the client ID, others use a custom URI. + +### JWKS fetch timeout + +- Ensure network connectivity to the authorization server +- Consider using a cache to reduce dependency on the auth server +- Check firewall rules allow outbound HTTPS + +### Token expired + +- Check clock synchronization between servers +- Tokens typically have a 5-minute clock skew tolerance +- Ensure clients refresh tokens before expiration diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile new file mode 100644 index 0000000..f877c73 --- /dev/null +++ b/examples/server/oauth-keycloak/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.1-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php new file mode 100644 index 0000000..6825f19 --- /dev/null +++ b/examples/server/oauth-keycloak/McpElements.php @@ -0,0 +1,134 @@ + + */ + #[McpTool( + name: 'get_auth_status', + description: 'Confirm authentication status - only accessible with valid OAuth token' + )] + public function getAuthStatus(RequestContext $context): array + { + $meta = $context->getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + + return [ + 'authenticated' => true, + 'provider' => 'Keycloak', + 'message' => 'You have successfully authenticated with OAuth!', + 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'username' => $claims['preferred_username'] ?? null, + 'name' => $claims['name'] ?? null, + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], + 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.', + ]; + } + + /** + * Simulates calling a protected external API. + * + * @return array + */ + #[McpTool( + name: 'call_protected_api', + description: 'Simulate calling a protected external API endpoint' + )] + public function callProtectedApi( + string $endpoint, + string $method = 'GET', + ): array { + // In a real implementation, you would: + // 1. Use token exchange to get a token for the downstream API + // 2. Or use client credentials with the user's context + // 3. Make the actual HTTP call to the protected API + + return [ + 'status' => 'success', + 'message' => \sprintf('Simulated %s request to %s', $method, $endpoint), + 'simulated_response' => [ + 'data' => 'This is simulated data from the protected API', + 'timestamp' => date('c'), + ], + ]; + } + + /** + * Returns the current server time and status. + * + * @return array + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status (protected resource)', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'php_version' => \PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'protected' => true, + ]; + } + + /** + * A greeting prompt. + */ + #[McpPrompt( + name: 'greeting', + description: 'Generate a greeting message' + )] + public function greeting(string $style = 'formal'): string + { + return match ($style) { + 'casual' => 'Hey there! Welcome to the protected MCP server!', + 'formal' => 'Good day. Welcome to the OAuth-protected MCP server.', + 'friendly' => 'Hello! Great to have you here!', + default => 'Welcome to the MCP server!', + }; + } +} diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md new file mode 100644 index 0000000..f9f46de --- /dev/null +++ b/examples/server/oauth-keycloak/README.md @@ -0,0 +1,135 @@ +# OAuth Keycloak Example + +This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with automatic JWKS discovery +- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource` +- MCP tools protected by OAuth authentication +- Pre-configured Keycloak realm with test user + +## Quick Start + +1. **Start the services:** + +```bash +docker compose up -d +``` + +2. **Wait for Keycloak to be ready** (may take 30-60 seconds): + +```bash +docker compose logs -f keycloak +# Wait until you see "Running the server in development mode" +``` + +3. **Get an access token:** + +```bash +# Using Resource Owner Password Credentials (for testing only) +TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=mcp-client" \ + -d "username=demo" \ + -d "password=demo123" \ + -d "grant_type=password" \ + -d "scope=openid mcp" | jq -r '.access_token') + +echo $TOKEN +``` + +4. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +5. **Use with MCP Inspector:** + +MCP Inspector can call this server if you provide a valid Bearer token manually (Authorization header). It does not run the OAuth login flow automatically. + +## Keycloak Configuration + +The realm is pre-configured with: + +| Item | Value | +|------|-------| +| Realm | `mcp` | +| Client (public) | `mcp-client` | +| Client (resource) | `mcp-server` | +| Test User | `demo` / `demo123` | +| Scopes | `mcp:read`, `mcp:write` | + +### Keycloak Admin Console + +Access at http://localhost:8180/admin with: +- Username: `admin` +- Password: `admin` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Keycloak │◀───────────────────────────│ JWKS Fetch │ +│ (port 8180) │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container with dependencies +- `nginx/default.conf` - Nginx configuration for MCP endpoint +- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools and resources + +## Configuration + +This example uses hard-coded values in `server.php` for consistency with other examples: +- Keycloak external URL: `http://localhost:8180` +- Keycloak internal URL: `http://keycloak:8180` +- Realm: `mcp` +- Audience: `mcp-server` + +## Troubleshooting + +### Token validation fails + +1. Ensure Keycloak is fully started (check health endpoint) +2. Verify the token hasn't expired (default: 5 minutes) +3. Check that the audience claim matches `mcp-server` + +### Connection refused + +1. Wait for Keycloak health check to pass +2. Check Docker network connectivity: `docker compose logs` + +### JWKS fetch fails + +The MCP server needs to reach Keycloak at `http://keycloak:8180` (Docker network). +For local development outside Docker, use `http://localhost:8180`. + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml new file mode 100644 index 0000000..2156244 --- /dev/null +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -0,0 +1,69 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: mcp-keycloak + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro + command: + - start-dev + - --import-realm + - --http-port=8180 + ports: + - "8180:8180" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + networks: + - mcp-network + + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php + volumes: + - ../../../:/app + working_dir: /app + environment: + KEYCLOAK_EXTERNAL_URL: http://localhost:8180 + KEYCLOAK_INTERNAL_URL: http://keycloak:8180 + KEYCLOAK_REALM: mcp + MCP_AUDIENCE: mcp-server + depends_on: + keycloak: + condition: service_healthy + command: > + sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions; + chmod -R 0777 /app/examples/server/oauth-keycloak/sessions; + touch /app/examples/server/oauth-keycloak/dev.log; + chmod 0666 /app/examples/server/oauth-keycloak/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-keycloak/keycloak/mcp-realm.json b/examples/server/oauth-keycloak/keycloak/mcp-realm.json new file mode 100644 index 0000000..55d2875 --- /dev/null +++ b/examples/server/oauth-keycloak/keycloak/mcp-realm.json @@ -0,0 +1,128 @@ +{ + "realm": "mcp", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "mcp-client", + "name": "MCP Client Application", + "description": "Public client for MCP client applications", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "webOrigins": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "mcp" + ], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "mcp-server", + "name": "MCP Server Resource", + "description": "Resource server representing the MCP server", + "enabled": true, + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false + } + ], + "clientScopes": [ + { + "name": "mcp", + "description": "MCP access scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP server resources" + }, + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "mcp-scopes", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "scope", + "claim.value": "mcp:read mcp:write", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "demo123", + "temporary": false + } + ], + "realmRoles": ["default-roles-mcp"] + } + ], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp", + "description": "Default roles for MCP realm", + "composite": false + } + ] + } +} diff --git a/examples/server/oauth-keycloak/nginx/default.conf b/examples/server/oauth-keycloak/nginx/default.conf new file mode 100644 index 0000000..f7a265a --- /dev/null +++ b/examples/server/oauth-keycloak/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-keycloak; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php new file mode 100644 index 0000000..e9f5acb --- /dev/null +++ b/examples/server/oauth-keycloak/server.php @@ -0,0 +1,68 @@ +setServerInfo('OAuth Keycloak Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +$transport = new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], +); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/.env.dist b/examples/server/oauth-microsoft/.env.dist new file mode 100644 index 0000000..de4376e --- /dev/null +++ b/examples/server/oauth-microsoft/.env.dist @@ -0,0 +1,3 @@ +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= \ No newline at end of file diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile new file mode 100644 index 0000000..f877c73 --- /dev/null +++ b/examples/server/oauth-microsoft/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.1-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php new file mode 100644 index 0000000..06eeaa0 --- /dev/null +++ b/examples/server/oauth-microsoft/McpElements.php @@ -0,0 +1,154 @@ + + */ + #[McpTool( + name: 'get_auth_status', + description: 'Confirm Microsoft Entra ID authentication status' + )] + public function getAuthStatus(RequestContext $context): array + { + $meta = $context->getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + + return [ + 'authenticated' => true, + 'provider' => 'Microsoft Entra ID', + 'message' => 'You have successfully authenticated with Microsoft!', + 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null), + 'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null), + 'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null), + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'tenant_id' => $claims['tid'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], + ]; + } + + /** + * Simulates calling Microsoft Graph API. + * + * @return array + */ + #[McpTool( + name: 'call_graph_api', + description: 'Simulate calling Microsoft Graph API' + )] + public function callGraphApi( + string $endpoint = '/me', + ): array { + // In a real implementation, you would: + // 1. Use the On-Behalf-Of flow to exchange tokens + // 2. Call Microsoft Graph with the new token + + return [ + 'status' => 'simulated', + 'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}", + 'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls', + 'simulated_response' => [ + 'displayName' => 'Demo User', + 'mail' => 'demo@example.com', + ], + ]; + } + + /** + * Lists simulated emails. + * + * @return array + */ + #[McpTool( + name: 'list_emails', + description: 'List recent emails (simulated)' + )] + public function listEmails(int $count = 5): array + { + return [ + 'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.', + 'emails' => array_map(static fn ($i) => [ + 'id' => 'msg_'.uniqid(), + 'subject' => "Sample Email #{$i}", + 'from' => "sender{$i}@example.com", + 'receivedDateTime' => date('c', strtotime("-{$i} hours")), + ], range(1, $count)), + ]; + } + + /** + * Returns the current server status. + * + * @return array + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status with Microsoft auth info', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'auth_provider' => 'Microsoft Entra ID', + 'php_version' => \PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + ]; + } + + /** + * A Microsoft Teams-style message prompt. + */ + #[McpPrompt( + name: 'teams_message', + description: 'Generate a Microsoft Teams-style message' + )] + public function teamsMessage(string $messageType = 'announcement'): string + { + return match ($messageType) { + 'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.", + 'question' => "❓ **Question**\n\nType your question here.", + 'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2", + default => "💬 **Message**\n\nYour message content here.", + }; + } +} diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php new file mode 100644 index 0000000..f389430 --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -0,0 +1,187 @@ + + */ +class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens + * @param string $scopeClaim Claim name for scopes in Graph tokens + * @param list $trustedGraphIssuers Allowed Graph issuer host markers + * @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim + */ + public function __construct( + private readonly JwtTokenValidator $jwtTokenValidator, + private readonly string $scopeClaim = 'scp', + private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'], + private readonly int $notBeforeLeewaySeconds = 60, + ) { + } + + public function validate(string $accessToken): AuthorizationResult + { + $parts = explode('.', $accessToken); + if (!$this->isGraphToken($parts)) { + return $this->jwtTokenValidator->validate($accessToken); + } + + return $this->validateGraphToken($parts); + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + return $this->jwtTokenValidator->requireScopes($result, $requiredScopes); + } + + /** + * @param array $parts + */ + private function isGraphToken(array $parts): bool + { + if ([] === $parts) { + return false; + } + + $header = $this->decodePartToArray($parts[0]); + if (null === $header) { + return false; + } + + return isset($header['nonce']); + } + + /** + * @param array $parts + */ + private function validateGraphToken(array $parts): AuthorizationResult + { + // Intentionally claim-based only for example Graph token compatibility. + if (\count($parts) < 2) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); + } + + $payload = $this->decodePartToArray($parts[1]); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); + } + + if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } + + if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } + + $issuer = $payload['iss'] ?? ''; + if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); + } + + $scopes = $this->extractScopes($payload); + + $attributes = [ + 'oauth.claims' => $payload, + 'oauth.scopes' => $scopes, + 'oauth.graph_token' => true, + ]; + + if (isset($payload['sub'])) { + $attributes['oauth.subject'] = $payload['sub']; + } + + if (isset($payload['oid'])) { + $attributes['oauth.object_id'] = $payload['oid']; + } + + if (isset($payload['name'])) { + $attributes['oauth.name'] = $payload['name']; + } + + return AuthorizationResult::allow($attributes); + } + + private function isTrustedGraphIssuer(string $issuer): bool + { + $host = parse_url($issuer, \PHP_URL_HOST); + + return \in_array($host, $this->trustedGraphIssuers, true); + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } + + /** + * @return array|null + */ + private function decodePartToArray(string $part): ?array + { + $decoded = base64_decode(strtr($part, '-_', '+/')); + if (false === $decoded) { + return null; + } + + $data = json_decode($decoded, true); + + return \is_array($data) ? $data : null; + } +} diff --git a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php new file mode 100644 index 0000000..fb16cda --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php @@ -0,0 +1,38 @@ + + */ +final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + return \is_array($metadata) + && isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + && \is_string($metadata['authorization_endpoint']) + && '' !== trim($metadata['authorization_endpoint']) + && \is_string($metadata['token_endpoint']) + && '' !== trim($metadata['token_endpoint']) + && \is_string($metadata['jwks_uri']) + && '' !== trim($metadata['jwks_uri']); + } +} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md new file mode 100644 index 0000000..7423949 --- /dev/null +++ b/examples/server/oauth-microsoft/README.md @@ -0,0 +1,222 @@ +# OAuth Microsoft Entra ID Example + +This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with Microsoft Entra ID +- Microsoft-specific validator/discovery overrides for Entra quirks +- Protected Resource Metadata (RFC 9728) +- MCP tools that access Microsoft claims +- Optional Microsoft Graph API integration + +## Prerequisites + +1. **Azure Subscription** with access to Entra ID +2. **App Registration** in Azure Portal + +## Azure Setup + +### 1. Create App Registration + +1. Go to [Azure Portal](https://portal.azure.com) > **Entra ID** > **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: `MCP Server` + - **Supported account types**: Choose based on your needs + - **Redirect URI**: Leave empty for now (this is a resource server) +4. Click **Register** + +### 2. Configure the App + +After registration: + +1. **Copy values for `.env`**: + - **Application (client) ID** → `AZURE_CLIENT_ID` + - **Directory (tenant) ID** → `AZURE_TENANT_ID` + +2. **Expose an API** (optional, for custom scopes): + - Go to **Expose an API** + - Set **Application ID URI** (e.g., `api://your-client-id`) + - Add scopes like `mcp.read`, `mcp.write` + +3. **Create client secret** (for Graph API calls): + - Go to **Certificates & secrets** + - Click **New client secret** + - Copy the secret value → `AZURE_CLIENT_SECRET` + +4. **API Permissions** (for Graph API): + - Go to **API permissions** + - Add **Microsoft Graph** > **Delegated permissions**: + - `User.Read` (for profile) + - `Mail.Read` (for emails, optional) + - Grant admin consent if required + +### 3. Create a Client App (for testing) + +Create a separate app registration for the client: + +1. **New registration**: + - **Name**: `MCP Client` + - **Redirect URI**: `http://localhost` (Public client/native) + +2. **Authentication**: + - Enable **Allow public client flows** for PKCE + +3. **API permissions**: + - Add permission to your MCP Server app's exposed API + +## Quick Start + +1. **Copy environment file:** + +```bash +cp env.example .env +``` + +2. **Edit `.env` with your Azure values:** + +```bash +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API +``` + +3. **Start the services:** + +```bash +docker compose up -d +``` + +4. **Get an access token:** + +Using Azure CLI: +```bash +# Login +az login + +# Get token for your app +TOKEN=$(az account get-access-token \ + --resource api://your-client-id \ + --query accessToken -o tsv) +``` + +Or using MSAL / OAuth flow in your client application. + +5. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Microsoft │◀───────────────────────────│ JWKS Fetch │ +│ Entra ID │ │ │ +└─────────────────┘ └─────────────────┘ + │ + │ (Optional) Graph API + ▼ +┌─────────────────┐ +│ Microsoft │ +│ Graph API │ +└─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container +- `nginx/default.conf` - Nginx configuration +- `env.example` - Environment variables template +- `server.php` - MCP server with OAuth middleware +- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens +- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy +- `McpElements.php` - MCP tools including Graph API integration + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID | +| `AZURE_CLIENT_ID` | Yes | Application (client) ID | +| `AZURE_CLIENT_SECRET` | No | Client secret for Graph API calls | + +## Microsoft Token Structure + +Microsoft Entra ID tokens include these common claims: + +| Claim | Description | +|-------|-------------| +| `oid` | Object ID (unique user identifier in tenant) | +| `tid` | Tenant ID | +| `sub` | Subject (unique user identifier) | +| `name` | Display name | +| `preferred_username` | Usually the UPN | +| `email` | Email address (if available) | +| `upn` | User Principal Name | + +## Troubleshooting + +### "Invalid issuer" error + +Microsoft uses different issuer URLs depending on the token flow: +- v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0` +- v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/` + +This example **automatically accepts both formats** by configuring multiple issuers in the `MicrosoftJwtTokenValidator`. +Check your token's `iss` claim to verify which format is being used. + +### "Invalid audience" error + +The `aud` claim must match `AZURE_CLIENT_ID`. For v2.0 tokens with custom scopes, +the audience might be `api://your-client-id`. + +### JWKS fetch fails + +Microsoft's JWKS endpoint is public. Ensure your container can reach: +`https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` + +### `code_challenge_methods_supported` missing in discovery metadata + +This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this +field can be missing or malformed and will not fail discovery. + +### Graph API errors + +1. Ensure `AZURE_CLIENT_SECRET` is set +2. Verify API permissions have admin consent +3. Check that the user exists in your tenant + +## Security Notes + +1. **Never commit `.env` files** - they contain secrets +2. **Use managed identities** in Azure deployments instead of client secrets +3. **Implement proper token refresh** in production clients +4. **Validate scopes** for sensitive operations +5. **Important:** `MicrosoftJwtTokenValidator` in this example accepts `nonce` Graph-style tokens via claim checks only (`iss`/`exp`/`nbf`) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production. + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml new file mode 100644 index 0000000..4b02d65 --- /dev/null +++ b/examples/server/oauth-microsoft/docker-compose.yml @@ -0,0 +1,43 @@ +services: + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php-microsoft + volumes: + - ../../../:/app + working_dir: /app + env_file: + - .env + environment: + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + command: > + sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions; + chmod -R 0777 /app/examples/server/oauth-microsoft/sessions; + touch /app/examples/server/oauth-microsoft/dev.log; + chmod 0666 /app/examples/server/oauth-microsoft/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx-microsoft + ports: + - "${MCP_HTTP_PORT:-8000}:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-microsoft/env.example b/examples/server/oauth-microsoft/env.example new file mode 100644 index 0000000..7ce041f --- /dev/null +++ b/examples/server/oauth-microsoft/env.example @@ -0,0 +1,18 @@ +# Microsoft Entra ID (Azure AD) Configuration +# Copy this file to .env and fill in your values + +# Your Azure AD tenant ID +# Find at: Azure Portal > Entra ID > Overview > Tenant ID +AZURE_TENANT_ID=your-tenant-id-here + +# Application (client) ID for the MCP server app registration +# This is the audience that tokens must be issued for +AZURE_CLIENT_ID=your-client-id-here + +# Client secret for calling Microsoft Graph API (optional) +# Only needed if your MCP tools call Graph API on behalf of users +AZURE_CLIENT_SECRET=your-client-secret-here + +# Optional: Specific API permissions/scopes your MCP server accepts +# Comma-separated list of custom scopes defined in your app registration +# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write diff --git a/examples/server/oauth-microsoft/nginx/default.conf b/examples/server/oauth-microsoft/nginx/default.conf new file mode 100644 index 0000000..ad99015 --- /dev/null +++ b/examples/server/oauth-microsoft/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-microsoft; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php new file mode 100644 index 0000000..de16ab5 --- /dev/null +++ b/examples/server/oauth-microsoft/server.php @@ -0,0 +1,91 @@ +setServerInfo('OAuth Microsoft Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +$transport = new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], +); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php new file mode 100644 index 0000000..b8ae511 --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php @@ -0,0 +1,300 @@ + + */ +class MicrosoftJwtTokenValidatorTest extends TestCase +{ + #[TestDox('non-Graph Microsoft token is validated via JWKS')] + public function testNonGraphTokenUsesStandardJwtValidation(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', + audience: 'mcp-api', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + jwksUri: $jwksUri, + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator); + + $token = JWT::encode( + [ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-123', $result->getAttributes()['oauth.subject']); + $this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes()); + } + + #[TestDox('Graph token with nonce header is validated by claims only')] + public function testGraphTokenWithNonceHeaderIsAllowed(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertTrue($result->getAttributes()['oauth.graph_token']); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-graph', $result->getAttributes()['oauth.subject']); + } + + #[TestDox('Graph token with invalid payload is unauthorized')] + public function testGraphTokenInvalidPayloadIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + $token = $header.'..'; + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token payload.', $result->getErrorDescription()); + } + + #[TestDox('Graph token with invalid issuer is unauthorized')] + public function testGraphTokenInvalidIssuerIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://evil.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); + } + + #[TestDox('scope checks are delegated to base JwtTokenValidator')] + public function testRequireScopesDelegatesToJwtTokenValidator(): void + { + $factory = new Psr17Factory(); + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([], 0), + requestFactory: $factory, + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = AuthorizationResult::allow([ + 'oauth.scopes' => ['files.read'], + ]); + $scoped = $validator->requireScopes($result, ['files.read', 'files.write']); + + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + } + + /** + * @param array $claims + */ + private function buildGraphToken(array $claims): string + { + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header.'.'.$payload.'.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + $this->fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + $this->fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + $this->fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= \count($responses); + + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)) + ->willReturnCallback(static function () use (&$responses): ResponseInterface { + if ([] === $responses) { + throw new \RuntimeException('No more mocked responses available.'); + } + + return array_shift($responses); + }); + + return $client; + } +} diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php new file mode 100644 index 0000000..218e0fb --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php @@ -0,0 +1,64 @@ + + */ +class MicrosoftOidcMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is accepted')] + public function testMissingCodeChallengeMethodsIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('malformed code challenge methods are ignored for validity')] + public function testMalformedCodeChallengeMethodsSupportedIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => 'S256', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('required endpoint fields still enforce validity')] + public function testIsValidRequiresCoreEndpoints(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + // token_endpoint missing + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5adf9e..54c2a8e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ tests/Unit + + examples/server/oauth-microsoft/tests + tests/Inspector diff --git a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php new file mode 100644 index 0000000..45c997e --- /dev/null +++ b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php @@ -0,0 +1,182 @@ + + */ +final class AuthorizationMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + + /** + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata object used for challenge hints + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + */ + public function __construct( + private AuthorizationTokenValidatorInterface $validator, + private ProtectedResourceMetadata $resourceMetadata, + ?ResponseFactoryInterface $responseFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $authorization = $request->getHeaderLine('Authorization'); + if ('' === $authorization) { + return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); + } + + $accessToken = $this->parseBearerToken($authorization); + if (null === $accessToken) { + return $this->buildErrorResponse( + $request, + AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'), + ); + } + + $result = $this->validator->validate($accessToken); + if (!$result->isAllowed()) { + return $this->buildErrorResponse($request, $result); + } + + return $handler->handle($this->applyAttributes($request, $result->getAttributes())); + } + + private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface + { + $response = $this->responseFactory->createResponse($result->getStatusCode()); + $header = $this->buildAuthenticateHeader($request, $result); + + $response = $response->withHeader('WWW-Authenticate', $header); + + return $response; + } + + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string + { + $parts = []; + + $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($this->resolveResourceMetadataUrl($request)).'"'; + + $scopes = $this->resolveScopes($result); + if (null !== $scopes) { + $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; + } + + if (null !== $result->getError()) { + $parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"'; + } + + if (null !== $result->getErrorDescription()) { + $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; + } + + return 'Bearer '.implode(', ', $parts); + } + + /** + * @return list|null + */ + private function resolveScopes(AuthorizationResult $result): ?array + { + $scopes = $this->normalizeScopes($result->getScopes()); + if (null !== $scopes) { + return $scopes; + } + + return $this->normalizeScopes($this->resourceMetadata->getScopesSupported()); + } + + /** + * @param list|null $scopes + * + * @return list|null + */ + private function normalizeScopes(?array $scopes): ?array + { + if (null === $scopes) { + return null; + } + + $normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool { + return '' !== $scope; + })); + + return [] === $normalized ? null : $normalized; + } + + private function resolveResourceMetadataUrl(ServerRequestInterface $request): string + { + $metadataPath = $this->resourceMetadata->getPrimaryMetadataPath(); + + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $authority = $uri->getAuthority(); + + if ('' === $scheme || '' === $authority) { + throw new RuntimeException('Cannot resolve resource metadata URL: request URI must have scheme and authority'); + } + + return $scheme.'://'.$authority.$metadataPath; + } + + /** + * @param array $attributes + */ + private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface + { + foreach ($attributes as $name => $value) { + $request = $request->withAttribute($name, $value); + } + + return $request; + } + + private function parseBearerToken(string $authorization): ?string + { + if (!preg_match('/^Bearer\\s+(.+)$/', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + + return '' === $token ? null : $token; + } + + private function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } +} diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php new file mode 100644 index 0000000..846182b --- /dev/null +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -0,0 +1,273 @@ + + */ +final class OAuthProxyMiddleware implements MiddlewareInterface +{ + private const CLIENT_SECRET_BASIC = 'client_secret_basic'; + private const CLIENT_SECRET_POST = 'client_secret_post'; + + private ?ClientInterface $httpClient; + private ?RequestFactoryInterface $requestFactory; + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients + * @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata + */ + public function __construct( + private readonly string $upstreamIssuer, + private readonly string $localBaseUrl, + private readonly OidcDiscoveryInterface $discovery, + private readonly ?string $clientSecret = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { + return $this->createAuthServerMetadataResponse(); + } + + if ('GET' === $request->getMethod() && '/authorize' === $path) { + return $this->handleAuthorize($request); + } + + if ('POST' === $request->getMethod() && '/token' === $path) { + return $this->handleToken($request); + } + + return $handler->handle($request); + } + + private function handleAuthorize(ServerRequestInterface $request): ResponseInterface + { + try { + $authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer); + } catch (RuntimeException) { + return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); + } + + $rawQueryString = $request->getUri()->getQuery(); + $upstreamUrl = $authorizationEndpoint; + if ('' !== $rawQueryString) { + $upstreamUrl .= '?'.$rawQueryString; + } + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $upstreamUrl) + ->withHeader('Cache-Control', 'no-store'); + } + + private function handleToken(ServerRequestInterface $request): ResponseInterface + { + try { + $tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer); + } catch (RuntimeException) { + return $this->createErrorResponse(500, 'Upstream token endpoint not found'); + } + + $body = $request->getBody()->__toString(); + parse_str($body, $params); + + $upstreamAuthorization = trim($request->getHeaderLine('Authorization')); + if ('' === $upstreamAuthorization) { + $upstreamAuthorization = null; + } + + if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) { + $authMethod = $this->resolveTokenEndpointAuthMethod(); + + if (self::CLIENT_SECRET_BASIC === $authMethod) { + $clientId = $params['client_id'] ?? null; + + if (\is_string($clientId) && '' !== trim($clientId)) { + $upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret); + } else { + $params['client_secret'] = $this->clientSecret; + } + } else { + $params['client_secret'] = $this->clientSecret; + } + } + + $body = http_build_query($params); + + $upstreamRequest = $this->getRequestFactory() + ->createRequest('POST', $tokenEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->streamFactory->createStream($body)); + + if (null !== $upstreamAuthorization) { + $upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization); + } + + try { + $upstreamResponse = $this->getHttpClient()->sendRequest($upstreamRequest); + $responseBody = $upstreamResponse->getBody()->__toString(); + + return $this->responseFactory + ->createResponse($upstreamResponse->getStatusCode()) + ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) + ->withHeader('Cache-Control', 'no-store') + ->withBody($this->streamFactory->createStream($responseBody)); + } catch (ClientExceptionInterface $e) { + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); + } + } + + private function createAuthServerMetadataResponse(): ResponseInterface + { + try { + $upstreamMetadata = $this->discovery->discover($this->upstreamIssuer); + } catch (RuntimeException) { + return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); + } + + $localBaseUrl = rtrim($this->localBaseUrl, '/'); + $localMetadata = [ + 'issuer' => $localBaseUrl, + 'authorization_endpoint' => $localBaseUrl.'/authorize', + 'token_endpoint' => $localBaseUrl.'/token', + 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], + 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], + ]; + + $copyFields = [ + 'scopes_supported', + 'token_endpoint_auth_methods_supported', + 'jwks_uri', + ]; + + foreach ($copyFields as $field) { + if (isset($upstreamMetadata[$field])) { + $localMetadata[$field] = $upstreamMetadata[$field]; + } + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } + + private function createErrorResponse(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => 'server_error', 'error_description' => $message]); + + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } + + private function resolveTokenEndpointAuthMethod(): string + { + $supportedMethods = $this->getTokenEndpointAuthMethods(); + + if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) { + return self::CLIENT_SECRET_BASIC; + } + + if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) { + return self::CLIENT_SECRET_POST; + } + + return self::CLIENT_SECRET_POST; + } + + /** + * @return list + */ + private function getTokenEndpointAuthMethods(): array + { + try { + $metadata = $this->discovery->discover($this->upstreamIssuer); + } catch (RuntimeException) { + return []; + } + + $methods = $metadata['token_endpoint_auth_methods_supported'] ?? null; + if (!\is_array($methods)) { + return []; + } + + $normalized = []; + foreach ($methods as $method) { + if (!\is_string($method)) { + continue; + } + + $method = trim($method); + if ('' === $method) { + continue; + } + + $normalized[] = $method; + } + + return array_values(array_unique($normalized)); + } + + private function getHttpClient(): ClientInterface + { + return $this->httpClient ??= Psr18ClientDiscovery::find(); + } + + private function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); + } +} diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php new file mode 100644 index 0000000..9301352 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php @@ -0,0 +1,149 @@ + + */ +final class OAuthRequestMetaMiddleware implements MiddlewareInterface +{ + public function __construct( + private ?StreamFactoryInterface $streamFactory = null, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('POST' !== $request->getMethod()) { + return $handler->handle($request); + } + + $oauthMeta = $this->extractOAuthAttributes($request); + if ([] === $oauthMeta) { + return $handler->handle($request); + } + + $body = $request->getBody()->__toString(); + if ('' === trim($body)) { + return $handler->handle($request); + } + + try { + $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $handler->handle($request); + } + + $updatedPayload = $this->injectOauthMeta($payload, $oauthMeta); + if (null === $updatedPayload) { + return $handler->handle($request); + } + + try { + $updatedBody = json_encode($updatedPayload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } catch (\JsonException) { + return $handler->handle($request); + } + + $request = $request->withBody($this->getStreamFactory()->createStream($updatedBody)); + + return $handler->handle($request); + } + + /** + * @return array + */ + private function extractOAuthAttributes(ServerRequestInterface $request): array + { + $result = []; + foreach ($request->getAttributes() as $key => $value) { + if (\is_string($key) && str_starts_with($key, 'oauth.')) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * @param array $oauthMeta + */ + private function injectOauthMeta(mixed $payload, array $oauthMeta): mixed + { + if (!\is_array($payload)) { + return null; + } + + if (array_is_list($payload)) { + $updated = []; + foreach ($payload as $entry) { + if (!\is_array($entry)) { + $updated[] = $entry; + continue; + } + + $updated[] = $this->injectIntoMessage($entry, $oauthMeta); + } + + return $updated; + } + + return $this->injectIntoMessage($payload, $oauthMeta); + } + + /** + * @param array $message + * @param array $oauthMeta + * + * @return array + */ + private function injectIntoMessage(array $message, array $oauthMeta): array + { + $params = $message['params'] ?? []; + if (!\is_array($params)) { + return $message; + } + + $meta = $params['_meta'] ?? []; + if (!\is_array($meta)) { + $meta = []; + } + + $existingOAuth = $meta['oauth'] ?? []; + if (!\is_array($existingOAuth)) { + $existingOAuth = []; + } + + $meta['oauth'] = array_merge($existingOAuth, $oauthMeta); + $params['_meta'] = $meta; + $message['params'] = $params; + + return $message; + } + + private function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); + } +} diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php new file mode 100644 index 0000000..315f585 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -0,0 +1,64 @@ + + */ +final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly ProtectedResourceMetadata $metadata, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$this->isMetadataRequest($request)) { + return $handler->handle($request); + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } + + private function isMetadataRequest(ServerRequestInterface $request): bool + { + if ('GET' !== $request->getMethod()) { + return false; + } + + return \in_array($request->getUri()->getPath(), $this->metadata->getMetadataPaths(), true); + } +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationResult.php b/src/Server/Transport/Http/OAuth/AuthorizationResult.php new file mode 100644 index 0000000..4db13f4 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationResult.php @@ -0,0 +1,135 @@ + + */ +final class AuthorizationResult +{ + /** + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param array $attributes Attributes to attach to the request on success + */ + private function __construct( + private readonly bool $allowed, + private readonly int $statusCode, + private readonly ?string $error, + private readonly ?string $errorDescription, + private readonly ?array $scopes, + private readonly array $attributes, + ) { + } + + /** + * Creates a result indicating access is allowed. + * + * @param array $attributes Attributes to attach to the request (e.g., user_id, scopes) + */ + public static function allow(array $attributes = []): self + { + return new self(true, 200, null, null, null, $attributes); + } + + /** + * Creates a result indicating the request is unauthorized (401). + * + * Use when no valid credentials are provided or the token is invalid. + * + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function unauthorized( + ?string $error = null, + ?string $errorDescription = null, + ?array $scopes = null, + ): self { + return new self(false, 401, $error, $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating the request is forbidden (403). + * + * Use when the token is valid but lacks required permissions/scopes. + * + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function forbidden( + ?string $error = 'insufficient_scope', + ?string $errorDescription = null, + ?array $scopes = null, + ): self { + return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating a bad request (400). + * + * Use when the Authorization header is malformed. + * + * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $errorDescription Human-readable error description + */ + public static function badRequest( + ?string $error = 'invalid_request', + ?string $errorDescription = null, + ): self { + return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); + } + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getError(): ?string + { + return $this->error; + } + + public function getErrorDescription(): ?string + { + return $this->errorDescription; + } + + /** + * @return list|null + */ + public function getScopes(): ?array + { + return $this->scopes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php new file mode 100644 index 0000000..78b849e --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php @@ -0,0 +1,32 @@ + + */ +interface AuthorizationTokenValidatorInterface +{ + /** + * Validates an access token extracted from the Authorization header. + * + * @param string $accessToken The bearer token (without "Bearer " prefix) + * + * @return AuthorizationResult The result of the validation + */ + public function validate(string $accessToken): AuthorizationResult; +} diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php new file mode 100644 index 0000000..794ce3b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -0,0 +1,122 @@ + + */ +class JwksProvider implements JwksProviderInterface +{ + private const CACHE_KEY_PREFIX = 'mcp_jwks_'; + + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + + /** + * @param OidcDiscoveryInterface $discovery OIDC discovery provider (required for JWKS URI resolution when $jwksUri is not explicit) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache Optional PSR-16 cache + * @param int $cacheTtl JWKS cache TTL in seconds + */ + public function __construct( + private readonly OidcDiscoveryInterface $discovery, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + /** + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array + { + $jwksUri ??= $this->discovery->getJwksUri($issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if ($this->isJwksValid($cached)) { + /* @var array $cached */ + return $cached; + } + } + + $jwks = $this->fetchJwks($jwksUri); + + if (!$this->isJwksValid($jwks)) { + throw new RuntimeException(\sprintf('JWKS response from %s has invalid format: expected non-empty "keys" array.', $jwksUri)); + } + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwks, $this->cacheTtl); + } + + return $jwks; + } + + /** + * @return array + */ + private function fetchJwks(string $jwksUri): array + { + $request = $this->requestFactory->createRequest('GET', $jwksUri) + ->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e); + } + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); + } + + try { + $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new RuntimeException('Invalid JWKS format: expected JSON object.'); + } + + return $data; + } + + private function isJwksValid(mixed $jwks): bool + { + if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys'])) { + return false; + } + + $nonEmptyKeys = array_filter($jwks['keys'], static fn (mixed $key): bool => \is_array($key) && [] !== $key); + + return [] !== $nonEmptyKeys; + } +} diff --git a/src/Server/Transport/Http/OAuth/JwksProviderInterface.php b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php new file mode 100644 index 0000000..f33ac65 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php @@ -0,0 +1,28 @@ + + */ +interface JwksProviderInterface +{ + /** + * @param string $issuer authorization server issuer URL + * @param string|null $jwksUri Optional explicit JWKS URI. If null, implementation may resolve via discovery. + * + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array; +} diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php new file mode 100644 index 0000000..a0ffe06 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php @@ -0,0 +1,205 @@ + + */ +class JwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") + * @param string|list $audience Expected audience(s) for the token + * @param JwksProviderInterface $jwksProvider JWKS provider + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") + */ + public function __construct( + private readonly string|array $issuer, + private readonly string|array $audience, + private readonly JwksProviderInterface $jwksProvider, + private readonly ?string $jwksUri = null, + private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], + private readonly string $scopeClaim = 'scope', + ) { + if (!class_exists(JWT::class)) { + throw new RuntimeException('For using the JwtTokenValidator, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".'); + } + } + + public function validate(string $accessToken): AuthorizationResult + { + try { + /** @var array $claims */ + $claims = (array) JWT::decode($accessToken, $this->getJwks()); + + // Validate issuer + if (!$this->validateIssuer($claims)) { + return AuthorizationResult::unauthorized('invalid_token', 'Token issuer mismatch.'); + } + + // Validate audience + if (!$this->validateAudience($claims)) { + return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.'); + } + + // Build attributes to attach to request + $attributes = [ + 'oauth.claims' => $claims, + 'oauth.scopes' => $this->extractScopes($claims), + ]; + + // Add common claims as individual attributes + if (isset($claims['sub'])) { + $attributes['oauth.subject'] = $claims['sub']; + } + + if (isset($claims['client_id'])) { + $attributes['oauth.client_id'] = $claims['client_id']; + } + + // Add azp (authorized party) for OIDC tokens + if (isset($claims['azp'])) { + $attributes['oauth.authorized_party'] = $claims['azp']; + } + + return AuthorizationResult::allow($attributes); + } catch (ExpiredException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } catch (SignatureInvalidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); + } catch (BeforeValidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } catch (\InvalidArgumentException|\UnexpectedValueException|\DomainException $e) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); + } + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + if (!$result->isAllowed()) { + return $result; + } + + $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; + + if (!\is_array($tokenScopes)) { + $tokenScopes = []; + } + + foreach ($requiredScopes as $required) { + if (!\in_array($required, $tokenScopes, true)) { + return AuthorizationResult::forbidden('insufficient_scope', \sprintf('Required scope: %s', $required), $requiredScopes); + } + } + + return $result; + } + + /** + * @return array + */ + private function getJwks(): array + { + $issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer; + $jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri); + + /* @var array */ + return JWK::parseKeySet($jwksData, $this->algorithms[0]); + } + + /** + * @param array $claims + */ + private function validateAudience(array $claims): bool + { + if (!isset($claims['aud'])) { + return false; + } + + $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; + + foreach ($expectedAudiences as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + */ + private function validateIssuer(array $claims): bool + { + if (!isset($claims['iss'])) { + return false; + } + + $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return \in_array($claims['iss'], $expectedIssuers, true); + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php new file mode 100644 index 0000000..2f24685 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -0,0 +1,236 @@ + + */ +class OidcDiscovery implements OidcDiscoveryInterface +{ + private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; + + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private OidcDiscoveryMetadataPolicyInterface $metadataPolicy; + + /** + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param OidcDiscoveryMetadataPolicyInterface|null $metadataPolicy Metadata validation policy + */ + public function __construct( + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ?OidcDiscoveryMetadataPolicyInterface $metadataPolicy = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->metadataPolicy = $metadataPolicy ?? new StrictOidcDiscoveryMetadataPolicy(); + } + + /** + * Gets the JWKS URI from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The JWKS URI + * + * @throws RuntimeException If discover fails + */ + public function getJwksUri(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['jwks_uri']; + } + + /** + * Gets the token endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The token endpoint URL + * + * @throws RuntimeException If discover fails + */ + public function getTokenEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['token_endpoint']; + } + + /** + * Gets the authorization endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The authorization endpoint URL + * + * @throws RuntimeException If discover fails + */ + public function getAuthorizationEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['authorization_endpoint']; + } + + /** + * Discovers authorization server metadata from the issuer URL. + * + * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: + * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} + * 2. OIDC path insertion: /.well-known/openid-configuration/{path} + * 3. OIDC path appending: {path}/.well-known/openid-configuration + * + * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") + * + * @return array The authorization server metadata + * + * @throws RuntimeException If discovery fails + */ + public function discover(string $issuer): array + { + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + /* @var array $cached */ + return $cached; + } + } + + $metadata = $this->fetchMetadata($issuer); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $metadata, $this->cacheTtl); + } + + return $metadata; + } + + /** + * @return array + */ + private function fetchMetadata(string $issuer): array + { + $issuer = rtrim($issuer, '/'); + $parsed = parse_url($issuer); + + if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { + throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); + } + + $scheme = $parsed['scheme']; + $host = $parsed['host']; + $port = isset($parsed['port']) ? ':'.$parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + + $baseUrl = $scheme.'://'.$host.$port; + + // Build discovery URLs in priority order per RFC 8414 Section 3.1 + $discoveryUrls = []; + + if ('' !== $path && '/' !== $path) { + // For issuer URLs with path components + // 1. OAuth 2.0 path insertion + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path; + // 2. OIDC path insertion + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; + // 3. OIDC path appending + $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; + } else { + // For issuer URLs without path components + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'; + } + + $lastException = null; + + foreach ($discoveryUrls as $url) { + try { + $metadata = $this->fetchJson($url); + if (!$this->metadataPolicy->isValid($metadata)) { + throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url)); + } + + if (!isset($metadata['issuer']) || !\is_string($metadata['issuer'])) { + throw new RuntimeException(\sprintf('OIDC discovery response from %s is missing required "issuer" field.', $url)); + } + if ($metadata['issuer'] !== $issuer) { + throw new RuntimeException(\sprintf('OIDC discovery issuer mismatch for %s: expected %s, got %s.', $url, $issuer, $metadata['issuer'])); + } + + return $metadata; + } catch (RuntimeException $e) { + $lastException = $e; + continue; + } + } + + throw new RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); + } + + /** + * @return array + */ + private function fetchJson(string $url): array + { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e); + } + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); + } + + try { + $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + } + + return $data; + } +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php new file mode 100644 index 0000000..aeabf0b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php @@ -0,0 +1,31 @@ + + */ +interface OidcDiscoveryInterface +{ + /** + * @return array + */ + public function discover(string $issuer): array; + + public function getAuthorizationEndpoint(string $issuer): string; + + public function getTokenEndpoint(string $issuer): string; + + public function getJwksUri(string $issuer): string; +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php new file mode 100644 index 0000000..edf94c4 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php @@ -0,0 +1,22 @@ + + */ +interface OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool; +} diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php new file mode 100644 index 0000000..dd96982 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php @@ -0,0 +1,245 @@ + + */ +final class ProtectedResourceMetadata implements \JsonSerializable +{ + public const DEFAULT_METADATA_PATH = '/.well-known/oauth-protected-resource'; + + private const LOCALIZED_HUMAN_READABLE_FIELD_PATTERN = '/^(resource_name|resource_documentation|resource_policy_uri|resource_tos_uri)#[A-Za-z0-9-]+$/'; + + /** @var list */ + private array $authorizationServers; + + /** @var list|null */ + private ?array $scopesSupported; + + /** @var list */ + private array $metadataPaths; + + /** @var array */ + private array $localizedHumanReadable; + + /** @var array */ + private array $extra; + + private ?string $resource; + private ?string $resourceName; + private ?string $resourceDocumentation; + private ?string $resourcePolicyUri; + private ?string $resourceTosUri; + + /** + * @param list $authorizationServers + * @param list|null $scopesSupported + * @param array $localizedHumanReadable Locale-specific values, e.g. resource_name#en => "My Resource" + * @param array $extra Additional RFC 9728 metadata fields + * @param list $metadataPaths + */ + public function __construct( + array $authorizationServers, + ?array $scopesSupported = null, + ?string $resource = null, + ?string $resourceName = null, + ?string $resourceDocumentation = null, + ?string $resourcePolicyUri = null, + ?string $resourceTosUri = null, + array $localizedHumanReadable = [], + array $extra = [], + array $metadataPaths = [self::DEFAULT_METADATA_PATH], + ) { + $this->authorizationServers = $this->normalizeStringList($authorizationServers, 'authorizationServers'); + if ([] === $this->authorizationServers) { + throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + } + + $normalizedScopes = $this->normalizeStringList($scopesSupported ?? [], 'scopesSupported'); + $this->scopesSupported = [] === $normalizedScopes ? null : $normalizedScopes; + + $this->resource = $this->normalizeNullableString($resource); + $this->resourceName = $this->normalizeNullableString($resourceName); + $this->resourceDocumentation = $this->normalizeNullableString($resourceDocumentation); + $this->resourcePolicyUri = $this->normalizeNullableString($resourcePolicyUri); + $this->resourceTosUri = $this->normalizeNullableString($resourceTosUri); + $this->localizedHumanReadable = $this->normalizeLocalizedHumanReadable($localizedHumanReadable); + $this->extra = $extra; + + $this->metadataPaths = $this->normalizePaths($metadataPaths); + if ([] === $this->metadataPaths) { + throw new InvalidArgumentException('Protected resource metadata requires at least one metadata path.'); + } + } + + /** + * @return list + */ + public function getMetadataPaths(): array + { + return $this->metadataPaths; + } + + public function getPrimaryMetadataPath(): string + { + return $this->metadataPaths[0]; + } + + /** + * @return list|null + */ + public function getScopesSupported(): ?array + { + return $this->scopesSupported; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'authorization_servers' => $this->authorizationServers, + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = $this->scopesSupported; + } + + if (null !== $this->resource) { + $data['resource'] = $this->resource; + } + + if (null !== $this->resourceName) { + $data['resource_name'] = $this->resourceName; + } + + if (null !== $this->resourceDocumentation) { + $data['resource_documentation'] = $this->resourceDocumentation; + } + + if (null !== $this->resourcePolicyUri) { + $data['resource_policy_uri'] = $this->resourcePolicyUri; + } + + if (null !== $this->resourceTosUri) { + $data['resource_tos_uri'] = $this->resourceTosUri; + } + + foreach ($this->localizedHumanReadable as $key => $value) { + $data[$key] = $value; + } + + return array_merge($this->extra, $data); + } + + /** + * @param list $values + * + * @return list + */ + private function normalizeStringList(array $values, string $parameterName): array + { + $normalized = []; + + foreach ($values as $value) { + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Protected resource metadata parameter "%s" must contain strings.', $parameterName)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + private function normalizeNullableString(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } + + /** + * @param list $paths + * + * @return list + */ + private function normalizePaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + if (!\is_string($path)) { + throw new InvalidArgumentException('Protected resource metadata paths must be strings.'); + } + + $path = trim($path); + if ('' === $path) { + continue; + } + + if ('/' !== $path[0]) { + $path = '/'.$path; + } + + $normalized[] = $path; + } + + return array_values(array_unique($normalized)); + } + + /** + * @param array $localizedHumanReadable + * + * @return array + */ + private function normalizeLocalizedHumanReadable(array $localizedHumanReadable): array + { + $normalized = []; + + foreach ($localizedHumanReadable as $field => $value) { + if (!\is_string($field) || !preg_match(self::LOCALIZED_HUMAN_READABLE_FIELD_PATTERN, $field)) { + throw new InvalidArgumentException(\sprintf('Invalid localized human-readable field: "%s".', (string) $field)); + } + + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Localized human-readable value for "%s" must be a string.', $field)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[$field] = $value; + } + + return $normalized; + } +} diff --git a/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php new file mode 100644 index 0000000..b89a3f8 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,48 @@ + + */ +final class StrictOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + if (!\is_array($metadata) + || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + || !\is_string($metadata['authorization_endpoint']) + || '' === trim($metadata['authorization_endpoint']) + || !\is_string($metadata['token_endpoint']) + || '' === trim($metadata['token_endpoint']) + || !\is_string($metadata['jwks_uri']) + || '' === trim($metadata['jwks_uri']) + || !isset($metadata['code_challenge_methods_supported']) + ) { + return false; + } + + if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { + return false; + } + + foreach ($metadata['code_challenge_methods_supported'] as $method) { + if (!\is_string($method) || '' === trim($method)) { + return false; + } + } + + return true; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php new file mode 100644 index 0000000..bd05af1 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php @@ -0,0 +1,284 @@ + + */ +class AuthorizationMiddlewareTest extends TestCase +{ + #[TestDox('missing Authorization header returns 401 with metadata and scope guidance')] + public function testMissingAuthorizationReturns401(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('Bearer', $header); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="mcp:read"', $header); + } + + #[TestDox('malformed Authorization header returns 400 with invalid_request')] + public function testMalformedAuthorizationReturns400(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Basic abc'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('error="invalid_request"', $response->getHeaderLine('WWW-Authenticate')); + } + + #[TestDox('insufficient scopes return 403 with scope challenge')] + public function testInsufficientScopeReturns403(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(403, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('error="insufficient_scope"', $header); + $this->assertStringContainsString('scope="mcp:write"', $header); + } + + #[TestDox('metadata scopes are used in challenge when result has no scopes')] + public function testMetadataScopesAreUsedWhenResultHasNoScopes(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); + + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); + } + + #[TestDox('resource metadata object path and scopes are reflected in challenge')] + public function testResourceMetadataObjectProvidesMetadataAndScopes(): void + { + $factory = new Psr17Factory(); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + metadataPaths: ['/oauth/resource-meta'], + ); + + $middleware = new AuthorizationMiddleware( + validator: $validator, + responseFactory: $factory, + resourceMetadata: $resourceMetadata, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/oauth/resource-meta"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); + } + + #[TestDox('authorized requests reach the handler with attributes applied')] + public function testAllowedRequestPassesAttributes(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(['subject' => 'user-1']); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('X-Subject', (string) $request->getAttribute('subject')); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('user-1', $response->getHeaderLine('X-Subject')); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php new file mode 100644 index 0000000..4ab3661 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php @@ -0,0 +1,299 @@ + + */ +class OAuthProxyMiddlewareTest extends TestCase +{ + #[TestDox('metadata endpoint returns local oauth metadata with upstream capabilities')] + public function testMetadataEndpointReturnsLocalMetadata(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => ['S256'], + 'scopes_supported' => ['openid', 'profile'], + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('http://localhost:8000', $payload['issuer']); + $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); + $this->assertSame('http://localhost:8000/token', $payload['token_endpoint']); + $this->assertSame(['openid', 'profile'], $payload['scopes_supported']); + $this->assertSame('https://login.example.com/discovery/v2.0/keys', $payload['jwks_uri']); + } + + #[TestDox('authorize endpoint redirects to upstream authorization endpoint preserving query')] + public function testAuthorizeEndpointRedirectsToUpstream(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getAuthorizationEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/authorize'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'http://localhost:8000/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'https://login.example.com/oauth2/v2.0/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + $response->getHeaderLine('Location'), + ); + } + + #[TestDox('token endpoint proxies request and injects client secret')] + public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertSame('secret-value', $params['client_secret'] ?? null); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + + #[TestDox('token endpoint uses client_secret_basic when supported by upstream metadata')] + public function testTokenEndpointUsesClientSecretBasicWhenSupported(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], + ]); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('Basic ZGVtby1jbGllbnQ6c2VjcmV0LXZhbHVl', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertArrayNotHasKey('client_secret', $params); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&client_id=demo-client&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + + #[TestDox('non oauth proxy requests are delegated to next middleware')] + public function testNonOAuthRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->never())->method('discover'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php new file mode 100644 index 0000000..c66dfb4 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php @@ -0,0 +1,179 @@ + + */ +class OAuthRequestMetaMiddlewareTest extends TestCase +{ + #[TestDox('oauth request attributes are copied to json-rpc params _meta')] + public function testInjectsOauthAttributesIntoSingleRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.claims', ['sub' => 'user-1']) + ->withAttribute('oauth.scopes', ['openid', 'profile']) + ->withAttribute('oauth.subject', 'user-1') + ->withAttribute('not_oauth', 'ignored'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame(['sub' => 'user-1'], $decoded['params']['_meta']['oauth']['oauth.claims']); + $this->assertSame(['openid', 'profile'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + $this->assertSame('user-1', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertArrayNotHasKey('not_oauth', $decoded['params']['_meta']['oauth']); + } + + #[TestDox('existing _meta is preserved and oauth keys are merged')] + public function testMergesWithExistingMeta(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'trace_id' => 'trace-1', + 'oauth' => [ + 'client_hint' => 'web', + 'oauth.subject' => 'spoofed', + ], + ], + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'trusted-user') + ->withAttribute('oauth.scopes', ['mcp.read']); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('trace-1', $decoded['params']['_meta']['trace_id']); + $this->assertSame('web', $decoded['params']['_meta']['oauth']['client_hint']); + $this->assertSame('trusted-user', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame(['mcp.read'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + } + + #[TestDox('oauth request attributes are copied for each batch entry')] + public function testInjectsOauthAttributesIntoBatchRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ], + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'batch-user'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('batch-user', $decoded[0]['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame('batch-user', $decoded[1]['params']['_meta']['oauth']['oauth.subject']); + } + + #[TestDox('request without oauth attributes passes through unchanged')] + public function testNoOauthAttributesPassThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + #[TestDox('non post requests pass through unchanged')] + public function testNonPostPassesThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping"}'; + + $request = $factory + ->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)) + ->withAttribute('oauth.subject', 'user-1'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + private function createEchoHandler(Psr17Factory $factory): RequestHandlerInterface + { + return new class($factory) implements RequestHandlerInterface { + public function __construct(private readonly Psr17Factory $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory + ->createResponse(200) + ->withBody($this->factory->createStream($request->getBody()->__toString())); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php new file mode 100644 index 0000000..66e57e0 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php @@ -0,0 +1,125 @@ + + */ +class ProtectedResourceMetadataMiddlewareTest extends TestCase +{ + #[TestDox('default metadata endpoint returns protected resource metadata JSON')] + public function testDefaultMetadataEndpointReturnsJson(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://mcp.example.com/docs', + localizedHumanReadable: [ + 'resource_name#uk' => 'Pryklad MCP API', + ], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); + $this->assertSame('Example MCP API', $payload['resource_name']); + $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); + $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); + } + + #[TestDox('non metadata request passes to next middleware')] + public function testNonMetadataRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php new file mode 100644 index 0000000..78c1572 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php @@ -0,0 +1,168 @@ + + */ +class JwksProviderTest extends TestCase +{ + #[TestDox('JWKS are loaded from explicit URI')] + public function testGetJwksFromExplicitUri(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('invalid cached JWKS are ignored and replaced by fetched values')] + public function testInvalidCachedJwksAreIgnored(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn(['keys' => []]); + $cache->expects($this->once()) + ->method('set'); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('discovery is used when explicit JWKS URI is not provided')] + public function testDiscoveryIsUsedWhenUriIsMissing(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getJwksUri') + ->with('https://auth.example.com') + ->willReturn($jwksUri); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + discovery: $discovery, + ); + + $result = $provider->getJwks('https://auth.example.com'); + + $this->assertSame($jwks, $result); + } + + #[TestDox('empty keys in fetched JWKS throw RuntimeException')] + public function testEmptyKeysThrow(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode(['keys' => []], \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('expected non-empty "keys" array'); + + $provider->getJwks('https://auth.example.com', $jwksUri); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php new file mode 100644 index 0000000..b6e518a --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php @@ -0,0 +1,589 @@ + + */ +class JwtTokenValidatorTest extends TestCase +{ + #[TestDox('valid JWT is allowed and claims/scopes are exposed as request attributes')] + public function testValidJwtAllowsAndExposesAttributes(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'client_id' => 'client-abc', + 'azp' => 'client-abc', + 'scope' => 'mcp:read mcp:write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertArrayHasKey('oauth.claims', $attributes); + $this->assertArrayHasKey('oauth.scopes', $attributes); + $this->assertSame(['mcp:read', 'mcp:write'], $attributes['oauth.scopes']); + $this->assertSame('user-123', $attributes['oauth.subject']); + $this->assertSame('client-abc', $attributes['oauth.client_id']); + $this->assertSame('client-abc', $attributes['oauth.authorized_party']); + } + + #[TestDox('issuer mismatch yields unauthorized result')] + public function testIssuerMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://other-issuer.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); + } + + #[TestDox('audience mismatch yields unauthorized result')] + public function testAudienceMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: ['mcp-api'], + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'different-aud', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); + } + + #[TestDox('expired token yields unauthorized invalid_token with expired message')] + public function testExpiredTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 7200, + 'exp' => time() - 10, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token has expired.', $result->getErrorDescription()); + } + + #[TestDox('token with future nbf yields unauthorized invalid_token with not-yet-valid message')] + public function testBeforeValidTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time(), + 'nbf' => time() + 3600, + 'exp' => time() + 7200, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token is not yet valid.', $result->getErrorDescription()); + } + + #[TestDox('signature verification failure yields unauthorized invalid_token with signature message')] + public function testSignatureInvalidIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + // Create a mismatched JWK with the same kid so the key lookup succeeds but signature verification fails. + [, $mismatchedJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + $mismatchedJwk['kid'] = $publicJwk['kid']; + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$mismatchedJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token signature verification failed.', $result->getErrorDescription()); + } + + #[TestDox('JWKS HTTP error results in RuntimeException')] + public function testJwksHttpErrorThrowsRuntimeException(): void + { + $factory = new Psr17Factory(); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $this->expectException(RuntimeException::class); + $validator->validate($token); + } + + #[TestDox('Invalid JWKS JSON results in RuntimeException')] + public function testInvalidJwksJsonThrowsRuntimeException(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{not-json')), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $this->expectException(RuntimeException::class); + $validator->validate($token); + } + + #[TestDox('JWKS without keys array results in RuntimeException')] + public function testJwksMissingKeysThrowsRuntimeException(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['nope' => []], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $this->expectException(RuntimeException::class); + $validator->validate($token); + } + + #[TestDox('requireScopes returns forbidden when any required scope is missing')] + public function testRequireScopesForbiddenWhenMissing(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + $this->assertSame(['mcp:read', 'mcp:write'], $scoped->getScopes()); + } + + #[TestDox('requireScopes passes through when all required scopes are present')] + public function testRequireScopesPassesWhenPresent(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => ['mcp:read', 'mcp:write'], + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read']); + $this->assertTrue($scoped->isAllowed()); + } + + #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] + public function testExtractScopesEdgeCases(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksResponse = $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))); + + $httpClient = $this->createHttpClientMock([$jwksResponse]); + + // missing scope + $validatorMissing = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $tokenMissing = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultMissing = $validatorMissing->validate($tokenMissing); + $this->assertTrue($resultMissing->isAllowed()); + $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); + + // invalid scope type + $httpClient2 = $this->createHttpClientMock([$jwksResponse]); + + $validatorInvalid = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient2, requestFactory: $factory), + ); + + $tokenInvalid = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 123, + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultInvalid = $validatorInvalid->validate($tokenInvalid); + $this->assertTrue($resultInvalid->isAllowed()); + $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); + } + + private function unsignedJwt(array $claims): string + { + $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header.'.'.$payload.'.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + $this->fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + $this->fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + $this->fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= \count($responses); + + $httpClient = $this->createMock(ClientInterface::class); + $expectation = $httpClient + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)); + + if (1 === $expectedCalls) { + $expectation->willReturn($responses[0]); + } else { + // If expectedCalls > count(responses), keep returning the last response. + $sequence = $responses; + while (\count($sequence) < $expectedCalls) { + $sequence[] = $responses[array_key_last($responses)]; + } + $expectation->willReturnOnConsecutiveCalls(...$sequence); + } + + return $httpClient; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php new file mode 100644 index 0000000..14eb54f --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -0,0 +1,268 @@ + + */ +class OidcDiscoveryTest extends TestCase +{ + #[TestDox('invalid issuer URL throws RuntimeException')] + public function testInvalidIssuerUrlThrows(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $discovery = new OidcDiscovery( + httpClient: $this->createMock(ClientInterface::class), + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid issuer URL'); + $discovery->discover('invalid-issuer'); + } + + #[TestDox('strict discovery rejects metadata without code challenge methods')] + public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $issuer = 'https://auth.example.com'; + $metadataWithoutCodeChallengeMethods = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturn($factory->createResponse(200)->withBody( + $factory->createStream(json_encode($metadataWithoutCodeChallengeMethods, \JSON_THROW_ON_ERROR)), + )); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to discover authorization server metadata'); + $discovery->discover($issuer); + } + + #[TestDox('discover falls back to the next metadata URL when first response is invalid')] + public function testDiscoverFallsBackOnInvalidMetadataResponse(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $invalidMetadata = [ + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + // token_endpoint is intentionally missing + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $invalidMetadata, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $invalidMetadata : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['authorization_endpoint'], $metadata['authorization_endpoint']); + $this->assertSame($validMetadata['token_endpoint'], $metadata['token_endpoint']); + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('valid metadata from cache is returned without HTTP call')] + public function testDiscoverUsesValidCacheWithoutHttpCall(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $cachedMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn($cachedMetadata); + $cache->expects($this->never())->method('set'); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($cachedMetadata, $metadata); + } + + #[TestDox('discover skips metadata when issuer claim does not match requested issuer')] + public function testDiscoverSkipsIssuerMismatch(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $issuerMismatch = [ + 'issuer' => 'https://auth.example.com/other-tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $issuerMismatch, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $issuerMismatch : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['issuer'], $metadata['issuer']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('issuer without path uses standard well-known endpoints')] + public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $validMetadata = [ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + if (1 === \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('https://auth.example.com'); + + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server', $requestedUrls[0]); + $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); + } + + private function skipIfPsrHttpClientIsMissing(): void + { + if (!interface_exists(ClientInterface::class)) { + $this->markTestSkipped('psr/http-client is not available in this runtime.'); + } + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php new file mode 100644 index 0000000..fbb51a9 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -0,0 +1,86 @@ + + */ +class ProtectedResourceMetadataTest extends TestCase +{ + #[TestDox('serializes RFC 9728 metadata including human-readable fields')] + public function testJsonSerializeIncludesHumanReadableFields(): void + { + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + resource: 'https://api.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://api.example.com/docs', + resourcePolicyUri: 'https://api.example.com/policy', + resourceTosUri: 'https://api.example.com/tos', + localizedHumanReadable: [ + 'resource_name#en' => 'Example MCP API', + ], + extra: [ + 'bearer_methods_supported' => ['header'], + ], + metadataPaths: ['.well-known/oauth-protected-resource'], + ); + + $this->assertSame( + [ + 'bearer_methods_supported' => ['header'], + 'authorization_servers' => ['https://auth.example.com'], + 'scopes_supported' => ['openid', 'profile'], + 'resource' => 'https://api.example.com/mcp', + 'resource_name' => 'Example MCP API', + 'resource_documentation' => 'https://api.example.com/docs', + 'resource_policy_uri' => 'https://api.example.com/policy', + 'resource_tos_uri' => 'https://api.example.com/tos', + 'resource_name#en' => 'Example MCP API', + ], + $metadata->jsonSerialize(), + ); + $this->assertSame('/.well-known/oauth-protected-resource', $metadata->getPrimaryMetadataPath()); + $this->assertSame(['openid', 'profile'], $metadata->getScopesSupported()); + } + + #[TestDox('invalid localized human-readable field is rejected')] + public function testInvalidLocalizedHumanReadableFieldThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid localized human-readable field'); + + new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + localizedHumanReadable: [ + 'invalid#en' => 'value', + ], + ); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php new file mode 100644 index 0000000..3d1e8a7 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php @@ -0,0 +1,79 @@ + + */ +class StrictOidcDiscoveryMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is invalid in strict mode')] + public function testMissingCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('valid code challenge methods list is accepted in strict mode')] + public function testValidCodeChallengeMethodsIsAccepted(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('empty code challenge methods list is invalid in strict mode')] + public function testEmptyCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => [], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('non string code challenge method is invalid in strict mode')] + public function testNonStringCodeChallengeMethodIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256', 123], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +} From 528cff269f886da0a3bc9458b714142c04dafe67 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 17:10:58 +0100 Subject: [PATCH 004/143] Add basic roadmap (#249) --- README.md | 17 ++--------------- ROADMAP.md | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 ROADMAP.md diff --git a/README.md b/README.md index e45e495..3232afd 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,12 @@ The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers and clients in PHP. -> [!IMPORTANT] -> This SDK is currently in active development with ongoing refinement of its architecture and features. While -> functional, the API may experience changes as we work toward stabilization. -> -> If you want to help us stabilize the SDK, please see the [issue tracker](https://github.com/modelcontextprotocol/php-sdk/issues). - This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the [Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). -Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html). - -## Roadmap - -**Features** -- [ ] Stabilize server component with all needed handlers and functional tests -- [ ] Extend documentation, including integration guides for popular frameworks -- [ ] Implement Client component -- [ ] Support multiple schema versions +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. ## Installation diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..cab6bf0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,15 @@ +# Roadmap + +This roadmap is a living document that outlines the planned features and improvements for our project. + +## Goals for the First Major Release + +- **Server** +- [ ] Implement full support for elicitations +- [ ] Implement OAuth2 authentication for server +- **Client** +- [ ] Implement client-side support +- [ ] Implement client examples and documentation +- **Schema** +- [ ] Implement schema generation based on TS or JSON Schema + From a5a738396d88becbbdd6935a42a781157370d175 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 17:11:17 +0100 Subject: [PATCH 005/143] Add dependabot configuration for composer and github actions (#248) --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a6fc01e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From 3ef864c444e1a26615fa256250cc3ada956e9db8 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 10 Mar 2026 17:22:40 +0100 Subject: [PATCH 006/143] [Client] Feat: Implement MCP client component (#192) * feat(client): add complete MCP client SDK with STDIO and HTTP transports Add Client, Builder, ClientProtocol, ClientSession, and transport implementations for communicating with MCP servers. Supports tools, resources, prompts, and real-time progress/logging notifications. * refactor(examples): reorganize examples into server/ and client/ folders * feat: add SamplingRequestHandler for handling server sampling requests * feat: add setLoggingLevel method to control server logging verbosity * refactor: make progress token dependent on request ID * docs: Update server IP in usage instructions and add notes on sampling server requirements. * feat: add complete() method for completion/complete requests * refactor: properly parse and type initialize result * refactor: standardize method names * fix: revert changes to server related components * fix: revert changes to server related components * refactor: Restructure client transports and handlers into dedicated namespaces. * fix: cs and php-stan errors fixes(partial) * feat: Refactor request handlers to return `Response` or `Error` objects directly, adding a `SamplingException` and error logging for sampling requests. * feat: Replace `ClientTransportInterface` with `TransportInterface` and move response result deserialization to client methods. * refactor: remove redundant timeout check for intialize on transports The processFiber that executes per tick while fiber is suspended already handles timeouts while waiting * feat: broaden symfony/http-client dependency to support Symfony 5.4 to 8.0 * Update examples/client/README.md Co-authored-by: Christopher Hertel * Update examples/client/README.md Co-authored-by: Christopher Hertel * refactor: remove outgoing message queue from session and simplify transport send method to directly dispatch messages. * refactor: Replace ClientSession with ClientState for clearer intent on runtime state management * refactor: rename connectAndInitialize to connect for consistency * chore: cs fix * chore: use SamplingCallbackInterface instead of a closure for sampling request handler --------- Co-authored-by: Christopher Hertel --- composer.json | 2 +- examples/client/README.md | 27 ++ examples/client/http_client_communication.php | 134 ++++++++ examples/client/http_discovery_calculator.php | 77 +++++ .../client/stdio_client_communication.php | 126 +++++++ .../client/stdio_discovery_calculator.php | 89 +++++ examples/server/README.md | 8 +- examples/server/bootstrap.php | 2 +- .../server/client-communication/server.php | 1 - src/Client.php | 284 ++++++++++++++++ src/Client/Builder.php | 170 ++++++++++ src/Client/Configuration.php | 34 ++ .../LoggingNotificationHandler.php | 43 +++ .../NotificationHandlerInterface.php | 32 ++ .../ProgressNotificationHandler.php | 52 +++ .../Request/RequestHandlerInterface.php | 38 +++ .../Request/SamplingCallbackInterface.php | 25 ++ .../Request/SamplingRequestHandler.php | 67 ++++ src/Client/Protocol.php | 318 ++++++++++++++++++ src/Client/State/ClientState.php | 136 ++++++++ src/Client/State/ClientStateInterface.php | 115 +++++++ src/Client/Transport/BaseTransport.php | 126 +++++++ src/Client/Transport/HttpTransport.php | 298 ++++++++++++++++ src/Client/Transport/StdioTransport.php | 289 ++++++++++++++++ src/Client/Transport/TransportInterface.php | 107 ++++++ src/Exception/ConnectionException.php | 21 ++ src/Exception/RequestException.php | 40 +++ src/Exception/SamplingException.php | 24 ++ src/Exception/TimeoutException.php | 21 ++ .../Request/CreateSamplingMessageRequest.php | 20 +- .../Result/CompletionCompleteResult.php | 14 + 31 files changed, 2731 insertions(+), 9 deletions(-) create mode 100644 examples/client/README.md create mode 100644 examples/client/http_client_communication.php create mode 100644 examples/client/http_discovery_calculator.php create mode 100644 examples/client/stdio_client_communication.php create mode 100644 examples/client/stdio_discovery_calculator.php create mode 100644 src/Client.php create mode 100644 src/Client/Builder.php create mode 100644 src/Client/Configuration.php create mode 100644 src/Client/Handler/Notification/LoggingNotificationHandler.php create mode 100644 src/Client/Handler/Notification/NotificationHandlerInterface.php create mode 100644 src/Client/Handler/Notification/ProgressNotificationHandler.php create mode 100644 src/Client/Handler/Request/RequestHandlerInterface.php create mode 100644 src/Client/Handler/Request/SamplingCallbackInterface.php create mode 100644 src/Client/Handler/Request/SamplingRequestHandler.php create mode 100644 src/Client/Protocol.php create mode 100644 src/Client/State/ClientState.php create mode 100644 src/Client/State/ClientStateInterface.php create mode 100644 src/Client/Transport/BaseTransport.php create mode 100644 src/Client/Transport/HttpTransport.php create mode 100644 src/Client/Transport/StdioTransport.php create mode 100644 src/Client/Transport/TransportInterface.php create mode 100644 src/Exception/ConnectionException.php create mode 100644 src/Exception/RequestException.php create mode 100644 src/Exception/SamplingException.php create mode 100644 src/Exception/TimeoutException.php diff --git a/composer.json b/composer.json index 992f8d0..d926e5a 100644 --- a/composer.json +++ b/composer.json @@ -85,4 +85,4 @@ }, "sort-packages": true } -} +} \ No newline at end of file diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 0000000..3e3bc09 --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,27 @@ +# Client Examples + +These examples demonstrate how to use the MCP PHP Client SDK. + +## STDIO Client + +Connects to an MCP server running as a child process: + +```bash +php examples/client/stdio_discovery_calculator.php +``` + +## HTTP Client + +Connects to an MCP server over HTTP: + +```bash +# First, start an HTTP server +php -S localhost:8000 examples/server/discovery-calculator/server.php + +# Then run the client +php examples/client/http_discovery_calculator.php +``` + +## Requirements + +All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php new file mode 100644 index 0000000..987b190 --- /dev/null +++ b/examples/client/http_client_communication.php @@ -0,0 +1,134 @@ +level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '. + '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.'; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); + } +}); + +$client = Client::builder() + ->setClientInfo('HTTP Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) + ->build(); + +$transport = new HttpTransport(endpoint: $endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'sales_transactions_q4'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php new file mode 100644 index 0000000..ae8babd --- /dev/null +++ b/examples/client/http_discovery_calculator.php @@ -0,0 +1,77 @@ +setClientInfo('HTTP Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new HttpTransport($endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo ' Name: '.($serverInfo->name ?? 'unknown')."\n"; + echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Available prompts:\n"; + $promptsResult = $client->listPrompts(); + foreach ($promptsResult->prompts as $prompt) { + echo " - {$prompt->name}: {$prompt->description}\n"; + } + echo "\n"; +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php new file mode 100644 index 0000000..7d83935 --- /dev/null +++ b/examples/client/stdio_client_communication.php @@ -0,0 +1,126 @@ +level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '. + '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.'; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); + } +}); + +$client = Client::builder() + ->setClientInfo('STDIO Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/client-communication/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'customer_orders_2024'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php new file mode 100644 index 0000000..7d25a80 --- /dev/null +++ b/examples/client/stdio_discovery_calculator.php @@ -0,0 +1,89 @@ +setClientInfo('STDIO Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/discovery-calculator/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo ' Name: '.($serverInfo->name ?? 'unknown')."\n"; + echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Calling 'calculate' tool with a=5, b=3, operation='add'...\n"; + $result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); + echo 'Result: '; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } + } + echo "\n\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Reading resource 'config://calculator/settings'...\n"; + $resourceContent = $client->readResource('config://calculator/settings'); + foreach ($resourceContent->contents as $content) { + if ($content instanceof TextResourceContents) { + echo ' Content: '.$content->text."\n"; + echo ' Mimetype: '.$content->mimeType."\n"; + } + } +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/server/README.md b/examples/server/README.md index 27874d7..a932639 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -8,10 +8,10 @@ The bootstrapping of the example will choose the used transport based on the SAP For running an example, you execute the `server.php` like this: ```bash # For using the STDIO transport: -php examples/discovery-calculator/server.php +php examples/server/discovery-calculator/server.php # For using the Streamable HTTP transport: -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -19,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` ## Debugging @@ -30,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php ``` diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index 77fa744..cbe3fb5 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -55,7 +55,7 @@ function shutdown(ResponseInterface|int $result): never function logger(): LoggerInterface { return new class extends AbstractLogger { - public function log($level, Stringable|string $message, array $context = []): void + public function log($level, string|Stringable $message, array $context = []): void { $debug = $_SERVER['DEBUG'] ?? false; diff --git a/examples/server/client-communication/server.php b/examples/server/client-communication/server.php index 698993d..16b4bd5 100644 --- a/examples/server/client-communication/server.php +++ b/examples/server/client-communication/server.php @@ -1,4 +1,3 @@ -#!/usr/bin/env php + */ +class Client +{ + private ?TransportInterface $transport = null; + + public function __construct( + private readonly Protocol $protocol, + private readonly Configuration $config, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Create a new client builder for fluent configuration. + */ + public static function builder(): Builder + { + return new Builder(); + } + + /** + * Connect to an MCP server using the provided transport. + * + * @throws ConnectionException If connection or initialization fails + */ + public function connect(TransportInterface $transport): void + { + $this->transport = $transport; + $this->protocol->connect($transport, $this->config); + + $transport->connect(); + + $this->logger->info('Client connected and initialized'); + } + + /** + * Check if connected and initialized. + */ + public function isConnected(): bool + { + return null !== $this->transport && $this->protocol->getState()->isInitialized(); + } + + /** + * Get server information from initialization. + */ + public function getServerInfo(): ?Implementation + { + return $this->protocol->getState()->getServerInfo(); + } + + /** + * Get server instructions. + */ + public function getInstructions(): ?string + { + return $this->protocol->getState()->getInstructions(); + } + + /** + * Send a ping request to the server. + */ + public function ping(): void + { + $request = new PingRequest(); + + $this->sendRequest($request); + } + + /** + * List available tools from the server. + */ + public function listTools(?string $cursor = null): ListToolsResult + { + $request = new ListToolsRequest($cursor); + + $response = $this->sendRequest($request); + + return ListToolsResult::fromArray($response->result); + } + + /** + * Call a tool on the server. + * + * @param string $name Tool name + * @param array $arguments Tool arguments + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + */ + public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult + { + $request = new CallToolRequest($name, $arguments); + + $response = $this->sendRequest($request, $onProgress); + + return CallToolResult::fromArray($response->result); + } + + /** + * List available resources from the server. + */ + public function listResources(?string $cursor = null): ListResourcesResult + { + $request = new ListResourcesRequest($cursor); + + $response = $this->sendRequest($request); + + return ListResourcesResult::fromArray($response->result); + } + + /** + * List available resource templates from the server. + */ + public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult + { + $request = new ListResourceTemplatesRequest($cursor); + + $response = $this->sendRequest($request); + + return ListResourceTemplatesResult::fromArray($response->result); + } + + /** + * Read a resource by URI. + * + * @param string $uri The resource URI + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + */ + public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult + { + $request = new ReadResourceRequest($uri); + + $response = $this->sendRequest($request, $onProgress); + + return ReadResourceResult::fromArray($response->result); + } + + /** + * List available prompts from the server. + */ + public function listPrompts(?string $cursor = null): ListPromptsResult + { + $request = new ListPromptsRequest($cursor); + + $response = $this->sendRequest($request); + + return ListPromptsResult::fromArray($response->result); + } + + /** + * Get a prompt from the server. + * + * @param string $name Prompt name + * @param array $arguments Prompt arguments + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + */ + public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult + { + $request = new GetPromptRequest($name, $arguments); + + $response = $this->sendRequest($request, $onProgress); + + return GetPromptResult::fromArray($response->result); + } + + /** + * Request completion suggestions for a prompt or resource argument. + * + * @param PromptReference|ResourceReference $ref The prompt or resource reference + * @param array{name: string, value: string} $argument The argument to complete + */ + public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult + { + $request = new CompletionCompleteRequest($ref, $argument); + + $response = $this->sendRequest($request); + + return CompletionCompleteResult::fromArray($response->result); + } + + /** + * Set the minimum logging level for server log messages. + */ + public function setLoggingLevel(LoggingLevel $level): void + { + $request = new SetLogLevelRequest($level); + + $this->sendRequest($request); + } + + /** + * Send a request to the server and wait for response. + * + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * + * @return Response + * + * @throws RequestException|ConnectionException + */ + private function sendRequest(Request $request, ?callable $onProgress = null): Response + { + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + + $withProgress = null !== $onProgress; + $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress)); + $response = $this->transport->runRequest($fiber, $onProgress); + + if ($response instanceof Error) { + throw RequestException::fromError($response); + } + + return $response; + } + + /** + * Disconnect from the server. + */ + public function disconnect(): void + { + if (null !== $this->transport) { + $this->transport->close(); + $this->transport = null; + $this->logger->info('Client disconnected'); + } + } +} diff --git a/src/Client/Builder.php b/src/Client/Builder.php new file mode 100644 index 0000000..6cfe334 --- /dev/null +++ b/src/Client/Builder.php @@ -0,0 +1,170 @@ + + */ +final class Builder +{ + private string $name = 'mcp-php-client'; + private string $version = '1.0.0'; + private ?string $description = null; + private ?ProtocolVersion $protocolVersion = null; + private ?ClientCapabilities $capabilities = null; + private int $initTimeout = 30; + private int $requestTimeout = 120; + private int $maxRetries = 3; + private ?LoggerInterface $logger = null; + + /** @var NotificationHandlerInterface[] */ + private array $notificationHandlers = []; + + /** @var RequestHandlerInterface[] */ + private array $requestHandlers = []; + + /** + * Set the client name and version. + */ + public function setClientInfo(string $name, string $version, ?string $description = null): self + { + $this->name = $name; + $this->version = $version; + $this->description = $description; + + return $this; + } + + /** + * Set the protocol version to use. + */ + public function setProtocolVersion(ProtocolVersion $protocolVersion): self + { + $this->protocolVersion = $protocolVersion; + + return $this; + } + + /** + * Set client capabilities. + */ + public function setCapabilities(ClientCapabilities $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** + * Set initialization timeout in seconds. + */ + public function setInitTimeout(int $seconds): self + { + $this->initTimeout = $seconds; + + return $this; + } + + /** + * Set request timeout in seconds. + */ + public function setRequestTimeout(int $seconds): self + { + $this->requestTimeout = $seconds; + + return $this; + } + + /** + * Set maximum retry attempts for failed connections. + */ + public function setMaxRetries(int $retries): self + { + $this->maxRetries = $retries; + + return $this; + } + + /** + * Set the logger. + */ + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + /** + * Add a notification handler for server notifications. + */ + public function addNotificationHandler(NotificationHandlerInterface $handler): self + { + $this->notificationHandlers[] = $handler; + + return $this; + } + + /** + * Add a request handler for server requests (e.g., sampling). + * + * @param RequestHandlerInterface $handler + */ + public function addRequestHandler(RequestHandlerInterface $handler): self + { + $this->requestHandlers[] = $handler; + + return $this; + } + + /** + * Build the client instance. + */ + public function build(): Client + { + $logger = $this->logger ?? new NullLogger(); + + $clientInfo = new Implementation( + $this->name, + $this->version, + $this->description, + ); + + $config = new Configuration( + clientInfo: $clientInfo, + capabilities: $this->capabilities ?? new ClientCapabilities(), + protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_06_18, + initTimeout: $this->initTimeout, + requestTimeout: $this->requestTimeout, + maxRetries: $this->maxRetries, + ); + + $protocol = new Protocol( + requestHandlers: $this->requestHandlers, + notificationHandlers: $this->notificationHandlers, + logger: $logger, + ); + + return new Client($protocol, $config, $logger); + } +} diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php new file mode 100644 index 0000000..4dce634 --- /dev/null +++ b/src/Client/Configuration.php @@ -0,0 +1,34 @@ + + */ +class Configuration +{ + public function __construct( + public readonly Implementation $clientInfo, + public readonly ClientCapabilities $capabilities, + public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_06_18, + public readonly int $initTimeout = 30, + public readonly int $requestTimeout = 120, + public readonly int $maxRetries = 3, + ) { + } +} diff --git a/src/Client/Handler/Notification/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php new file mode 100644 index 0000000..c160ccd --- /dev/null +++ b/src/Client/Handler/Notification/LoggingNotificationHandler.php @@ -0,0 +1,43 @@ + + */ +class LoggingNotificationHandler implements NotificationHandlerInterface +{ + /** + * @param callable(LoggingMessageNotification): void $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof LoggingMessageNotification; + } + + public function handle(Notification $notification): void + { + \assert($notification instanceof LoggingMessageNotification); + + ($this->callback)($notification); + } +} diff --git a/src/Client/Handler/Notification/NotificationHandlerInterface.php b/src/Client/Handler/Notification/NotificationHandlerInterface.php new file mode 100644 index 0000000..82092aa --- /dev/null +++ b/src/Client/Handler/Notification/NotificationHandlerInterface.php @@ -0,0 +1,32 @@ + + */ +interface NotificationHandlerInterface +{ + /** + * Check if this handler supports the given notification. + */ + public function supports(Notification $notification): bool; + + /** + * Handle the notification. + */ + public function handle(Notification $notification): void; +} diff --git a/src/Client/Handler/Notification/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php new file mode 100644 index 0000000..3c489bf --- /dev/null +++ b/src/Client/Handler/Notification/ProgressNotificationHandler.php @@ -0,0 +1,52 @@ + + * + * @internal + */ +class ProgressNotificationHandler implements NotificationHandlerInterface +{ + public function __construct( + private readonly ClientStateInterface $state, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof ProgressNotification; + } + + public function handle(Notification $notification): void + { + if (!$notification instanceof ProgressNotification) { + return; + } + + $this->state->storeProgress( + (string) $notification->progressToken, + $notification->progress, + $notification->total, + $notification->message, + ); + } +} diff --git a/src/Client/Handler/Request/RequestHandlerInterface.php b/src/Client/Handler/Request/RequestHandlerInterface.php new file mode 100644 index 0000000..1c05018 --- /dev/null +++ b/src/Client/Handler/Request/RequestHandlerInterface.php @@ -0,0 +1,38 @@ + + */ +interface RequestHandlerInterface +{ + /** + * Check if this handler supports the given request. + */ + public function supports(Request $request): bool; + + /** + * Handle the request and return a response or error. + * + * @return Response|Error + */ + public function handle(Request $request): Response|Error; +} diff --git a/src/Client/Handler/Request/SamplingCallbackInterface.php b/src/Client/Handler/Request/SamplingCallbackInterface.php new file mode 100644 index 0000000..bc0d554 --- /dev/null +++ b/src/Client/Handler/Request/SamplingCallbackInterface.php @@ -0,0 +1,25 @@ + + * + * @author Kyrian Obikwelu + */ +class SamplingRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly SamplingCallbackInterface $callback, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CreateSamplingMessageRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request): Response|Error + { + \assert($request instanceof CreateSamplingMessageRequest); + + try { + $result = $this->callback->__invoke($request); + + return new Response($request->getId(), $result); + } catch (SamplingException $e) { + $this->logger->error('Sampling failed: '.$e->getMessage()); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error during sampling', ['exception' => $e]); + + return Error::forInternalError('Error while sampling LLM', $request->getId()); + } + } +} diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php new file mode 100644 index 0000000..7564845 --- /dev/null +++ b/src/Client/Protocol.php @@ -0,0 +1,318 @@ + + */ +class Protocol +{ + private ?TransportInterface $transport = null; + private ClientStateInterface $state; + private MessageFactory $messageFactory; + private LoggerInterface $logger; + + /** @var NotificationHandlerInterface[] */ + private array $notificationHandlers; + + /** + * @param RequestHandlerInterface[] $requestHandlers + * @param NotificationHandlerInterface[] $notificationHandlers + */ + public function __construct( + private readonly array $requestHandlers = [], + array $notificationHandlers = [], + ?MessageFactory $messageFactory = null, + ?LoggerInterface $logger = null, + ) { + $this->state = new ClientState(); + $this->messageFactory = $messageFactory ?? MessageFactory::make(); + $this->logger = $logger ?? new NullLogger(); + + $this->notificationHandlers = [ + new ProgressNotificationHandler($this->state), + ...$notificationHandlers, + ]; + } + + /** + * Connect this protocol to a transport. + * + * Sets up message handling callbacks. + * + * @param TransportInterface $transport The transport to connect + * @param Configuration $config The client configuration for initialization + */ + public function connect(TransportInterface $transport, Configuration $config): void + { + $this->transport = $transport; + $transport->setState($this->state); + $transport->onInitialize(fn () => $this->initialize($config)); + $transport->onMessage($this->processMessage(...)); + $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); + } + + /** + * Perform the MCP initialization handshake. + * + * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * + * @param Configuration $config The client configuration + * + * @return Response>|Error + */ + public function initialize(Configuration $config): Response|Error + { + $request = new InitializeRequest( + $config->protocolVersion->value, + $config->capabilities, + $config->clientInfo, + ); + + $response = $this->request($request, $config->initTimeout); + + if ($response instanceof Response) { + $initResult = InitializeResult::fromArray($response->result); + $this->state->setServerInfo($initResult->serverInfo); + $this->state->setInstructions($initResult->instructions); + $this->state->setInitialized(true); + + $this->sendNotification(new InitializedNotification()); + + $this->logger->info('Initialization complete', [ + 'server' => $initResult->serverInfo->name, + ]); + } + + return $response; + } + + /** + * Send a request to the server and wait for response. + * + * If a response is immediately available (sync HTTP), returns it. + * Otherwise, suspends the Fiber and waits for the transport to resume it. + * + * @param Request $request The request to send + * @param int $timeout The timeout in seconds + * @param bool $withProgress Whether to attach a progress token to the request + * + * @return Response>|Error + */ + public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error + { + $requestId = $this->state->nextRequestId(); + $request = $request->withId($requestId); + + if ($withProgress) { + $progressToken = "prog-{$requestId}"; + $request = $request->withMeta(['progressToken' => $progressToken]); + } + + $this->state->addPendingRequest($requestId, $timeout); + $this->sendRequest($request); + + $immediate = $this->state->consumeResponse($requestId); + if (null !== $immediate) { + $this->logger->debug('Received immediate response', ['id' => $requestId]); + + return $immediate; + } + + $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + + return \Fiber::suspend([ + 'type' => 'await_response', + 'request_id' => $requestId, + 'timeout' => $timeout, + ]); + } + + /** + * Send a request to the server. + */ + private function sendRequest(Request $request): void + { + $this->logger->debug('Sending request', [ + 'id' => $request->getId(), + 'method' => $request::getMethod(), + ]); + + $encoded = json_encode($request, \JSON_THROW_ON_ERROR); + $this->transport?->send($encoded); + } + + /** + * Send a notification to the server (fire and forget). + */ + public function sendNotification(Notification $notification): void + { + $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + + $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); + $this->transport?->send($encoded); + } + + /** + * Send a response back to the server (for server-initiated requests). + * + * @param Response|Error $response + */ + private function sendResponse(Response|Error $response): void + { + $this->logger->debug('Sending response', ['id' => $response->getId()]); + + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $this->transport?->send($encoded); + } + + /** + * Process an incoming message from the server. + * + * Routes to appropriate handler based on message type. + */ + public function processMessage(string $input): void + { + $this->logger->debug('Received message', ['input' => $input]); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to parse message', ['exception' => $e]); + + return; + } + + foreach ($messages as $message) { + if ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message); + } elseif ($message instanceof Request) { + $this->handleRequest($message); + } elseif ($message instanceof Notification) { + $this->handleNotification($message); + } + } + } + + /** + * Handle a response from the server. + * + * This stores it in session. The transport will pick it up and resume the Fiber. + * + * @param Response|Error $response + */ + private function handleResponse(Response|Error $response): void + { + $requestId = $response->getId(); + + $this->logger->debug('Handling response', ['id' => $requestId]); + + $this->state->storeResponse($requestId, $response->jsonSerialize()); + } + + /** + * Handle a request from the server (e.g., sampling request). + */ + private function handleRequest(Request $request): void + { + $method = $request::getMethod(); + + $this->logger->debug('Received server request', [ + 'method' => $method, + 'id' => $request->getId(), + ]); + + foreach ($this->requestHandlers as $handler) { + if ($handler->supports($request)) { + try { + $response = $handler->handle($request); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error while handling request', [ + 'method' => $method, + 'exception' => $e, + ]); + + $response = Error::forInternalError( + \sprintf('Unexpected error while handling "%s" request', $method), + $request->getId() + ); + } + + $this->sendResponse($response); + + return; + } + } + + $error = Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $method), + $request->getId() + ); + + $this->sendResponse($error); + } + + /** + * Handle a notification from the server. + */ + private function handleNotification(Notification $notification): void + { + $method = $notification::getMethod(); + + $this->logger->debug('Received server notification', [ + 'method' => $method, + ]); + + foreach ($this->notificationHandlers as $handler) { + if ($handler->supports($notification)) { + try { + $handler->handle($notification); + } catch (\Throwable $e) { + $this->logger->warning('Notification handler failed', ['exception' => $e]); + } + + return; + } + } + } + + public function getState(): ClientStateInterface + { + return $this->state; + } +} diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php new file mode 100644 index 0000000..b3e6ba8 --- /dev/null +++ b/src/Client/State/ClientState.php @@ -0,0 +1,136 @@ + + */ +class ClientState implements ClientStateInterface +{ + private int $requestIdCounter = 1; + private bool $initialized = false; + private ?Implementation $serverInfo = null; + private ?string $instructions = null; + + /** @var array */ + private array $pendingRequests = []; + + /** @var array> */ + private array $responses = []; + + /** @var array */ + private array $progressUpdates = []; + + public function nextRequestId(): int + { + return $this->requestIdCounter++; + } + + public function addPendingRequest(int|string $requestId, int $timeout): void + { + $this->pendingRequests[$requestId] = [ + 'request_id' => $requestId, + 'timestamp' => time(), + 'timeout' => $timeout, + ]; + } + + public function removePendingRequest(int|string $requestId): void + { + unset($this->pendingRequests[$requestId]); + } + + public function getPendingRequests(): array + { + return $this->pendingRequests; + } + + public function storeResponse(int|string $requestId, array $responseData): void + { + $this->responses[$requestId] = $responseData; + } + + public function consumeResponse(int|string $requestId): Response|Error|null + { + if (!isset($this->responses[$requestId])) { + return null; + } + + $data = $this->responses[$requestId]; + unset($this->responses[$requestId]); + $this->removePendingRequest($requestId); + + if (isset($data['error'])) { + return Error::fromArray($data); + } + + return Response::fromArray($data); + } + + public function setInitialized(bool $initialized): void + { + $this->initialized = $initialized; + } + + public function isInitialized(): bool + { + return $this->initialized; + } + + public function setServerInfo(Implementation $serverInfo): void + { + $this->serverInfo = $serverInfo; + } + + public function getServerInfo(): ?Implementation + { + return $this->serverInfo; + } + + public function setInstructions(?string $instructions): void + { + $this->instructions = $instructions; + } + + public function getInstructions(): ?string + { + return $this->instructions; + } + + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void + { + $this->progressUpdates[] = [ + 'token' => $token, + 'progress' => $progress, + 'total' => $total, + 'message' => $message, + ]; + } + + public function consumeProgressUpdates(): array + { + $updates = $this->progressUpdates; + $this->progressUpdates = []; + + return $updates; + } +} diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php new file mode 100644 index 0000000..5be054d --- /dev/null +++ b/src/Client/State/ClientStateInterface.php @@ -0,0 +1,115 @@ + + */ +interface ClientStateInterface +{ + /** + * Get the next request ID for outgoing requests. + */ + public function nextRequestId(): int; + + /** + * Add a pending request to track. + * + * @param int|string $requestId The request ID + * @param int $timeout Timeout in seconds + */ + public function addPendingRequest(int|string $requestId, int $timeout): void; + + /** + * Remove a pending request. + */ + public function removePendingRequest(int|string $requestId): void; + + /** + * Get all pending requests. + * + * @return array + */ + public function getPendingRequests(): array; + + /** + * Store a received response. + * + * @param int|string $requestId The request ID + * @param array $responseData The raw response data + */ + public function storeResponse(int|string $requestId, array $responseData): void; + + /** + * Check and consume a response for a request ID. + * + * @return Response>|Error|null + */ + public function consumeResponse(int|string $requestId): Response|Error|null; + + /** + * Set initialization state. + */ + public function setInitialized(bool $initialized): void; + + /** + * Check if connection is initialized. + */ + public function isInitialized(): bool; + + /** + * Store the server info from initialization. + */ + public function setServerInfo(Implementation $serverInfo): void; + + /** + * Get the server info from initialization. + */ + public function getServerInfo(): ?Implementation; + + /** + * Store the server instructions from initialization. + */ + public function setInstructions(?string $instructions): void; + + /** + * Get the server instructions from initialization. + */ + public function getInstructions(): ?string; + + /** + * Store progress data received from a notification. + * + * @param string $token The progress token + * @param float $progress Current progress value + * @param float|null $total Total progress value (if known) + * @param string|null $message Progress message + */ + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void; + + /** + * Consume all pending progress updates. + * + * @return array + */ + public function consumeProgressUpdates(): array; +} diff --git a/src/Client/Transport/BaseTransport.php b/src/Client/Transport/BaseTransport.php new file mode 100644 index 0000000..a1fc193 --- /dev/null +++ b/src/Client/Transport/BaseTransport.php @@ -0,0 +1,126 @@ + + */ +abstract class BaseTransport implements TransportInterface +{ + /** @var callable(): mixed|null */ + protected $initializeCallback; + + /** @var callable(string): void|null */ + protected $messageCallback; + + /** @var callable(\Throwable): void|null */ + protected $errorCallback; + + /** @var callable(string): void|null */ + protected $closeCallback; + + protected ?ClientStateInterface $state = null; + protected LoggerInterface $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + + public function onInitialize(callable $listener): void + { + $this->initializeCallback = $listener; + } + + public function onMessage(callable $listener): void + { + $this->messageCallback = $listener; + } + + public function onError(callable $listener): void + { + $this->errorCallback = $listener; + } + + public function onClose(callable $listener): void + { + $this->closeCallback = $listener; + } + + public function setState(ClientStateInterface $state): void + { + $this->state = $state; + } + + /** + * Perform initialization via the registered callback. + * + * @return mixed The result from the initialization callback + * + * @throws RuntimeException If no initialize listener is registered + */ + protected function handleInitialize(): mixed + { + if (!\is_callable($this->initializeCallback)) { + throw new RuntimeException('No initialize listener registered'); + } + + return ($this->initializeCallback)(); + } + + /** + * Handle an incoming message from the server. + */ + protected function handleMessage(string $message): void + { + if (\is_callable($this->messageCallback)) { + try { + ($this->messageCallback)($message); + } catch (\Throwable $e) { + $this->handleError($e); + } + } + } + + /** + * Handle a transport error. + */ + protected function handleError(\Throwable $error): void + { + $this->logger->error('Transport error', ['exception' => $error]); + + if (\is_callable($this->errorCallback)) { + ($this->errorCallback)($error); + } + } + + /** + * Handle connection close. + */ + protected function handleClose(string $reason): void + { + $this->logger->info('Transport closed', ['reason' => $reason]); + + if (\is_callable($this->closeCallback)) { + ($this->closeCallback)($reason); + } + } +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php new file mode 100644 index 0000000..ca2ff44 --- /dev/null +++ b/src/Client/Transport/HttpTransport.php @@ -0,0 +1,298 @@ + + */ +class HttpTransport extends BaseTransport +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + private ?string $sessionId = null; + + /** @var McpFiber|null */ + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback; + + /** @var StreamInterface|null Active SSE stream being read */ + private ?StreamInterface $activeStream = null; + + /** @var string Buffer for incomplete SSE data */ + private string $sseBuffer = ''; + + /** + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + private readonly string $endpoint, + private readonly array $headers = [], + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function connect(): void + { + $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); + + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + throw new ConnectionException('Initialization failed: '.$result->message); + } + + $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); + } + + public function send(string $data): void + { + $request = $this->requestFactory->createRequest('POST', $this->endpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->streamFactory->createStream($data)); + + if (null !== $this->sessionId) { + $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); + } + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->logger->debug('Sending HTTP request', ['data' => $data]); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + $this->handleError($e); + throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e); + } + + if ($response->hasHeader('Mcp-Session-Id')) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); + } + + $contentType = $response->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'text/event-stream')) { + $this->activeStream = $response->getBody(); + $this->sseBuffer = ''; + } elseif (str_contains($contentType, 'application/json')) { + $body = $response->getBody()->getContents(); + if (!empty($body)) { + $this->handleMessage($body); + } + } + } + + /** + * @param McpFiber $fiber + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + if (null !== $this->sessionId) { + try { + $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) + ->withHeader('Mcp-Session-Id', $this->sessionId); + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->httpClient->sendRequest($request); + $this->logger->info('Session closed', ['session_id' => $this->sessionId]); + } catch (\Throwable $e) { + $this->logger->warning('Failed to close session', ['error' => $e->getMessage()]); + } + } + + $this->sessionId = null; + $this->activeStream = null; + $this->handleClose('Transport closed'); + } + + private function tick(): void + { + $this->processSSEStream(); + $this->processProgress(); + $this->processFiber(); + + usleep(1000); // 1ms + } + + /** + * Read SSE data incrementally from active stream. + */ + private function processSSEStream(): void + { + if (null === $this->activeStream) { + return; + } + + if (!$this->activeStream->eof()) { + $chunk = $this->activeStream->read(4096); + if ('' !== $chunk) { + $this->sseBuffer .= $chunk; + } + } + + while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) { + $event = substr($this->sseBuffer, 0, $pos); + $this->sseBuffer = substr($this->sseBuffer, $pos + 2); + + if (!empty(trim($event))) { + $this->processSSEEvent($event); + } + } + + if ($this->activeStream->eof() && empty($this->sseBuffer)) { + $this->activeStream = null; + } + } + + /** + * Parse a single SSE event and handle the message. + */ + private function processSSEEvent(string $event): void + { + $data = ''; + + foreach (explode("\n", $event) as $line) { + if (str_starts_with($line, 'data:')) { + $data .= trim(substr($line, 5)); + } + } + + if (!empty($data)) { + $this->handleMessage($data); + } + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->state) { + return; + } + + $updates = $this->state->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function processFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->state) { + return; + } + + $pendingRequests = $this->state->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + $response = $this->state->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } +} diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php new file mode 100644 index 0000000..4807461 --- /dev/null +++ b/src/Client/Transport/StdioTransport.php @@ -0,0 +1,289 @@ + + */ +class StdioTransport extends BaseTransport +{ + /** @var resource|null */ + private $process; + + /** @var resource|null */ + private $stdin; + + /** @var resource|null */ + private $stdout; + + /** @var resource|null */ + private $stderr; + + private string $inputBuffer = ''; + + /** @var McpFiber|null */ + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback; + + /** + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables + */ + public function __construct( + private readonly string $command, + private readonly array $args = [], + private readonly ?string $cwd = null, + private readonly ?array $env = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + } + + public function connect(): void + { + $this->spawnProcess(); + + $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); + + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + $this->close(); + throw new ConnectionException('Initialization failed: '.$result->message); + } + + $this->logger->info('Client connected and initialized'); + } + + public function send(string $data): void + { + if (null === $this->stdin || !\is_resource($this->stdin)) { + throw new ConnectionException('Process stdin not available'); + } + + fwrite($this->stdin, $data."\n"); + fflush($this->stdin); + + $this->logger->debug('Sent message to server', ['data' => $data]); + } + + /** + * @param McpFiber $fiber + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + if (\is_resource($this->stdin)) { + fclose($this->stdin); + $this->stdin = null; + } + if (\is_resource($this->stdout)) { + fclose($this->stdout); + $this->stdout = null; + } + if (\is_resource($this->stderr)) { + fclose($this->stderr); + $this->stderr = null; + } + if (\is_resource($this->process)) { + proc_terminate($this->process, 15); // SIGTERM + proc_close($this->process); + $this->process = null; + } + + $this->handleClose('Transport closed'); + } + + private function spawnProcess(): void + { + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $cmd = escapeshellcmd($this->command); + foreach ($this->args as $arg) { + $cmd .= ' '.escapeshellarg($arg); + } + + $this->process = proc_open( + $cmd, + $descriptors, + $pipes, + $this->cwd, + $this->env + ); + + if (!\is_resource($this->process)) { + throw new ConnectionException('Failed to start process: '.$cmd); + } + + $this->stdin = $pipes[0]; + $this->stdout = $pipes[1]; + $this->stderr = $pipes[2]; + + // Set non-blocking mode for reading + stream_set_blocking($this->stdout, false); + stream_set_blocking($this->stderr, false); + + $this->logger->info('Started MCP server process', ['command' => $cmd]); + } + + private function tick(): void + { + $this->processInput(); + $this->processProgress(); + $this->processFiber(); + $this->processStderr(); + + usleep(1000); // 1ms + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->state) { + return; + } + + $updates = $this->state->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function processInput(): void + { + if (null === $this->stdout || !\is_resource($this->stdout)) { + return; + } + + $data = fread($this->stdout, 8192); + if (false !== $data && '' !== $data) { + $this->inputBuffer .= $data; + } + + while (false !== ($pos = strpos($this->inputBuffer, "\n"))) { + $line = substr($this->inputBuffer, 0, $pos); + $this->inputBuffer = substr($this->inputBuffer, $pos + 1); + + $trimmed = trim($line); + if (!empty($trimmed)) { + $this->handleMessage($trimmed); + } + } + } + + private function processFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->state) { + return; + } + + $pendingRequests = $this->state->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + // Check if response arrived + $response = $this->state->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + // Check timeout + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } + + private function processStderr(): void + { + if (null === $this->stderr || !\is_resource($this->stderr)) { + return; + } + + $stderr = fread($this->stderr, 8192); + if (false !== $stderr && '' !== $stderr) { + $this->logger->debug('Server stderr', ['output' => trim($stderr)]); + } + } +} diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php new file mode 100644 index 0000000..4860128 --- /dev/null +++ b/src/Client/Transport/TransportInterface.php @@ -0,0 +1,107 @@ +|Error) + * @phpstan-type FiberResume (Response|Error) + * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int} + * @phpstan-type McpFiber \Fiber + * + * @author Kyrian Obikwelu + */ +interface TransportInterface +{ + /** + * Connect to the MCP server and perform initialization handshake. + * + * This method blocks until: + * - Initialization completes successfully + * - Connection fails (throws ConnectionException) + * + * @throws \Mcp\Exception\ConnectionException + */ + public function connect(): void; + + /** + * Send a message to the server immediately. + * + * @param string $data JSON-encoded message + */ + public function send(string $data): void; + + /** + * Run a request fiber to completion. + * + * The transport starts the fiber, runs its internal loop, and resumes + * the fiber when a response arrives or timeout occurs. + * + * During the loop, the transport checks session for progress data and + * executes the callback if provided. + * + * @param McpFiber $fiber The fiber to execute + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + * + * @return Response>|Error The response or error + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error; + + /** + * Close the transport and clean up resources. + */ + public function close(): void; + + /** + * Register callback for initialization handshake. + * + * The callback should return a Fiber that performs the initialization. + * + * @param callable(): mixed $callback + */ + public function onInitialize(callable $callback): void; + + /** + * Register callback for incoming messages from server. + * + * @param callable(string $message): void $callback + */ + public function onMessage(callable $callback): void; + + /** + * Register callback for transport errors. + * + * @param callable(\Throwable $error): void $callback + */ + public function onError(callable $callback): void; + + /** + * Register callback for when connection closes. + * + * @param callable(string $reason): void $callback + */ + public function onClose(callable $callback): void; + + /** + * Set the client state for runtime state management. + */ + public function setState(ClientStateInterface $state): void; +} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 0000000..4e4527f --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,21 @@ + + */ +class ConnectionException extends Exception +{ +} diff --git a/src/Exception/RequestException.php b/src/Exception/RequestException.php new file mode 100644 index 0000000..44b8a91 --- /dev/null +++ b/src/Exception/RequestException.php @@ -0,0 +1,40 @@ + + */ +class RequestException extends Exception +{ + private ?Error $error; + + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, ?Error $error = null) + { + parent::__construct($message, $code, $previous); + $this->error = $error; + } + + public static function fromError(Error $error): self + { + return new self($error->message, $error->code, null, $error); + } + + public function getError(): ?Error + { + return $this->error; + } +} diff --git a/src/Exception/SamplingException.php b/src/Exception/SamplingException.php new file mode 100644 index 0000000..17abceb --- /dev/null +++ b/src/Exception/SamplingException.php @@ -0,0 +1,24 @@ + + */ +final class SamplingException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Exception/TimeoutException.php b/src/Exception/TimeoutException.php new file mode 100644 index 0000000..8fa5c74 --- /dev/null +++ b/src/Exception/TimeoutException.php @@ -0,0 +1,21 @@ + + */ +class TimeoutException extends Exception +{ +} diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 99aae11..3014405 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -74,17 +74,33 @@ final class CreateSamplingMessageRequest extends Request throw new InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); } + $messages = []; + foreach ($params['messages'] as $messageData) { + if ($messageData instanceof SamplingMessage) { + $messages[] = $messageData; + } elseif (\is_array($messageData)) { + $messages[] = SamplingMessage::fromArray($messageData); + } else { + throw new InvalidArgumentException('Invalid message format in sampling/createMessage.'); + } + } + $preferences = null; if (isset($params['preferences'])) { $preferences = ModelPreferences::fromArray($params['preferences']); } + $includeContext = null; + if (isset($params['includeContext']) && \is_string($params['includeContext'])) { + $includeContext = SamplingContext::tryFrom($params['includeContext']); + } + return new self( - $params['messages'], + $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, - $params['includeContext'] ?? null, + $includeContext, $params['temperature'] ?? null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index 813e8ab..d7fa4c4 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -60,4 +60,18 @@ class CompletionCompleteResult implements ResultInterface return ['completion' => $completion]; } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $completion = $data['completion'] ?? []; + + return new self( + $completion['values'] ?? [], + $completion['total'] ?? null, + $completion['hasMore'] ?? null, + ); + } } From 085e8d9ad61aff5b29747fb7c51ed9236027db06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:23:15 +0100 Subject: [PATCH 007/143] chore(deps): bump actions/setup-node from 5 to 6 (#254) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pipeline.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 5e8bfe8..b708762 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -62,7 +62,7 @@ jobs: coverage: "none" - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: '22' @@ -79,7 +79,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: '22' From a6ed01bec06868dfd6bcbd4577afe84b3f8a4d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:26:45 +0100 Subject: [PATCH 008/143] chore(deps): bump actions/checkout from 5 to 6 (#255) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/pipeline.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ff15d7f..6f1a038 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b708762..2cf12ff 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node uses: actions/setup-node@v6 @@ -126,7 +126,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 From ae320646bf9d0c8d4e3a278f8961dc8733e2820c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 18:09:15 +0100 Subject: [PATCH 009/143] Encapsulate session handling from protocol to new SessionManager (#247) --- CHANGELOG.md | 1 + src/Server/Builder.php | 31 +-- src/Server/Protocol.php | 43 +-- src/Server/Session/Session.php | 5 - src/Server/Session/SessionFactory.php | 32 --- src/Server/Session/SessionInterface.php | 5 - src/Server/Session/SessionManager.php | 69 +++++ ...erface.php => SessionManagerInterface.php} | 18 +- tests/Unit/Server/ProtocolTest.php | 185 ++++++------- tests/Unit/Server/Session/SessionTest.php | 255 ++++++++++++++++++ 10 files changed, 444 insertions(+), 200 deletions(-) delete mode 100644 src/Server/Session/SessionFactory.php create mode 100644 src/Server/Session/SessionManager.php rename src/Server/Session/{SessionFactoryInterface.php => SessionManagerInterface.php} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7b101..05d0364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Rename `Mcp\Server\Session\Psr16StoreSession` to `Mcp\Server\Session\Psr16SessionStore` * Add missing handlers for resource subscribe/unsubscribe and persist subscriptions via session +* Introduce `SessionManager` to encapsulate session handling (replaces `SessionFactory`) and move garbage collection logic from `Protocol`. 0.3.0 ----- diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 03ac1b1..f97dd50 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -23,6 +23,7 @@ use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; @@ -36,8 +37,8 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; -use Mcp\Server\Session\SessionFactory; -use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionManager; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -70,12 +71,10 @@ final class Builder private ?DiscovererInterface $discoverer = null; - private ?SessionFactoryInterface $sessionFactory = null; + private ?SessionManagerInterface $sessionManager = null; private ?SessionStoreInterface $sessionStore = null; - private int $sessionTtl = 3600; - private int $paginationLimit = 50; private ?string $instructions = null; @@ -321,13 +320,15 @@ final class Builder } public function setSession( - SessionStoreInterface $sessionStore, - SessionFactoryInterface $sessionFactory = new SessionFactory(), - int $ttl = 3600, + ?SessionStoreInterface $sessionStore = null, + ?SessionManagerInterface $sessionManager = null, ): self { - $this->sessionFactory = $sessionFactory; $this->sessionStore = $sessionStore; - $this->sessionTtl = $ttl; + $this->sessionManager = $sessionManager; + + if (null !== $sessionManager && null !== $sessionStore) { + throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.'); + } return $this; } @@ -506,9 +507,10 @@ final class Builder new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; - $sessionTtl = $this->sessionTtl ?? 3600; - $sessionFactory = $this->sessionFactory ?? new SessionFactory(); - $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + $sessionManager = $this->sessionManager ?? new SessionManager( + $this->sessionStore ?? new InMemorySessionStore(), + $logger, + ); if (null !== $this->discoveryBasePath) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); @@ -561,8 +563,7 @@ final class Builder requestHandlers: $requestHandlers, notificationHandlers: $notificationHandlers, messageFactory: $messageFactory, - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, + sessionManager: $sessionManager, logger: $logger, eventDispatcher: $this->eventDispatcher, ); diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 3ebe469..d9af4e4 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -25,9 +25,8 @@ use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -70,8 +69,7 @@ class Protocol private readonly array $requestHandlers, private readonly array $notificationHandlers, private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, + private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { @@ -112,7 +110,7 @@ class Protocol { $this->logger->info('Received message to process.', ['message' => $input]); - $this->gcSessions(); + $this->sessionManager->gc(); try { $messages = $this->messageFactory->create($input); @@ -410,7 +408,7 @@ class Protocol */ public function consumeOutgoingMessages(Uuid $sessionId): array { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); $session->set(self::SESSION_OUTGOING_QUEUE, []); $session->save(); @@ -429,7 +427,7 @@ class Protocol */ public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}"); if (null === $responseData) { @@ -471,7 +469,7 @@ class Protocol */ public function getPendingRequests(Uuid $sessionId): array { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); return $session->get(self::SESSION_PENDING_REQUESTS, []); } @@ -498,7 +496,7 @@ class Protocol return; } - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $payloadSessionId = $yieldedValue['session_id'] ?? null; if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) { @@ -582,7 +580,7 @@ class Protocol return null; } - $session = $this->sessionFactory->create($this->sessionStore); + $session = $this->sessionManager->create(); $this->logger->debug('Created new session for initialize', [ 'session_id' => $session->getId()->toRfc4122(), ]); @@ -599,33 +597,14 @@ class Protocol return null; } - if (!$this->sessionStore->exists($sessionId)) { + if (!$this->sessionManager->exists($sessionId)) { $error = Error::forInvalidRequest('Session not found or has expired.'); $this->sendResponse($transport, $error, null, ['status_code' => 404]); return null; } - return $this->sessionFactory->createWithId($sessionId, $this->sessionStore); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - private function gcSessions(): void - { - if (random_int(0, 100) > 1) { - return; - } - - $deletedSessions = $this->sessionStore->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } + return $this->sessionManager->createWithId($sessionId); } /** @@ -633,7 +612,7 @@ class Protocol */ public function destroySession(Uuid $sessionId): void { - $this->sessionStore->destroy($sessionId); + $this->sessionManager->destroy($sessionId); $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); } } diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index f777c22..66f9294 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -44,11 +44,6 @@ class Session implements SessionInterface return $this->id; } - public function getStore(): SessionStoreInterface - { - return $this->store; - } - public function save(): bool { return $this->store->write($this->id, json_encode($this->readData(), \JSON_THROW_ON_ERROR)); diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php deleted file mode 100644 index 0064ae4..0000000 --- a/src/Server/Session/SessionFactory.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -class SessionFactory implements SessionFactoryInterface -{ - public function create(SessionStoreInterface $store): SessionInterface - { - return new Session($store, Uuid::v4()); - } - - public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface - { - return new Session($store, $id); - } -} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php index 8f93a6e..e14d2c6 100644 --- a/src/Server/Session/SessionInterface.php +++ b/src/Server/Session/SessionInterface.php @@ -77,9 +77,4 @@ interface SessionInterface extends \JsonSerializable * @param array $attributes */ public function hydrate(array $attributes): void; - - /** - * Get the session store instance. - */ - public function getStore(): SessionStoreInterface; } diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php new file mode 100644 index 0000000..4932d39 --- /dev/null +++ b/src/Server/Session/SessionManager.php @@ -0,0 +1,69 @@ + + */ +class SessionManager implements SessionManagerInterface +{ + public function __construct( + private readonly SessionStoreInterface $store, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function create(): SessionInterface + { + return new Session($this->store, Uuid::v4()); + } + + public function createWithId(Uuid $id): SessionInterface + { + return new Session($this->store, $id); + } + + public function exists(Uuid $id): bool + { + return $this->store->exists($id); + } + + public function destroy(Uuid $id): bool + { + return $this->store->destroy($id); + } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + public function gc(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->store->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => \count($deletedSessions), + 'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } +} diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionManagerInterface.php similarity index 66% rename from src/Server/Session/SessionFactoryInterface.php rename to src/Server/Session/SessionManagerInterface.php index 1534334..dc601f4 100644 --- a/src/Server/Session/SessionFactoryInterface.php +++ b/src/Server/Session/SessionManagerInterface.php @@ -19,17 +19,29 @@ use Symfony\Component\Uid\Uuid; * * @author Kyrian Obikwelu */ -interface SessionFactoryInterface +interface SessionManagerInterface { /** * Creates a new session with an auto-generated UUID. * This is the standard factory method for creating sessions. */ - public function create(SessionStoreInterface $store): SessionInterface; + public function create(): SessionInterface; /** * Creates a session with a specific UUID. * Use this when you need to reconstruct a session with a known ID. */ - public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface; + public function createWithId(Uuid $id): SessionInterface; + + /** + * Checks if a session with the given UUID exists. + */ + public function exists(Uuid $id): bool; + + /** + * Destroys the session with the given UUID. + */ + public function destroy(Uuid $id): bool; + + public function gc(): void; } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index e15ce05..f1d1c83 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -24,9 +24,8 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; -use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; @@ -36,15 +35,13 @@ use Symfony\Component\Uid\Uuid; final class ProtocolTest extends TestCase { - private MockObject&SessionFactoryInterface $sessionFactory; - private MockObject&SessionStoreInterface $sessionStore; + private MockObject&SessionManagerInterface $sessionManager; /** @var MockObject&TransportInterface */ private MockObject&TransportInterface $transport; protected function setUp(): void { - $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); - $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); $this->transport = $this->createMock(TransportInterface::class); } @@ -65,15 +62,14 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handlerA, $handlerB, $handlerC], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -101,8 +97,8 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $session->method('getId')->willReturn(Uuid::v4()); // Configure session mock for queue operations @@ -130,8 +126,7 @@ final class ProtocolTest extends TestCase requestHandlers: [$handlerA, $handlerB, $handlerC], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -168,8 +163,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -199,8 +193,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -231,8 +224,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -245,7 +237,7 @@ final class ProtocolTest extends TestCase #[TestDox('Non-existent session ID returns error')] public function testNonExistentSessionIdReturnsError(): void { - $this->sessionStore->method('exists')->willReturn(false); + $this->sessionManager->method('exists')->willReturn(false); $this->transport->expects($this->once()) ->method('send') @@ -265,8 +257,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -296,8 +287,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -312,8 +302,8 @@ final class ProtocolTest extends TestCase { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -340,8 +330,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -365,8 +354,8 @@ final class ProtocolTest extends TestCase { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -393,8 +382,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -423,8 +411,8 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -451,8 +439,7 @@ final class ProtocolTest extends TestCase requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -481,8 +468,8 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -509,8 +496,7 @@ final class ProtocolTest extends TestCase requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -539,15 +525,14 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handler], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -571,8 +556,8 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn($sessionId); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -599,8 +584,7 @@ final class ProtocolTest extends TestCase requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -650,8 +634,8 @@ final class ProtocolTest extends TestCase } }); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // The protocol now queues responses instead of sending them directly $session->expects($this->exactly(2)) @@ -661,8 +645,7 @@ final class ProtocolTest extends TestCase requestHandlers: [$handlerA], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -687,8 +670,8 @@ final class ProtocolTest extends TestCase { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $session->expects($this->once())->method('save'); @@ -696,8 +679,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -713,7 +695,7 @@ final class ProtocolTest extends TestCase { $sessionId = Uuid::v4(); - $this->sessionStore->expects($this->once()) + $this->sessionManager->expects($this->once()) ->method('destroy') ->with($sessionId); @@ -721,8 +703,7 @@ final class ProtocolTest extends TestCase requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->destroySession($sessionId); @@ -750,15 +731,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -820,15 +800,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -857,15 +836,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: null, ); @@ -902,15 +880,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -969,15 +946,14 @@ final class ProtocolTest extends TestCase } }); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1021,15 +997,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1074,15 +1049,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1126,15 +1100,14 @@ final class ProtocolTest extends TestCase $session->method('getId')->willReturn(Uuid::v4()); $session->method('get')->willReturn([]); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1176,15 +1149,14 @@ final class ProtocolTest extends TestCase $session->expects($this->once())->method('save'); $session->expects($this->atLeastOnce())->method('set'); - $this->sessionFactory->method('create')->willReturn($session); // create() for initialize - $this->sessionStore->method('exists')->willReturn(false); // No existing session + $this->sessionManager->method('create')->willReturn($session); // create() for initialize + $this->sessionManager->method('exists')->willReturn(false); // No existing session $protocol = new Protocol( requestHandlers: [], // No handlers notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1227,15 +1199,14 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handler], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1289,15 +1260,14 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handler], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: $eventDispatcher, ); @@ -1324,15 +1294,14 @@ final class ProtocolTest extends TestCase $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handler], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, eventDispatcher: null, ); diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index 4d16e1a..bd5e94c 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -14,9 +14,264 @@ namespace Mcp\Tests\Unit\Server\Session; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\UuidV4; class SessionTest extends TestCase { + private InMemorySessionStore $store; + private Session $session; + + protected function setUp(): void + { + $this->store = new InMemorySessionStore(); + $this->session = new Session($this->store); + } + + public function testGetIdReturnsSessionId(): void + { + $id = new UuidV4(); + $session = new Session($this->store, $id); + + $this->assertSame($id, $session->getId()); + } + + public function testSetAndGetSimpleKey(): void + { + $this->session->set('foo', 'bar'); + + $this->assertSame('bar', $this->session->get('foo')); + } + + public function testGetReturnsDefaultWhenKeyDoesNotExist(): void + { + $this->assertNull($this->session->get('nonexistent')); + $this->assertSame('default', $this->session->get('nonexistent', 'default')); + } + + public function testSetAndGetNestedKey(): void + { + $this->session->set('user.name', 'John'); + $this->session->set('user.email', 'john@example.com'); + $this->session->set('user.address.city', 'New York'); + + $this->assertSame('John', $this->session->get('user.name')); + $this->assertSame('john@example.com', $this->session->get('user.email')); + $this->assertSame('New York', $this->session->get('user.address.city')); + $this->assertSame(['city' => 'New York'], $this->session->get('user.address')); + } + + public function testSetDoesNotOverwriteWhenOverwriteIsFalse(): void + { + $this->session->set('key', 'original'); + $this->session->set('key', 'new', overwrite: false); + + $this->assertSame('original', $this->session->get('key')); + } + + public function testSetOverwritesWhenOverwriteIsTrue(): void + { + $this->session->set('key', 'original'); + $this->session->set('key', 'new'); + + $this->assertSame('new', $this->session->get('key')); + } + + public function testHasReturnsTrueForExistingKey(): void + { + $this->session->set('foo', 'bar'); + + $this->assertTrue($this->session->has('foo')); + } + + public function testHasReturnsFalseForNonExistingKey(): void + { + $this->assertFalse($this->session->has('nonexistent')); + } + + public function testHasWorksWithNestedKeys(): void + { + $this->session->set('user.name', 'John'); + + $this->assertTrue($this->session->has('user')); + $this->assertTrue($this->session->has('user.name')); + $this->assertFalse($this->session->has('user.email')); + $this->assertFalse($this->session->has('user.name.first')); + } + + public function testForgetRemovesSimpleKey(): void + { + $this->session->set('foo', 'bar'); + $this->session->forget('foo'); + + $this->assertFalse($this->session->has('foo')); + $this->assertNull($this->session->get('foo')); + } + + public function testForgetRemovesNestedKey(): void + { + $this->session->set('user.name', 'John'); + $this->session->set('user.email', 'john@example.com'); + $this->session->forget('user.name'); + + $this->assertFalse($this->session->has('user.name')); + $this->assertTrue($this->session->has('user.email')); + } + + public function testClearRemovesAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('baz', 'qux'); + $this->session->clear(); + + $this->assertSame([], $this->session->all()); + $this->assertFalse($this->session->has('foo')); + $this->assertFalse($this->session->has('baz')); + } + + public function testPullReturnsValueAndRemovesKey(): void + { + $this->session->set('foo', 'bar'); + + $value = $this->session->pull('foo'); + + $this->assertSame('bar', $value); + $this->assertFalse($this->session->has('foo')); + } + + public function testPullReturnsDefaultWhenKeyDoesNotExist(): void + { + $this->assertNull($this->session->pull('nonexistent')); + $this->assertSame('default', $this->session->pull('also_nonexistent', 'default')); + } + + public function testAllReturnsAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('user.name', 'John'); + + $all = $this->session->all(); + + $this->assertSame([ + 'foo' => 'bar', + 'user' => ['name' => 'John'], + ], $all); + } + + public function testHydrateReplacesAllData(): void + { + $this->session->set('original', 'value'); + + $this->session->hydrate(['new' => 'data', 'nested' => ['key' => 'value']]); + + $this->assertSame([ + 'new' => 'data', + 'nested' => ['key' => 'value'], + ], $this->session->all()); + $this->assertFalse($this->session->has('original')); + } + + public function testJsonSerializeReturnsAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('user.name', 'John'); + + $serialized = $this->session->jsonSerialize(); + + $this->assertSame([ + 'foo' => 'bar', + 'user' => ['name' => 'John'], + ], $serialized); + } + + public function testSavePersistsDataToStore(): void + { + $this->session->set('foo', 'bar'); + $result = $this->session->save(); + + $this->assertTrue($result); + + // Verify data was persisted by creating a new session with the same ID + $newSession = new Session($this->store, $this->session->getId()); + $this->assertSame('bar', $newSession->get('foo')); + } + + public function testSessionLoadsDataFromStoreOnConstruction(): void + { + // Set and save data in one session + $this->session->set('persisted', 'value'); + $this->session->save(); + $sessionId = $this->session->getId(); + + // Create a new session instance with the same ID + $newSession = new Session($this->store, $sessionId); + + $this->assertSame('value', $newSession->get('persisted')); + } + + public function testSetCreatesNestedStructure(): void + { + $this->session->set('a.b.c.d', 'value'); + + $this->assertSame('value', $this->session->get('a.b.c.d')); + $this->assertSame(['d' => 'value'], $this->session->get('a.b.c')); + $this->assertSame(['c' => ['d' => 'value']], $this->session->get('a.b')); + $this->assertSame(['b' => ['c' => ['d' => 'value']]], $this->session->get('a')); + } + + public function testSetOverwritesNonArrayWithNestedStructure(): void + { + $this->session->set('key', 'string_value'); + $this->session->set('key.nested', 'nested_value'); + + $this->assertSame('nested_value', $this->session->get('key.nested')); + $this->assertSame(['nested' => 'nested_value'], $this->session->get('key')); + } + + public function testGetReturnsArrayForIntermediateKey(): void + { + $this->session->set('user.profile.name', 'John'); + $this->session->set('user.profile.age', 30); + + $profile = $this->session->get('user.profile'); + + $this->assertSame(['name' => 'John', 'age' => 30], $profile); + } + + public function testForgetDoesNotThrowWhenKeyDoesNotExist(): void + { + $this->session->forget('nonexistent'); + $this->session->forget('nested.nonexistent'); + + $this->assertFalse($this->session->has('nonexistent')); + } + + public function testSessionCanStoreVariousDataTypes(): void + { + $this->session->set('string', 'value'); + $this->session->set('int', 42); + $this->session->set('float', 3.14); + $this->session->set('bool', true); + $this->session->set('null', null); + $this->session->set('array', ['a', 'b', 'c']); + $this->session->set('assoc', ['key' => 'value']); + + $this->assertSame('value', $this->session->get('string')); + $this->assertSame(42, $this->session->get('int')); + $this->assertSame(3.14, $this->session->get('float')); + $this->assertTrue($this->session->get('bool')); + $this->assertNull($this->session->get('null')); + $this->assertSame(['a', 'b', 'c'], $this->session->get('array')); + $this->assertSame(['key' => 'value'], $this->session->get('assoc')); + } + + public function testSessionGeneratesUniqueIdIfNotProvided(): void + { + $session1 = new Session($this->store); + $session2 = new Session($this->store); + + $this->assertNotEquals($session1->getId()->toRfc4122(), $session2->getId()->toRfc4122()); + } + public function testAll() { $store = $this->getMockBuilder(InMemorySessionStore::class) From ee3132a2f94edb1bae9a7d9dd669f71d31ef80d8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Mar 2026 22:52:59 +0100 Subject: [PATCH 010/143] Add changelog entries for client and server auth (#257) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d0364..8c3e703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.5.0 +----- + +* Add built-in authentication middleware for HTTP transport using OAuth +* Add client component for building MCP clients + 0.4.0 ----- From 591abb46209d4769ba33b0d806b5131b34a28252 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:52:01 +0100 Subject: [PATCH 011/143] chore(deps): update phpdocumentor/reflection-docblock requirement || ^6.0 (#256) Updates the requirements on [phpdocumentor/reflection-docblock](https://github.com/phpDocumentor/ReflectionDocBlock) to permit the latest version. - [Release notes](https://github.com/phpDocumentor/ReflectionDocBlock/releases) - [Commits](https://github.com/phpDocumentor/ReflectionDocBlock/compare/5.6.0...6.0.2) --- updated-dependencies: - dependency-name: phpdocumentor/reflection-docblock dependency-version: 6.0.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 3 ++- tests/Unit/Capability/Discovery/DocBlockParserTest.php | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index d926e5a..5d1c529 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "ext-fileinfo": "*", "opis/json-schema": "^2.4", "php-http/discovery": "^1.20", - "phpdocumentor/reflection-docblock": "^5.6", + "phpdocumentor/reflection-docblock": "^5.6 || ^6.0", "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", @@ -37,6 +37,7 @@ }, "require-dev": { "ext-openssl": "*", + "composer/semver": "^3.0", "firebase/php-jwt": "^6.10 || ^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", diff --git a/tests/Unit/Capability/Discovery/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php index ce924b5..a99a9ca 100644 --- a/tests/Unit/Capability/Discovery/DocBlockParserTest.php +++ b/tests/Unit/Capability/Discovery/DocBlockParserTest.php @@ -11,6 +11,8 @@ namespace Mcp\Tests\Unit\Capability\Discovery; +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; use Mcp\Capability\Discovery\DocBlockParser; use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; @@ -78,7 +80,12 @@ class DocBlockParserTest extends TestCase $this->assertInstanceOf(Param::class, $params['$param5']); $this->assertEquals('param5', $params['$param5']->getVariableName()); - $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); + // Remove if when dropping support for phpdocumentor/reflection-docblock:^5.6 + if (InstalledVersions::satisfies(new VersionParser(), 'phpdocumentor/reflection-docblock', '^6.0')) { + $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); + } else { + $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); + } $this->assertEquals('array description', $this->parser->getParamDescription($params['$param5'])); $this->assertInstanceOf(Param::class, $params['$param6']); From eac6f01d58ac0fc0de439924d210630593a731ac Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 11 Mar 2026 10:25:23 +0100 Subject: [PATCH 012/143] Improve middleware pipeline consistency (#262) - Make MiddlewareRequestHandler final and use index counter instead of array_shift to avoid mutating internal state - Make OAuthProxyMiddleware factory discovery consistently lazy via getter methods, matching httpClient and requestFactory pattern Co-authored-by: Claude Opus 4.6 --- .../Http/Middleware/OAuthProxyMiddleware.php | 22 +++++-------------- .../Middleware/OAuthRequestMetaMiddleware.php | 15 +++++-------- .../Http/MiddlewareRequestHandler.php | 13 ++++++----- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index 846182b..9274852 100644 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -43,8 +43,8 @@ final class OAuthProxyMiddleware implements MiddlewareInterface private const CLIENT_SECRET_BASIC = 'client_secret_basic'; private const CLIENT_SECRET_POST = 'client_secret_post'; - private ?ClientInterface $httpClient; - private ?RequestFactoryInterface $requestFactory; + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; @@ -64,8 +64,8 @@ final class OAuthProxyMiddleware implements MiddlewareInterface ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $this->httpClient = $httpClient; - $this->requestFactory = $requestFactory; + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } @@ -143,7 +143,7 @@ final class OAuthProxyMiddleware implements MiddlewareInterface $body = http_build_query($params); - $upstreamRequest = $this->getRequestFactory() + $upstreamRequest = $this->requestFactory ->createRequest('POST', $tokenEndpoint) ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($this->streamFactory->createStream($body)); @@ -153,7 +153,7 @@ final class OAuthProxyMiddleware implements MiddlewareInterface } try { - $upstreamResponse = $this->getHttpClient()->sendRequest($upstreamRequest); + $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); $responseBody = $upstreamResponse->getBody()->__toString(); return $this->responseFactory @@ -260,14 +260,4 @@ final class OAuthProxyMiddleware implements MiddlewareInterface return array_values(array_unique($normalized)); } - - private function getHttpClient(): ClientInterface - { - return $this->httpClient ??= Psr18ClientDiscovery::find(); - } - - private function getRequestFactory(): RequestFactoryInterface - { - return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); - } } diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php index 9301352..d1595a5 100644 --- a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php @@ -28,9 +28,11 @@ use Psr\Http\Server\RequestHandlerInterface; */ final class OAuthRequestMetaMiddleware implements MiddlewareInterface { - public function __construct( - private ?StreamFactoryInterface $streamFactory = null, - ) { + private StreamFactoryInterface $streamFactory; + + public function __construct(?StreamFactoryInterface $streamFactory = null) + { + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -66,7 +68,7 @@ final class OAuthRequestMetaMiddleware implements MiddlewareInterface return $handler->handle($request); } - $request = $request->withBody($this->getStreamFactory()->createStream($updatedBody)); + $request = $request->withBody($this->streamFactory->createStream($updatedBody)); return $handler->handle($request); } @@ -141,9 +143,4 @@ final class OAuthRequestMetaMiddleware implements MiddlewareInterface return $message; } - - private function getStreamFactory(): StreamFactoryInterface - { - return $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); - } } diff --git a/src/Server/Transport/Http/MiddlewareRequestHandler.php b/src/Server/Transport/Http/MiddlewareRequestHandler.php index 7d37162..32ad372 100644 --- a/src/Server/Transport/Http/MiddlewareRequestHandler.php +++ b/src/Server/Transport/Http/MiddlewareRequestHandler.php @@ -24,24 +24,25 @@ use Psr\Http\Server\RequestHandlerInterface; * * @internal */ -class MiddlewareRequestHandler implements RequestHandlerInterface +final class MiddlewareRequestHandler implements RequestHandlerInterface { + private int $index = 0; + /** * @param list $middleware */ public function __construct( - private array $middleware, - private \Closure $application, + private readonly array $middleware, + private readonly \Closure $application, ) { } public function handle(ServerRequestInterface $request): ResponseInterface { - $middleware = array_shift($this->middleware); - if (null === $middleware) { + if (!isset($this->middleware[$this->index])) { return ($this->application)($request); } - return $middleware->process($request, $this); + return $this->middleware[$this->index++]->process($request, $this); } } From faac4a525c823e74cf18841cb28bf80ac4dbb39e Mon Sep 17 00:00:00 2001 From: simon chrzanowski Date: Fri, 13 Mar 2026 14:39:13 +0100 Subject: [PATCH 013/143] [Server] add a reference handler setter to server builder (#265) * feat: add setReferenceHandler to Builder Allow consumers to provide a custom ReferenceHandlerInterface implementation (e.g. a security-aware decorator) instead of always using the default ReferenceHandler. * test: add BuilderTest for setReferenceHandler - Fluent API returns builder instance - build() succeeds with and without custom handler - Integration test verifies custom handler intercepts tool calls * style: fix codestyle in BuilderTest * style: use imports instead of inline FQCNs in BuilderTest * docs: add changelog entry for setReferenceHandler --- CHANGELOG.md | 1 + src/Server/Builder.php | 12 +++- tests/Unit/Server/BuilderTest.php | 111 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Server/BuilderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3e703..def81ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients +* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) 0.4.0 ----- diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f97dd50..be0866c 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -22,6 +22,7 @@ use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\JsonRpc\MessageFactory; @@ -69,6 +70,8 @@ final class Builder private ?SchemaGeneratorInterface $schemaGenerator = null; + private ?ReferenceHandlerInterface $referenceHandler = null; + private ?DiscovererInterface $discoverer = null; private ?SessionManagerInterface $sessionManager = null; @@ -305,6 +308,13 @@ final class Builder return $this; } + public function setReferenceHandler(ReferenceHandlerInterface $referenceHandler): self + { + $this->referenceHandler = $referenceHandler; + + return $this; + } + public function setDiscoverer(DiscovererInterface $discoverer): self { $this->discoverer = $discoverer; @@ -537,7 +547,7 @@ final class Builder $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); - $referenceHandler = new ReferenceHandler($container); + $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php new file mode 100644 index 0000000..e8e54af --- /dev/null +++ b/tests/Unit/Server/BuilderTest.php @@ -0,0 +1,111 @@ +createStub(ReferenceHandlerInterface::class); + + $builder = Server::builder(); + $result = $builder->setReferenceHandler($referenceHandler); + + $this->assertSame($builder, $result); + } + + #[TestDox('build() succeeds with a custom ReferenceHandler')] + public function testBuildWithCustomReferenceHandler(): void + { + $referenceHandler = $this->createStub(ReferenceHandlerInterface::class); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setReferenceHandler($referenceHandler) + ->build(); + + $this->assertInstanceOf(Server::class, $server); + } + + #[TestDox('build() succeeds without a custom ReferenceHandler (uses default)')] + public function testBuildWithoutCustomReferenceHandler(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->build(); + + $this->assertInstanceOf(Server::class, $server); + } + + #[TestDox('Custom ReferenceHandler is used when calling a tool')] + public function testCustomReferenceHandlerIsUsedForToolCalls(): void + { + $referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $referenceHandler->expects($this->once()) + ->method('handle') + ->willReturnCallback(static function (ElementReference $reference, array $arguments): string { + return 'intercepted'; + }); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setReferenceHandler($referenceHandler) + ->addTool(static fn (): string => 'original', 'test_tool', 'A test tool') + ->build(); + + $result = $this->callTool($server, 'test_tool'); + + $this->assertSame('intercepted', $result); + } + + private function callTool(Server $server, string $toolName): mixed + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof CallToolHandler) { + $request = CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'id' => 'test-1', + 'params' => ['name' => $toolName, 'arguments' => []], + ]); + $session = $this->createStub(SessionInterface::class); + + $response = $handler->handle($request, $session); + + if ($response instanceof Response) { + $content = $response->result->content[0] ?? null; + + return $content instanceof TextContent ? $content->text : null; + } + + $this->fail('Expected Response, got '.$response::class); + } + } + + $this->fail('CallToolHandler not found in request handlers'); + } +} From 6a99196ca0d340f2e1a8b225c943d92d09e24f0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:42:50 +0100 Subject: [PATCH 014/143] chore(deps): bump ramsey/composer-install from 3 to 4 (#268) Bumps [ramsey/composer-install](https://github.com/ramsey/composer-install) from 3 to 4. - [Release notes](https://github.com/ramsey/composer-install/releases) - [Commits](https://github.com/ramsey/composer-install/compare/v3...v4) --- updated-dependencies: - dependency-name: ramsey/composer-install dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/pipeline.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6f1a038..6243cac 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: coverage: "none" - name: Install Composer - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@v4" - name: Generate Documentation run: make docs diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 2cf12ff..b1f1e27 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -42,7 +42,7 @@ jobs: coverage: "none" - name: Install Composer - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@v4" with: dependency-versions: "${{ matrix.dependencies }}" @@ -67,7 +67,7 @@ jobs: node-version: '22' - name: Install Composer - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@v4" - name: Tests run: vendor/bin/phpunit --testsuite=inspector @@ -84,7 +84,7 @@ jobs: node-version: '22' - name: Install Composer - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@v4" - name: Start conformance server run: | @@ -138,7 +138,7 @@ jobs: run: composer validate --strict - name: Install Composer - uses: "ramsey/composer-install@v3" + uses: "ramsey/composer-install@v4" - name: Code Style PHP run: vendor/bin/php-cs-fixer fix --dry-run From e8f5a8ba2253beb2c9657e649cd235b74893833e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 22 Mar 2026 18:46:40 +0100 Subject: [PATCH 015/143] Extend elicitation enum schema compliance (#261) --- CHANGELOG.md | 1 + src/Schema/Elicitation/ElicitationSchema.php | 58 +++- .../MultiSelectEnumSchemaDefinition.php | 127 +++++++++ .../Elicitation/PrimitiveSchemaDefinition.php | 57 ---- .../TitledEnumSchemaDefinition.php | 98 +++++++ .../TitledMultiSelectEnumSchemaDefinition.php | 131 +++++++++ tests/Conformance/Elements.php | 67 +++++ tests/Conformance/conformance-baseline.yml | 4 +- tests/Conformance/server.php | 3 + .../Elicitation/ElicitationSchemaTest.php | 98 +++++++ .../MultiSelectEnumSchemaDefinitionTest.php | 218 +++++++++++++++ .../PrimitiveSchemaDefinitionTest.php | 115 -------- .../TitledEnumSchemaDefinitionTest.php | 200 ++++++++++++++ ...ledMultiSelectEnumSchemaDefinitionTest.php | 253 ++++++++++++++++++ 14 files changed, 1252 insertions(+), 178 deletions(-) create mode 100644 src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php delete mode 100644 src/Schema/Elicitation/PrimitiveSchemaDefinition.php create mode 100644 src/Schema/Elicitation/TitledEnumSchemaDefinition.php create mode 100644 src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php create mode 100644 tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php delete mode 100644 tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index def81ea..9c882ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) +* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` 0.4.0 ----- diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index ff1d3f1..4e4221a 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -25,8 +25,8 @@ use Mcp\Exception\InvalidArgumentException; final class ElicitationSchema implements \JsonSerializable { /** - * @param array $properties Property definitions keyed by name - * @param string[] $required Array of required property names + * @param array $properties Property definitions keyed by name + * @param string[] $required Array of required property names */ public function __construct( public readonly array $properties, @@ -67,7 +67,7 @@ final class ElicitationSchema implements \JsonSerializable if (!\is_array($propertyData)) { throw new InvalidArgumentException(\sprintf('Property "%s" must be an array.', $name)); } - $properties[$name] = PrimitiveSchemaDefinition::fromArray($propertyData); + $properties[$name] = self::createSchemaDefinition($propertyData); } return new self( @@ -76,6 +76,58 @@ final class ElicitationSchema implements \JsonSerializable ); } + /** + * Create a schema definition from array data. + * + * @param array $data + */ + private static function createSchemaDefinition(array $data): AbstractSchemaDefinition + { + if (!isset($data['type']) || !\is_string($data['type'])) { + throw new InvalidArgumentException('Missing or invalid "type" for schema definition.'); + } + + return match ($data['type']) { + 'string' => self::resolveStringType($data), + 'integer', 'number' => NumberSchemaDefinition::fromArray($data), + 'boolean' => BooleanSchemaDefinition::fromArray($data), + 'array' => self::resolveArrayType($data), + default => throw new InvalidArgumentException(\sprintf('Unsupported type "%s". Supported types are: string, integer, number, boolean, array.', $data['type'])), + }; + } + + /** + * @param array $data + */ + private static function resolveStringType(array $data): AbstractSchemaDefinition + { + if (isset($data['oneOf'])) { + return TitledEnumSchemaDefinition::fromArray($data); + } + + if (isset($data['enum'])) { + return EnumSchemaDefinition::fromArray($data); + } + + return StringSchemaDefinition::fromArray($data); + } + + /** + * @param array $data + */ + private static function resolveArrayType(array $data): AbstractSchemaDefinition + { + if (isset($data['items']['anyOf'])) { + return TitledMultiSelectEnumSchemaDefinition::fromArray($data); + } + + if (isset($data['items']['enum'])) { + return MultiSelectEnumSchemaDefinition::fromArray($data); + } + + throw new InvalidArgumentException('Array type must have "items" with either "enum" or "anyOf".'); + } + /** * @return array{ * type: string, diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php new file mode 100644 index 0000000..28046bc --- /dev/null +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -0,0 +1,127 @@ + $maxItems) { + throw new InvalidArgumentException('minItems cannot be greater than maxItems.'); + } + + if (null !== $default) { + foreach ($default as $value) { + if (!\in_array($value, $enum, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $value)); + } + } + } + } + + /** + * @param array{ + * title: string, + * items: array{type: string, enum: string[]}, + * description?: string, + * default?: string[], + * minItems?: int, + * maxItems?: int, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'multi-select enum'); + + if (!isset($data['items']['enum']) || !\is_array($data['items']['enum'])) { + throw new InvalidArgumentException('Missing or invalid "items.enum" for multi-select enum schema definition.'); + } + + return new self( + title: $data['title'], + enum: $data['items']['enum'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + minItems: isset($data['minItems']) ? (int) $data['minItems'] : null, + maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('array'); + $data['items'] = [ + 'type' => 'string', + 'enum' => $this->enum, + ]; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->minItems) { + $data['minItems'] = $this->minItems; + } + + if (null !== $this->maxItems) { + $data['maxItems'] = $this->maxItems; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/PrimitiveSchemaDefinition.php b/src/Schema/Elicitation/PrimitiveSchemaDefinition.php deleted file mode 100644 index fa77d44..0000000 --- a/src/Schema/Elicitation/PrimitiveSchemaDefinition.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -final class PrimitiveSchemaDefinition -{ - /** - * Create a schema definition from array data. - * - * @param array{ - * type: string, - * title: string, - * description?: string, - * default?: mixed, - * enum?: string[], - * enumNames?: string[], - * format?: string, - * minLength?: int, - * maxLength?: int, - * minimum?: int|float, - * maximum?: int|float, - * } $data - */ - public static function fromArray(array $data): StringSchemaDefinition|NumberSchemaDefinition|BooleanSchemaDefinition|EnumSchemaDefinition - { - if (!isset($data['type']) || !\is_string($data['type'])) { - throw new InvalidArgumentException('Missing or invalid "type" for primitive schema definition.'); - } - - return match ($data['type']) { - 'string' => isset($data['enum']) ? EnumSchemaDefinition::fromArray($data) : StringSchemaDefinition::fromArray($data), - 'integer', 'number' => NumberSchemaDefinition::fromArray($data), - 'boolean' => BooleanSchemaDefinition::fromArray($data), - default => throw new InvalidArgumentException(\sprintf('Unsupported primitive type "%s". Supported types are: string, integer, number, boolean.', $data['type'])), - }; - } -} diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php new file mode 100644 index 0000000..54eb568 --- /dev/null +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -0,0 +1,98 @@ + $oneOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string|null $default Optional default value (must match a const) + */ + public function __construct( + string $title, + public readonly array $oneOf, + ?string $description = null, + public readonly ?string $default = null, + ) { + parent::__construct($title, $description); + + if ([] === $oneOf) { + throw new InvalidArgumentException('oneOf array must not be empty.'); + } + + $consts = []; + foreach ($oneOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "title" property.'); + } + $consts[] = $item['const']; + } + + if (null !== $default && !\in_array($default, $consts, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default)); + } + } + + /** + * @param array{ + * title: string, + * oneOf: list, + * description?: string, + * default?: string, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'titled enum'); + + if (!isset($data['oneOf']) || !\is_array($data['oneOf'])) { + throw new InvalidArgumentException('Missing or invalid "oneOf" for titled enum schema definition.'); + } + + return new self( + title: $data['title'], + oneOf: $data['oneOf'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('string'); + $data['oneOf'] = $this->oneOf; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php new file mode 100644 index 0000000..baab9b0 --- /dev/null +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -0,0 +1,131 @@ + $anyOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) + * @param int|null $minItems Optional minimum number of selections + * @param int|null $maxItems Optional maximum number of selections + */ + public function __construct( + string $title, + public readonly array $anyOf, + ?string $description = null, + public readonly ?array $default = null, + public readonly ?int $minItems = null, + public readonly ?int $maxItems = null, + ) { + parent::__construct($title, $description); + + if ([] === $anyOf) { + throw new InvalidArgumentException('anyOf array must not be empty.'); + } + + $consts = []; + foreach ($anyOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "title" property.'); + } + $consts[] = $item['const']; + } + + if (null !== $minItems && $minItems < 0) { + throw new InvalidArgumentException('minItems must be non-negative.'); + } + + if (null !== $maxItems && $maxItems < 0) { + throw new InvalidArgumentException('maxItems must be non-negative.'); + } + + if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) { + throw new InvalidArgumentException('minItems cannot be greater than maxItems.'); + } + + if (null !== $default) { + foreach ($default as $value) { + if (!\in_array($value, $consts, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the anyOf const values.', $value)); + } + } + } + } + + /** + * @param array{ + * title: string, + * items: array{anyOf: list}, + * description?: string, + * default?: string[], + * minItems?: int, + * maxItems?: int, + * } $data + */ + public static function fromArray(array $data): self + { + self::validateTitle($data, 'titled multi-select enum'); + + if (!isset($data['items']['anyOf']) || !\is_array($data['items']['anyOf'])) { + throw new InvalidArgumentException('Missing or invalid "items.anyOf" for titled multi-select enum schema definition.'); + } + + return new self( + title: $data['title'], + anyOf: $data['items']['anyOf'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + minItems: isset($data['minItems']) ? (int) $data['minItems'] : null, + maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->buildBaseJson('array'); + $data['items'] = [ + 'anyOf' => $this->anyOf, + ]; + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->minItems) { + $data['minItems'] = $this->minItems; + } + + if (null !== $this->maxItems) { + $data['maxItems'] = $this->maxItems; + } + + return $data; + } +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 8256a37..6d65fd2 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -16,6 +16,14 @@ use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Elicitation\BooleanSchemaDefinition; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\EnumSchemaDefinition; +use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition; +use Mcp\Schema\Elicitation\NumberSchemaDefinition; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition; +use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition; use Mcp\Schema\Enum\Role; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Protocol; @@ -76,6 +84,65 @@ final class Elements ); } + /** + * @param string $message The message to display to the user + */ + public function toolWithElicitation(RequestContext $context, string $message): string + { + $schema = new ElicitationSchema( + properties: [ + 'username' => new StringSchemaDefinition('Username'), + 'email' => new StringSchemaDefinition('Email'), + ], + ); + + $context->getClientGateway()->elicit($message, $schema); + + return 'ok'; + } + + public function toolWithElicitationDefaults(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'name' => new StringSchemaDefinition('Name', default: 'John Doe'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30), + 'score' => new NumberSchemaDefinition('Score', default: 95.5), + 'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'), + 'verified' => new BooleanSchemaDefinition('Verified', default: true), + ], + ); + + $context->getClientGateway()->elicit('Provide profile information', $schema); + + return 'ok'; + } + + public function toolWithElicitationEnums(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + 'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']), + 'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + ], + ); + + $context->getClientGateway()->elicit('Select options', $schema); + + return 'ok'; + } + public function resourceTemplate(string $id): TextResourceContents { return new TextResourceContents( diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index 2613c0d..de676e8 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,5 +1,3 @@ server: - - tools-call-elicitation - - elicitation-sep1034-defaults - - elicitation-sep1330-enums - dns-rebinding-protection + diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 1b69b8f..35855f6 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -49,6 +49,9 @@ $server = Server::builder() ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') + ->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation') + ->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values') + ->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') diff --git a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php index 1f15c7b..d604558 100644 --- a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php +++ b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php @@ -17,8 +17,11 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Elicitation\BooleanSchemaDefinition; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\EnumSchemaDefinition; +use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition; +use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition; use PHPUnit\Framework\TestCase; final class ElicitationSchemaTest extends TestCase @@ -161,6 +164,101 @@ final class ElicitationSchemaTest extends TestCase $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); } + public function testFromArrayWithMissingPropertyType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "type"'); + + /* @phpstan-ignore argument.type */ + ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['title' => 'Name'], + ], + ]); + } + + public function testFromArrayWithUnsupportedPropertyType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported type "object"'); + + ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['type' => 'object', 'title' => 'Name'], + ], + ]); + } + + public function testFromArrayWithArrayTypeMissingItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array type must have "items" with either "enum" or "anyOf"'); + + ElicitationSchema::fromArray([ + 'properties' => [ + 'tags' => ['type' => 'array', 'title' => 'Tags'], + ], + ]); + } + + public function testFromArrayWithNewEnumTypes(): void + { + $schema = ElicitationSchema::fromArray([ + 'properties' => [ + 'titledSingle' => [ + 'type' => 'string', + 'title' => 'Titled Single', + 'oneOf' => [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ], + ], + 'multiSelect' => [ + 'type' => 'array', + 'title' => 'Multi Select', + 'items' => ['type' => 'string', 'enum' => ['x', 'y']], + ], + 'titledMulti' => [ + 'type' => 'array', + 'title' => 'Titled Multi', + 'items' => [ + 'anyOf' => [ + ['const' => 'c', 'title' => 'Option C'], + ['const' => 'd', 'title' => 'Option D'], + ], + ], + ], + ], + ]); + + $this->assertInstanceOf(TitledEnumSchemaDefinition::class, $schema->properties['titledSingle']); + $this->assertInstanceOf(MultiSelectEnumSchemaDefinition::class, $schema->properties['multiSelect']); + $this->assertInstanceOf(TitledMultiSelectEnumSchemaDefinition::class, $schema->properties['titledMulti']); + } + + public function testFromArrayJsonSerializeRoundTripWithAllTypes(): void + { + $schema = new ElicitationSchema( + [ + 'name' => new StringSchemaDefinition('Name'), + 'rating' => new EnumSchemaDefinition('Rating', ['1', '2', '3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled', [ + ['const' => 'a', 'title' => 'A'], + ]), + 'tags' => new MultiSelectEnumSchemaDefinition('Tags', ['x', 'y']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Multi', [ + ['const' => 'c', 'title' => 'C'], + ]), + ], + ['name'], + ); + + $serialized = $schema->jsonSerialize(); + $restored = ElicitationSchema::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } + public function testJsonSerializeWithMinimalParams(): void { $schema = new ElicitationSchema([ diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php new file mode 100644 index 0000000..167ad08 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -0,0 +1,218 @@ +assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testConstructorWithAllParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertSame('Select languages', $schema->description); + $this->assertSame(['php'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testConstructorWithEmptyEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('enum array must not be empty'); + + new MultiSelectEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithNonStringEnumValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All enum values must be strings'); + + /* @phpstan-ignore argument.type */ + new MultiSelectEnumSchemaDefinition('Test', ['a', 1, 'b']); + } + + public function testConstructorWithNegativeMinItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems must be non-negative'); + + new MultiSelectEnumSchemaDefinition('Test', ['a'], minItems: -1); + } + + public function testConstructorWithNegativeMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('maxItems must be non-negative'); + + new MultiSelectEnumSchemaDefinition('Test', ['a'], maxItems: -1); + } + + public function testConstructorWithMinItemsGreaterThanMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems cannot be greater than maxItems'); + + new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], minItems: 3, maxItems: 1); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the enum array'); + + new MultiSelectEnumSchemaDefinition('Test', ['a', 'b'], default: ['invalid']); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Tags', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ]); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testFromArrayWithAllParams(): void + { + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Tags', + 'description' => 'Select languages', + 'default' => ['php'], + 'minItems' => 1, + 'maxItems' => 3, + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ]); + + $this->assertSame('Tags', $schema->title); + $this->assertSame(['php', 'js', 'go'], $schema->enum); + $this->assertSame('Select languages', $schema->description); + $this->assertSame(['php'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + MultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['type' => 'string', 'enum' => ['a']], + ]); + } + + public function testFromArrayWithMissingItemsEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "items.enum"'); + + /* @phpstan-ignore argument.type */ + MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Test', + 'items' => ['type' => 'string'], + ]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition('Tags', ['php', 'js', 'go']); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Tags', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Tags', + 'description' => 'Select languages', + 'items' => [ + 'type' => 'string', + 'enum' => ['php', 'js', 'go'], + ], + 'default' => ['php'], + 'minItems' => 1, + 'maxItems' => 3, + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $original = new MultiSelectEnumSchemaDefinition( + title: 'Tags', + enum: ['php', 'js', 'go'], + description: 'Select languages', + default: ['php', 'go'], + minItems: 1, + maxItems: 3, + ); + + $serialized = $original->jsonSerialize(); + $restored = MultiSelectEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php deleted file mode 100644 index cbe5f06..0000000 --- a/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php +++ /dev/null @@ -1,115 +0,0 @@ - 'string', - 'title' => 'Name', - ]); - - $this->assertInstanceOf(StringSchemaDefinition::class, $schema); - $this->assertSame('Name', $schema->title); - } - - public function testFromArrayCreatesEnumSchemaForStringWithEnum(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'string', - 'title' => 'Rating', - 'enum' => ['1', '2', '3'], - ]); - - $this->assertInstanceOf(EnumSchemaDefinition::class, $schema); - $this->assertSame('Rating', $schema->title); - $this->assertSame(['1', '2', '3'], $schema->enum); - } - - public function testFromArrayCreatesIntegerSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'integer', - 'title' => 'Age', - ]); - - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); - $this->assertSame('Age', $schema->title); - $this->assertTrue($schema->integerOnly); - } - - public function testFromArrayCreatesNumberSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'number', - 'title' => 'Price', - ]); - - $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); - $this->assertSame('Price', $schema->title); - $this->assertFalse($schema->integerOnly); - } - - public function testFromArrayCreatesBooleanSchema(): void - { - $schema = PrimitiveSchemaDefinition::fromArray([ - 'type' => 'boolean', - 'title' => 'Confirm', - ]); - - $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema); - $this->assertSame('Confirm', $schema->title); - } - - public function testFromArrayWithMissingType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "type"'); - - /* @phpstan-ignore argument.type */ - PrimitiveSchemaDefinition::fromArray(['title' => 'Test']); - } - - public function testFromArrayWithUnsupportedType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported primitive type "object"'); - - PrimitiveSchemaDefinition::fromArray([ - 'type' => 'object', - 'title' => 'Test', - ]); - } - - public function testFromArrayWithArrayType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported primitive type "array"'); - - PrimitiveSchemaDefinition::fromArray([ - 'type' => 'array', - 'title' => 'Test', - ]); - } -} diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php new file mode 100644 index 0000000..f8c706f --- /dev/null +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -0,0 +1,200 @@ + 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testConstructorWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertSame('Choose wisely', $schema->description); + $this->assertSame('b', $schema->default); + } + + public function testConstructorWithEmptyOneOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('oneOf array must not be empty'); + + new TitledEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithMissingConst(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each oneOf item must have a string "const" property'); + + /* @phpstan-ignore argument.type */ + new TitledEnumSchemaDefinition('Test', [['title' => 'A']]); + } + + public function testConstructorWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each oneOf item must have a string "title" property'); + + /* @phpstan-ignore argument.type */ + new TitledEnumSchemaDefinition('Test', [['const' => 'a']]); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the oneOf const values'); + + new TitledEnumSchemaDefinition( + title: 'Test', + oneOf: [['const' => 'a', 'title' => 'A']], + default: 'invalid', + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledEnumSchemaDefinition::fromArray([ + 'title' => 'Pick one', + 'oneOf' => $oneOf, + ]); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testFromArrayWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledEnumSchemaDefinition::fromArray([ + 'title' => 'Pick one', + 'oneOf' => $oneOf, + 'description' => 'Choose wisely', + 'default' => 'b', + ]); + + $this->assertSame('Pick one', $schema->title); + $this->assertSame($oneOf, $schema->oneOf); + $this->assertSame('Choose wisely', $schema->description); + $this->assertSame('b', $schema->default); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + } + + public function testFromArrayWithMissingOneOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "oneOf"'); + + /* @phpstan-ignore argument.type */ + TitledEnumSchemaDefinition::fromArray(['title' => 'Test']); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition('Pick one', $oneOf); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Pick one', + 'oneOf' => $oneOf, + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Pick one', + 'description' => 'Choose wisely', + 'oneOf' => $oneOf, + 'default' => 'b', + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $oneOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $original = new TitledEnumSchemaDefinition( + title: 'Pick one', + oneOf: $oneOf, + description: 'Choose wisely', + default: 'b', + ); + + $serialized = $original->jsonSerialize(); + $restored = TitledEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php new file mode 100644 index 0000000..6c1b776 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -0,0 +1,253 @@ + 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testConstructorWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ['const' => 'c', 'title' => 'Option C'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a', 'c'], + minItems: 1, + maxItems: 3, + ); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertSame('Select all that apply', $schema->description); + $this->assertSame(['a', 'c'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(3, $schema->maxItems); + } + + public function testConstructorWithEmptyAnyOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('anyOf array must not be empty'); + + new TitledMultiSelectEnumSchemaDefinition('Test', []); + } + + public function testConstructorWithMissingConst(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each anyOf item must have a string "const" property'); + + /* @phpstan-ignore argument.type */ + new TitledMultiSelectEnumSchemaDefinition('Test', [['title' => 'A']]); + } + + public function testConstructorWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each anyOf item must have a string "title" property'); + + /* @phpstan-ignore argument.type */ + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a']]); + } + + public function testConstructorWithNegativeMinItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems must be non-negative'); + + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], minItems: -1); + } + + public function testConstructorWithNegativeMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('maxItems must be non-negative'); + + new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a', 'title' => 'A']], maxItems: -1); + } + + public function testConstructorWithMinItemsGreaterThanMaxItems(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minItems cannot be greater than maxItems'); + + new TitledMultiSelectEnumSchemaDefinition( + 'Test', + [['const' => 'a', 'title' => 'A'], ['const' => 'b', 'title' => 'B']], + minItems: 3, + maxItems: 1, + ); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the anyOf const values'); + + new TitledMultiSelectEnumSchemaDefinition( + 'Test', + [['const' => 'a', 'title' => 'A']], + default: ['invalid'], + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Pick many', + 'items' => ['anyOf' => $anyOf], + ]); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minItems); + $this->assertNull($schema->maxItems); + } + + public function testFromArrayWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Pick many', + 'description' => 'Select all that apply', + 'default' => ['a'], + 'minItems' => 1, + 'maxItems' => 2, + 'items' => ['anyOf' => $anyOf], + ]); + + $this->assertSame('Pick many', $schema->title); + $this->assertSame($anyOf, $schema->anyOf); + $this->assertSame('Select all that apply', $schema->description); + $this->assertSame(['a'], $schema->default); + $this->assertSame(1, $schema->minItems); + $this->assertSame(2, $schema->maxItems); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], + ]); + } + + public function testFromArrayWithMissingItemsAnyOf(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "items.anyOf"'); + + /* @phpstan-ignore argument.type */ + TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 'Test', + 'items' => [], + ]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition('Pick many', $anyOf); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Pick many', + 'items' => ['anyOf' => $anyOf], + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $schema = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a'], + minItems: 1, + maxItems: 2, + ); + + $this->assertSame([ + 'type' => 'array', + 'title' => 'Pick many', + 'description' => 'Select all that apply', + 'items' => ['anyOf' => $anyOf], + 'default' => ['a'], + 'minItems' => 1, + 'maxItems' => 2, + ], $schema->jsonSerialize()); + } + + public function testFromArrayJsonSerializeRoundTrip(): void + { + $anyOf = [ + ['const' => 'a', 'title' => 'Option A'], + ['const' => 'b', 'title' => 'Option B'], + ]; + $original = new TitledMultiSelectEnumSchemaDefinition( + title: 'Pick many', + anyOf: $anyOf, + description: 'Select all that apply', + default: ['a', 'b'], + minItems: 1, + maxItems: 2, + ); + + $serialized = $original->jsonSerialize(); + $restored = TitledMultiSelectEnumSchemaDefinition::fromArray($serialized); + + $this->assertSame($serialized, $restored->jsonSerialize()); + } +} From 7a5d5f5cfbbaac52b5f24e5083b22db478f0b011 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 23 Mar 2026 11:52:00 +0100 Subject: [PATCH 016/143] Add soyuka to codeowners to make approvals count (#272) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a478140..29e79b5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @chr-hertel @Nyholm @CodeWithKyrian +* @chr-hertel @Nyholm @CodeWithKyrian @soyuka From 85fddf5f0fd8eb7dfa6503895998a97194cecfae Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 31 Mar 2026 20:37:32 +0100 Subject: [PATCH 017/143] [Client] docs: introduce client SDK documentation and improve README structure (#259) * docs: add comprehensive client SDK documentation - Create new client documentation with examples and API reference - Restructure README for better clarity and flow - Add client examples to documentation index - Update roadmap to mark client component as complete * Update README.md Co-authored-by: Christopher Hertel * Apply suggestions from code review Co-authored-by: Christopher Hertel --------- Co-authored-by: Christopher Hertel --- README.md | 334 ++++++++++++----------- ROADMAP.md | 4 +- docs/client.md | 691 +++++++++++++++++++++++++++++++++++++++++++++++ docs/examples.md | 151 ++++++++++- docs/index.md | 1 + 5 files changed, 1021 insertions(+), 160 deletions(-) create mode 100644 docs/client.md diff --git a/README.md b/README.md index 3232afd..37335b1 100644 --- a/README.md +++ b/README.md @@ -10,202 +10,143 @@ 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. +## Table of Contents + +- [Installation](#installation) +- [Overview](#overview) +- [Server SDK](#server-sdk) +- [Client SDK](#client-sdk) +- [Documentation](#documentation) +- [External Resources](#external-resources) +- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk) +- [Contributing](#contributing) +- [Credits](#credits) +- [License](#license) + ## Installation ```bash composer require mcp/sdk ``` -## Quick Start +## Overview -This example demonstrates the most common usage pattern - a STDIO server using attribute discovery. +The MCP PHP SDK provides both **server** and **client** implementations for the Model Context Protocol, enabling you to: -### 1. Define Your MCP Elements +- **Build MCP Servers**: Expose your PHP application's functionality (tools, resources, prompts) to AI agents +- **Build MCP Clients**: Connect to and interact with MCP servers from your PHP applications -Create a class with MCP capabilities using attributes: +## Server SDK + +Build MCP servers to expose your PHP application's capabilities to AI agents like Claude, Codex, and others. + +### Quick Start ```php - $a + $b, - 'subtract' => $a - $b, - 'multiply' => $a * $b, - 'divide' => $b != 0 ? $a / $b : 'Error: Division by zero', - default => 'Error: Unknown operation' - }; - } - - #[McpResource( - uri: 'config://calculator/settings', - name: 'calculator_config', - mimeType: 'application/json' - )] + #[McpResource(uri: 'config://calculator/settings')] public function getSettings(): array { - return ['precision' => 2, 'allow_negative' => true]; + return ['precision' => 2]; } } -``` - -### 2. Create the Server Script - -Create your MCP server: - -```php -#!/usr/bin/env php -setServerInfo('Calculator Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__, ['.']) // Auto-discover attributes ->build(); $transport = new StdioTransport(); - $server->run($transport); ``` -### 3. Configure Your MCP Client +### Server Capabilities -Add to your client configuration (e.g., Claude Desktop's `mcp.json`): +- **Tools**: Executable functions that AI agents can call +- **Resources**: Data sources that can be read (files, configs, databases) +- **Resource Templates**: Dynamic resources with URI parameters +- **Prompts**: Pre-defined templates for AI interactions +- **Server-Initiated Communication**: Elicitations, sampling, logging, progress notifications -```json -{ - "mcpServers": { - "php-calculator": { - "command": "php", - "args": ["/absolute/path/to/your/server.php"] - } - } -} -``` +### Registration Methods -### 4. Test Your Server - -```bash -# Test with MCP Inspector -npx @modelcontextprotocol/inspector php /path/to/server.php - -# Your AI assistant can now call: -# - add: Add two integers -# - calculate: Perform arithmetic operations -# - Read config://calculator/settings resource -``` - -## Key Features - -### Attribute-Based Discovery - -Define MCP elements using PHP attributes with automatic discovery: +There are multiple ways to register your MCP capabilities—choose the approach that best fits your application's architecture: +**1. Attribute-Based Discovery** — Define capabilities using PHP attributes for automatic discovery: ```php -// Tool with automatic name and description from method #[McpTool] public function generateReport(): string { /* ... */ } -// Tool with custom name -#[McpTool(name: 'custom_name')] -public function myMethod(): string { /* ... */ } - -// Resource with URI and metadata -#[McpResource(uri: 'config://app/settings', mimeType: 'application/json')] +#[McpResource(uri: 'config://app/settings')] public function getConfig(): array { /* ... */ } ``` -### Manual Registration - -Register capabilities programmatically: - +**2. Manual Registration** — Register capabilities programmatically without attributes: ```php $server = Server::builder() - ->addTool([MyClass::class, 'myMethod'], 'tool_name') - ->addResource([MyClass::class, 'getData'], 'data://config') + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') ->build(); ``` -### Multiple Transport Options +**3. Hybrid Approach** — Combine both methods for maximum flexibility: +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) + ->addTool([ExternalService::class, 'process'], 'external') + ->build(); +``` -**STDIO Transport** (Command-line integration): +### Transports + +Choose the transport that matches your deployment environment: + +**1. STDIO Transport** — For command-line integration and local processes: ```php $transport = new StdioTransport(); $server->run($transport); ``` -**HTTP Transport** (Web-based communication): +**2. HTTP Transport** — For web-based servers and distributed systems: ```php $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); $response = $server->run($transport); -// Handle $response in your web application ``` ### Session Management -By default, the SDK uses in-memory sessions. You can configure different session stores: +Configure session storage to maintain state between requests. Choose the backend that fits your infrastructure: +**In-Memory** (default, suitable for STDIO): ```php -use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Session\InMemorySessionStore; -use Mcp\Server\Session\Psr16SessionStore; -use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Adapter\RedisAdapter; - -// Use default in-memory sessions with custom TTL $server = Server::builder() ->setSession(ttl: 7200) // 2 hours ->build(); +``` -// Override with file-based storage +**File-Based** (suitable for single-server HTTP deployments): +```php $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); +``` -// Override with in-memory storage and custom TTL -$server = Server::builder() - ->setSession(new InMemorySessionStore(3600)) - ->build(); - -// Override with PSR-16 cache-based storage -// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) -// composer require psr/simple-cache symfony/cache -$redisAdapter = new RedisAdapter( - RedisAdapter::createConnection('redis://localhost:6379'), - 'mcp_sessions' -); - +**PSR-16 Cache** (for example with Redis for scaled deployments): +```php $server = Server::builder() ->setSession(new Psr16SessionStore( cache: new Psr16Cache($redisAdapter), @@ -215,60 +156,141 @@ $server = Server::builder() ->build(); ``` -### Discovery Caching +[→ Server Documentation](docs/server-builder.md) -Use any PSR-16 cache implementation to cache discovery results and avoid running discovery on every server start: +## Client SDK + +Connect to MCP servers from your PHP applications to access their tools, resources, and prompts. + +### Quick Start ```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); +// Build the client +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); -$server = Server::builder() - ->setDiscovery( - basePath: __DIR__, - scanDirs: ['.', 'src'], // Default: ['.', 'src'] - excludeDirs: ['vendor'], // Default: ['vendor', 'node_modules'] - cache: $cache - ) +// Connect to a server +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +$client->connect($transport); + +// Discover and use capabilities +$tools = $client->listTools(); +$result = $client->callTool('add', ['a' => 5, 'b' => 3]); + +$resources = $client->listResources(); +$content = $client->readResource('config://calculator/settings'); + +$client->disconnect(); +``` + +### Client Capabilities + +- **Tool Calling**: List and execute tools from any MCP server +- **Resource Access**: Read static and dynamic resources +- **Prompt Management**: List and retrieve prompt templates +- **Completion Support**: Request argument completion suggestions + +### Advanced Features + +- **Progress Tracking**: Real-time progress during long operations +```php +$result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: function (float $progress, ?float $total, ?string $message) { + echo "Progress: {$progress}/{$total} - {$message}\n"; + } +); +``` + +- **Sampling Support**: Handle server LLM sampling requests +```php +$samplingHandler = new SamplingRequestHandler($myCallback); +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler($samplingHandler) ->build(); ``` +- **Logging Notifications**: Receive server log messages +```php +$loggingHandler = new LoggingNotificationHandler($myCallback); +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Transports + +Connect to MCP servers using the transport that matches your setup: + +**1. STDIO Transport** — Connect to local server processes: +```php +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +$client->connect($transport); +``` + +**2. HTTP Transport** — Connect to remote or web-based servers: +```php +$transport = new HttpTransport('http://localhost:8000'); + +$client->connect($transport); +``` + +[→ Client Documentation](docs/client.md) + ## Documentation -**Core Concepts:** -- [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration -- [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage -- [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts -- [Client Communication](docs/client-communication.md) - Communicating back to the client from server-side -- [Events](docs/events.md) - Hooking into server lifecycle with events +### Core Concepts -**Learning:** -- [Examples](docs/examples.md) - Comprehensive example walkthroughs +- **[Server Builder](docs/server-builder.md)** — Complete ServerBuilder reference and configuration +- **[Client](docs/client.md)** — Client SDK for connecting to and communicating with MCP servers +- **[Transports](docs/transports.md)** — STDIO and HTTP transport setup and usage +- **[MCP Elements](docs/mcp-elements.md)** — Creating tools, resources, prompts, and templates +- **[Server-Client Communication](docs/server-client-communication.md)** — Sampling, logging, progress, and notifications +- **[Events](docs/events.md)** — Hooking into server lifecycle with events -**External Resources:** -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://spec.modelcontextprotocol.io) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) +### Learning & Examples + +- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients +- **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap + +## External Resources + +- **[Model Context Protocol Documentation](https://modelcontextprotocol.io)** — Official MCP documentation +- **[Model Context Protocol Specification](https://spec.modelcontextprotocol.io)** — Protocol specification +- **[Officially Supported Servers](https://github.com/modelcontextprotocol/servers)** — Reference server implementations ## PHP Libraries Using the MCP SDK -* [pronskiy/mcp](https://github.com/pronskiy/mcp) - Additional DX layer -* [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle) - Symfony integration bundle -* [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) - CakePHP integration plugin +- [pronskiy/mcp](https://github.com/pronskiy/mcp) — Additional developer experience layer +- [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle) — Symfony integration bundle +- [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP integration plugin ## Contributing -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in -the project. See the [contributing guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. + +See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits -The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by -[Kyrian Obikwelu](https://github.com/CodeWithKyrian), and the [Symfony AI initiative](https://github.com/symfony/ai). We are grateful for the work -done by both projects and their contributors, which created a solid foundation for this SDK. +The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by [Kyrian Obikwelu](https://github.com/CodeWithKyrian), and the [Symfony AI initiative](https://github.com/symfony/ai). We are grateful for the work done by both projects and their contributors, which created a solid foundation for this SDK. ## License -This project is licensed under the Apache License, Version 2.0 for new contributions, with existing code under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the Apache License, Version 2.0 for new contributions, with existing code under the MIT License — see the [LICENSE](LICENSE) file for details. diff --git a/ROADMAP.md b/ROADMAP.md index cab6bf0..03e4751 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,8 +8,8 @@ This roadmap is a living document that outlines the planned features and improve - [ ] Implement full support for elicitations - [ ] Implement OAuth2 authentication for server - **Client** -- [ ] Implement client-side support -- [ ] Implement client examples and documentation +- [x] Implement client-side support +- [x] Implement client examples and documentation - **Schema** - [ ] Implement schema generation based on TS or JSON Schema diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..304fad7 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,691 @@ +# Client + +The MCP Client SDK provides a synchronous, framework-agnostic API for communicating with MCP servers from PHP applications. +It handles connection management, request/response correlation, server-initiated requests (sampling), and real-time notifications. + +## Table of Contents + +- [Overview](#overview) +- [Client Builder](#client-builder) +- [Transports](#transports) +- [Connecting to Servers](#connecting-to-servers) +- [Server Information](#server-information) +- [Working with Tools](#working-with-tools) +- [Working with Resources](#working-with-resources) +- [Working with Prompts](#working-with-prompts) +- [Server-Initiated Communication](#server-initiated-communication) +- [Error Handling](#error-handling) +- [Complete Example](#complete-example) + +## Overview + +The client follows a builder pattern for configuration and provides a synchronous API for all operations: + +```php +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +// Build and configure the client +$client = Client::builder() + ->setClientInfo('My Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); + +// Create a transport +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +// Connect and use the server +$client->connect($transport); +$tools = $client->listTools(); +$client->disconnect(); +``` + +## Client Builder + +The `Client\Builder` provides fluent configuration of client instances. + +### Basic Configuration + +```php +use Mcp\Client; + +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0', 'Description of my client') + ->setInitTimeout(30) // Seconds to wait for initialization + ->setRequestTimeout(120) // Seconds to wait for request responses + ->setMaxRetries(3) // Retry attempts for failed connections + ->build(); +``` + +### Client Information + +Set the client's identity reported to servers during initialization: + +```php +$client = Client::builder() + ->setClientInfo( + name: 'AI Assistant Client', + version: '2.1.0', + description: 'Client for automated AI workflows' + ) + ->build(); +``` + +### Protocol Version + +Specify the MCP protocol version (defaults to latest): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18) + ->build(); +``` + +### Capabilities + +Declare client capabilities to enable server features: + +```php +use Mcp\Schema\ClientCapabilities; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, // Enable LLM sampling requests from server + roots: true, // Enable filesystem root listing + )) + ->build(); +``` + +### Notification Handlers + +Register handlers for server-initiated notifications: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + echo "[{$notification->level->value}] {$notification->data}\n"; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Request Handlers + +Register handlers for server-initiated requests (e.g., sampling): + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}; + +$client = Client::builder() + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); +``` + +### Logger + +Configure PSR-3 logging for debugging: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-client'); +$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); + +$client = Client::builder() + ->setLogger($logger) + ->build(); +``` + +## Transports + +Transports handle the communication layer between client and server. + +### STDIO Transport + +Spawns a server process and communicates via standard input/output: + +```php +use Mcp\Client\Transport\StdioTransport; + +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], + cwd: '/working/directory', // Optional working directory + env: ['KEY' => 'value'], // Optional environment variables +); +``` + +**Parameters:** +- `command` (string): The command to execute +- `args` (array): Command arguments +- `cwd` (string|null): Working directory for the process +- `env` (array|null): Environment variables +- `logger` (LoggerInterface|null): Optional PSR-3 logger + +### HTTP Transport + +Communicates with remote MCP servers over HTTP: + +```php +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + endpoint: 'http://localhost:8000', + headers: ['Authorization' => 'Bearer token'], +); +``` + +**Parameters:** +- `endpoint` (string): The MCP server URL +- `headers` (array): Additional HTTP headers +- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) +- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) +- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) +- `logger` (LoggerInterface|null): Optional PSR-3 logger + +**PSR-18 Auto-Discovery:** + +The transport automatically discovers PSR-18 HTTP clients from: +- `php-http/guzzle7-adapter` +- `php-http/curl-client` +- `symfony/http-client` +- And other PSR-18 compatible implementations + +```bash +# Install any PSR-18 client - discovery works automatically +composer require php-http/guzzle7-adapter +``` + + +## Connecting to Servers + +### Establishing Connection + +```php +$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 +4. Sends InitializedNotification + +> [!IMPORTANT] +> Always wrap connection in try/catch to handle `ConnectionException` for failed connections. + +### Checking Connection State + +```php +if ($client->isConnected()) { + // Client is connected and initialized +} +``` + +### Disconnecting + +```php +$client->disconnect(); +``` + +Always disconnect when finished to clean up resources: + +```php +try { + $client->connect($transport); + // ... use the client ... +} finally { + $client->disconnect(); +} +``` + +## Server Information + +After successful connection, retrieve server metadata: + +```php +// Get server implementation info +$serverInfo = $client->getServerInfo(); +echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; + +// Get server instructions +$instructions = $client->getInstructions(); +if ($instructions) { + echo "Instructions: {$instructions}\n"; +} +``` + +## Working with Tools + +### Listing Tools + +```php +$toolsResult = $client->listTools(); + +foreach ($toolsResult->tools as $tool) { + echo "- {$tool->name}: {$tool->description}\n"; +} + +// Handle pagination +if ($toolsResult->nextCursor) { + $moreTools = $client->listTools($toolsResult->nextCursor); +} +``` + +### Calling Tools + +```php +$result = $client->callTool( + name: 'calculate', + arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], +); + +// Access results +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } +} +``` + +### Progress Notifications + +Hook into tool execution progress (if server supports it): + +```php +$result = $client->callTool( + name: 'long_running_task', + arguments: ['data' => 'large_dataset'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo "Progress: {$percent}% - {$message}\n"; + } +); +``` + +> [!NOTE] +> Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. + +## Working with Resources + +### Listing Resources + +```php +$resourcesResult = $client->listResources(); + +foreach ($resourcesResult->resources as $resource) { + echo "- {$resource->uri}: {$resource->name}\n"; +} +``` + +### Listing Resource Templates + +```php +$templatesResult = $client->listResourceTemplates(); + +foreach ($templatesResult->resourceTemplates as $template) { + echo "- {$template->uriTemplate}: {$template->name}\n"; +} +``` + +### Reading Resources + +```php +$resourceResult = $client->readResource('config://app/settings'); + +foreach ($resourceResult->contents as $content) { + if ($content instanceof TextResourceContents) { + echo "Text: {$content->text}\n"; + } elseif ($content instanceof BlobResourceContents) { + echo "Binary data (base64): {$content->blob}\n"; + } +} +``` + +Resources also support progress notifications: + +```php +$result = $client->readResource( + uri: 'file://large-file.bin', + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Reading: {$progress}/{$total} bytes\n"; + } +); +``` + +## Working with Prompts + +### Listing Prompts + +```php +$promptsResult = $client->listPrompts(); + +foreach ($promptsResult->prompts as $prompt) { + echo "- {$prompt->name}: {$prompt->description}\n"; +} +``` + +### Getting Prompts + +```php +$promptResult = $client->getPrompt( + name: 'code_review', + arguments: ['language' => 'php', 'code' => '...'], +); + +foreach ($promptResult->messages as $message) { + echo "{$message->role->value}: {$message->content->text}\n"; +} +``` + +Prompts also support progress notifications: + +```php +$result = $client->getPrompt( + name: 'generate_report', + arguments: ['topic' => 'quarterly_analysis'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Generating: {$message}\n"; + } +); +``` + +### Requesting Completions + +Request auto-completion suggestions for prompt or resource arguments: + +```php +use Mcp\Schema\PromptReference; + +$completionResult = $client->complete( + ref: new PromptReference('code_review'), + argument: ['name' => 'language', 'value' => 'ph'], +); + +foreach ($completionResult->values as $value) { + echo "Suggestion: {$value}\n"; +} +``` + +## Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +### Logging Notifications + +Receive structured log messages from the server: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); + +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` + +### Sampling (LLM Requests) + +Handle server requests for LLM completions: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Exception\SamplingException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; + +class LlmSamplingCallback implements SamplingCallbackInterface +{ + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + try { + // Call your LLM provider + $response = $this->llmClient->complete( + messages: $request->messages, + maxTokens: $request->maxTokens, + temperature: $request->temperature ?? 0.7, + ); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response->text), + model: $response->model, + stopReason: $response->stopReason, + ); + } catch (\Throwable $e) { + // Throw SamplingException to surface error to server + throw new SamplingException( + "LLM sampling failed: {$e->getMessage()}", + (int) $e->getCode(), + $e + ); + } + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) + ->build(); +``` + +> [!IMPORTANT] +> **Error Handling in Sampling Callbacks:** +> +> When implementing sampling callbacks, error handling is critical: +> +> - **Throw `SamplingException`** to forward specific error messages to the server +> - **Any other exception** will be logged but return a generic error to the server +> +> This distinction allows you to control what error information the server receives: +> +> ```php +> // Good: Server receives "Rate limit exceeded" message +> throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); +> +> // Bad: Server receives generic "Error while sampling LLM" message +> throw new \RuntimeException('Rate limit exceeded'); +> ``` + +## Error Handling + +The client throws exceptions for various error conditions: + +### ConnectionException + +Thrown when connection or initialization fails: + +```php +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + echo "Failed to connect: {$e->getMessage()}\n"; +} +``` + +### RequestException + +Thrown when a request returns an error response: + +```php +use Mcp\Exception\RequestException; + +try { + $result = $client->callTool('unknown_tool', []); +} catch (RequestException $e) { + echo "Request failed: {$e->getMessage()}\n"; + echo "Error code: {$e->getCode()}\n"; +} +``` + +## Complete Example + +Here's a comprehensive example demonstrating client usage: + +```php +level->value}] {$notification->data}\n"; + } +); + +// Configure sampling callback +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; + + try { + // Integration with your LLM provider + $response = "This is a mock LLM response for: " . + json_encode($request->messages); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response), + model: 'mock-llm', + stopReason: 'end_turn', + ); + } catch (\Throwable $e) { + throw new SamplingException( + "Sampling failed: {$e->getMessage()}", + 0, + $e + ); + } + } +}; + +// Build client +$client = Client::builder() + ->setClientInfo('Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Create transport +$transport = new StdioTransport( + command: 'php', + args: [__DIR__ . '/server.php'], +); + +// Connect and use server +try { + echo "Connecting to server...\n"; + $client->connect($transport); + + // Get server info + $serverInfo = $client->getServerInfo(); + echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; + + // List capabilities + echo "Available tools:\n"; + $tools = $client->listTools(); + foreach ($tools->tools as $tool) { + echo " - {$tool->name}\n"; + } + + echo "\nAvailable resources:\n"; + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + echo " - {$resource->uri}\n"; + } + + // Set logging level + $client->setLoggingLevel(LoggingLevel::Debug); + + // Call tool with progress + echo "\nCalling tool with progress...\n"; + $result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo " Progress: {$percent}% - {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); + echo "\nDisconnected.\n"; +} +``` diff --git a/docs/examples.md b/docs/examples.md index e356f48..4475f13 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,7 +7,8 @@ specific features and can be run independently to understand how the SDK works. - [Getting Started](#getting-started) - [Running Examples](#running-examples) -- [Examples](#examples) +- [Server Examples](#server-examples) +- [Client Examples](#client-examples) ## Getting Started @@ -58,7 +59,7 @@ curl -X POST http://localhost:8000 \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' ``` -## Examples +## Server Examples ### Discovery Calculator @@ -353,3 +354,149 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php 1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields 2. **confirm_action** - Simple boolean confirmation dialog 3. **collect_feedback** - Rating and comments form with optional fields + +## Client Examples + +### STDIO Discovery Calculator (Client) + +**File**: `examples/client/stdio_discovery_calculator.php` + +**What it demonstrates:** +- Basic MCP client usage with STDIO transport +- Connecting to a local MCP server process +- Listing and calling tools +- Reading resources + +**Key Features:** +```php +$client = Client::builder() + ->setClientInfo('STDIO Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/discovery-calculator/server.php'], +); + +$client->connect($transport); +$tools = $client->listTools(); +$result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); +$resourceContent = $client->readResource('config://calculator/settings'); +``` + +**Usage:** +```bash +# Run the client (automatically starts the server) +php examples/client/stdio_discovery_calculator.php +``` + +### HTTP Discovery Calculator (Client) + +**File**: `examples/client/http_discovery_calculator.php` + +**What it demonstrates:** +- MCP client with HTTP transport +- Connecting to remote MCP servers +- Listing tools, resources, and prompts + +**Key Features:** +```php +$transport = new HttpTransport('http://localhost:8000'); +$client->connect($transport); + +$tools = $client->listTools(); +$resources = $client->listResources(); +$prompts = $client->listPrompts(); +``` + +**Usage:** +```bash +# Start the server first +php -S localhost:8000 examples/server/http-discovery-calculator/server.php + +# Then run the client +php examples/client/http_discovery_calculator.php +``` + +### STDIO Client Communication + +**File**: `examples/client/stdio_client_communication.php` + +**What it demonstrates:** +- Server-to-client communication (logging, progress, sampling) +- Handling logging notifications from server +- Implementing sampling callbacks for LLM requests +- Progress tracking during tool execution + +**Key Features:** +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\ClientCapabilities; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $n) { + echo "[LOG {$n->level->value}] {$n->data}\n"; + } +); + +$samplingHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}); + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler($samplingHandler) + ->build(); + +// Call tool with progress tracking +$result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'customer_orders_2024'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } +); +``` + +**Usage:** +```bash +# Run the client (automatically starts the communication server) +php examples/client/stdio_client_communication.php +``` + +### HTTP Client Communication + +**File**: `examples/client/http_client_communication.php` + +**What it demonstrates:** +- Server-to-client communication over HTTP +- Receiving logging and progress notifications via SSE streaming +- Implementing sampling for HTTP-based servers +- Progress tracking with long-running operations + +**Key Features:** +- Same client-side code as STDIO version +- Uses HttpTransport instead of StdioTransport +- Demonstrates SSE-based real-time notifications +- Shows HTTP session management + +**Usage:** +```bash +# Start the server +php -S 127.0.0.1:8000 examples/server/client-communication/server.php + +# Run the client +php examples/client/http_client_communication.php +``` + +> [!NOTE] +> For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. diff --git a/docs/index.md b/docs/index.md index 5bfe45e..86a3018 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,7 @@ - [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. - [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. +- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. - [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. From ea38ef7b621bca33130873f96337e70f96d7de94 Mon Sep 17 00:00:00 2001 From: Sule-Balogun Olanrewaju Date: Sat, 11 Apr 2026 16:19:47 +0100 Subject: [PATCH 018/143] Fix to make Symfony Finder component optional (#267) --- CHANGELOG.md | 1 + composer.json | 5 ++++- src/Capability/Discovery/Discoverer.php | 5 +++++ src/Server/Builder.php | 9 +++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c882ab..9b76d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) * Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` +* [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves 0.4.0 ----- diff --git a/composer.json b/composer.json index 5d1c529..46ea2d7 100644 --- a/composer.json +++ b/composer.json @@ -32,9 +32,11 @@ "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, + "suggest": { + "symfony/finder": "Required for file-based discovery." + }, "require-dev": { "ext-openssl": "*", "composer/semver": "^3.0", @@ -50,6 +52,7 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 3b9ed3a..c682027 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -24,6 +24,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Exception\ExceptionInterface; +use Mcp\Exception\RuntimeException; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; use Mcp\Schema\Resource; @@ -53,6 +54,10 @@ final class Discoverer implements DiscovererInterface private ?DocBlockParser $docBlockParser = null, private ?SchemaGeneratorInterface $schemaGenerator = null, ) { + if (!class_exists(Finder::class)) { + throw new RuntimeException('File-based discovery requires symfony/finder. Run: composer require symfony/finder'); + } + $this->docBlockParser = $docBlockParser ?? new DocBlockParser(logger: $this->logger); $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index be0866c..d2c6c3a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -46,6 +46,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Finder\Finder; /** * @phpstan-import-type Handler from ElementReference @@ -523,8 +524,12 @@ final class Builder ); if (null !== $this->discoveryBasePath) { - $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); + if (null !== $this->discoverer || class_exists(Finder::class)) { + $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); + } else { + $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); + } } foreach ($loaders as $loader) { From 5d57b685a23dc2a984184a403472fa09018958ba Mon Sep 17 00:00:00 2001 From: Sule-Balogun Olanrewaju Date: Sat, 11 Apr 2026 16:21:14 +0100 Subject: [PATCH 019/143] Fix to let schema attribute enum type hint allow null values (#271) --- src/Capability/Attribute/Schema.php | 46 +++++++++---------- src/Capability/Discovery/SchemaGenerator.php | 2 +- .../Discovery/SchemaValidatorTest.php | 3 ++ 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/Capability/Attribute/Schema.php b/src/Capability/Attribute/Schema.php index 5e5507f..80ec4b5 100644 --- a/src/Capability/Attribute/Schema.php +++ b/src/Capability/Attribute/Schema.php @@ -24,8 +24,8 @@ namespace Mcp\Capability\Attribute; * definition?: array, * type?: string, * description?: string, - * enum?: array, - * gormat?: string, + * enum?: array, + * format?: string, * minLength?: int, * maxLength?: int, * pattern?: string, @@ -64,7 +64,7 @@ class Schema public ?string $description = null; public mixed $default = null; /** - * @var ?array + * @var ?array */ public ?array $enum = null; // list of allowed values public ?string $format = null; // e.g., 'email', 'date-time' @@ -105,26 +105,26 @@ class Schema public bool|array|null $additionalProperties = null; // true, false, or a schema array /** - * @param ?array $definition A complete JSON schema array. If provided, other parameters are ignored. - * @param ?string $type the JSON schema type - * @param ?string $description description of the element - * @param ?array $enum allowed enum values - * @param ?string $format String format (e.g., 'date-time', 'email'). - * @param ?int $minLength minimum length for strings - * @param ?int $maxLength maximum length for strings - * @param ?string $pattern regex pattern for strings - * @param int|float|null $minimum minimum value for numbers/integers - * @param int|float|null $maximum maximum value for numbers/integers - * @param ?bool $exclusiveMinimum exclusive minimum - * @param ?bool $exclusiveMaximum exclusive maximum - * @param int|float|null $multipleOf must be a multiple of this value - * @param ?array $items JSON Schema for items if type is 'array' - * @param ?int $minItems minimum items for an array - * @param ?int $maxItems maximum items for an array - * @param ?bool $uniqueItems whether array items must be unique - * @param ?array $properties Property definitions if type is 'object'. [name => schema_array]. - * @param ?array $required list of required properties for an object - * @param bool|array|null $additionalProperties policy for additional properties in an object + * @param ?array $definition A complete JSON schema array. If provided, other parameters are ignored. + * @param ?string $type the JSON schema type + * @param ?string $description description of the element + * @param ?array $enum allowed enum values + * @param ?string $format String format (e.g., 'date-time', 'email'). + * @param ?int $minLength minimum length for strings + * @param ?int $maxLength maximum length for strings + * @param ?string $pattern regex pattern for strings + * @param int|float|null $minimum minimum value for numbers/integers + * @param int|float|null $maximum maximum value for numbers/integers + * @param ?bool $exclusiveMinimum exclusive minimum + * @param ?bool $exclusiveMaximum exclusive maximum + * @param int|float|null $multipleOf must be a multiple of this value + * @param ?array $items JSON Schema for items if type is 'array' + * @param ?int $minItems minimum items for an array + * @param ?int $maxItems maximum items for an array + * @param ?bool $uniqueItems whether array items must be unique + * @param ?array $properties Property definitions if type is 'object'. [name => schema_array]. + * @param ?array $required list of required properties for an object + * @param bool|array|null $additionalProperties policy for additional properties in an object */ public function __construct( ?array $definition = null, diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index dab9cbb..6366fd4 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -46,7 +46,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Param; * type?: string|array, * description?: string, * default?: mixed, - * enum?: array, + * enum?: array, * items?: array, * } * @phpstan-type VariadicParameterSchema array{ diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php index 417ddbd..7e8a207 100644 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -471,6 +471,7 @@ class SchemaValidatorTest extends TestCase 'active' => ['type' => 'boolean'], 'score' => ['type' => 'number'], 'items' => ['type' => 'array', 'items' => ['type' => 'string']], + 'status' => ['enum' => [null, 'active', 'inactive']], 'nullableValue' => ['type' => ['string', 'null']], 'optionalValue' => ['type' => 'string'], ], @@ -486,6 +487,7 @@ class SchemaValidatorTest extends TestCase * active: bool, * score: float, * items: string[], + * status: 'active'|'inactive'|null, * nullableValue: null, * optionalValue: string * } @@ -498,6 +500,7 @@ class SchemaValidatorTest extends TestCase 'active' => true, 'score' => 99.5, 'items' => ['a', 'b'], + 'status' => null, 'nullableValue' => null, 'optionalValue' => 'present', ]; From 863cfc1e78ecc42d34f1c3d3b7e46cebbf28a91a Mon Sep 17 00:00:00 2001 From: simon chrzanowski Date: Sat, 11 Apr 2026 17:27:27 +0200 Subject: [PATCH 020/143] [Server] feat: relax StrictOidcDiscoveryMetadataPolicy and add Dynamic Client Registration middleware (RFC 7591) (#269) * feat: relax OIDC discovery policy and add Dynamic Client Registration middleware (RFC 7591) - Add LenientOidcDiscoveryMetadataPolicy for providers that omit code_challenge_methods_supported (e.g. FusionAuth, Microsoft Entra ID) - Keep StrictOidcDiscoveryMetadataPolicy RFC-aligned - Add ClientRegistrationMiddleware handling POST /register and enriching /.well-known/oauth-authorization-server with registration_endpoint - Update Microsoft example to use built-in LenientOidcDiscoveryMetadataPolicy * fix: move metadata policy note under server.php in Files section Address review comment: the LenientOidcDiscoveryMetadataPolicy bullet was a behavioral note, not a file entry. Inline it into the server.php description to keep the Files list consistent. * fix: address Copilot review comments on ClientRegistrationMiddleware - Decode JSON with assoc=true for registrar data so nested objects are associative arrays, not stdClass instances - Add Cache-Control: no-store to all error responses (400), not just the success response (201) - Rewind response body stream before returning unmodified response when metadata is not valid JSON, preventing empty body on read * fix: harden ClientRegistrationMiddleware and improve test coverage - Revert spurious .gitignore trailing newline - Use JSON_THROW_ON_ERROR consistently in enrichAuthServerMetadata() - Add comment clarifying the second json_decode in handleRegistration() - Assert Cache-Control: no-store on registrar exception error response - Add test for non-object JSON body in metadata enrichment path - Extract createPlainTextHandler helper for non-JSON response tests * fix: reject JSON array bodies in metadata enrichment Treat JSON list arrays (e.g. [] or ["..."]) as invalid metadata in enrichAuthServerMetadata() to prevent corrupting the response shape by adding registration_endpoint to a non-object. * fix: add RFC 7591 error codes, Content-Type validation, and docblock safety warning Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Christopher Hertel Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 + .../MicrosoftOidcMetadataPolicy.php | 38 -- examples/server/oauth-microsoft/README.md | 9 +- examples/server/oauth-microsoft/server.php | 4 +- .../Unit/MicrosoftOidcMetadataPolicyTest.php | 64 --- src/Exception/ClientRegistrationException.php | 23 + .../ClientRegistrationMiddleware.php | 177 +++++++ .../Http/OAuth/ClientRegistrarInterface.php | 44 ++ .../LenientOidcDiscoveryMetadataPolicy.php | 54 ++ .../ClientRegistrationMiddlewareTest.php | 495 ++++++++++++++++++ ...LenientOidcDiscoveryMetadataPolicyTest.php | 125 +++++ .../Http/OAuth/OidcDiscoveryTest.php | 38 +- 12 files changed, 963 insertions(+), 110 deletions(-) delete mode 100644 examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php delete mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php create mode 100644 src/Exception/ClientRegistrationException.php create mode 100644 src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php create mode 100644 src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php create mode 100644 src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b76d86..f0b8b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) * Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` * [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves +* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID) +* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) 0.4.0 ----- diff --git a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php deleted file mode 100644 index fb16cda..0000000 --- a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface -{ - public function isValid(mixed $metadata): bool - { - return \is_array($metadata) - && isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) - && \is_string($metadata['authorization_endpoint']) - && '' !== trim($metadata['authorization_endpoint']) - && \is_string($metadata['token_endpoint']) - && '' !== trim($metadata['token_endpoint']) - && \is_string($metadata['jwks_uri']) - && '' !== trim($metadata['jwks_uri']); - } -} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 7423949..66d51d8 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -148,9 +148,8 @@ curl -X POST http://localhost:8000/mcp \ - `Dockerfile` - PHP-FPM container - `nginx/default.conf` - Nginx configuration - `env.example` - Environment variables template -- `server.php` - MCP server with OAuth middleware +- `server.php` - MCP server with OAuth middleware (uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation) - `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens -- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy - `McpElements.php` - MCP tools including Graph API integration ## Environment Variables @@ -198,8 +197,10 @@ Microsoft's JWKS endpoint is public. Ensure your container can reach: ### `code_challenge_methods_supported` missing in discovery metadata -This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this -field can be missing or malformed and will not fail discovery. +The default `StrictOidcDiscoveryMetadataPolicy` requires `code_challenge_methods_supported`. +Microsoft Entra ID omits this field despite supporting PKCE with S256. +This example uses the built-in `LenientOidcDiscoveryMetadataPolicy` which accepts missing +`code_challenge_methods_supported` (defaults to S256 downstream). ### Graph API errors diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index de16ab5..ad827e0 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -16,7 +16,6 @@ require_once dirname(__DIR__).'/bootstrap.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator; -use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; @@ -25,6 +24,7 @@ use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy; use Mcp\Server\Transport\Http\OAuth\OidcDiscovery; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; @@ -37,7 +37,7 @@ $issuerV1 = "https://sts.windows.net/{$tenantId}/"; $localBaseUrl = 'http://localhost:8000'; $discovery = new OidcDiscovery( - metadataPolicy: new MicrosoftOidcMetadataPolicy(), + metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(), ); $jwtTokenValidator = new JwtTokenValidator( diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php deleted file mode 100644 index 218e0fb..0000000 --- a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -class MicrosoftOidcMetadataPolicyTest extends TestCase -{ - #[TestDox('metadata without code challenge methods is accepted')] - public function testMissingCodeChallengeMethodsIsAccepted(): void - { - $policy = new MicrosoftOidcMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - ]; - - $this->assertTrue($policy->isValid($metadata)); - } - - #[TestDox('malformed code challenge methods are ignored for validity')] - public function testMalformedCodeChallengeMethodsSupportedIsAccepted(): void - { - $policy = new MicrosoftOidcMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - 'token_endpoint' => 'https://auth.example.com/token', - 'jwks_uri' => 'https://auth.example.com/jwks', - 'code_challenge_methods_supported' => 'S256', - ]; - - $this->assertTrue($policy->isValid($metadata)); - } - - #[TestDox('required endpoint fields still enforce validity')] - public function testIsValidRequiresCoreEndpoints(): void - { - $policy = new MicrosoftOidcMetadataPolicy(); - $metadata = [ - 'authorization_endpoint' => 'https://auth.example.com/authorize', - // token_endpoint missing - 'jwks_uri' => 'https://auth.example.com/jwks', - ]; - - $this->assertFalse($policy->isValid($metadata)); - } -} diff --git a/src/Exception/ClientRegistrationException.php b/src/Exception/ClientRegistrationException.php new file mode 100644 index 0000000..3871146 --- /dev/null +++ b/src/Exception/ClientRegistrationException.php @@ -0,0 +1,23 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + if ('POST' === $request->getMethod() && self::REGISTRATION_PATH === $path) { + return $this->handleRegistration($request); + } + + $response = $handler->handle($request); + + if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { + return $this->enrichAuthServerMetadata($response); + } + + return $response; + } + + private function handleRegistration(ServerRequestInterface $request): ResponseInterface + { + $contentType = $request->getHeaderLine('Content-Type'); + if (!str_starts_with($contentType, 'application/json')) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => 'Content-Type must be application/json.', + ], ['Cache-Control' => 'no-store']); + } + + $body = $request->getBody()->__toString(); + + try { + $decoded = json_decode($body, false, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => 'Request body must be valid JSON.', + ], ['Cache-Control' => 'no-store']); + } + + if (!$decoded instanceof \stdClass) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => 'Request body must be a JSON object.', + ], ['Cache-Control' => 'no-store']); + } + + // Re-decode with assoc=true so nested objects become arrays (safe — already validated above) + /** @var array $data */ + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + + try { + $result = $this->registrar->register($data); + } catch (ClientRegistrationException $e) { + return $this->jsonResponse(400, [ + 'error' => $e->errorCode, + 'error_description' => $e->getMessage(), + ], ['Cache-Control' => 'no-store']); + } + + return $this->jsonResponse(201, $result, [ + 'Cache-Control' => 'no-store', + ]); + } + + private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface + { + if (200 !== $response->getStatusCode()) { + return $response; + } + + $stream = $response->getBody(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + try { + $metadata = json_decode($stream->__toString(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + if ($stream->isSeekable()) { + $stream->rewind(); + } + + return $response; + } + + if (!\is_array($metadata) || ([] !== $metadata && array_is_list($metadata))) { + if ($stream->isSeekable()) { + $stream->rewind(); + } + + return $response; + } + + $metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH; + + return $response + ->withBody($this->streamFactory->createStream( + json_encode($metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), + )) + ->withHeader('Content-Type', 'application/json') + ->withoutHeader('Content-Length'); + } + + /** + * @param array $data + * @param array $extraHeaders + */ + private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface + { + $response = $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream( + json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), + )); + + foreach ($extraHeaders as $name => $value) { + if ('' !== $value) { + $response = $response->withHeader($name, $value); + } + } + + return $response; + } +} diff --git a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php new file mode 100644 index 0000000..c0b58dc --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php @@ -0,0 +1,44 @@ + $registrationRequest Client metadata from the registration request body + * + * @return array Registration response including client_id and optional client_secret + * + * @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error). + * The exception message is returned to the client as error_description — + * do not include internal details (database errors, stack traces, etc.). + */ + public function register(array $registrationRequest): array; +} diff --git a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php new file mode 100644 index 0000000..2a8c507 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,54 @@ + + */ +final class LenientOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + if (!\is_array($metadata) + || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + || !\is_string($metadata['authorization_endpoint']) + || '' === trim($metadata['authorization_endpoint']) + || !\is_string($metadata['token_endpoint']) + || '' === trim($metadata['token_endpoint']) + || !\is_string($metadata['jwks_uri']) + || '' === trim($metadata['jwks_uri']) + ) { + return false; + } + + if (\array_key_exists('code_challenge_methods_supported', $metadata)) { + if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { + return false; + } + + foreach ($metadata['code_challenge_methods_supported'] as $method) { + if (!\is_string($method) || '' === trim($method)) { + return false; + } + } + } + + return true; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php new file mode 100644 index 0000000..5636400 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -0,0 +1,495 @@ +factory = new Psr17Factory(); + } + + #[TestDox('POST /register with valid JSON delegates to registrar and returns 201')] + public function testRegistrationSuccess(): void + { + $registrar = $this->createMock(ClientRegistrarInterface::class); + $registrar->expects($this->once()) + ->method('register') + ->with(['redirect_uris' => ['https://example.com/callback']]) + ->willReturn(['client_id' => 'new-client', 'client_secret' => 's3cret']); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']]))); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('new-client', $payload['client_id']); + $this->assertSame('s3cret', $payload['client_secret']); + } + + #[TestDox('POST /register with invalid JSON returns 400')] + public function testRegistrationWithInvalidJson(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('not json')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_client_metadata', $payload['error']); + $this->assertSame('Request body must be valid JSON.', $payload['error_description']); + } + + #[TestDox('POST /register with JSON array instead of object returns 400')] + public function testRegistrationWithJsonArrayReturns400(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('["not","an","object"]')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_client_metadata', $payload['error']); + $this->assertSame('Request body must be a JSON object.', $payload['error_description']); + } + + #[TestDox('POST /register with empty JSON array returns 400')] + public function testRegistrationWithEmptyJsonArrayReturns400(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('[]')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_client_metadata', $payload['error']); + $this->assertSame('Request body must be a JSON object.', $payload['error_description']); + } + + #[TestDox('POST /register with nested JSON objects passes associative arrays to registrar')] + public function testRegistrationWithNestedObjectsPassesAssociativeArrays(): void + { + $registrar = $this->createMock(ClientRegistrarInterface::class); + $registrar->expects($this->once()) + ->method('register') + ->with($this->callback(function (array $data): bool { + // Nested objects must be associative arrays, not stdClass + $this->assertIsArray($data['jwks']); + $this->assertIsArray($data['jwks']['keys'][0]); + $this->assertSame('RSA', $data['jwks']['keys'][0]['kty']); + + return true; + })) + ->willReturn(['client_id' => 'nested-client']); + + $middleware = $this->createMiddleware($registrar); + + $body = json_encode([ + 'redirect_uris' => ['https://example.com/callback'], + 'jwks' => ['keys' => [['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB']]], + ]); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream($body)); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(201, $response->getStatusCode()); + } + + #[TestDox('POST /register error responses include Cache-Control: no-store')] + public function testRegistrationErrorResponsesIncludeCacheControl(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + // Invalid JSON + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('not json')); + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + + // JSON array (not object) + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('["array"]')); + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + } + + #[TestDox('POST /register returns 400 when registrar throws ClientRegistrationException')] + public function testRegistrationWithRegistrarException(): void + { + $registrar = $this->createMock(ClientRegistrarInterface::class); + $registrar->expects($this->once()) + ->method('register') + ->willThrowException(new ClientRegistrationException('redirect_uris is required')); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('{}')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_client_metadata', $payload['error']); + $this->assertSame('redirect_uris is required', $payload['error_description']); + } + + #[TestDox('POST /register without application/json Content-Type returns 400')] + public function testRegistrationRejectsNonJsonContentType(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->factory->createStream('key=value')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_client_metadata', $payload['error']); + $this->assertSame('Content-Type must be application/json.', $payload['error_description']); + } + + #[TestDox('POST /register uses error code from ClientRegistrationException')] + public function testRegistrationUsesCustomErrorCode(): void + { + $registrar = $this->createMock(ClientRegistrarInterface::class); + $registrar->expects($this->once()) + ->method('register') + ->willThrowException(new ClientRegistrationException('Invalid redirect URI', 'invalid_redirect_uri')); + + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('{}')); + + $response = $middleware->process($request, $this->createPassthroughHandler(404)); + + $this->assertSame(400, $response->getStatusCode()); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('invalid_redirect_uri', $payload['error']); + $this->assertSame('Invalid redirect URI', $payload['error_description']); + } + + #[TestDox('GET /.well-known/oauth-authorization-server enriches response with registration_endpoint')] + public function testMetadataEnrichment(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $upstreamMetadata = [ + 'issuer' => 'http://localhost:8000', + 'authorization_endpoint' => 'http://localhost:8000/authorize', + 'token_endpoint' => 'http://localhost:8000/token', + ]; + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createJsonHandler(200, $upstreamMetadata); + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']); + $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); + } + + #[TestDox('GET /.well-known/oauth-authorization-server preserves original response headers')] + public function testMetadataEnrichmentPreservesOriginalHeaders(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], 'max-age=3600', [ + 'X-Custom' => 'preserved', + 'Vary' => 'Origin', + ]); + + $response = $middleware->process($request, $handler); + + $this->assertSame('max-age=3600', $response->getHeaderLine('Cache-Control')); + $this->assertSame('preserved', $response->getHeaderLine('X-Custom')); + $this->assertSame('Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('GET /.well-known/oauth-authorization-server removes stale Content-Length after body mutation')] + public function testMetadataEnrichmentRemovesContentLength(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], '', [ + 'Content-Length' => '42', + ]); + + $response = $middleware->process($request, $handler); + + $this->assertFalse($response->hasHeader('Content-Length')); + } + + #[TestDox('GET /.well-known/oauth-authorization-server with invalid JSON body rewinds stream before returning')] + public function testMetadataEnrichmentRewindsStreamOnInvalidJsonBody(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createPlainTextHandler(200, 'not json'); + + $response = $middleware->process($request, $handler); + + $this->assertSame('not json', $response->getBody()->getContents()); + } + + #[TestDox('GET /.well-known/oauth-authorization-server with non-object JSON body rewinds stream before returning')] + public function testMetadataEnrichmentRewindsStreamOnNonObjectJsonBody(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createPlainTextHandler(200, '"just a string"'); + + $response = $middleware->process($request, $handler); + + $this->assertSame('"just a string"', $response->getBody()->getContents()); + } + + #[TestDox('GET /.well-known/oauth-authorization-server with JSON array body passes through unchanged')] + public function testMetadataEnrichmentPassesThroughJsonArrayBody(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createPlainTextHandler(200, '["not","an","object"]'); + + $response = $middleware->process($request, $handler); + + $this->assertSame('["not","an","object"]', $response->getBody()->getContents()); + } + + #[TestDox('GET /.well-known/oauth-authorization-server with non-200 status passes through unchanged')] + public function testMetadataNon200PassesThrough(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createPassthroughHandler(500); + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + } + + #[TestDox('non-matching routes pass through to next handler')] + public function testNonMatchingRoutePassesThrough(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/mcp'); + $handler = $this->createPassthroughHandler(204); + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } + + #[TestDox('constructor rejects empty localBaseUrl')] + public function testConstructorRejectsEmptyBaseUrl(): void + { + $this->expectException(InvalidArgumentException::class); + + new ClientRegistrationMiddleware( + $this->createStub(ClientRegistrarInterface::class), + '', + $this->factory, + $this->factory, + ); + } + + #[TestDox('localBaseUrl trailing slash is normalized in registration_endpoint')] + public function testTrailingSlashNormalization(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + + $middleware = new ClientRegistrationMiddleware( + $registrar, + 'http://localhost:8000/', + $this->factory, + $this->factory, + ); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000']); + + $response = $middleware->process($request, $handler); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']); + } + + private function createMiddleware(ClientRegistrarInterface $registrar): ClientRegistrationMiddleware + { + return new ClientRegistrationMiddleware( + $registrar, + 'http://localhost:8000', + $this->factory, + $this->factory, + ); + } + + private function createPassthroughHandler(int $status): RequestHandlerInterface + { + $factory = $this->factory; + + return new class($factory, $status) implements RequestHandlerInterface { + public function __construct( + private readonly ResponseFactoryInterface $factory, + private readonly int $status, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse($this->status); + } + }; + } + + /** + * @param array $data + * @param array $extraHeaders + */ + private function createJsonHandler(int $status, array $data, string $cacheControl = '', array $extraHeaders = []): RequestHandlerInterface + { + $factory = $this->factory; + + return new class($factory, $status, $data, $cacheControl, $extraHeaders) implements RequestHandlerInterface { + /** + * @param array $data + * @param array $extraHeaders + */ + public function __construct( + private readonly ResponseFactoryInterface $factory, + private readonly int $status, + private readonly array $data, + private readonly string $cacheControl, + private readonly array $extraHeaders, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = $this->factory->createResponse($this->status) + ->withHeader('Content-Type', 'application/json') + ->withBody((new Psr17Factory())->createStream( + json_encode($this->data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), + )); + + if ('' !== $this->cacheControl) { + $response = $response->withHeader('Cache-Control', $this->cacheControl); + } + + foreach ($this->extraHeaders as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $response; + } + }; + } + + private function createPlainTextHandler(int $status, string $body): RequestHandlerInterface + { + $factory = $this->factory; + + return new class($factory, $status, $body) implements RequestHandlerInterface { + public function __construct( + private readonly ResponseFactoryInterface $factory, + private readonly int $status, + private readonly string $body, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse($this->status) + ->withHeader('Content-Type', 'text/plain') + ->withBody((new Psr17Factory())->createStream($this->body)); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php new file mode 100644 index 0000000..2b67fac --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php @@ -0,0 +1,125 @@ + + */ +class LenientOidcDiscoveryMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is valid (defaults to S256 downstream)')] + public function testMissingCodeChallengeMethodsIsValid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('valid code challenge methods list is accepted')] + public function testValidCodeChallengeMethodsIsAccepted(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('empty code challenge methods list is invalid')] + public function testEmptyCodeChallengeMethodsIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => [], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('non string code challenge method is invalid')] + public function testNonStringCodeChallengeMethodIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256', 123], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('missing required fields is invalid')] + public function testMissingRequiredFieldsIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + + $this->assertFalse($policy->isValid([ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + // missing jwks_uri + ])); + } + + #[TestDox('empty string endpoint is invalid')] + public function testEmptyStringEndpointIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + + $this->assertFalse($policy->isValid([ + 'authorization_endpoint' => '', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ])); + } + + #[TestDox('null code challenge methods is invalid')] + public function testNullCodeChallengeMethodsIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => null, + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('non-array input is invalid')] + public function testNonArrayInputIsInvalid(): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + + $this->assertFalse($policy->isValid('not an array')); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 14eb54f..29c86d6 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Server\Transport\Http\OAuth; use Mcp\Exception\RuntimeException; +use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy; use Mcp\Server\Transport\Http\OAuth\OidcDiscovery; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; @@ -51,7 +52,7 @@ class OidcDiscoveryTest extends TestCase $factory = new Psr17Factory(); $issuer = 'https://auth.example.com'; - $metadataWithoutCodeChallengeMethods = [ + $metadata = [ 'issuer' => $issuer, 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', @@ -62,7 +63,7 @@ class OidcDiscoveryTest extends TestCase $httpClient->expects($this->exactly(2)) ->method('sendRequest') ->willReturn($factory->createResponse(200)->withBody( - $factory->createStream(json_encode($metadataWithoutCodeChallengeMethods, \JSON_THROW_ON_ERROR)), + $factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)), )); $discovery = new OidcDiscovery( @@ -75,6 +76,39 @@ class OidcDiscoveryTest extends TestCase $discovery->discover($issuer); } + #[TestDox('lenient discovery accepts metadata without code challenge methods')] + public function testDiscoverAcceptsMetadataWithoutCodeChallengeMethodsUsingLenientPolicy(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $issuer = 'https://auth.example.com'; + $metadata = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn($factory->createResponse(200)->withBody( + $factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)), + )); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(), + ); + + $result = $discovery->discover($issuer); + + $this->assertSame($metadata['authorization_endpoint'], $result['authorization_endpoint']); + $this->assertArrayNotHasKey('code_challenge_methods_supported', $result); + } + #[TestDox('discover falls back to the next metadata URL when first response is invalid')] public function testDiscoverFallsBackOnInvalidMetadataResponse(): void { From 7af5a345c15d62ab21731b4fb2171740a9152292 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 14 Apr 2026 10:04:35 +0200 Subject: [PATCH 021/143] Add `title` field to Prompt for MCP spec compliance (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP 2025-11-25 specification defines an optional `title` property on Prompt for human-readable display in UI contexts, distinct from the programmatic `name` identifier. This was missing from the SDK. BC Break: Builder::addPrompt() signature changed — $title parameter added between $name and $description. Callers using positional arguments for $description must switch to named arguments. Closes #276 --- CHANGELOG.md | 2 ++ docs/server-builder.md | 1 + src/Capability/Attribute/McpPrompt.php | 2 ++ src/Capability/Discovery/Discoverer.php | 2 +- src/Capability/Registry/Loader/ArrayLoader.php | 1 + src/Schema/Prompt.php | 8 ++++++++ src/Server/Builder.php | 3 ++- tests/Conformance/server.php | 8 ++++---- 8 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b8b1b..2aa1614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves * Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID) * Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) +* Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance +* [BC Break] `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. 0.4.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index c149bf1..3ec22eb 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -352,6 +352,7 @@ $server = Server::builder() - `handler` (callable|string): The prompt handler - `name` (string|null): Optional prompt name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional prompt description - `icons` (Icon[]|null): Optional array of icons for the prompt diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index edb163b..cd47fe2 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -24,12 +24,14 @@ class McpPrompt { /** * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $title Optional human-readable title for display in UI * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. * @param ?Icon[] $icons Optional list of icon URLs representing the prompt * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?array $icons = null, public ?array $meta = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index c682027..586b711 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -276,7 +276,7 @@ final class Discoverer implements DiscovererInterface $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $prompt = new Prompt($name, $description, $arguments, $instance->icons, $instance->meta); + $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 7be19b6..d9a337e 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -252,6 +252,7 @@ final class ArrayLoader implements LoaderInterface } $prompt = new Prompt( name: $name, + title: $data['title'] ?? null, description: $description, arguments: $arguments, icons: $data['icons'] ?? null, diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 45e37f0..68ae773 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -21,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException; * * @phpstan-type PromptData array{ * name: string, + * title?: string, * description?: string, * arguments?: PromptArgumentData[], * icons?: IconData[], @@ -33,6 +34,7 @@ class Prompt implements \JsonSerializable { /** * @param string $name the name of the prompt or prompt template + * @param ?string $title Optional human-readable title for display in UI * @param ?string $description an optional description of what this prompt provides * @param ?PromptArgument[] $arguments A list of arguments for templating. Null if not a template. * @param ?Icon[] $icons optional icons representing the prompt @@ -40,6 +42,7 @@ class Prompt implements \JsonSerializable */ public function __construct( public readonly string $name, + public readonly ?string $title = null, public readonly ?string $description = null, public readonly ?array $arguments = null, public readonly ?array $icons = null, @@ -73,6 +76,7 @@ class Prompt implements \JsonSerializable return new self( name: $data['name'], + title: $data['title'] ?? null, description: $data['description'] ?? null, arguments: $arguments, icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, @@ -83,6 +87,7 @@ class Prompt implements \JsonSerializable /** * @return array{ * name: string, + * title?: string, * description?: string, * arguments?: array, * icons?: Icon[], @@ -92,6 +97,9 @@ class Prompt implements \JsonSerializable public function jsonSerialize(): array { $data = ['name' => $this->name]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index d2c6c3a..f1c5c05 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -473,11 +473,12 @@ final class Builder public function addPrompt( \Closure|array|string $handler, ?string $name = null, + ?string $title = null, ?string $description = null, ?array $icons = null, ?array $meta = null, ): self { - $this->prompts[] = compact('handler', 'name', 'description', 'icons', 'meta'); + $this->prompts[] = compact('handler', 'name', 'title', 'description', 'icons', 'meta'); return $this; } diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 35855f6..471d5d8 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -58,10 +58,10 @@ $server = Server::builder() ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') ->addResource(static fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts - ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments') - ->addPrompt([Elements::class, 'promptWithArguments'], 'test_prompt_with_arguments', 'A prompt with required arguments') - ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], 'test_prompt_with_embedded_resource', 'A prompt that includes an embedded resource') - ->addPrompt([Elements::class, 'promptWithImage'], 'test_prompt_with_image', 'A prompt that includes image content') + ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') + ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') + ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') + ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') ->build(); $response = $server->run($transport); From 73e4cc2456f02721a3f20137aeaede0b320c056e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Mon, 20 Apr 2026 20:49:15 +0200 Subject: [PATCH 022/143] style: apply php-cs-fixer and PHPStan fixes (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: apply php-cs-fixer to test files Addresses drift flagged by the default PHP-CS-Fixer config — mostly adds missing `: void` return types on pre-existing test methods. No behavioural changes. * fix: resolve phpstan errors surfaced by qa - Drop the obsolete `Method ::test*() has no return type specified` ignore rule — every test method now declares a return type, so the pattern never matches and phpstan reports it as unmatched. - Remove `?? []` from two reads of `CallToolRequest::$arguments`; the property is typed `array` (non-nullable), so the coalesce is a no-op that phpstan flags. --------- Co-authored-by: DDEV User --- .../CallToolRequestHandler.php | 2 +- phpstan.dist.neon | 2 - .../Handler/Request/CallToolHandler.php | 2 +- .../Capability/Discovery/DiscoveryTest.php | 10 ++-- .../Discovery/DocBlockParserTest.php | 8 +-- .../Fixtures/NonDiscoverableClass.php | 2 +- .../Discovery/Fixtures/SubDir/HiddenTool.php | 2 +- .../Discovery/HandlerResolverTest.php | 34 ++++++------- .../Discovery/SchemaGeneratorTest.php | 50 +++++++++---------- .../Discovery/SchemaValidatorTest.php | 46 ++++++++--------- .../Capability/Logger/ClientLoggerTest.php | 8 +-- tests/Unit/Schema/IconTest.php | 18 +++---- .../Unit/Schema/JsonRpc/NotificationTest.php | 2 +- tests/Unit/Schema/JsonRpc/RequestTest.php | 2 +- .../CreateSamplingMessageRequestTest.php | 4 +- tests/Unit/Server/Session/SessionTest.php | 6 +-- 16 files changed, 98 insertions(+), 100 deletions(-) diff --git a/examples/server/custom-method-handlers/CallToolRequestHandler.php b/examples/server/custom-method-handlers/CallToolRequestHandler.php index 6714cb5..683d8a0 100644 --- a/examples/server/custom-method-handlers/CallToolRequestHandler.php +++ b/examples/server/custom-method-handlers/CallToolRequestHandler.php @@ -44,7 +44,7 @@ class CallToolRequestHandler implements RequestHandlerInterface \assert($request instanceof CallToolRequest); $name = $request->name; - $args = $request->arguments ?? []; + $args = $request->arguments; if (!isset($this->toolDefinitions[$name])) { return new Error($request->getId(), Error::METHOD_NOT_FOUND, \sprintf('Tool not found: %s', $name)); diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 900f12f..259c468 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -13,8 +13,6 @@ parameters: - tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php treatPhpDocTypesAsCertain: false ignoreErrors: - - - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" - identifier: missingType.iterableValue path: tests/ diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index ab92239..a6a6cd2 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -58,7 +58,7 @@ final class CallToolHandler implements RequestHandlerInterface \assert($request instanceof CallToolRequest); $toolName = $request->name; - $arguments = $request->arguments ?? []; + $arguments = $request->arguments; $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index af024e9..8108106 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -32,7 +32,7 @@ class DiscoveryTest extends TestCase $this->discoverer = new Discoverer(); } - public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() + public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); @@ -105,7 +105,7 @@ class DiscoveryTest extends TestCase $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler); } - public function testDoesNotDiscoverElementsFromExcludedDirectories() + public function testDoesNotDiscoverElementsFromExcludedDirectories(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools()); @@ -114,14 +114,14 @@ class DiscoveryTest extends TestCase $this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools()); } - public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() + public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void { $discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']); $this->assertTrue($discovery->isEmpty()); } - public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() + public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); @@ -138,7 +138,7 @@ class DiscoveryTest extends TestCase $this->assertEquals('An invokable calculator tool.', $tools['InvokableCalculator']->tool->description); } - public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes() + public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); diff --git a/tests/Unit/Capability/Discovery/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php index a99a9ca..a2a832a 100644 --- a/tests/Unit/Capability/Discovery/DocBlockParserTest.php +++ b/tests/Unit/Capability/Discovery/DocBlockParserTest.php @@ -29,7 +29,7 @@ class DocBlockParserTest extends TestCase $this->parser = new DocBlockParser(); } - public function testGetDescriptionReturnsCorrectDescription() + public function testGetDescriptionReturnsCorrectDescription(): void { $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment = $method->getDocComment() ?: null; @@ -43,7 +43,7 @@ class DocBlockParserTest extends TestCase $this->assertEquals('Simple summary line.', $this->parser->getDescription($docBlock2)); } - public function testGetParamTagsReturnsStructuredParamInfo() + public function testGetParamTagsReturnsStructuredParamInfo(): void { $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); $docComment = $method->getDocComment() ?: null; @@ -94,7 +94,7 @@ class DocBlockParserTest extends TestCase $this->assertEquals('object param', $this->parser->getParamDescription($params['$param6'])); } - public function testGetTagsByNameReturnsSpecificTags() + public function testGetTagsByNameReturnsSpecificTags(): void { $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); $docComment = $method->getDocComment() ?: null; @@ -116,7 +116,7 @@ class DocBlockParserTest extends TestCase $this->assertEmpty($nonExistentTags); } - public function testHandlesMethodWithNoDocblockGracefully() + public function testHandlesMethodWithNoDocblockGracefully(): void { $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); $docComment = $method->getDocComment() ?: null; diff --git a/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php b/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php index 21b84c6..eb42d0a 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php +++ b/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php @@ -25,7 +25,7 @@ interface MyDiscoverableInterface trait MyDiscoverableTrait { - public function traitMethod() + public function traitMethod(): void { } } diff --git a/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php b/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php index c519b0b..8053aa6 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php +++ b/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php @@ -16,7 +16,7 @@ use Mcp\Capability\Attribute\McpTool; class HiddenTool { #[McpTool(name: 'hidden_subdir_tool')] - public function run() + public function run(): void { } } diff --git a/tests/Unit/Capability/Discovery/HandlerResolverTest.php b/tests/Unit/Capability/Discovery/HandlerResolverTest.php index 53188bf..6a87a0b 100644 --- a/tests/Unit/Capability/Discovery/HandlerResolverTest.php +++ b/tests/Unit/Capability/Discovery/HandlerResolverTest.php @@ -17,7 +17,7 @@ use PHPUnit\Framework\TestCase; class HandlerResolverTest extends TestCase { - public function testResolvesClosuresToReflectionFunction() + public function testResolvesClosuresToReflectionFunction(): void { $closure = static function (string $input): string { return "processed: $input"; @@ -29,7 +29,7 @@ class HandlerResolverTest extends TestCase $this->assertEquals('string', $returnType->getName()); } - public function testResolvesValidArrayHandler() + public function testResolvesValidArrayHandler(): void { $handler = [ValidHandlerClass::class, 'publicMethod']; $resolved = HandlerResolver::resolve($handler); @@ -38,7 +38,7 @@ class HandlerResolverTest extends TestCase $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); } - public function testResolvesValidInvokableClassStringHandler() + public function testResolvesValidInvokableClassStringHandler(): void { $handler = ValidInvokableClass::class; $resolved = HandlerResolver::resolve($handler); @@ -47,7 +47,7 @@ class HandlerResolverTest extends TestCase $this->assertEquals(ValidInvokableClass::class, $resolved->getDeclaringClass()->getName()); } - public function testResolvesStaticMethodsForManualRegistration() + public function testResolvesStaticMethodsForManualRegistration(): void { $handler = [ValidHandlerClass::class, 'staticMethod']; $resolved = HandlerResolver::resolve($handler); @@ -56,84 +56,84 @@ class HandlerResolverTest extends TestCase $this->assertTrue($resolved->isStatic()); } - public function testThrowsForInvalidArrayHandlerFormatCount() + public function testThrowsForInvalidArrayHandlerFormatCount(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */ } - public function testThrowsForInvalidArrayHandlerFormatTypes() + public function testThrowsForInvalidArrayHandlerFormatTypes(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */ } - public function testThrowsForNonExistentClassInArrayHandler() + public function testThrowsForNonExistentClassInArrayHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Handler class "NonExistentClass" not found'); HandlerResolver::resolve(['NonExistentClass', 'method']); } - public function testThrowsForNonExistentMethodInArrayHandler() + public function testThrowsForNonExistentMethodInArrayHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Handler method "nonExistentMethod" not found in class'); HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']); } - public function testThrowsForNonExistentClassInStringHandler() + public function testThrowsForNonExistentClassInStringHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); HandlerResolver::resolve('NonExistentInvokableClass'); } - public function testThrowsForNonInvokableClassStringHandler() + public function testThrowsForNonInvokableClassStringHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invokable handler class "Mcp\Tests\Unit\Capability\Discovery\NonInvokableClass" must have a public "__invoke" method.'); HandlerResolver::resolve(NonInvokableClass::class); } - public function testThrowsForProtectedMethodHandler() + public function testThrowsForProtectedMethodHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('must be public'); HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); } - public function testThrowsForPrivateMethodHandler() + public function testThrowsForPrivateMethodHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('must be public'); HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']); } - public function testThrowsForConstructorAsHandler() + public function testThrowsForConstructorAsHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('cannot be a constructor or destructor'); HandlerResolver::resolve([ValidHandlerClass::class, '__construct']); } - public function testThrowsForDestructorAsHandler() + public function testThrowsForDestructorAsHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('cannot be a constructor or destructor'); HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); } - public function testThrowsForAbstractMethodHandler() + public function testThrowsForAbstractMethodHandler(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.'); HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); } - public function testResolvesClosuresWithDifferentSignatures() + public function testResolvesClosuresWithDifferentSignatures(): void { $noParams = static function () { return 'test'; @@ -152,7 +152,7 @@ class HandlerResolverTest extends TestCase $this->assertTrue(HandlerResolver::resolve($variadic)->isVariadic()); } - public function testDistinguishesBetweenClosuresAndCallableArrays() + public function testDistinguishesBetweenClosuresAndCallableArrays(): void { $closure = static function () { return 'closure'; diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 5089e7d..299e985 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -26,7 +26,7 @@ final class SchemaGeneratorTest extends TestCase $this->schemaGenerator = new SchemaGenerator(new DocBlockParser()); } - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters() + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); $schema = $this->schemaGenerator->generate($method); @@ -37,7 +37,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertArrayNotHasKey('required', $schema); } - public function testInfersBasicTypesFromPhpTypeHints() + public function testInfersBasicTypesFromPhpTypeHints(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); $schema = $this->schemaGenerator->generate($method); @@ -49,7 +49,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); } - public function testInfersTypesAndDescriptionsFromDocBlockTags() + public function testInfersTypesAndDescriptionsFromDocBlockTags(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); $schema = $this->schemaGenerator->generate($method); @@ -60,7 +60,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']); } - public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions() + public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); $schema = $this->schemaGenerator->generate($method); @@ -70,7 +70,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']); } - public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute() + public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); $schema = $this->schemaGenerator->generate($method); @@ -92,7 +92,7 @@ final class SchemaGeneratorTest extends TestCase ], $schema); } - public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties() + public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); $schema = $this->schemaGenerator->generate($method); @@ -104,7 +104,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']); } - public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute() + public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); $schema = $this->schemaGenerator->generate($method); @@ -124,7 +124,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['profiles'], $schema['required']); } - public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes() + public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); $schema = $this->schemaGenerator->generate($method); @@ -143,7 +143,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']); } - public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -153,7 +153,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']); } - public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -163,7 +163,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']); } - public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -172,7 +172,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']); } - public function testMergesMethodLevelAndParameterLevelSchemaAttributes() + public function testMergesMethodLevelAndParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); $schema = $this->schemaGenerator->generate($method); @@ -181,7 +181,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']); } - public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints() + public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); $schema = $this->schemaGenerator->generate($method); @@ -190,7 +190,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); } - public function testGeneratesCorrectSchemaForEnumParameters() + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); $schema = $this->schemaGenerator->generate($method); @@ -202,7 +202,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']); } - public function testGeneratesCorrectSchemaForArrayTypeDeclarations() + public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); $schema = $this->schemaGenerator->generate($method); @@ -218,7 +218,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); } - public function testHandlesNullableTypeHintsAndOptionalParameters() + public function testHandlesNullableTypeHintsAndOptionalParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); $schema = $this->schemaGenerator->generate($method); @@ -230,7 +230,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']); } - public function testGeneratesSchemaForPhpUnionTypes() + public function testGeneratesSchemaForPhpUnionTypes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); $schema = $this->schemaGenerator->generate($method); @@ -239,7 +239,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']); } - public function testRepresentsVariadicStringParametersAsArrayOfStrings() + public function testRepresentsVariadicStringParametersAsArrayOfStrings(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); $schema = $this->schemaGenerator->generate($method); @@ -247,7 +247,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertArrayNotHasKey('required', $schema); } - public function testAppliesItemConstraintsToVariadicParameters() + public function testAppliesItemConstraintsToVariadicParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -255,7 +255,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertArrayNotHasKey('required', $schema); } - public function testHandlesMixedTypeHintsOmittingExplicitType() + public function testHandlesMixedTypeHintsOmittingExplicitType(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); $schema = $this->schemaGenerator->generate($method); @@ -264,7 +264,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['anyValue'], $schema['required']); } - public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures() + public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); $schema = $this->schemaGenerator->generate($method); @@ -303,7 +303,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['order'], $schema['required']); } - public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint() + public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); $schema = $this->schemaGenerator->generate($method); @@ -313,7 +313,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']); } - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema() + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); $schema = $this->schemaGenerator->generate($method); @@ -322,7 +322,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertArrayNotHasKey('required', $schema); } - public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() + public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); $schema = $this->schemaGenerator->generate($method); @@ -340,7 +340,7 @@ final class SchemaGeneratorTest extends TestCase } #[DataProvider('methodsWithForbiddenParameter')] - public function testGenerateWithForbiddenParameterNames(string $methodName) + public function testGenerateWithForbiddenParameterNames(string $methodName): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, $methodName); $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php index 7e8a207..9464dca 100644 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -26,7 +26,7 @@ class SchemaValidatorTest extends TestCase // --- Basic Validation Tests --- - public function testValidDataPassesValidation() + public function testValidDataPassesValidation(): void { $schema = $this->getSimpleSchema(); $data = $this->getValidData(); @@ -36,7 +36,7 @@ class SchemaValidatorTest extends TestCase $this->assertEmpty($errors); } - public function testInvalidTypeGeneratesTypeError() + public function testInvalidTypeGeneratesTypeError(): void { $schema = $this->getSimpleSchema(); $data = $this->getValidData(); @@ -50,7 +50,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Expected `integer`', $errors[0]['message']); } - public function testMissingRequiredPropertyGeneratesRequiredError() + public function testMissingRequiredPropertyGeneratesRequiredError(): void { $schema = $this->getSimpleSchema(); $data = $this->getValidData(); @@ -62,7 +62,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Missing required properties: `name`', $errors[0]['message']); } - public function testAdditionalPropertyGeneratesAdditionalPropertiesError() + public function testAdditionalPropertyGeneratesAdditionalPropertiesError(): void { $schema = $this->getSimpleSchema(); $data = $this->getValidData(); @@ -77,7 +77,7 @@ class SchemaValidatorTest extends TestCase // --- Keyword Constraint Tests --- - public function testEnumConstraintViolation() + public function testEnumConstraintViolation(): void { $schema = ['type' => 'string', 'enum' => ['A', 'B']]; $data = 'C'; @@ -88,7 +88,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('must be one of the allowed values: "A", "B"', $errors[0]['message']); } - public function testMinimumConstraintViolation() + public function testMinimumConstraintViolation(): void { $schema = ['type' => 'integer', 'minimum' => 10]; $data = 5; @@ -99,7 +99,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('must be greater than or equal to 10', $errors[0]['message']); } - public function testMaxLengthConstraintViolation() + public function testMaxLengthConstraintViolation(): void { $schema = ['type' => 'string', 'maxLength' => 5]; $data = 'toolong'; @@ -110,7 +110,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Maximum string length is 5, found 7', $errors[0]['message']); } - public function testPatternConstraintViolation() + public function testPatternConstraintViolation(): void { $schema = ['type' => 'string', 'pattern' => '^[a-z]+$']; $data = '123'; @@ -121,7 +121,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('does not match the required pattern: `^[a-z]+$`', $errors[0]['message']); } - public function testMinItemsConstraintViolation() + public function testMinItemsConstraintViolation(): void { $schema = ['type' => 'array', 'minItems' => 2]; $data = ['one']; @@ -132,7 +132,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Array should have at least 2 items, 1 found', $errors[0]['message']); } - public function testUniqueItemsConstraintViolation() + public function testUniqueItemsConstraintViolation(): void { $schema = ['type' => 'array', 'uniqueItems' => true]; $data = ['a', 'b', 'a']; @@ -144,7 +144,7 @@ class SchemaValidatorTest extends TestCase } // --- Nested Structures and Pointers --- - public function testNestedObjectValidationErrorPointer() + public function testNestedObjectValidationErrorPointer(): void { $schema = [ 'type' => 'object', @@ -164,7 +164,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('/user/id', $errors[0]['pointer']); } - public function testArrayItemValidationErrorPointer() + public function testArrayItemValidationErrorPointer(): void { $schema = [ 'type' => 'array', @@ -178,7 +178,7 @@ class SchemaValidatorTest extends TestCase } // --- Data Conversion Tests --- - public function testValidatesDataPassedAsStdClassObject() + public function testValidatesDataPassedAsStdClassObject(): void { $schema = $this->getSimpleSchema(); $dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass @@ -187,7 +187,7 @@ class SchemaValidatorTest extends TestCase $this->assertEmpty($errors); } - public function testValidatesDataWithNestedAssociativeArraysCorrectly() + public function testValidatesDataWithNestedAssociativeArraysCorrectly(): void { $schema = [ 'type' => 'object', @@ -207,7 +207,7 @@ class SchemaValidatorTest extends TestCase } // --- Edge Cases --- - public function testHandlesInvalidSchemaStructureGracefully() + public function testHandlesInvalidSchemaStructureGracefully(): void { $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value $data = ['name' => 'test']; @@ -218,7 +218,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Schema validation process failed', $errors[0]['message']); } - public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties() + public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties(): void { $schema = $this->getSimpleSchema(); // Requires name, age etc. $data = []; // Empty data @@ -229,7 +229,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('required', $errors[0]['keyword']); } - public function testHandlesEmptySchemaAllowsAnything() + public function testHandlesEmptySchemaAllowsAnything(): void { $schema = []; // Empty schema object/array implies no constraints $data = ['anything' => [1, 2], 'goes' => true]; @@ -241,7 +241,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('Invalid schema', $errors[0]['message']); } - public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute(): void { $emailSchema = (new Schema(format: 'email'))->toArray(); @@ -256,7 +256,7 @@ class SchemaValidatorTest extends TestCase $this->assertStringContainsString('email', $invalidErrors[0]['message']); } - public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute(): void { $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray(); @@ -275,7 +275,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('pattern', $noDigitErrors[0]['keyword']); } - public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute(): void { $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray(); @@ -294,7 +294,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('maximum', $tooHighErrors[0]['keyword']); } - public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute(): void { $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray(); @@ -313,7 +313,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('minItems', $tooFewErrors[0]['keyword']); } - public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute(): void { $userSchema = (new Schema( properties: [ @@ -363,7 +363,7 @@ class SchemaValidatorTest extends TestCase $this->assertEquals('minimum', $ageErrors[0]['keyword']); } - public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute() + public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute(): void { $orderSchema = (new Schema( properties: [ diff --git a/tests/Unit/Capability/Logger/ClientLoggerTest.php b/tests/Unit/Capability/Logger/ClientLoggerTest.php index aa0dc48..6a443b7 100644 --- a/tests/Unit/Capability/Logger/ClientLoggerTest.php +++ b/tests/Unit/Capability/Logger/ClientLoggerTest.php @@ -22,7 +22,7 @@ use PHPUnit\Framework\TestCase; */ final class ClientLoggerTest extends TestCase { - public function testLog() + public function testLog(): void { $session = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() @@ -39,7 +39,7 @@ final class ClientLoggerTest extends TestCase $logger->notice('test'); } - public function testLogFilter() + public function testLogFilter(): void { $session = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() @@ -56,7 +56,7 @@ final class ClientLoggerTest extends TestCase $logger->debug('test'); } - public function testLogFilterSameLevel() + public function testLogFilterSameLevel(): void { $session = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() @@ -73,7 +73,7 @@ final class ClientLoggerTest extends TestCase $logger->info('test'); } - public function testLogWithInvalidLevel() + public function testLogWithInvalidLevel(): void { $session = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php index f390617..500a81b 100644 --- a/tests/Unit/Schema/IconTest.php +++ b/tests/Unit/Schema/IconTest.php @@ -17,7 +17,7 @@ use PHPUnit\Framework\TestCase; class IconTest extends TestCase { - public function testValidConstructor() + public function testValidConstructor(): void { $icon = new Icon('https://www.php.net/images/logos/php-logo-white.svg', 'image/svg+xml', ['any']); @@ -26,7 +26,7 @@ class IconTest extends TestCase $this->assertSame('any', $icon->sizes[0]); } - public function testConstructorWithMultipleSizes() + public function testConstructorWithMultipleSizes(): void { $icon = new Icon('https://example.com/icon.png', 'image/png', ['48x48', '96x96']); @@ -34,14 +34,14 @@ class IconTest extends TestCase $this->assertSame(['48x48', '96x96'], $icon->sizes); } - public function testConstructorWithAnySizes() + public function testConstructorWithAnySizes(): void { $icon = new Icon('https://example.com/icon.svg', 'image/png', ['any']); $this->assertSame(['any'], $icon->sizes); } - public function testConstructorWithNullOptionalFields() + public function testConstructorWithNullOptionalFields(): void { $icon = new Icon('https://example.com/icon.png'); @@ -50,35 +50,35 @@ class IconTest extends TestCase $this->assertNull($icon->sizes); } - public function testInvalidSizesFormatThrowsException() + public function testInvalidSizesFormatThrowsException(): void { $this->expectException(InvalidArgumentException::class); new Icon('https://example.com/icon.png', 'image/png', ['invalid-size']); } - public function testInvalidPixelSizesFormatThrowsException() + public function testInvalidPixelSizesFormatThrowsException(): void { $this->expectException(InvalidArgumentException::class); new Icon('https://example.com/icon.png', 'image/png', ['180x48x48']); } - public function testEmptySrcThrowsException() + public function testEmptySrcThrowsException(): void { $this->expectException(InvalidArgumentException::class); new Icon('', 'image/png', ['48x48']); } - public function testInvalidSrcThrowsException() + public function testInvalidSrcThrowsException(): void { $this->expectException(InvalidArgumentException::class); new Icon('not-a-url', 'image/png', ['48x48']); } - public function testValidDataUriSrc() + public function testValidDataUriSrc(): void { $dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; $icon = new Icon($dataUri, 'image/png', ['48x48']); diff --git a/tests/Unit/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php index af98671..0d7d7c4 100644 --- a/tests/Unit/Schema/JsonRpc/NotificationTest.php +++ b/tests/Unit/Schema/JsonRpc/NotificationTest.php @@ -16,7 +16,7 @@ use PHPUnit\Framework\TestCase; final class NotificationTest extends TestCase { - public function testMetaIsLoopedThrough() + public function testMetaIsLoopedThrough(): void { $notificationImplementation = new class extends Notification { public static function getMethod(): string diff --git a/tests/Unit/Schema/JsonRpc/RequestTest.php b/tests/Unit/Schema/JsonRpc/RequestTest.php index 86e01d3..f400a90 100644 --- a/tests/Unit/Schema/JsonRpc/RequestTest.php +++ b/tests/Unit/Schema/JsonRpc/RequestTest.php @@ -16,7 +16,7 @@ use PHPUnit\Framework\TestCase; final class RequestTest extends TestCase { - public function testMetaAndIdAreLoopedThrough() + public function testMetaAndIdAreLoopedThrough(): void { $requestImplementation = new class extends Request { public static function getMethod(): string diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 77ea794..57f790a 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -20,7 +20,7 @@ use PHPUnit\Framework\TestCase; final class CreateSamplingMessageRequestTest extends TestCase { - public function testConstructorWithValidSetOfMessages() + public function testConstructorWithValidSetOfMessages(): void { $messages = [ new SamplingMessage(Role::User, new TextContent('My name is George.')), @@ -34,7 +34,7 @@ final class CreateSamplingMessageRequestTest extends TestCase $this->assertSame(150, $request->maxTokens); } - public function testConstructorWithInvalidSetOfMessages() + public function testConstructorWithInvalidSetOfMessages(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Messages must be instance of SamplingMessage.'); diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index bd5e94c..40483d3 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -272,7 +272,7 @@ class SessionTest extends TestCase $this->assertNotEquals($session1->getId()->toRfc4122(), $session2->getId()->toRfc4122()); } - public function testAll() + public function testAll(): void { $store = $this->getMockBuilder(InMemorySessionStore::class) ->disableOriginalConstructor() @@ -289,7 +289,7 @@ class SessionTest extends TestCase $this->assertEquals(['foo' => 'bar'], $result); } - public function testSaveBeforeReadInitializesData() + public function testSaveBeforeReadInitializesData(): void { $store = new InMemorySessionStore(); $session = new Session($store); @@ -298,7 +298,7 @@ class SessionTest extends TestCase $this->assertTrue($session->save()); } - public function testAllReturnsEmptyArrayForNullPayload() + public function testAllReturnsEmptyArrayForNullPayload(): void { $store = $this->getMockBuilder(InMemorySessionStore::class) ->disableOriginalConstructor() From d5b00cd8ae7d61b31e04a6b7a7dbf7ac84f0f4cb Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Mon, 20 Apr 2026 22:13:58 +0300 Subject: [PATCH 023/143] Fix PHPDoc type of `GetPromptRequest::$arguments` (#285) --- src/Schema/Request/GetPromptRequest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Schema/Request/GetPromptRequest.php b/src/Schema/Request/GetPromptRequest.php index bc6903f..5d91ff5 100644 --- a/src/Schema/Request/GetPromptRequest.php +++ b/src/Schema/Request/GetPromptRequest.php @@ -22,8 +22,8 @@ use Mcp\Schema\JsonRpc\Request; final class GetPromptRequest extends Request { /** - * @param string $name the name of the prompt to get - * @param array|null $arguments the arguments to pass to the prompt + * @param string $name the name of the prompt to get + * @param array|null $arguments the arguments to pass to the prompt */ public function __construct( public readonly string $name, @@ -56,7 +56,7 @@ final class GetPromptRequest extends Request } /** - * @return array{name: string, arguments?: array} + * @return array{name: string, arguments?: array} */ protected function getParams(): array { From abae36e8887417995f77be168b972493c3b8349d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 26 Apr 2026 12:33:21 +0200 Subject: [PATCH 024/143] [Client] add client conformance test suite (#279) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/pipeline.yaml | 44 ++++++- Makefile | 9 +- src/Schema/ClientCapabilities.php | 6 +- src/Schema/Request/CallToolRequest.php | 2 +- tests/Conformance/client.php | 108 ++++++++++++++++++ tests/Conformance/conformance-baseline.yml | 25 ++++ .../Handler/Request/InitializeHandlerTest.php | 4 +- 7 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 tests/Conformance/client.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b1f1e27..957d6f9 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -72,7 +72,8 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=inspector - conformance: + conformance-server: + name: conformance / server runs-on: ubuntu-latest steps: - name: Checkout @@ -122,6 +123,47 @@ jobs: if: always() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down + conformance-client: + name: conformance / client + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: "none" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install Composer + uses: "ramsey/composer-install@v4" + + - name: Create log directory + run: mkdir -p tests/Conformance/logs + + - name: Run client conformance tests + working-directory: ./tests/Conformance + run: npx @modelcontextprotocol/conformance client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml + + - name: Show logs on failure + if: failure() + run: | + echo "=== Client Conformance Log ===" + cat tests/Conformance/logs/client-conformance.log 2>/dev/null || echo "No client conformance log found" + echo "" + echo "=== Test Results ===" + find tests/Conformance/results -name "checks.json" 2>/dev/null | head -3 | while read f; do + echo "--- $f ---" + cat "$f" + echo "" + done || echo "No results found" + qa: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 5358de2..49f0d9e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs deps-stable: composer update --prefer-stable @@ -21,13 +21,18 @@ unit-tests: inspector-tests: vendor/bin/phpunit --testsuite=inspector -conformance-tests: +conformance-tests: conformance-server conformance-client + +conformance-server: docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d @echo "Waiting for server to start..." @sleep 5 cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ || true docker compose -f tests/Conformance/Fixtures/docker-compose.yml down +conformance-client: + cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml || true + coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index f6f277a..bfe4e6e 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -78,9 +78,9 @@ class ClientCapabilities implements \JsonSerializable * sampling?: object, * elicitation?: object, * experimental?: object, - * } + * }|\stdClass */ - public function jsonSerialize(): array + public function jsonSerialize(): array|object { $data = []; if ($this->roots || $this->rootsListChanged) { @@ -102,6 +102,6 @@ class ClientCapabilities implements \JsonSerializable $data['experimental'] = (object) $this->experimental; } - return $data; + return $data ?: new \stdClass(); } } diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 49bcd0d..0674066 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -65,7 +65,7 @@ final class CallToolRequest extends Request { return [ 'name' => $this->name, - 'arguments' => $this->arguments, + 'arguments' => $this->arguments ?: new \stdClass(), ]; } } diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php new file mode 100644 index 0000000..5c2ee2c --- /dev/null +++ b/tests/Conformance/client.php @@ -0,0 +1,108 @@ + php client.php \n"); + exit(1); +} + +@mkdir(__DIR__.'/logs', 0777, true); +$logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true); +$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url)); + +$builder = Client::builder() + ->setClientInfo('mcp-conformance-test-client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->setLogger($logger); + +if ('elicitation-sep1034-client-defaults' === $scenario) { + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class($logger) implements RequestHandlerInterface { + public function __construct(private readonly Psr\Log\LoggerInterface $logger) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + $this->logger->info('Received elicitation request, accepting with empty content'); + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } + }); +} + +$client = $builder->build(); +$transport = new HttpTransport($url, logger: $logger); + +try { + $client->connect($transport); + $logger->info('Connected to server'); + + $toolsResult = $client->listTools(); + $logger->info(sprintf('Listed %d tools', count($toolsResult->tools))); + + switch ($scenario) { + case 'initialize': + break; + + case 'tools_call': + $toolName = $toolsResult->tools[0]->name ?? 'test-tool'; + $client->callTool($toolName, []); + $logger->info(sprintf('Called tool: %s', $toolName)); + break; + + case 'elicitation-sep1034-client-defaults': + $toolName = $toolsResult->tools[0]->name ?? 'test_client_elicitation_defaults'; + $client->callTool($toolName, []); + $logger->info(sprintf('Called tool: %s', $toolName)); + break; + + default: + $logger->warning(sprintf('Unknown scenario: %s', $scenario)); + break; + } + + $client->disconnect(); + $logger->info('Disconnected'); + exit(0); +} catch (Throwable $e) { + $logger->error(sprintf('Error: %s', $e->getMessage())); + fwrite(\STDERR, sprintf("Error: %s\n%s\n", $e->getMessage(), $e->getTraceAsString())); + + try { + $client->disconnect(); + } catch (Throwable $ignored) { + } + + exit(1); +} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index de676e8..61f9783 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,3 +1,28 @@ server: - dns-rebinding-protection +client: + - elicitation-sep1034-client-defaults + - sse-retry + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + - auth/2025-03-26-oauth-metadata-backcompat + - auth/2025-03-26-oauth-endpoint-fallback + - auth/offline-access-scope + - auth/offline-access-not-supported + - auth/client-credentials-jwt + - auth/client-credentials-basic + - auth/cross-app-access-complete-flow + diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index bdd79d4..c56eac4 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -41,10 +41,10 @@ class InitializeHandlerTest extends TestCase $session = $this->createMock(SessionInterface::class); $session->expects($this->exactly(2)) ->method('set') - ->willReturnCallback(function (string $key, array $value): void { + ->willReturnCallback(function (string $key, mixed $value): void { match ($key) { 'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value), - 'client_capabilities' => $this->assertSame([], $value), + 'client_capabilities' => $this->assertEquals(new \stdClass(), $value), default => $this->fail("Unexpected session key: {$key}"), }; }); From 97979ec48d1c4e4086a8c3dbad3a185f53fa9620 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 26 Apr 2026 12:39:27 +0200 Subject: [PATCH 025/143] CS update after release of v3.95 (#282) --- .php-cs-fixer.dist.php | 2 -- examples/client/http_client_communication.php | 20 +++++++++---------- examples/client/http_discovery_calculator.php | 20 +++++++++---------- .../client/stdio_client_communication.php | 20 +++++++++---------- .../client/stdio_discovery_calculator.php | 20 +++++++++---------- .../CachedCalculatorElements.php | 2 -- examples/server/cached-discovery/server.php | 2 -- .../elicitation/ElicitationHandlers.php | 2 -- examples/server/elicitation/server.php | 2 -- .../server/oauth-keycloak/McpElements.php | 2 -- examples/server/oauth-keycloak/server.php | 2 -- .../server/oauth-microsoft/McpElements.php | 2 -- examples/server/oauth-microsoft/server.php | 2 -- src/Event/ErrorEvent.php | 2 -- src/Event/NotificationEvent.php | 2 -- src/Event/RequestEvent.php | 2 -- src/Event/ResponseEvent.php | 2 -- src/Exception/ClientException.php | 2 -- .../Elicitation/AbstractSchemaDefinition.php | 2 -- .../Elicitation/BooleanSchemaDefinition.php | 2 -- src/Schema/Elicitation/ElicitationSchema.php | 2 -- .../Elicitation/EnumSchemaDefinition.php | 2 -- .../Elicitation/NumberSchemaDefinition.php | 2 -- .../Elicitation/StringSchemaDefinition.php | 2 -- src/Schema/Enum/ElicitAction.php | 2 -- src/Schema/Request/ElicitRequest.php | 2 -- src/Schema/Result/ElicitResult.php | 2 -- src/Server/NativeClock.php | 2 -- src/Server/Session/FileSessionStore.php | 2 -- src/Server/Session/InMemorySessionStore.php | 2 -- src/Server/Session/Psr16SessionStore.php | 2 -- src/Server/Session/Session.php | 2 -- .../BooleanSchemaDefinitionTest.php | 2 -- .../Elicitation/ElicitationSchemaTest.php | 2 -- .../Elicitation/EnumSchemaDefinitionTest.php | 2 -- .../NumberSchemaDefinitionTest.php | 2 -- .../StringSchemaDefinitionTest.php | 2 -- tests/Unit/Schema/Enum/ElicitActionTest.php | 2 -- .../Unit/Schema/Request/ElicitRequestTest.php | 2 -- tests/Unit/Schema/Result/ElicitResultTest.php | 2 -- 40 files changed, 36 insertions(+), 116 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b4da5e2..a424ace 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -31,8 +31,6 @@ return (new Config()) ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, - 'protected_to_private' => false, - 'declare_strict_types' => false, 'header_comment' => ['header' => $fileHeader], 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], ]) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index 987b190..f5a39ea 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -1,5 +1,14 @@ Date: Sun, 26 Apr 2026 15:37:40 +0200 Subject: [PATCH 026/143] [Schema][Server] Add top-level title to Tool (#288) * feat(schema): add top-level title to Tool Mirror PR #278's Prompt change for Tool: - Add optional ?string $title to Mcp\Schema\Tool between $name and $inputSchema. fromArray() now reads $data['title']; jsonSerialize() emits 'title' right after 'name'. The @phpstan-type ToolData gains title?: string. - Add ?string $title to the #[McpTool] attribute between $name and $description. - Note ToolAnnotations::$title as deprecated-for-display in favor of Tool::$title per MCP spec 2025-06-18 (PHPDoc only, field retained for BC). Phase 1 of plan 01--tool-title-spec-compliance. Later phases update Discoverer, Builder::addTool(), ArrayLoader, and the conformance fixture to match the new signature. * feat(server): thread title through discovery - Discoverer::processMethod() now propagates McpTool::$title into every discovered Tool via named arguments. - Builder::addTool() accepts ?string $title between $name and $description. [BC Break] positional callers passing the old $description as 3rd positional argument must switch to named arguments, matching the precedent set for addPrompt() in 0.5.0. - tests/Conformance/server.php migrated to named-argument form for every addTool() call. Phase 2 of plan 01--tool-title-spec-compliance. * feat(loader): forward title in ArrayLoader ArrayLoader now reads 'title' from each tool config entry and passes it into the Tool constructor, completing the wiring from Builder::addTool() through array-driven registration. The $tools @param array-shape typedef gains title: ?string between name and description. Phase 3 of plan 01--tool-title-spec-compliance. * test(schema): cover Tool.title end-to-end - New tests/Unit/Schema/ToolTest.php: constructor round-trip, jsonSerialize key-order when title set vs null, fromArray tolerance. - New tests/Unit/Capability/Discovery/DiscovererToolTitleTest plus a TitleFixture class verifying McpTool(title: ...) propagates through discovery into the registered Tool. - New tests/Unit/Capability/Registry/Loader/ArrayLoader... test verifying array-driven title propagation. - Extend McpToolTest for title named-arg and default-null. - Extend BuilderTest with addTool(title: 't') propagation. - Update tests/Unit/.../RegistryTest, CallToolHandlerTest, ListToolsHandlerTest and examples/.../server.php new Tool(...) call sites to pass title (named args) so they match the new constructor signature. - CHANGELOG 0.6.0 section: feature bullet + [BC Break] note for addTool() signature, mirroring 0.5.0 Prompt entries. - docs/server-builder.md: addTool() row and prose now list title? between name and description. Phase 4 of plan 01--tool-title-spec-compliance. All 730 phpunit tests pass; remaining phpstan warnings are pre-existing and unrelated to Tool.title. * refactor(schema): make Tool.title non-BC-breaking Move `title` to the end of the Tool, Builder::addTool, and McpTool attribute constructors with a default of null. This preserves positional-argument compatibility and removes the need for `title: null` boilerplate at call sites. JSON serialization order (name, title, inputSchema, ...) is unchanged. Also tighten the new tests: parameterize ToolTest with data providers, fold the Discoverer title fixture into the existing DiscoverableToolHandler, and drop the reflection-heavy Builder test that overlapped with ArrayLoaderToolTitleTest. * fix: move title after name --------- Co-authored-by: DDEV User --- CHANGELOG.md | 4 + docs/server-builder.md | 3 +- docs/server-client-communication.md | 2 +- .../ClientAwareService.php | 2 +- .../server/custom-method-handlers/server.php | 2 + .../elicitation/ElicitationHandlers.php | 6 +- src/Capability/Attribute/McpTool.php | 2 + src/Capability/Discovery/Discoverer.php | 15 +-- .../Registry/Loader/ArrayLoader.php | 2 + src/Schema/Tool.php | 30 +++--- src/Schema/ToolAnnotations.php | 2 +- src/Server/Builder.php | 4 + tests/Conformance/server.php | 24 ++--- .../Unit/Capability/Attribute/McpToolTest.php | 15 +++ .../Discovery/DiscovererToolTitleTest.php | 33 ++++++ .../Fixtures/DiscoverableToolHandler.php | 2 +- .../Loader/ArrayLoaderToolTitleTest.php | 48 +++++++++ tests/Unit/Capability/RegistryTest.php | 1 + tests/Unit/Schema/ToolTest.php | 100 ++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 2 +- .../Handler/Request/CallToolHandlerTest.php | 4 +- .../Handler/Request/ListToolsHandlerTest.php | 1 + 22 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php create mode 100644 tests/Unit/Schema/ToolTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa1614..67ae392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) * Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance * [BC Break] `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. +* Add optional `title` field to `Tool` and `McpTool` for MCP spec compliance +* [BC Break] `Tool::__construct()` signature changed — `$title` parameter added between `$name` and `$inputSchema`. Callers using positional arguments must switch to named arguments or pass `null` for `$title`. +* [BC Break] `McpTool` attribute signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. +* [BC Break] `Builder::addTool()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. 0.4.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index 3ec22eb..1a8e466 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -278,6 +278,7 @@ $server = Server::builder() - `handler` (callable|string): The tool handler - `name` (string|null): Optional tool name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional tool description - `annotations` (ToolAnnotations|null): Optional annotations for the tool - `inputSchema` (array|null): Optional input schema for the tool @@ -578,7 +579,7 @@ $server = Server::builder() | `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | | `addNotificationHandler()` | handler | Prepend a single custom notification handler | | `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | -| `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | +| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 5122d0b..b37ff63 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -24,7 +24,7 @@ use Mcp\Server\RequestContext; class MyService { - #[McpTool('my_tool', 'My Tool Description')] + #[McpTool(name: 'my_tool', description: 'My Tool Description')] public function myTool(RequestContext $context): string { $context->getClientGateway()->log(...); diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index b33f4a7..2a3d834 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -28,7 +28,7 @@ final class ClientAwareService /** * @return array{incident: string, recommended_actions: string, model: string} */ - #[McpTool('coordinate_incident_response', 'Coordinate an incident response with logging, progress, and sampling.')] + #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] public function coordinateIncident(RequestContext $context, string $incidentTitle): array { $clientGateway = $context->getClientGateway(); diff --git a/examples/server/custom-method-handlers/server.php b/examples/server/custom-method-handlers/server.php index 23bb6b7..461d9d9 100644 --- a/examples/server/custom-method-handlers/server.php +++ b/examples/server/custom-method-handlers/server.php @@ -25,6 +25,7 @@ logger()->info('Starting MCP Custom Method Handlers Server...'); $toolDefinitions = [ 'say_hello' => new Tool( name: 'say_hello', + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -37,6 +38,7 @@ $toolDefinitions = [ ), 'sum' => new Tool( name: 'sum', + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index 9105574..aaa1328 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -45,7 +45,7 @@ final class ElicitationHandlers * * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} */ - #[McpTool('book_restaurant', 'Book a restaurant reservation, collecting details via elicitation.')] + #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] public function bookRestaurant(RequestContext $context, string $restaurantName): array { if (!$context->getClientGateway()->supportsElicitation()) { @@ -156,7 +156,7 @@ final class ElicitationHandlers * * @return array{status: string, message: string} */ - #[McpTool('confirm_action', 'Request user confirmation before proceeding with an action.')] + #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] public function confirmAction(RequestContext $context, string $actionDescription): array { if (!$context->getClientGateway()->supportsElicitation()) { @@ -224,7 +224,7 @@ final class ElicitationHandlers * * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} */ - #[McpTool('collect_feedback', 'Collect user feedback via elicitation form.')] + #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] public function collectFeedback(RequestContext $context, string $topic): array { if (!$context->getClientGateway()->supportsElicitation()) { diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index fe754e9..1c71e2c 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -22,6 +22,7 @@ class McpTool { /** * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $title Optional human-readable title for display in UI * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior * @param ?Icon[] $icons Optional list of icon URLs representing the tool @@ -30,6 +31,7 @@ class McpTool */ public function __construct( public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, public ?array $icons = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 586b711..2554a30 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -231,13 +231,14 @@ final class Discoverer implements DiscovererInterface $inputSchema = $this->schemaGenerator->generate($method); $outputSchema = $this->schemaGenerator->generateOutputSchema($method); $tool = new Tool( - $name, - $inputSchema, - $description, - $instance->annotations, - $instance->icons, - $instance->meta, - $outputSchema, + name: $name, + title: $instance->title, + inputSchema: $inputSchema, + description: $description, + annotations: $instance->annotations, + icons: $instance->icons, + meta: $instance->meta, + outputSchema: $outputSchema, ); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index d9a337e..34055a2 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -45,6 +45,7 @@ final class ArrayLoader implements LoaderInterface * @param array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], @@ -115,6 +116,7 @@ final class ArrayLoader implements LoaderInterface $tool = new Tool( name: $name, + title: $data['title'] ?? null, inputSchema: $inputSchema, description: $description, annotations: $data['annotations'] ?? null, diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 6255701..1817804 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -33,6 +33,7 @@ use Mcp\Exception\InvalidArgumentException; * } * @phpstan-type ToolData array{ * name: string, + * title?: string, * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, @@ -47,10 +48,11 @@ class Tool implements \JsonSerializable { /** * @param string $name the name of the tool + * @param ?string $title Optional human-readable title for display in UI + * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool * @param ?string $description A human-readable description of the tool. * This can be used by clients to improve the LLM's understanding of * available tools. It can be thought of like a "hint" to the model. - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool * @param ?ToolAnnotations $annotations optional additional tool information * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata @@ -58,6 +60,7 @@ class Tool implements \JsonSerializable */ public function __construct( public readonly string $name, + public readonly ?string $title, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, @@ -95,19 +98,21 @@ class Tool implements \JsonSerializable } return new self( - $data['name'], - $inputSchema, - isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, - isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, - isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, - $outputSchema, + name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, + inputSchema: $inputSchema, + description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, + annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + outputSchema: $outputSchema, ); } /** * @return array{ * name: string, + * title?: string, * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, @@ -118,10 +123,11 @@ class Tool implements \JsonSerializable */ public function jsonSerialize(): array { - $data = [ - 'name' => $this->name, - 'inputSchema' => $this->inputSchema, - ]; + $data = ['name' => $this->name]; + if (null !== $this->title) { + $data['title'] = $this->title; + } + $data['inputSchema'] = $this->inputSchema; if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php index 60e457f..783d959 100644 --- a/src/Schema/ToolAnnotations.php +++ b/src/Schema/ToolAnnotations.php @@ -28,7 +28,7 @@ namespace Mcp\Schema; class ToolAnnotations implements \JsonSerializable { /** - * @param ?string $title a human-readable title for the tool + * @param ?string $title a human-readable title for the tool — deprecated for display in favor of `Mcp\Schema\Tool::$title` per MCP spec revision 2025-06-18; retained for backward compatibility * @param ?bool $readOnlyHint if true, the tool does not modify its environment * @param ?bool $destructiveHint If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. * @param ?bool $idempotentHint If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. (This property is meaningful only when `readOnlyHint == false`) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f1c5c05..953c4c2 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -99,6 +99,7 @@ final class Builder * @var array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], @@ -373,6 +374,7 @@ final class Builder * Manually registers a tool handler. * * @param Handler $handler + * @param ?string $title Optional human-readable title for display in UI * @param array|null $inputSchema * @param ?Icon[] $icons * @param array|null $meta @@ -381,6 +383,7 @@ final class Builder public function addTool( callable|array|string $handler, ?string $name = null, + ?string $title = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, @@ -391,6 +394,7 @@ final class Builder $this->tools[] = compact( 'handler', 'name', + 'title', 'description', 'annotations', 'inputSchema', diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 471d5d8..02c78c2 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -40,18 +40,18 @@ $server = Server::builder() ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger($logger) // Tools - ->addTool(static fn () => 'This is a simple text response for testing.', 'test_simple_text', 'Tests simple text content response') - ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), 'test_image_content', 'Tests image content response') - ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), 'test_audio_content', 'Tests audio content response') - ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), 'test_embedded_resource', 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], 'test_multiple_content_types', 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], 'test_tool_with_logging', 'Tests tool that emits log messages') - ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') - ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') - ->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation') - ->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values') - ->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas') + ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') + ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') + ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') + ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') + ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') + ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') + ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') + ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') + ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') + ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') diff --git a/tests/Unit/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php index b6ab86d..e631458 100644 --- a/tests/Unit/Capability/Attribute/McpToolTest.php +++ b/tests/Unit/Capability/Attribute/McpToolTest.php @@ -52,6 +52,21 @@ class McpToolTest extends TestCase $this->assertNull($attribute->outputSchema); } + public function testInstantiatesWithTitle(): void + { + $attribute = new McpTool(name: 'n', title: 'T'); + + $this->assertSame('n', $attribute->name); + $this->assertSame('T', $attribute->title); + } + + public function testDefaultTitleIsNull(): void + { + $attribute = new McpTool(); + + $this->assertNull($attribute->title); + } + public function testInstantiatesWithOutputSchema(): void { // Arrange diff --git a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php new file mode 100644 index 0000000..9375425 --- /dev/null +++ b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php @@ -0,0 +1,33 @@ +discover(__DIR__, ['Fixtures']); + + $tools = $discovery->getTools(); + + $this->assertArrayHasKey('greet_user', $tools); + $toolRef = $tools['greet_user']; + $this->assertInstanceOf(ToolReference::class, $toolRef); + $this->assertSame('Greet User', $toolRef->tool->title); + } +} diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php index 1c6bc39..f20cda5 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php @@ -24,7 +24,7 @@ class DiscoverableToolHandler * * @return string the greeting */ - #[McpTool(name: 'greet_user', description: 'Greets a user by name.')] + #[McpTool(name: 'greet_user', description: 'Greets a user by name.', title: 'Greet User')] public function greet(string $name): string { return "Hello, {$name}!"; diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php new file mode 100644 index 0000000..68a1bfb --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php @@ -0,0 +1,48 @@ + static fn (): string => 'ok', + 'name' => 'weather_lookup', + 'title' => 'Weather Lookup', + 'description' => null, + 'annotations' => null, + 'inputSchema' => [ + 'type' => 'object', + 'properties' => new \stdClass(), + 'required' => null, + ], + 'icons' => null, + 'meta' => null, + 'outputSchema' => null, + ], + ]; + + $loader = new ArrayLoader($tools); + $registry = new Registry(); + + $loader->load($registry); + + $toolRef = $registry->getTool('weather_lookup'); + $this->assertSame('Weather Lookup', $toolRef->tool->title); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index e8b1958..90ac887 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -615,6 +615,7 @@ class RegistryTest extends TestCase { return new Tool( name: $name, + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php new file mode 100644 index 0000000..dd6861c --- /dev/null +++ b/tests/Unit/Schema/ToolTest.php @@ -0,0 +1,100 @@ +, required: string[]|null} + */ + private static function validInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + 'required' => null, + ]; + } + + private static function makeTool(?string $title, ?string $description = null): Tool + { + return new Tool( + name: 'x', + inputSchema: self::validInputSchema(), + description: $description, + annotations: null, + title: $title, + ); + } + + /** + * @return iterable}> + */ + public static function serializationKeyOrderProvider(): iterable + { + yield 'with title' => ['Friendly Title', ['name', 'title', 'inputSchema']]; + yield 'without title' => [null, ['name', 'inputSchema']]; + } + + /** + * @param list $expectedKeys + */ + #[DataProvider('serializationKeyOrderProvider')] + public function testSerializationPlacesTitleBetweenNameAndInputSchema(?string $title, array $expectedKeys): void + { + $serialized = self::makeTool($title)->jsonSerialize(); + + $this->assertSame($expectedKeys, array_keys($serialized)); + if (null !== $title) { + $this->assertSame($title, $serialized['title']); + } else { + $this->assertArrayNotHasKey('title', $serialized); + } + } + + /** + * @return iterable, ?string}> + */ + public static function fromArrayTitleProvider(): iterable + { + yield 'title present' => [['title' => 'Friendly Title'], 'Friendly Title']; + yield 'title missing' => [[], null]; + } + + /** + * @param array $extra + */ + #[DataProvider('fromArrayTitleProvider')] + public function testFromArrayReadsTitle(array $extra, ?string $expectedTitle): void + { + $tool = Tool::fromArray(['name' => 'x', 'inputSchema' => self::validInputSchema()] + $extra); + + $this->assertSame($expectedTitle, $tool->title); + } + + public function testRoundTripPreservesTitle(): void + { + $original = self::makeTool('Friendly Title', 'desc'); + + /** @var array{name: string, title?: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}, description?: string|null} $serialized */ + $serialized = $original->jsonSerialize(); + $restored = Tool::fromArray($serialized); + + $this->assertSame('Friendly Title', $restored->title); + $this->assertSame($original->name, $restored->name); + $this->assertSame($original->description, $restored->description); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index e8e54af..8a92d59 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -71,7 +71,7 @@ final class BuilderTest extends TestCase $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setReferenceHandler($referenceHandler) - ->addTool(static fn (): string => 'original', 'test_tool', 'A test tool') + ->addTool(static fn (): string => 'original', name: 'test_tool', description: 'A test tool') ->build(); $result = $this->callTool($server, 'test_tool'); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b8ec53e..cad7af1 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -483,7 +483,7 @@ class CallToolHandlerTest extends TestCase $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); $toolReference = $this->getMockBuilder(ToolReference::class) - ->setConstructorArgs([new Tool('simple_tool', $schema, null, null), static function () {}]) + ->setConstructorArgs([new Tool('simple_tool', null, $schema, null, null), static function () {}]) ->getMock(); $this->registry @@ -535,7 +535,7 @@ class CallToolHandlerTest extends TestCase ], 'required' => [], ]; - $tool = new Tool($name, $schema, null, null, null, null, $outputSchema); + $tool = new Tool($name, null, $schema, null, null, null, null, $outputSchema); $builder = $this->getMockBuilder(ToolReference::class) ->setConstructorArgs([$tool, $handler]); diff --git a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php index 2453630..b97185a 100644 --- a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php @@ -289,6 +289,7 @@ class ListToolsHandlerTest extends TestCase for ($i = 0; $i < $count; ++$i) { $tool = new Tool( name: "tool_$i", + title: null, inputSchema: [ 'type' => 'object', 'properties' => [], From 86bd845924f98372754eb3b95aefb3b5f7084110 Mon Sep 17 00:00:00 2001 From: johnhunt-lc Date: Fri, 8 May 2026 03:48:29 -0400 Subject: [PATCH 027/143] [Server] Allow overriding the default file name pattern for Discovery (#292) * Add optional namePatterns capability to discovery so that the default scan for `*.php` files can be overridden. * add default to namePatterns docs * add an error check if we manage to pass in an empty list of patterns * add unit tests (and a static entry to classmap for composer.json so it autoloads from a non .php file for the test) * run php-cs-fixer * change how default namePatterns are set and make sure all the annotations are correct * Fix bad whitespace Co-authored-by: Christopher Hertel * switch to a const default instead of nullable with a check in the function to convert null to a default * make sure we handle empty arrays * formatting fixes * Apply suggestion from @chr-hertel Co-authored-by: Christopher Hertel * Add changelog entry --------- Co-authored-by: Christopher Hertel --- CHANGELOG.md | 5 +++ composer.json | 7 ++-- docs/server-builder.md | 36 ++++++++++--------- src/Capability/Discovery/CachedDiscoverer.php | 11 +++--- src/Capability/Discovery/Discoverer.php | 13 ++++--- .../Discovery/DiscovererInterface.php | 11 +++--- .../Registry/Loader/DiscoveryLoader.php | 4 ++- src/Server/Builder.php | 10 +++++- .../Capability/Discovery/DiscoveryTest.php | 27 ++++++++++++-- .../AlternativeFileNameToolHandler.class.inc | 22 ++++++++++++ 10 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ae392..4f02538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.6.0 +----- + +* Allow overriding the default name pattern for Discovery + 0.5.0 ----- diff --git a/composer.json b/composer.json index 46ea2d7..d9f1507 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,10 @@ "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" - } + }, + "classmap": [ + "tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc" + ] }, "config": { "allow-plugins": { @@ -89,4 +92,4 @@ }, "sort-packages": true } -} \ No newline at end of file +} diff --git a/docs/server-builder.md b/docs/server-builder.md index 1a8e466..893618f 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -99,8 +99,9 @@ $server = Server::builder() ->setDiscovery( basePath: __DIR__, scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes - excludeDirs: ['vendor', 'tests'], // Where NOT to look - cache: $cacheInstance // Optional: cache discovered elements + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance, // Optional: cache discovered elements + namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match ); ``` @@ -109,6 +110,7 @@ $server = Server::builder() - `$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 - `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance +- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) **Basic Discovery (scans current directory and `src/`):** ```php @@ -137,7 +139,7 @@ $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 +- 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`) > **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making @@ -255,19 +257,19 @@ $server = Server::builder() name: 'add_numbers', description: 'Adds two numbers together' ) - + // Using class method pair ->addTool( handler: [Calculator::class, 'multiply'], name: 'multiply_numbers' // name and description are optional - derived from method name and docblock ) - + // Using instance method ->addTool( handler: [$calculatorInstance, 'divide'] ) - + // Using invokable class ->addTool( handler: InvokableCalculator::class @@ -421,17 +423,17 @@ $server = Server::builder() individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass those dependencies in yourself. -> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless > you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable > taking on the additional plumbing. ### Request Handlers -Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a `Response` or an `Error` object. -Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these methods as many times as needed; each call prepends the handlers so they execute before the defaults: ```php @@ -508,7 +510,7 @@ interface NotificationHandlerInterface ### Example -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement custom `tools/list` and `tools/call` request handlers independently of the registry. ## Complete Example @@ -540,25 +542,25 @@ $container->set(DatabaseService::class, new DatabaseService($container->get(\PDO $server = Server::builder() // Server identity ->setServerInfo('Advanced Calculator', '2.1.0') - + // Performance and behavior ->setPaginationLimit(100) ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') - + // Discovery with caching ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) - + // Session management ->setSession($sessionStore) - + // Services ->setLogger($logger) ->setContainer($container) - + // Manual capability registration ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') - + // Build the server ->build(); ``` diff --git a/src/Capability/Discovery/CachedDiscoverer.php b/src/Capability/Discovery/CachedDiscoverer.php index bf03973..d5ed852 100644 --- a/src/Capability/Discovery/CachedDiscoverer.php +++ b/src/Capability/Discovery/CachedDiscoverer.php @@ -38,11 +38,12 @@ final class CachedDiscoverer implements DiscovererInterface /** * Discover MCP elements in the specified directories with caching. * - * @param string $basePath the base path for resolving directories - * @param array $directories list of directories (relative to base path) to scan - * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param string $basePath the base path for resolving directories + * @param array $directories list of directories (relative to base path) to scan + * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState { $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs); @@ -63,7 +64,7 @@ final class CachedDiscoverer implements DiscovererInterface 'directories' => $directories, ]); - $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs); + $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs, $namePatterns); $this->cache->set($cacheKey, $discoveryState); diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 2554a30..b321608 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -65,11 +65,12 @@ final class Discoverer implements DiscovererInterface /** * Discover MCP elements in the specified directories and return the discovery state. * - * @param string $basePath the base path for resolving directories - * @param array $directories list of directories (relative to base path) to scan - * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param string $basePath the base path for resolving directories + * @param array $directories list of directories (relative to base path) to scan + * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState { $startTime = microtime(true); $discoveredCount = [ @@ -79,6 +80,8 @@ final class Discoverer implements DiscovererInterface 'resourceTemplates' => 0, ]; + $namePatterns = !empty($namePatterns) ? $namePatterns : self::DEFAULT_NAME_PATERNS; + $tools = []; $resources = []; $prompts = []; @@ -106,7 +109,7 @@ final class Discoverer implements DiscovererInterface $finder->files() ->in($absolutePaths) ->exclude($excludeDirs) - ->name('*.php'); + ->name($namePatterns); foreach ($finder as $file) { $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates); diff --git a/src/Capability/Discovery/DiscovererInterface.php b/src/Capability/Discovery/DiscovererInterface.php index 0de02de..2df69f3 100644 --- a/src/Capability/Discovery/DiscovererInterface.php +++ b/src/Capability/Discovery/DiscovererInterface.php @@ -20,12 +20,15 @@ namespace Mcp\Capability\Discovery; */ interface DiscovererInterface { + public const DEFAULT_NAME_PATERNS = ['*.php']; + /** * Discover MCP elements in the specified directories and return the discovery state. * - * @param string $basePath the base path for resolving directories - * @param array $directories list of directories (relative to base path) to scan - * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param string $basePath the base path for resolving directories + * @param array $directories list of directories (relative to base path) to scan + * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + * @param array $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState; + public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState; } diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php index 25261ff..a2ec40f 100644 --- a/src/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -24,18 +24,20 @@ final class DiscoveryLoader implements LoaderInterface /** * @param string[] $scanDirs * @param array|string[] $excludeDirs + * @param string[] $namePatterns */ public function __construct( private string $basePath, private array $scanDirs, private array $excludeDirs, private DiscovererInterface $discoverer, + private array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS, ) { } public function load(RegistryInterface $registry): void { - $discoveryState = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + $discoveryState = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns); $registry->setDiscoveryState($discoveryState); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 953c4c2..280272e 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -160,6 +160,11 @@ final class Builder */ private array $discoveryExcludeDirs = []; + /** + * @var string[]|null + */ + private ?array $discoveryNamePatterns = null; + private ?ServerCapabilities $serverCapabilities = null; /** @@ -348,17 +353,20 @@ final class Builder /** * @param string[] $scanDirs * @param string[] $excludeDirs + * @param string[] $namePatterns */ public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], array $excludeDirs = [], ?CacheInterface $cache = null, + array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS, ): self { $this->discoveryBasePath = $basePath; $this->discoveryScanDirs = $scanDirs; $this->discoveryExcludeDirs = $excludeDirs; $this->discoveryCache = $cache; + $this->discoveryNamePatterns = $namePatterns; return $this; } @@ -531,7 +539,7 @@ final class Builder if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); } diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 8108106..1451361 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -16,6 +16,7 @@ use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Registry\ToolReference; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; +use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture; @@ -34,10 +35,10 @@ class DiscoveryTest extends TestCase public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); $tools = $discovery->getTools(); - $this->assertCount(4, $tools); + $this->assertCount(5, $tools); $this->assertArrayHasKey('greet_user', $tools); $this->assertFalse($tools['greet_user']->isManual); @@ -56,6 +57,9 @@ class DiscoveryTest extends TestCase $this->assertFalse($tools['InvokableCalculator']->isManual); $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); + $this->assertArrayHasKey('inc_file_name_tool', $tools); + $this->assertEquals([AlternativeFileNameToolHandler::class, 'run'], $tools['inc_file_name_tool']->handler); + $this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); @@ -121,6 +125,25 @@ class DiscoveryTest extends TestCase $this->assertTrue($discovery->isEmpty()); } + public function testHandlesDefaultAndOverriddenFileNamePatterns(): void + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], []); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.inc']); + $this->assertArrayNotHasKey('greet_user', $discovery->getTools()); + $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); + } + public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); diff --git a/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc b/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc new file mode 100644 index 0000000..67c78eb --- /dev/null +++ b/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc @@ -0,0 +1,22 @@ + Date: Fri, 8 May 2026 03:56:36 -0400 Subject: [PATCH 028/143] force application/json for the 202 if there are no messages in the queue when building a json response (#295) --- src/Server/Transport/StreamableHttpTransport.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 3c9b2f6..62e82ae 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -144,7 +144,8 @@ class StreamableHttpTransport extends BaseTransport $outgoingMessages = $this->getOutgoingMessages($this->sessionId); if (empty($outgoingMessages)) { - return $this->responseFactory->createResponse(202); + return $this->responseFactory->createResponse(202) + ->withHeader('Content-Type', 'application/json'); } $messages = array_column($outgoingMessages, 'message'); From 797472777f1a42a3aa098862aba46834e1e51b6c Mon Sep 17 00:00:00 2001 From: Dayna Blackwell Date: Fri, 8 May 2026 01:14:08 -0700 Subject: [PATCH 029/143] [Schema] fix: allow URIs without double slash in Resource and ResourceTemplate (RFC 3986) (#297) * fix: allow URIs without double slash in Resource and ResourceTemplate (RFC 3986) The URI regex required "://" after the scheme, rejecting valid URIs like urn:isbn:123, mailto:user@example.com, config:key, and data: URIs. RFC 3986 only requires ":" after the scheme; the "//" is part of the authority component which is optional. Changed both Resource.URI_PATTERN and ResourceTemplate.URI_TEMPLATE_PATTERN to require ":" instead of "://". Adds data-provider tests for URIs without double slash in both classes. Fixes #293 * Reword unit tests --------- Co-authored-by: Christopher Hertel --- src/Schema/Resource.php | 6 +++--- src/Schema/ResourceTemplate.php | 6 +++--- tests/Unit/Schema/ResourceTemplateTest.php | 19 +++++++++++++++++++ tests/Unit/Schema/ResourceTest.php | 21 +++++++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index 049e19e..28b8ae5 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -40,10 +40,10 @@ class Resource implements \JsonSerializable private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; /** - * URI pattern regex - requires a valid scheme, followed by colon and optional path. - * Example patterns: config://, file://path, db://table, etc. + * URI pattern regex - requires a valid scheme followed by colon and optional path (RFC 3986). + * Example patterns: file://path, db://table, urn:isbn:123, config:key, etc. */ - private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*$/'; + private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]*$/'; /** * @param string $uri the URI of this resource diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index b8c366d..a19d0c7 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -37,10 +37,10 @@ class ResourceTemplate implements \JsonSerializable private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; /** - * URI Template pattern regex - requires a valid scheme, followed by colon and path with at least one placeholder. - * Example patterns: config://{key}, file://{path}/contents.txt, db://{table}/{id}, etc. + * URI Template pattern regex - requires a valid scheme followed by colon and path with at least one placeholder (RFC 3986). + * Example patterns: file://{path}/contents.txt, db://{table}/{id}, config:{key}, etc. */ - private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; + private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:.*{[^{}]+}.*/'; /** * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index 35391a1..54feaf4 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -46,6 +46,25 @@ class ResourceTemplateTest extends TestCase ); } + #[DataProvider('provideValidTemplates')] + public function testConstructorAcceptsTemplates(string $uriTemplate): void + { + $resource = new ResourceTemplate( + uriTemplate: $uriTemplate, + name: 'test-template', + ); + + $this->assertInstanceOf(ResourceTemplate::class, $resource); + $this->assertSame($uriTemplate, $resource->uriTemplate); + } + + public static function provideValidTemplates(): iterable + { + yield 'custom scheme without slashes' => ['config:{key}']; + yield 'custom scheme with slashes' => ['config://{key}']; + yield 'urn-style template' => ['urn:resource:{id}']; + } + public function testFromArrayValid(): void { $resource = ResourceTemplate::fromArray([ diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceTest.php index 316f48f..c6c8562 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceTest.php @@ -46,6 +46,27 @@ class ResourceTest extends TestCase ); } + #[DataProvider('provideValidUris')] + public function testConstructorAcceptsUris(string $uri): void + { + $resource = new Resource( + uri: $uri, + name: 'test-resource', + ); + + $this->assertInstanceOf(Resource::class, $resource); + $this->assertSame($uri, $resource->uri); + } + + public static function provideValidUris(): iterable + { + yield 'urn' => ['urn:isbn:0451450523']; + yield 'mailto' => ['mailto:user@example.com']; + yield 'data' => ['data:text/plain;base64,SGVsbG8=']; + yield 'custom scheme without slashes' => ['config:myapp/settings']; + yield 'custom scheme with slashes' => ['config://myapp/settings']; + } + public function testFromArrayValid(): void { $resource = Resource::fromArray([ From 06917f6af6d225edf4b7eb38aeb9aa369567cda9 Mon Sep 17 00:00:00 2001 From: SBNTT Date: Fri, 8 May 2026 10:15:14 +0200 Subject: [PATCH 030/143] fix: Cannot pass null to nullable enum parameters (#299) * nullable enum value * fix tests --- src/Capability/Discovery/SchemaGenerator.php | 6 ++++-- tests/Unit/Capability/Discovery/SchemaGeneratorTest.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 6366fd4..04ee08f 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -374,7 +374,8 @@ final class SchemaGenerator implements SchemaGeneratorInterface if ($jsonBackingType) { if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = ['null', $jsonBackingType]; + $paramSchema['type'] = [$jsonBackingType, 'null']; + $paramSchema['enum'][] = null; } else { $paramSchema['type'] = $jsonBackingType; } @@ -383,7 +384,8 @@ final class SchemaGenerator implements SchemaGeneratorInterface // Non-backed enum - use names as enum values $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = ['null', 'string']; + $paramSchema['type'] = ['string', 'null']; + $paramSchema['enum'][] = null; } else { $paramSchema['type'] = 'string'; } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 299e985..c92121e 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -197,7 +197,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']], $schema['properties']['stringEnum']); $this->assertEquals(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]], $schema['properties']['intEnum']); $this->assertEquals(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']], $schema['properties']['unitEnum']); - $this->assertEquals(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null], $schema['properties']['nullableEnum']); + $this->assertEquals(['type' => ['string', 'null'], 'enum' => ['A', 'B', null], 'default' => null], $schema['properties']['nullableEnum']); $this->assertEquals(['type' => 'integer', 'enum' => [1, 2], 'default' => 1], $schema['properties']['enumWithDefault']); $this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']); } From bfc4728792bf844ebf9883b4f2f13c7a2dfeadb0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 12 May 2026 23:20:14 +0200 Subject: [PATCH 031/143] Fix case sensitivity for http header (#283) --- src/Client/Transport/HttpTransport.php | 2 +- .../Transport/Http/Middleware/AuthorizationMiddleware.php | 2 +- .../Transport/Http/Middleware/ClientRegistrationMiddleware.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index ca2ff44..d0eb140 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -122,7 +122,7 @@ class HttpTransport extends BaseTransport $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); } - $contentType = $response->getHeaderLine('Content-Type'); + $contentType = strtolower($response->getHeaderLine('Content-Type')); if (str_contains($contentType, 'text/event-stream')) { $this->activeStream = $response->getBody(); diff --git a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php index 45c997e..d417598 100644 --- a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php @@ -166,7 +166,7 @@ final class AuthorizationMiddleware implements MiddlewareInterface private function parseBearerToken(string $authorization): ?string { - if (!preg_match('/^Bearer\\s+(.+)$/', $authorization, $matches)) { + if (!preg_match('/^Bearer\\s+(.+)$/i', $authorization, $matches)) { return null; } diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php index 82a0b48..6b94e30 100644 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php @@ -70,7 +70,7 @@ final class ClientRegistrationMiddleware implements MiddlewareInterface private function handleRegistration(ServerRequestInterface $request): ResponseInterface { $contentType = $request->getHeaderLine('Content-Type'); - if (!str_starts_with($contentType, 'application/json')) { + if (!str_starts_with(strtolower($contentType), 'application/json')) { return $this->jsonResponse(400, [ 'error' => 'invalid_client_metadata', 'error_description' => 'Content-Type must be application/json.', From 73414d65f6be4036535bcafa9e8ab93562c30a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20M=C3=BCnnich?= Date: Tue, 12 May 2026 23:20:42 +0200 Subject: [PATCH 032/143] Add PSR-3 compliant `exception` context when logging exceptions (#300) * Add PSR-3 exception context to caught error logs * Apply review feedback --- src/Capability/Discovery/Discoverer.php | 12 +++++++---- src/Capability/Discovery/DocBlockParser.php | 3 +-- src/Capability/Discovery/SchemaValidator.php | 3 +-- .../Request/SamplingRequestHandler.php | 2 +- src/Client/Transport/HttpTransport.php | 2 +- .../Handler/Request/CallToolHandler.php | 3 ++- .../Handler/Request/GetPromptHandler.php | 6 +++--- .../Handler/Request/ReadResourceHandler.php | 6 +++--- .../Request/ResourceSubscribeHandler.php | 2 +- .../Request/ResourceUnsubscribeHandler.php | 2 +- tests/Conformance/client.php | 2 +- .../Handler/Request/CallToolHandlerTest.php | 17 +++++++++++++--- .../Handler/Request/GetPromptHandlerTest.php | 15 +++++++++++++- .../Request/ReadResourceHandlerTest.php | 20 ++++++++++++++++++- .../Handler/Request/ResourceSubscribeTest.php | 10 +++++++++- .../Request/ResourceUnsubscribeTest.php | 10 +++++++++- 16 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index b321608..1a86947 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -117,7 +117,6 @@ final class Discoverer implements DiscovererInterface } catch (\Throwable $e) { $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [ 'exception' => $e, - 'trace' => $e->getTraceAsString(), ]); } @@ -199,7 +198,6 @@ final class Discoverer implements DiscovererInterface 'file' => $file->getPathname(), 'class' => $className, 'exception' => $e, - 'trace' => $e->getTraceAsString(), ]); } } @@ -300,9 +298,15 @@ final class Discoverer implements DiscovererInterface break; } } catch (ExceptionInterface $e) { - $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e, 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]); + $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", [ + 'attribute' => $attributeClassName, + 'exception' => $e, + ]); } catch (\Throwable $e) { - $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e, 'trace' => $e->getTraceAsString()]); + $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", [ + 'attribute' => $attributeClassName, + 'exception' => $e, + ]); } } diff --git a/src/Capability/Discovery/DocBlockParser.php b/src/Capability/Discovery/DocBlockParser.php index 5c867e8..eb14a14 100644 --- a/src/Capability/Discovery/DocBlockParser.php +++ b/src/Capability/Discovery/DocBlockParser.php @@ -47,8 +47,7 @@ class DocBlockParser } catch (\Throwable $e) { // Log error or handle gracefully if invalid DocBlock syntax is encountered $this->logger->warning('Failed to parse DocBlock', [ - 'error' => $e->getMessage(), - 'exception_trace' => $e->getTraceAsString(), + 'exception' => $e, ]); return null; diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php index c0432a0..56174bd 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -87,8 +87,7 @@ class SchemaValidator $result = $validator->validate($dataToValidate, $schemaObject); } catch (\Throwable $e) { $this->logger->error('MCP SDK: JSON Schema validation failed internally.', [ - 'exception_message' => $e->getMessage(), - 'exception_trace' => $e->getTraceAsString(), + 'exception' => $e, 'data' => json_encode($dataToValidate), 'schema' => json_encode($schemaObject), ]); diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index cda0f11..a00fa47 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -55,7 +55,7 @@ class SamplingRequestHandler implements RequestHandlerInterface return new Response($request->getId(), $result); } catch (SamplingException $e) { - $this->logger->error('Sampling failed: '.$e->getMessage()); + $this->logger->error('Sampling failed: '.$e->getMessage(), ['exception' => $e]); return Error::forInternalError($e->getMessage(), $request->getId()); } catch (\Throwable $e) { diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index d0eb140..33869f7 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -170,7 +170,7 @@ class HttpTransport extends BaseTransport $this->httpClient->sendRequest($request); $this->logger->info('Session closed', ['session_id' => $this->sessionId]); } catch (\Throwable $e) { - $this->logger->warning('Failed to close session', ['error' => $e->getMessage()]); + $this->logger->warning('Failed to close session', ['exception' => $e]); } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index a6a6cd2..e78ce1b 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -65,7 +65,7 @@ final class CallToolHandler implements RequestHandlerInterface try { $reference = $this->registry->getTool($toolName); } catch (ToolNotFoundException $e) { - $this->logger->error('Tool not found', ['name' => $toolName]); + $this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]); return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } @@ -112,6 +112,7 @@ final class CallToolHandler implements RequestHandlerInterface $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, + 'exception' => $e, ]); $errorContent = [new TextContent($e->getMessage())]; diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index eb968a1..745b9b8 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -65,15 +65,15 @@ final class GetPromptHandler implements RequestHandlerInterface return new Response($request->getId(), new GetPromptResult($formatted)); } catch (PromptGetException $e) { - $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); return Error::forInternalError($e->getMessage(), $request->getId()); } catch (PromptNotFoundException $e) { - $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); + $this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); return Error::forInternalError('Error while handling prompt', $request->getId()); } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index fc5bf13..a9551ef 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -77,15 +77,15 @@ final class ReadResourceHandler implements RequestHandlerInterface return new Response($request->getId(), new ReadResourceResult($formatted)); } catch (ResourceReadException $e) { - $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); return Error::forInternalError($e->getMessage(), $request->getId()); } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri]); + $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage())); + $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); return Error::forInternalError('Error while reading resource', $request->getId()); } diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php index ba0c879..b5421f8 100644 --- a/src/Server/Handler/Request/ResourceSubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -55,7 +55,7 @@ final class ResourceSubscribeHandler implements RequestHandlerInterface try { $this->registry->getResource($uri); } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri]); + $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); } diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php index 26db50e..50ab8bc 100644 --- a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -55,7 +55,7 @@ final class ResourceUnsubscribeHandler implements RequestHandlerInterface try { $this->registry->getResource($uri); } catch (ResourceNotFoundException $e) { - $this->logger->error('Resource not found', ['uri' => $uri]); + $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); } diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 5c2ee2c..74ac8e9 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -96,7 +96,7 @@ try { $logger->info('Disconnected'); exit(0); } catch (Throwable $e) { - $logger->error(sprintf('Error: %s', $e->getMessage())); + $logger->error(sprintf('Error: %s', $e->getMessage()), ['exception' => $e]); fwrite(\STDERR, sprintf("Error: %s\n%s\n", $e->getMessage(), $e->getTraceAsString())); try { diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index cad7af1..b804f76 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -166,16 +166,18 @@ class CallToolHandlerTest extends TestCase public function testHandleToolNotFoundExceptionReturnsError(): void { $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); + $exception = new ToolNotFoundException('nonexistent_tool'); $this->registry ->expects($this->once()) ->method('getTool') ->with('nonexistent_tool') - ->willThrowException(new ToolNotFoundException('nonexistent_tool')); + ->willThrowException($exception); $this->logger ->expects($this->once()) - ->method('error'); + ->method('error') + ->with('Tool not found', ['name' => 'nonexistent_tool', 'exception' => $exception]); $response = $this->handler->handle($request, $this->session); @@ -206,7 +208,15 @@ class CallToolHandlerTest extends TestCase $this->logger ->expects($this->once()) - ->method('error'); + ->method('error') + ->with( + 'Error while executing tool "failing_tool": "Tool execution failed".', + [ + 'tool' => 'failing_tool', + 'arguments' => ['param' => 'value', '_session' => $this->session, '_request' => $request], + 'exception' => $exception, + ], + ); $response = $this->handler->handle($request, $this->session); @@ -288,6 +298,7 @@ class CallToolHandlerTest extends TestCase [ 'tool' => 'test_tool', 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], + 'exception' => $exception, ], ); diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 75503e3..204f928 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -27,6 +27,7 @@ use Mcp\Server\Handler\Request\GetPromptHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class GetPromptHandlerTest extends TestCase { @@ -34,14 +35,16 @@ class GetPromptHandlerTest extends TestCase private RegistryInterface&MockObject $referenceProvider; private ReferenceHandlerInterface&MockObject $referenceHandler; private SessionInterface&MockObject $session; + private LoggerInterface&MockObject $logger; protected function setUp(): void { $this->referenceProvider = $this->createMock(RegistryInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); - $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler); + $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler, $this->logger); } public function testSupportsGetPromptRequest(): void @@ -239,6 +242,11 @@ class GetPromptHandlerTest extends TestCase ->with('nonexistent_prompt') ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Prompt not found', ['prompt_name' => 'nonexistent_prompt', 'exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); @@ -258,6 +266,11 @@ class GetPromptHandlerTest extends TestCase ->with('failing_prompt') ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Error while handling prompt "failing_prompt": "Failed to get prompt".', ['exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 7483f1c..66a2296 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -27,6 +27,7 @@ use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class ReadResourceHandlerTest extends TestCase { @@ -34,14 +35,16 @@ class ReadResourceHandlerTest extends TestCase private RegistryInterface&MockObject $registry; private ReferenceHandlerInterface&MockObject $referenceHandler; private SessionInterface&MockObject $session; + private LoggerInterface&MockObject $logger; protected function setUp(): void { $this->registry = $this->createMock(RegistryInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); - $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); + $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler, $this->logger); } public function testSupportsReadResourceRequest(): void @@ -186,6 +189,11 @@ class ReadResourceHandlerTest extends TestCase ->with($uri) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); @@ -206,6 +214,11 @@ class ReadResourceHandlerTest extends TestCase ->with($uri) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Error while reading resource "file://corrupted/file.txt": "Failed to read resource: corrupted data".', ['exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); @@ -226,6 +239,11 @@ class ReadResourceHandlerTest extends TestCase ->with($uri) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Unexpected error while reading resource "file://problematic/file.txt": "Internal database connection failed".', ['exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php index 00512fa..02e2e5c 100644 --- a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -26,6 +26,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class ResourceSubscribeTest extends TestCase { @@ -33,13 +34,15 @@ class ResourceSubscribeTest extends TestCase private RegistryInterface&MockObject $registry; private SessionInterface&MockObject $session; private SubscriptionManagerInterface&MockObject $subscriptionManager; + private LoggerInterface&MockObject $logger; protected function setUp(): void { $this->registry = $this->createMock(RegistryInterface::class); $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); $this->session = $this->createMock(SessionInterface::class); - $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager); + $this->logger = $this->createMock(LoggerInterface::class); + $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); } #[TestDox('Client can successfully subscribe to a resource')] @@ -121,6 +124,11 @@ class ResourceSubscribeTest extends TestCase ->with($uri) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php index 509c256..d71dfb3 100644 --- a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -26,6 +26,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class ResourceUnsubscribeTest extends TestCase { @@ -33,14 +34,16 @@ class ResourceUnsubscribeTest extends TestCase private RegistryInterface&MockObject $registry; private SessionInterface&MockObject $session; private SubscriptionManagerInterface&MockObject $subscriptionManager; + private LoggerInterface&MockObject $logger; protected function setUp(): void { $this->registry = $this->createMock(RegistryInterface::class); $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); - $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager); + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); } #[TestDox('Client can unsubscribe from a resource')] @@ -119,6 +122,11 @@ class ResourceUnsubscribeTest extends TestCase ->with($uri) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Resource not found', ['uri' => $uri, 'exception' => $exception]); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); From 5e0731f706830ec49889e68cf826f333c74c07cb Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 15 May 2026 08:14:38 +0200 Subject: [PATCH 033/143] Add configurable session garbage collection (gcProbability/gcDivisor) (#258) Make session GC probability configurable via gcProbability and gcDivisor parameters, mirroring PHP's session.gc_probability/session.gc_divisor. Exposed through both SessionManager constructor and Builder::setSession(). Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 1 + docs/server-builder.md | 46 +++++++++-- src/Server/Builder.php | 16 ++++ src/Server/Session/SessionManager.php | 19 ++++- .../Server/Session/SessionManagerTest.php | 80 +++++++++++++++++++ 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Server/Session/SessionManagerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f02538..9a47ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * Allow overriding the default name pattern for Discovery +* Add configurable session garbage collection (`gcProbability`/`gcDivisor`) 0.5.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index 893618f..a48e96d 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -156,11 +156,6 @@ use Mcp\Server\Session\Psr16SessionStore; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Adapter\RedisAdapter; -// Use default in-memory sessions with custom TTL -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); - // Override with file-based storage $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) @@ -188,6 +183,45 @@ $server = Server::builder() ->build(); ``` +### Garbage Collection Configuration + +The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native +`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given +request is `gcProbability / gcDivisor`. + +```php +// Default: 1/100 (1% chance per request) +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Higher frequency: 1/10 (10% chance per request) +$server = Server::builder() + ->setSession( + new FileSessionStore(__DIR__ . '/sessions'), + gcProbability: 1, + gcDivisor: 10, + ) + ->build(); + +// Run GC on every request +$server = Server::builder() + ->setSession(gcProbability: 1, gcDivisor: 1) + ->build(); + +// Disable GC entirely (e.g. when using an external cleanup process) +$server = Server::builder() + ->setSession(gcProbability: 0) + ->build(); +``` + +**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. + +> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, +> 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 @@ -573,7 +607,7 @@ $server = Server::builder() | `setPaginationLimit()` | limit | Set max items per page | | `setInstructions()` | instructions | Set usage instructions | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | -| `setSession()` | store?, factory?, ttl? | Configure session management | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 280272e..2ec21d1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -79,6 +79,10 @@ final class Builder private ?SessionStoreInterface $sessionStore = null; + private int $gcProbability = 1; + + private int $gcDivisor = 100; + private int $paginationLimit = 50; private ?string $instructions = null; @@ -336,12 +340,22 @@ final class Builder return $this; } + /** + * Configures the session layer. + * + * @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC. + * @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor. + */ public function setSession( ?SessionStoreInterface $sessionStore = null, ?SessionManagerInterface $sessionManager = null, + int $gcProbability = 1, + int $gcDivisor = 100, ): self { $this->sessionStore = $sessionStore; $this->sessionManager = $sessionManager; + $this->gcProbability = $gcProbability; + $this->gcDivisor = $gcDivisor; if (null !== $sessionManager && null !== $sessionStore) { throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.'); @@ -534,6 +548,8 @@ final class Builder $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), $logger, + $this->gcProbability, + $this->gcDivisor, ); if (null !== $this->discoveryBasePath) { diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php index 4932d39..2a08f7e 100644 --- a/src/Server/Session/SessionManager.php +++ b/src/Server/Session/SessionManager.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Session; +use Mcp\Exception\InvalidArgumentException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; @@ -22,10 +23,22 @@ use Symfony\Component\Uid\Uuid; */ class SessionManager implements SessionManagerInterface { + /** + * @param int $gcProbability The probability (numerator) that GC will run on any given request. Combined with $gcDivisor to calculate the actual probability. Set to 0 to disable GC. Similar to PHP's session.gc_probability. + * @param int $gcDivisor The divisor used with $gcProbability to calculate GC probability. The probability is gcProbability/gcDivisor (e.g. 1/100 = 1%). Similar to PHP's session.gc_divisor. + */ public function __construct( private readonly SessionStoreInterface $store, private readonly LoggerInterface $logger = new NullLogger(), + private readonly int $gcProbability = 1, + private readonly int $gcDivisor = 100, ) { + if ($gcProbability < 0) { + throw new InvalidArgumentException('gcProbability must be greater than or equal to 0.'); + } + if ($gcDivisor < 1) { + throw new InvalidArgumentException('gcDivisor must be greater than or equal to 1.'); + } } public function create(): SessionInterface @@ -54,7 +67,11 @@ class SessionManager implements SessionManagerInterface */ public function gc(): void { - if (random_int(0, 100) > 1) { + if (0 === $this->gcProbability) { + return; + } + + if (random_int(1, $this->gcDivisor) > $this->gcProbability) { return; } diff --git a/tests/Unit/Server/Session/SessionManagerTest.php b/tests/Unit/Server/Session/SessionManagerTest.php new file mode 100644 index 0000000..e145292 --- /dev/null +++ b/tests/Unit/Server/Session/SessionManagerTest.php @@ -0,0 +1,80 @@ +createMock(InMemorySessionStore::class); + $store->expects($this->never())->method('gc'); + + $manager = new SessionManager($store, gcProbability: 0); + + // Call gc many times — it should never trigger + for ($i = 0; $i < 100; ++$i) { + $manager->gc(); + } + } + + public function testGcAlwaysRunsWhenProbabilityEqualsDivisor(): void + { + $store = $this->createMock(InMemorySessionStore::class); + $store->expects($this->exactly(10))->method('gc')->willReturn([]); + + $manager = new SessionManager($store, gcProbability: 1, gcDivisor: 1); + + for ($i = 0; $i < 10; ++$i) { + $manager->gc(); + } + } + + public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void + { + $store = $this->createMock(InMemorySessionStore::class); + $store->expects($this->exactly(5))->method('gc')->willReturn([]); + + $manager = new SessionManager($store, gcProbability: 100, gcDivisor: 1); + + for ($i = 0; $i < 5; ++$i) { + $manager->gc(); + } + } + + public function testGcProbabilityMustBeNonNegative(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('gcProbability must be greater than or equal to 0.'); + + new SessionManager(new InMemorySessionStore(), gcProbability: -1); + } + + public function testGcDivisorMustBePositive(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.'); + + new SessionManager(new InMemorySessionStore(), gcDivisor: 0); + } + + public function testDefaultGcConfiguration(): void + { + // Default should be 1/100 — just verify construction works + $manager = new SessionManager(new InMemorySessionStore()); + $this->assertInstanceOf(SessionManager::class, $manager); + } +} From 1d7100060f364c13119a82dab7282620722f921b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 17 May 2026 14:02:19 +0200 Subject: [PATCH 034/143] [CI] Add weekly conformance run against latest upstream release (#304) The PR pipeline only catches conformance changes when someone opens a PR. Upstream `@modelcontextprotocol/conformance` releases between PRs go untested. This adds a weekly cron + `workflow_dispatch` trigger pinned to `@latest`, and opens a tracking issue on scheduled failures. --- .github/workflows/conformance-weekly.yaml | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/conformance-weekly.yaml diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml new file mode 100644 index 0000000..685b939 --- /dev/null +++ b/.github/workflows/conformance-weekly.yaml @@ -0,0 +1,98 @@ +name: conformance-weekly + +# Runs the MCP conformance suite weekly against the latest +# @modelcontextprotocol/conformance release. The on:pull_request pipeline +# pins to whatever version is available at PR time; this schedule catches +# upstream releases that add scenarios between PRs. +on: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + server: + name: conformance / server (latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - run: composer install --prefer-dist --no-progress --no-interaction + - name: Start conformance server + run: | + mkdir -p tests/Conformance/sessions tests/Conformance/logs + chmod -R 777 tests/Conformance/sessions tests/Conformance/logs + docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d + sleep 5 + - name: Run conformance tests + working-directory: ./tests/Conformance + run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml + - name: Show docker logs on failure + if: failure() + run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs + - name: Upload conformance results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: conformance-server-results + path: | + tests/Conformance/logs + tests/Conformance/results + + client: + name: conformance / client (latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - uses: actions/setup-node@v6 + with: + node-version: '22' + - run: composer install --prefer-dist --no-progress --no-interaction + - run: mkdir -p tests/Conformance/logs + - name: Run conformance tests + working-directory: ./tests/Conformance + run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml + - name: Upload conformance results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: conformance-client-results + path: | + tests/Conformance/logs + tests/Conformance/results + + notify: + name: Open issue on failure + runs-on: ubuntu-latest + needs: [server, client] + if: failure() && github.event_name == 'schedule' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: File or comment tracking issue + run: | + existing=$(gh issue list --label conformance-weekly --state open --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "New failure on $(date -u +%FT%TZ): $RUN_URL" + else + gh issue create \ + --title '[conformance] Weekly conformance run failed' \ + --label conformance-weekly \ + --body "Weekly conformance against \`@modelcontextprotocol/conformance@latest\` failed. + + - Run: $RUN_URL + - 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 \`tests/Conformance/conformance-baseline.yml\`." + fi From e00450a2189e559e07964848519c0db3406e56ca Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 17 May 2026 14:02:40 +0200 Subject: [PATCH 035/143] Bump protocol version to nov 25 (#308) --- CHANGELOG.md | 1 + src/Client/Builder.php | 2 +- src/Client/Configuration.php | 2 +- src/Schema/JsonRpc/MessageInterface.php | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a47ca5..0515ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.6.0 ----- +* [BC Break] Bump default protocol version to `2025-11-25` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 6cfe334..35f7f5d 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -153,7 +153,7 @@ final class Builder $config = new Configuration( clientInfo: $clientInfo, capabilities: $this->capabilities ?? new ClientCapabilities(), - protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_06_18, + protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, maxRetries: $this->maxRetries, diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index 4dce634..8c7e909 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -25,7 +25,7 @@ class Configuration public function __construct( public readonly Implementation $clientInfo, public readonly ClientCapabilities $capabilities, - public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_06_18, + public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_11_25, public readonly int $initTimeout = 30, public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, diff --git a/src/Schema/JsonRpc/MessageInterface.php b/src/Schema/JsonRpc/MessageInterface.php index 6e4d1c1..9427eaa 100644 --- a/src/Schema/JsonRpc/MessageInterface.php +++ b/src/Schema/JsonRpc/MessageInterface.php @@ -21,5 +21,5 @@ use Mcp\Schema\Enum\ProtocolVersion; interface MessageInterface extends \JsonSerializable { public const JSONRPC_VERSION = '2.0'; - public const PROTOCOL_VERSION = ProtocolVersion::V2025_06_18; + public const PROTOCOL_VERSION = ProtocolVersion::V2025_11_25; } From 967c5cda4d806559bbb8c6f586064bd6ec0f58f6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 17 May 2026 14:03:31 +0200 Subject: [PATCH 036/143] [Server] Untangle origin tracking from Registry (#302) Drop the isManual flag from ElementReference and the discovery-state methods from RegistryInterface. The Registry becomes a flat last-write-wins map; DiscoveryLoader owns its own owned-set bookkeeping so re-running discovery only removes elements it itself contributed. Adds ChainLoader for explicit composition. Manual-over-discovered precedence is preserved via loader ordering, not a flag on the reference. --- CHANGELOG.md | 8 + src/Capability/Discovery/Discoverer.php | 8 +- src/Capability/Discovery/DiscoveryState.php | 17 ++ src/Capability/Registry.php | 215 +++++-------- src/Capability/Registry/ElementReference.php | 1 - .../Registry/Loader/ArrayLoader.php | 8 +- .../Registry/Loader/ChainLoader.php | 38 +++ .../Registry/Loader/DiscoveryLoader.php | 151 +++++++++- src/Capability/Registry/PromptReference.php | 3 +- src/Capability/Registry/ResourceReference.php | 3 +- .../Registry/ResourceTemplateReference.php | 3 +- src/Capability/Registry/ToolReference.php | 3 +- src/Capability/RegistryInterface.php | 47 ++- src/Server/Builder.php | 20 +- ...mbinedRegistrationTest-resources_list.json | 4 +- ...onTest-resources_read-config_priority.json | 2 +- .../Discovery/DiscoveryStateTest.php | 164 ++++++++++ .../Capability/Discovery/DiscoveryTest.php | 10 - .../Registry/Loader/ChainLoaderTest.php | 57 ++++ .../Registry/Loader/DiscoveryLoaderTest.php | 284 ++++++++++++++++++ .../Loader/Stub/MutableDiscoverer.php | 27 ++ .../Registry/Loader/Stub/RecordingLoader.php | 30 ++ .../Registry/Loader/Stub/ToolWriterLoader.php | 37 +++ tests/Unit/Capability/RegistryTest.php | 203 ++++--------- 24 files changed, 1004 insertions(+), 339 deletions(-) create mode 100644 src/Capability/Registry/Loader/ChainLoader.php create mode 100644 tests/Unit/Capability/Discovery/DiscoveryStateTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php create mode 100644 tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php create mode 100644 tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0515ded..4e82b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Bump default protocol version to `2025-11-25` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) +* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering. +* Add `RegistryInterface::unregisterTool()`, `unregisterResource()`, `unregisterResourceTemplate()`, `unregisterPrompt()` — idempotent removals. +* Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks. +* `DiscoveryLoader` now refreshes only its own previously written entries; manual registrations (via `Builder::addTool()` etc. or runtime `$registry->registerTool()` calls) survive rediscovery, and a same-name manual registration takes precedence over discovery on collision. +* [BC Break] Removed `ElementReference::$isManual` public property and the `bool $isManual` parameter from all `*Reference` constructors. Origin tracking is no longer carried on the element; manual-over-discovered precedence is encoded by loader execution order. +* [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag. +* [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly. +* `Registry::register*()` semantics changed to plain last-write-wins (overwrites silently) and the methods now return the stored `*Reference`. The previous "discovered registration is ignored when a manual one already exists" precedence rule still applies, but is now enforced by `DiscoveryLoader` via reference-identity tracking — and still emits a debug log when a discovery is skipped due to a conflicting registration. 0.5.0 ----- diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 1a86947..7e7df9e 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -241,7 +241,7 @@ final class Discoverer implements DiscovererInterface meta: $instance->meta, outputSchema: $outputSchema, ); - $tools[$name] = new ToolReference($tool, [$className, $methodName], false); + $tools[$name] = new ToolReference($tool, [$className, $methodName]); ++$discoveredCount['tools']; break; @@ -259,7 +259,7 @@ final class Discoverer implements DiscovererInterface $instance->icons, $instance->meta, ); - $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); + $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]); ++$discoveredCount['resources']; break; @@ -280,7 +280,7 @@ final class Discoverer implements DiscovererInterface } $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); - $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); + $prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders); ++$discoveredCount['prompts']; break; @@ -293,7 +293,7 @@ final class Discoverer implements DiscovererInterface $meta = $instance->meta ?? null; $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); - $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); + $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders); ++$discoveredCount['resourceTemplates']; break; } diff --git a/src/Capability/Discovery/DiscoveryState.php b/src/Capability/Discovery/DiscoveryState.php index e5c089e..59a73ec 100644 --- a/src/Capability/Discovery/DiscoveryState.php +++ b/src/Capability/Discovery/DiscoveryState.php @@ -72,6 +72,23 @@ final class DiscoveryState return $this->resourceTemplates; } + /** + * Returns the subset of this state whose keys are absent from $next. + * + * Asymmetric by design: entries whose keys exist in both states are excluded + * regardless of value. Used to identify owned entries that a fresh discovery + * no longer produces. + */ + public function obsoletedBy(self $next): self + { + return new self( + array_diff_key($this->tools, $next->tools), + array_diff_key($this->resources, $next->resources), + array_diff_key($this->prompts, $next->prompts), + array_diff_key($this->resourceTemplates, $next->resourceTemplates), + ); + } + /** * Check if this state contains any discovered elements. */ diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2a327ae..c8fdca3 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,7 +11,6 @@ namespace Mcp\Capability; -use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; @@ -68,129 +67,120 @@ final class Registry implements RegistryInterface ) { } - public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference { - $toolName = $tool->name; - $existing = $this->tools[$toolName] ?? null; - - if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug( - \sprintf('Ignoring discovered tool "%s" as it conflicts with a manually registered one.', $toolName), - ); - - return; - } - - if (!$this->nameValidator->isValid($toolName)) { + if (!$this->nameValidator->isValid($tool->name)) { $this->logger->warning( - \sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $toolName), + \sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $tool->name), ); } - $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); + $reference = new ToolReference($tool, $handler); + $this->tools[$tool->name] = $reference; $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + + return $reference; } - public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void + public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference { - $uri = $resource->uri; - $existing = $this->resources[$uri] ?? null; - - if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug( - \sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri), - ); - - return; - } - - $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); + $reference = new ResourceReference($resource, $handler); + $this->resources[$resource->uri] = $reference; $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + + return $reference; } public function registerResourceTemplate( ResourceTemplate $template, callable|array|string $handler, array $completionProviders = [], - bool $isManual = false, - ): void { - $uriTemplate = $template->uriTemplate; - $existing = $this->resourceTemplates[$uriTemplate] ?? null; - - if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug( - \sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate), - ); - - return; - } - - $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference( - $template, - $handler, - $isManual, - $completionProviders, - ); + ): ResourceTemplateReference { + $reference = new ResourceTemplateReference($template, $handler, $completionProviders); + $this->resourceTemplates[$template->uriTemplate] = $reference; $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + + return $reference; } public function registerPrompt( Prompt $prompt, callable|array|string $handler, array $completionProviders = [], - bool $isManual = false, - ): void { - $promptName = $prompt->name; - $existing = $this->prompts[$promptName] ?? null; + ): PromptReference { + $reference = new PromptReference($prompt, $handler, $completionProviders); + $this->prompts[$prompt->name] = $reference; - if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug( - \sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName), - ); + $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + return $reference; + } + + public function unregisterTool(string $name): void + { + if (!isset($this->tools[$name])) { return; } - $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); + unset($this->tools[$name]); + + $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + } + + public function unregisterResource(string $uri): void + { + if (!isset($this->resources[$uri])) { + return; + } + + unset($this->resources[$uri]); + + $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + } + + public function unregisterResourceTemplate(string $uriTemplate): void + { + if (!isset($this->resourceTemplates[$uriTemplate])) { + return; + } + + unset($this->resourceTemplates[$uriTemplate]); + + $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + } + + public function unregisterPrompt(string $name): void + { + if (!isset($this->prompts[$name])) { + return; + } + + unset($this->prompts[$name]); $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } - public function clear(): void + public function hasTool(string $name): bool { - $clearCount = 0; + return isset($this->tools[$name]); + } - foreach ($this->tools as $name => $tool) { - if (!$tool->isManual) { - unset($this->tools[$name]); - ++$clearCount; - } - } - foreach ($this->resources as $uri => $resource) { - if (!$resource->isManual) { - unset($this->resources[$uri]); - ++$clearCount; - } - } - foreach ($this->prompts as $name => $prompt) { - if (!$prompt->isManual) { - unset($this->prompts[$name]); - ++$clearCount; - } - } - foreach ($this->resourceTemplates as $uriTemplate => $template) { - if (!$template->isManual) { - unset($this->resourceTemplates[$uriTemplate]); - ++$clearCount; - } - } + public function hasResource(string $uri): bool + { + return isset($this->resources[$uri]); + } - if ($clearCount > 0) { - $this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount)); - } + public function hasResourceTemplate(string $uriTemplate): bool + { + return isset($this->resourceTemplates[$uriTemplate]); + } + + public function hasPrompt(string $name): bool + { + return isset($this->prompts[$name]); } public function hasTools(): bool @@ -338,59 +328,6 @@ final class Registry implements RegistryInterface return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } - /** - * Get the current discovery state (only discovered elements, not manual ones). - */ - public function getDiscoveryState(): DiscoveryState - { - return new DiscoveryState( - tools: array_filter($this->tools, static fn ($tool) => !$tool->isManual), - resources: array_filter($this->resources, static fn ($resource) => !$resource->isManual), - prompts: array_filter($this->prompts, static fn ($prompt) => !$prompt->isManual), - resourceTemplates: array_filter($this->resourceTemplates, static fn ($template) => !$template->isManual), - ); - } - - /** - * Set the discovery state, replacing all discovered elements. - * Manual elements are preserved. - */ - public function setDiscoveryState(DiscoveryState $state): void - { - // Clear existing discovered elements - $this->clear(); - - // Import new discovered elements - foreach ($state->getTools() as $name => $tool) { - $this->tools[$name] = $tool; - } - - foreach ($state->getResources() as $uri => $resource) { - $this->resources[$uri] = $resource; - } - - foreach ($state->getPrompts() as $name => $prompt) { - $this->prompts[$name] = $prompt; - } - - foreach ($state->getResourceTemplates() as $uriTemplate => $template) { - $this->resourceTemplates[$uriTemplate] = $template; - } - - // Dispatch events for the imported elements - if ($this->eventDispatcher instanceof EventDispatcherInterface) { - if (!empty($state->getTools())) { - $this->eventDispatcher->dispatch(new ToolListChangedEvent()); - } - if (!empty($state->getResources()) || !empty($state->getResourceTemplates())) { - $this->eventDispatcher->dispatch(new ResourceListChangedEvent()); - } - if (!empty($state->getPrompts())) { - $this->eventDispatcher->dispatch(new PromptListChangedEvent()); - } - } - } - /** * Calculate next cursor for pagination. * diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 6425ba1..a49b2eb 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -23,7 +23,6 @@ class ElementReference */ public function __construct( public readonly \Closure|array|string $handler, - public readonly bool $isManual = false, ) { } } diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 34055a2..bce3adc 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -124,7 +124,7 @@ final class ArrayLoader implements LoaderInterface meta: $data['meta'] ?? null, outputSchema: $data['outputSchema'] ?? null, ); - $registry->registerTool($tool, $data['handler'], true); + $registry->registerTool($tool, $data['handler']); $handlerDesc = $this->getHandlerDescription($data['handler']); $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); @@ -164,7 +164,7 @@ final class ArrayLoader implements LoaderInterface icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, ); - $registry->registerResource($resource, $data['handler'], true); + $registry->registerResource($resource, $data['handler']); $handlerDesc = $this->getHandlerDescription($data['handler']); $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); @@ -203,7 +203,7 @@ final class ArrayLoader implements LoaderInterface meta: $data['meta'] ?? null, ); $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders); $handlerDesc = $this->getHandlerDescription($data['handler']); $this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); @@ -261,7 +261,7 @@ final class ArrayLoader implements LoaderInterface meta: $data['meta'] ?? null ); $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders); $handlerDesc = $this->getHandlerDescription($data['handler']); $this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); diff --git a/src/Capability/Registry/Loader/ChainLoader.php b/src/Capability/Registry/Loader/ChainLoader.php new file mode 100644 index 0000000..6c30a16 --- /dev/null +++ b/src/Capability/Registry/Loader/ChainLoader.php @@ -0,0 +1,38 @@ + + */ +final class ChainLoader implements LoaderInterface +{ + /** + * @param LoaderInterface[] $loaders + */ + public function __construct( + private readonly array $loaders, + ) { + } + + public function load(RegistryInterface $registry): void + { + foreach ($this->loaders as $loader) { + $loader->load($registry); + } + } +} diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php index a2ec40f..d2b9538 100644 --- a/src/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -12,7 +12,10 @@ namespace Mcp\Capability\Registry\Loader; use Mcp\Capability\Discovery\DiscovererInterface; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\RegistryInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @internal @@ -21,10 +24,12 @@ use Mcp\Capability\RegistryInterface; */ final class DiscoveryLoader implements LoaderInterface { + private DiscoveryState $owned; + /** - * @param string[] $scanDirs - * @param array|string[] $excludeDirs - * @param string[] $namePatterns + * @param string[] $scanDirs + * @param string[] $excludeDirs + * @param string[] $namePatterns */ public function __construct( private string $basePath, @@ -32,13 +37,149 @@ final class DiscoveryLoader implements LoaderInterface private array $excludeDirs, private DiscovererInterface $discoverer, private array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS, + private LoggerInterface $logger = new NullLogger(), ) { + $this->owned = new DiscoveryState(); } public function load(RegistryInterface $registry): void { - $discoveryState = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns); + $discovered = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns); - $registry->setDiscoveryState($discoveryState); + $this->unregisterOwned($registry, $this->owned->obsoletedBy($discovered)); + $this->owned = $this->writeDiscovered($registry, $discovered); + } + + /** + * Unregisters entries we previously wrote that the registry still attributes to us. + * Entries overwritten by someone else are left untouched (identity check fails). + */ + private function unregisterOwned(RegistryInterface $registry, DiscoveryState $obsolete): void + { + foreach ($obsolete->getTools() as $name => $owned) { + if ($registry->hasTool($name) && $registry->getTool($name) === $owned) { + $registry->unregisterTool($name); + } + } + foreach ($obsolete->getResources() as $uri => $owned) { + if ($registry->hasResource($uri) && $registry->getResource($uri, false) === $owned) { + $registry->unregisterResource($uri); + } + } + foreach ($obsolete->getResourceTemplates() as $uriTemplate => $owned) { + if ($registry->hasResourceTemplate($uriTemplate) && $registry->getResourceTemplate($uriTemplate) === $owned) { + $registry->unregisterResourceTemplate($uriTemplate); + } + } + foreach ($obsolete->getPrompts() as $name => $owned) { + if ($registry->hasPrompt($name) && $registry->getPrompt($name) === $owned) { + $registry->unregisterPrompt($name); + } + } + } + + /** + * Writes the discovered state into the registry, skipping entries that a conflicting + * registration already holds. Returns the new owned state (only the writes we actually performed). + */ + private function writeDiscovered(RegistryInterface $registry, DiscoveryState $discovered): DiscoveryState + { + $tools = []; + foreach ($discovered->getTools() as $name => $reference) { + if (!$this->mayWriteTool($registry, $name)) { + continue; + } + $tools[$name] = $registry->registerTool($reference->tool, $reference->handler); + } + + $resources = []; + foreach ($discovered->getResources() as $uri => $reference) { + if (!$this->mayWriteResource($registry, $uri)) { + continue; + } + $resources[$uri] = $registry->registerResource($reference->resource, $reference->handler); + } + + $resourceTemplates = []; + foreach ($discovered->getResourceTemplates() as $uriTemplate => $reference) { + if (!$this->mayWriteResourceTemplate($registry, $uriTemplate)) { + continue; + } + $resourceTemplates[$uriTemplate] = $registry->registerResourceTemplate( + $reference->resourceTemplate, + $reference->handler, + $reference->completionProviders, + ); + } + + $prompts = []; + foreach ($discovered->getPrompts() as $name => $reference) { + if (!$this->mayWritePrompt($registry, $name)) { + continue; + } + $prompts[$name] = $registry->registerPrompt( + $reference->prompt, + $reference->handler, + $reference->completionProviders, + ); + } + + return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); + } + + private function mayWriteTool(RegistryInterface $registry, string $name): bool + { + if (!$registry->hasTool($name) || $registry->getTool($name) === ($this->owned->getTools()[$name] ?? null)) { + return true; + } + + $this->logger->debug(\sprintf( + 'Ignoring discovered tool "%s": a conflicting manual or runtime registration already exists.', + $name, + )); + + return false; + } + + private function mayWriteResource(RegistryInterface $registry, string $uri): bool + { + if (!$registry->hasResource($uri) || $registry->getResource($uri, false) === ($this->owned->getResources()[$uri] ?? null)) { + return true; + } + + $this->logger->debug(\sprintf( + 'Ignoring discovered resource "%s": a conflicting manual or runtime registration already exists.', + $uri, + )); + + return false; + } + + private function mayWriteResourceTemplate(RegistryInterface $registry, string $uriTemplate): bool + { + if (!$registry->hasResourceTemplate($uriTemplate) || $registry->getResourceTemplate($uriTemplate) === ($this->owned->getResourceTemplates()[$uriTemplate] ?? null)) { + return true; + } + + $this->logger->debug(\sprintf( + 'Ignoring discovered resource template "%s": a conflicting manual or runtime registration already exists.', + $uriTemplate, + )); + + return false; + } + + private function mayWritePrompt(RegistryInterface $registry, string $name): bool + { + if (!$registry->hasPrompt($name) || $registry->getPrompt($name) === ($this->owned->getPrompts()[$name] ?? null)) { + return true; + } + + $this->logger->debug(\sprintf( + 'Ignoring discovered prompt "%s": a conflicting manual or runtime registration already exists.', + $name, + )); + + return false; } } diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 5de3a19..ec7d021 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -29,10 +29,9 @@ class PromptReference extends ElementReference public function __construct( public readonly Prompt $prompt, \Closure|array|string $handler, - bool $isManual = false, public readonly array $completionProviders = [], ) { - parent::__construct($handler, $isManual); + parent::__construct($handler); } /** diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index d65f461..bec8b9b 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -28,9 +28,8 @@ class ResourceReference extends ElementReference public function __construct( public readonly Resource $resource, callable|array|string $handler, - bool $isManual = false, ) { - parent::__construct($handler, $isManual); + parent::__construct($handler); } /** diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index ef2d915..49d03b3 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -36,10 +36,9 @@ class ResourceTemplateReference extends ElementReference public function __construct( public readonly ResourceTemplate $resourceTemplate, callable|array|string $handler, - bool $isManual = false, public readonly array $completionProviders = [], ) { - parent::__construct($handler, $isManual); + parent::__construct($handler); $this->compileTemplate(); } diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 9aa5a3c..36c0835 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -28,9 +28,8 @@ class ToolReference extends ElementReference public function __construct( public readonly Tool $tool, callable|array|string $handler, - bool $isManual = false, ) { - parent::__construct($handler, $isManual); + parent::__construct($handler); } /** diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 6729568..124704b 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -11,7 +11,6 @@ namespace Mcp\Capability; -use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; @@ -35,21 +34,25 @@ use Mcp\Schema\Tool; interface RegistryInterface { /** - * Registers a tool with its handler. + * Registers a tool with its handler. Overwrites any prior registration of the same name. + * Returns the stored reference, whose identity callers may track to detect later overwrites. * * @param Handler $handler */ - public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference; /** - * Registers a resource with its handler. + * Registers a resource with its handler. Overwrites any prior registration of the same URI. + * Returns the stored reference, whose identity callers may track to detect later overwrites. * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void; + public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference; /** * Registers a resource template with its handler and completion providers. + * Overwrites any prior registration of the same URI template. + * Returns the stored reference, whose identity callers may track to detect later overwrites. * * @param Handler $handler * @param array $completionProviders @@ -58,11 +61,12 @@ interface RegistryInterface ResourceTemplate $template, callable|array|string $handler, array $completionProviders = [], - bool $isManual = false, - ): void; + ): ResourceTemplateReference; /** * Registers a prompt with its handler and completion providers. + * Overwrites any prior registration of the same name. + * Returns the stored reference, whose identity callers may track to detect later overwrites. * * @param Handler $handler * @param array $completionProviders @@ -71,24 +75,35 @@ interface RegistryInterface Prompt $prompt, callable|array|string $handler, array $completionProviders = [], - bool $isManual = false, - ): void; + ): PromptReference; /** - * Clear discovered elements from registry. + * Removes a tool by name. No-op if absent. */ - public function clear(): void; + public function unregisterTool(string $name): void; /** - * Get the current discovery state (only discovered elements, not manual ones). + * Removes a resource by URI. No-op if absent. */ - public function getDiscoveryState(): DiscoveryState; + public function unregisterResource(string $uri): void; /** - * Set discovery state, replacing all discovered elements. - * Manual elements are preserved. + * Removes a resource template by URI template. No-op if absent. */ - public function setDiscoveryState(DiscoveryState $state): void; + public function unregisterResourceTemplate(string $uriTemplate): void; + + /** + * Removes a prompt by name. No-op if absent. + */ + public function unregisterPrompt(string $name): void; + + public function hasTool(string $name): bool; + + public function hasResource(string $uri): bool; + + public function hasResourceTemplate(string $uriTemplate): bool; + + public function hasPrompt(string $name): bool; /** * @return bool true if any tools are registered diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 2ec21d1..532f4fe 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -19,6 +19,7 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ChainLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; @@ -540,11 +541,6 @@ final class Builder $container = $this->container ?? new Container(); $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); - $loaders = [ - ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), - ]; - $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), $logger, @@ -552,18 +548,24 @@ final class Builder $this->gcDivisor, ); + // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's + // identity check then preserves them against same-name discovered entries. + $loaders = [ + ...$this->loaders, + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + ]; + if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); } } - foreach ($loaders as $loader) { - $loader->load($registry); - } + $loader = new ChainLoader($loaders); + $loader->load($registry); $messageFactory = MessageFactory::make(); diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json index 2c7912e..f3646e5 100644 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json @@ -1,9 +1,9 @@ { "resources": [ { - "name": "priority_config_discovered", + "name": "priority_config_manual", "uri": "config://priority", - "description": "A resource discovered via attributes.\n\nThis will be overridden by a manual registration with the same URI." + "description": "Manually registered resource that overrides a discovered one." } ] } diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json index 053dcce..85eef65 100644 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json @@ -3,7 +3,7 @@ { "uri": "config://priority", "mimeType": "text/plain", - "text": "Discovered Priority Config: Low" + "text": "Manual Priority Config: HIGH (overrides discovered)" } ] } diff --git a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php new file mode 100644 index 0000000..b5b60b1 --- /dev/null +++ b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php @@ -0,0 +1,164 @@ +tool('t1'); + $t2 = $this->tool('t2'); + + $owned = new DiscoveryState( + tools: ['t1' => $t1, 't2' => $t2], + ); + $next = new DiscoveryState( + tools: ['t2' => $this->tool('t2'), 't3' => $this->tool('t3')], + ); + + $obsolete = $owned->obsoletedBy($next); + + $this->assertSame(['t1' => $t1], $obsolete->getTools()); + } + + public function testObsoletedByIsAsymmetricAndIgnoresValuesOnSharedKeys(): void + { + // Same key, different reference instance — must NOT be reported as obsolete. + $owned = new DiscoveryState( + tools: ['t' => $this->tool('t')], + ); + $next = new DiscoveryState( + tools: ['t' => $this->tool('t')], + ); + + $this->assertTrue($owned->obsoletedBy($next)->isEmpty()); + } + + public function testObsoletedByOnEmptyNextReturnsAllOwned(): void + { + $owned = new DiscoveryState( + tools: ['t' => $this->tool('t')], + resources: ['r://x' => $this->resource('r://x')], + prompts: ['p' => $this->prompt('p')], + resourceTemplates: ['x://{id}' => $this->template('x://{id}')], + ); + + $obsolete = $owned->obsoletedBy(new DiscoveryState()); + + $this->assertSame(['t'], array_keys($obsolete->getTools())); + $this->assertSame(['r://x'], array_keys($obsolete->getResources())); + $this->assertSame(['p'], array_keys($obsolete->getPrompts())); + $this->assertSame(['x://{id}'], array_keys($obsolete->getResourceTemplates())); + } + + public function testObsoletedByOnEmptyOwnedReturnsEmpty(): void + { + $next = new DiscoveryState( + tools: ['t' => $this->tool('t')], + ); + + $this->assertTrue((new DiscoveryState())->obsoletedBy($next)->isEmpty()); + } + + public function testObsoletedByKeepsKindsIndependent(): void + { + // A key shared across different kinds must not cancel out. + $owned = new DiscoveryState( + tools: ['shared' => $this->tool('shared')], + prompts: ['shared' => $this->prompt('shared')], + ); + $next = new DiscoveryState( + tools: ['shared' => $this->tool('shared')], + // 'shared' prompt is gone. + ); + + $obsolete = $owned->obsoletedBy($next); + + $this->assertSame([], $obsolete->getTools()); + $this->assertSame(['shared'], array_keys($obsolete->getPrompts())); + } + + public function testObsoletedByAcrossAllKindsAtOnce(): void + { + $owned = new DiscoveryState( + tools: ['keep_t' => $this->tool('keep_t'), 'drop_t' => $this->tool('drop_t')], + resources: ['r://keep' => $this->resource('r://keep'), 'r://drop' => $this->resource('r://drop')], + prompts: ['keep_p' => $this->prompt('keep_p'), 'drop_p' => $this->prompt('drop_p')], + resourceTemplates: ['keep://{id}' => $this->template('keep://{id}'), 'drop://{id}' => $this->template('drop://{id}')], + ); + $next = new DiscoveryState( + tools: ['keep_t' => $this->tool('keep_t')], + resources: ['r://keep' => $this->resource('r://keep')], + prompts: ['keep_p' => $this->prompt('keep_p')], + resourceTemplates: ['keep://{id}' => $this->template('keep://{id}')], + ); + + $obsolete = $owned->obsoletedBy($next); + + $this->assertSame(['drop_t'], array_keys($obsolete->getTools())); + $this->assertSame(['r://drop'], array_keys($obsolete->getResources())); + $this->assertSame(['drop_p'], array_keys($obsolete->getPrompts())); + $this->assertSame(['drop://{id}'], array_keys($obsolete->getResourceTemplates())); + } + + private function tool(string $name): ToolReference + { + return new ToolReference( + new Tool( + name: $name, + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + icons: null, + meta: null, + outputSchema: null, + ), + static fn () => null, + ); + } + + private function resource(string $uri): ResourceReference + { + return new ResourceReference( + new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), + static fn () => null, + ); + } + + private function prompt(string $name): PromptReference + { + return new PromptReference( + new Prompt(name: $name, description: null, arguments: []), + static fn () => [], + ); + } + + private function template(string $uriTemplate): ResourceTemplateReference + { + return new ResourceTemplateReference( + new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain'), + static fn () => null, + ); + } +} diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 1451361..b419726 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -41,7 +41,6 @@ class DiscoveryTest extends TestCase $this->assertCount(5, $tools); $this->assertArrayHasKey('greet_user', $tools); - $this->assertFalse($tools['greet_user']->isManual); $this->assertEquals('greet_user', $tools['greet_user']->tool->name); $this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description); $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler); @@ -54,7 +53,6 @@ class DiscoveryTest extends TestCase $this->assertArrayHasKey('InvokableCalculator', $tools); $this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']); - $this->assertFalse($tools['InvokableCalculator']->isManual); $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); $this->assertArrayHasKey('inc_file_name_tool', $tools); @@ -68,44 +66,36 @@ class DiscoveryTest extends TestCase $this->assertCount(3, $resources); $this->assertArrayHasKey('app://info/version', $resources); - $this->assertFalse($resources['app://info/version']->isManual); $this->assertEquals('app_version', $resources['app://info/version']->resource->name); $this->assertEquals('text/plain', $resources['app://info/version']->resource->mimeType); $this->assertArrayHasKey('invokable://config/status', $resources); - $this->assertFalse($resources['invokable://config/status']->isManual); $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); $this->assertArrayHasKey('creative_story_prompt', $prompts); - $this->assertFalse($prompts['creative_story_prompt']->isManual); $this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments); $this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']); $this->assertArrayHasKey('simpleQuestionPrompt', $prompts); - $this->assertFalse($prompts['simpleQuestionPrompt']->isManual); $this->assertArrayHasKey('InvokableGreeterPrompt', $prompts); - $this->assertFalse($prompts['InvokableGreeterPrompt']->isManual); $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); $this->assertArrayHasKey('content_creator', $prompts); - $this->assertFalse($prompts['content_creator']->isManual); $this->assertCount(3, $prompts['content_creator']->completionProviders); $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); $this->assertArrayHasKey('product://{region}/details/{productId}', $templates); - $this->assertFalse($templates['product://{region}/details/{productId}']->isManual); $this->assertEquals('product_details_template', $templates['product://{region}/details/{productId}']->resourceTemplate->name); $this->assertEquals(CompletionProviderFixture::class, $templates['product://{region}/details/{productId}']->completionProviders['region']); $this->assertEqualsCanonicalizing(['region', 'productId'], $templates['product://{region}/details/{productId}']->getVariableNames()); $this->assertArrayHasKey('invokable://user-profile/{userId}', $templates); - $this->assertFalse($templates['invokable://user-profile/{userId}']->isManual); $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler); } diff --git a/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php new file mode 100644 index 0000000..5488818 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php @@ -0,0 +1,57 @@ +load(new Registry()); + + $this->assertSame(['A', 'B', 'C'], $calls->getArrayCopy()); + } + + public function testLastWriterWinsForConflictingKeys(): void + { + $registry = new Registry(); + + $first = new ToolWriterLoader('shared', static fn () => 'first'); + $second = new ToolWriterLoader('shared', static fn () => 'second'); + + (new ChainLoader([$first, $second]))->load($registry); + + $this->assertSame('second', ($registry->getTool('shared')->handler)()); + } + + public function testEmptyChainIsNoop(): void + { + $registry = new Registry(); + + (new ChainLoader([]))->load($registry); + + $this->assertFalse($registry->hasTools()); + $this->assertFalse($registry->hasResources()); + $this->assertFalse($registry->hasResourceTemplates()); + $this->assertFalse($registry->hasPrompts()); + } +} diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php new file mode 100644 index 0000000..eaab2df --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -0,0 +1,284 @@ +registry = new Registry(); + } + + public function testLoadRegistersAllDiscoveredElements(): void + { + $loader = new DiscoveryLoader('/base', [], [], new MutableDiscoverer(new DiscoveryState( + tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')], + resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')], + prompts: ['p1' => new PromptReference($this->makePrompt('p1'), static fn () => [])], + resourceTemplates: ['t://{id}' => new ResourceTemplateReference($this->makeTemplate('t://{id}'), static fn () => 'tpl')], + ))); + + $loader->load($this->registry); + + $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t1')); + $this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://1', false)); + $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p1')); + $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t://{id}')); + } + + public function testLoadTwiceUnregistersStaleAndKeepsNew(): void + { + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')], + resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + + $loader->load($this->registry); + + // Second discovery: t1 is gone, t2 appears; resource r://1 still present, new r://2 added. + $discoverer->state = new DiscoveryState( + tools: ['t2' => new ToolReference($this->makeTool('t2'), static fn () => 't2')], + resources: [ + 'r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1-updated'), + 'r://2' => new ResourceReference($this->makeResource('r://2'), static fn () => 'r2'), + ], + ); + $loader->load($this->registry); + + $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t2')); + $this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://2', false)); + + $updatedResource = $this->registry->getResource('r://1', false); + $this->assertInstanceOf(ResourceReference::class, $updatedResource); + $this->assertSame('r1-updated', ($updatedResource->handler)()); + + $this->expectException(ToolNotFoundException::class); + $this->registry->getTool('t1'); + } + + public function testLoadTwiceUnregistersStalePromptsAndTemplates(): void + { + $discoverer = new MutableDiscoverer(new DiscoveryState( + prompts: [ + 'p1' => new PromptReference($this->makePrompt('p1'), static fn () => []), + 'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []), + ], + resourceTemplates: [ + 't1://{id}' => new ResourceTemplateReference($this->makeTemplate('t1://{id}'), static fn () => 'tpl1'), + 't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'), + ], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + $loader->load($this->registry); + + // Second discovery: drop p1 and t1, keep p2 and t2, add p3 and t3. + $discoverer->state = new DiscoveryState( + prompts: [ + 'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []), + 'p3' => new PromptReference($this->makePrompt('p3'), static fn () => []), + ], + resourceTemplates: [ + 't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'), + 't3://{id}' => new ResourceTemplateReference($this->makeTemplate('t3://{id}'), static fn () => 'tpl3'), + ], + ); + $loader->load($this->registry); + + $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p2')); + $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p3')); + $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t2://{id}')); + $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t3://{id}')); + + $missing = 0; + try { + $this->registry->getPrompt('p1'); + } catch (PromptNotFoundException) { + ++$missing; + } + try { + $this->registry->getResourceTemplate('t1://{id}'); + } catch (ResourceNotFoundException) { + ++$missing; + } + $this->assertSame(2, $missing); + } + + public function testLoadOverwritesPreviousRegistrationOnSameKey(): void + { + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v1')], + prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => EnumCompletionProvider::class])], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + $loader->load($this->registry); + + // Same names, different handlers / completion providers. + $discoverer->state = new DiscoveryState( + tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v2')], + prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => ListCompletionProvider::class])], + ); + $loader->load($this->registry); + + $this->assertSame('v2', ($this->registry->getTool('t')->handler)()); + $this->assertSame(['arg' => ListCompletionProvider::class], $this->registry->getPrompt('p')->completionProviders); + } + + public function testLoadPreservesConflictingRuntimeRegistration(): void + { + // Application registers a tool directly. + $this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime'); + + // Discovery later finds the same name. The manual registration wins — + // discovery does not clobber entries it doesn't own. + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered')], + )); + (new DiscoveryLoader('/base', [], [], $discoverer))->load($this->registry); + + $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); + } + + public function testLoadPreservesRuntimeOverrideOfPreviouslyOwnedEntry(): void + { + // First discovery owns 'shared'. + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v1')], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + $loader->load($this->registry); + + // Developer overrides at runtime. + $this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime'); + + // Rediscovery still finds 'shared'; loader sees the registry no longer holds its instance and steps aside. + $discoverer->state = new DiscoveryState( + tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v2')], + ); + $loader->load($this->registry); + + $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); + } + + public function testLoadDoesNotUnregisterRuntimeAdditions(): void + { + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['discovered_tool' => new ToolReference($this->makeTool('discovered_tool'), static fn () => 'discovered')], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + + $loader->load($this->registry); + + // Application registers a tool directly between two discovery runs. + $this->registry->registerTool($this->makeTool('runtime_tool'), static fn () => 'runtime'); + + // Second discovery run with a different state. The runtime tool must survive. + $discoverer->state = new DiscoveryState( + tools: ['discovered_tool_v2' => new ToolReference($this->makeTool('discovered_tool_v2'), static fn () => 'v2')], + ); + $loader->load($this->registry); + + $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('runtime_tool')); + $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('discovered_tool_v2')); + + $this->expectException(ToolNotFoundException::class); + $this->registry->getTool('discovered_tool'); + } + + public function testEmptySecondLoadUnregistersAllPreviouslyDiscovered(): void + { + $discoverer = new MutableDiscoverer(new DiscoveryState( + tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 't')], + resources: ['r://x' => new ResourceReference($this->makeResource('r://x'), static fn () => 'rx')], + prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [])], + resourceTemplates: ['x://{id}' => new ResourceTemplateReference($this->makeTemplate('x://{id}'), static fn () => 'tpl')], + )); + $loader = new DiscoveryLoader('/base', [], [], $discoverer); + $loader->load($this->registry); + + $discoverer->state = new DiscoveryState(); + $loader->load($this->registry); + + $exceptions = 0; + try { + $this->registry->getTool('t'); + } catch (ToolNotFoundException) { + ++$exceptions; + } + try { + $this->registry->getResource('r://x', false); + } catch (ResourceNotFoundException) { + ++$exceptions; + } + try { + $this->registry->getPrompt('p'); + } catch (PromptNotFoundException) { + ++$exceptions; + } + try { + $this->registry->getResourceTemplate('x://{id}'); + } catch (ResourceNotFoundException) { + ++$exceptions; + } + $this->assertSame(4, $exceptions); + } + + private function makeTool(string $name): Tool + { + return new Tool( + name: $name, + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + icons: null, + meta: null, + outputSchema: null, + ); + } + + private function makeResource(string $uri): Resource + { + return new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); + } + + private function makePrompt(string $name): Prompt + { + return new Prompt(name: $name, description: null, arguments: []); + } + + private function makeTemplate(string $uriTemplate): ResourceTemplate + { + return new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain'); + } +} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php b/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php new file mode 100644 index 0000000..0b5b298 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/Stub/MutableDiscoverer.php @@ -0,0 +1,27 @@ +state; + } +} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php b/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php new file mode 100644 index 0000000..a1eccae --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/Stub/RecordingLoader.php @@ -0,0 +1,30 @@ + $calls + */ + public function __construct(private string $name, private \ArrayObject $calls) + { + } + + public function load(RegistryInterface $registry): void + { + $this->calls->append($this->name); + } +} diff --git a/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php b/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php new file mode 100644 index 0000000..d8ae3f1 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/Stub/ToolWriterLoader.php @@ -0,0 +1,37 @@ +registerTool(new Tool( + name: $this->toolName, + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + icons: null, + meta: null, + outputSchema: null, + ), $this->handler); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 90ac887..5173aaf 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -82,48 +82,18 @@ class RegistryTest extends TestCase $this->assertInstanceOf(ToolReference::class, $toolRef); $this->assertEquals($tool->name, $toolRef->tool->name); $this->assertEquals($handler, $toolRef->handler); - $this->assertFalse($toolRef->isManual); } - public function testRegisterToolWithManualFlag(): void + public function testRegisterToolOverwritesPriorRegistration(): void { - $tool = $this->createValidTool('test_tool'); - $handler = static fn () => 'result'; + $first = $this->createValidTool('test_tool'); + $second = $this->createValidTool('test_tool'); - $this->registry->registerTool($tool, $handler, true); + $this->registry->registerTool($first, static fn () => 'first'); + $this->registry->registerTool($second, static fn () => 'second'); $toolRef = $this->registry->getTool('test_tool'); - $this->assertTrue($toolRef->isManual); - } - - public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void - { - $manualTool = $this->createValidTool('test_tool'); - $discoveredTool = $this->createValidTool('test_tool'); - - $this->registry->registerTool($manualTool, static fn () => 'manual', true); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Ignoring discovered tool "test_tool" as it conflicts with a manually registered one.'); - - $this->registry->registerTool($discoveredTool, static fn () => 'discovered'); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertTrue($toolRef->isManual); - } - - public function testRegisterToolOverridesDiscoveredWithManual(): void - { - $discoveredTool = $this->createValidTool('test_tool'); - $manualTool = $this->createValidTool('test_tool'); - - $this->registry->registerTool($discoveredTool, static fn () => 'discovered'); - $this->registry->registerTool($manualTool, static fn () => 'manual', true); - - $toolRef = $this->registry->getTool('test_tool'); - $this->assertTrue($toolRef->isManual); + $this->assertEquals('second', ($toolRef->handler)()); } public function testGetToolThrowsExceptionForUnregisteredTool(): void @@ -169,36 +139,18 @@ class RegistryTest extends TestCase $this->assertInstanceOf(ResourceReference::class, $resourceRef); $this->assertEquals($resource->uri, $resourceRef->resource->uri); $this->assertEquals($handler, $resourceRef->handler); - $this->assertFalse($resourceRef->isManual); } - public function testRegisterResourceWithManualFlag(): void + public function testRegisterResourceOverwritesPriorRegistration(): void { - $resource = $this->createValidResource('test://resource'); - $handler = static fn () => 'content'; + $first = $this->createValidResource('test://resource'); + $second = $this->createValidResource('test://resource'); - $this->registry->registerResource($resource, $handler, true); + $this->registry->registerResource($first, static fn () => 'first'); + $this->registry->registerResource($second, static fn () => 'second'); $resourceRef = $this->registry->getResource('test://resource'); - $this->assertTrue($resourceRef->isManual); - } - - public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void - { - $manualResource = $this->createValidResource('test://resource'); - $discoveredResource = $this->createValidResource('test://resource'); - - $this->registry->registerResource($manualResource, static fn () => 'manual', true); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Ignoring discovered resource "test://resource" as it conflicts with a manually registered one.'); - - $this->registry->registerResource($discoveredResource, static fn () => 'discovered'); - - $resourceRef = $this->registry->getResource('test://resource'); - $this->assertTrue($resourceRef->isManual); + $this->assertEquals('second', ($resourceRef->handler)()); } public function testGetResourceThrowsExceptionForUnregisteredResource(): void @@ -244,7 +196,6 @@ class RegistryTest extends TestCase $this->assertInstanceOf(ResourceTemplateReference::class, $templateRef); $this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate); $this->assertEquals($handler, $templateRef->handler); - $this->assertFalse($templateRef->isManual); } public function testGetResourcePrefersDirectResourceOverTemplate(): void @@ -300,22 +251,16 @@ class RegistryTest extends TestCase $this->assertEquals($completionProviders, $templateRef->completionProviders); } - public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): void + public function testRegisterResourceTemplateOverwritesPriorRegistration(): void { - $manualTemplate = $this->createValidResourceTemplate('test://{id}'); - $discoveredTemplate = $this->createValidResourceTemplate('test://{id}'); + $first = $this->createValidResourceTemplate('test://{id}'); + $second = $this->createValidResourceTemplate('test://{id}'); - $this->registry->registerResourceTemplate($manualTemplate, static fn () => 'manual', [], true); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Ignoring discovered template "test://{id}" as it conflicts with a manually registered one.'); - - $this->registry->registerResourceTemplate($discoveredTemplate, static fn () => 'discovered'); + $this->registry->registerResourceTemplate($first, static fn () => 'first'); + $this->registry->registerResourceTemplate($second, static fn () => 'second'); $templateRef = $this->registry->getResourceTemplate('test://{id}'); - $this->assertTrue($templateRef->isManual); + $this->assertEquals('second', ($templateRef->handler)()); } public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void @@ -375,7 +320,6 @@ class RegistryTest extends TestCase $this->assertInstanceOf(PromptReference::class, $promptRef); $this->assertEquals($prompt->name, $promptRef->prompt->name); $this->assertEquals($handler, $promptRef->handler); - $this->assertFalse($promptRef->isManual); } public function testRegisterPromptWithCompletionProviders(): void @@ -389,22 +333,16 @@ class RegistryTest extends TestCase $this->assertEquals($completionProviders, $promptRef->completionProviders); } - public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void + public function testRegisterPromptOverwritesPriorRegistration(): void { - $manualPrompt = $this->createValidPrompt('test_prompt'); - $discoveredPrompt = $this->createValidPrompt('test_prompt'); + $first = $this->createValidPrompt('test_prompt'); + $second = $this->createValidPrompt('test_prompt'); - $this->registry->registerPrompt($manualPrompt, static fn () => 'manual', [], true); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Ignoring discovered prompt "test_prompt" as it conflicts with a manually registered one.'); - - $this->registry->registerPrompt($discoveredPrompt, static fn () => 'discovered'); + $this->registry->registerPrompt($first, static fn () => 'first'); + $this->registry->registerPrompt($second, static fn () => 'second'); $promptRef = $this->registry->getPrompt('test_prompt'); - $this->assertTrue($promptRef->isManual); + $this->assertEquals('second', ($promptRef->handler)()); } public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void @@ -415,70 +353,55 @@ class RegistryTest extends TestCase $this->registry->getPrompt('non_existent_prompt'); } - public function testClearRemovesOnlyDiscoveredElements(): void + public function testUnregisterToolRemovesRegisteredTool(): void { - $manualTool = $this->createValidTool('manual_tool'); - $discoveredTool = $this->createValidTool('discovered_tool'); - $manualResource = $this->createValidResource('test://manual'); - $discoveredResource = $this->createValidResource('test://discovered'); - $manualPrompt = $this->createValidPrompt('manual_prompt'); - $discoveredPrompt = $this->createValidPrompt('discovered_prompt'); - $manualTemplate = $this->createValidResourceTemplate('manual://{id}'); - $discoveredTemplate = $this->createValidResourceTemplate('discovered://{id}'); + $tool = $this->createValidTool('test_tool'); + $this->registry->registerTool($tool, static fn () => 'result'); - $this->registry->registerTool($manualTool, static fn () => 'manual', true); - $this->registry->registerTool($discoveredTool, static fn () => 'discovered'); - $this->registry->registerResource($manualResource, static fn () => 'manual', true); - $this->registry->registerResource($discoveredResource, static fn () => 'discovered'); - $this->registry->registerPrompt($manualPrompt, static fn () => [], [], true); - $this->registry->registerPrompt($discoveredPrompt, static fn () => []); - $this->registry->registerResourceTemplate($manualTemplate, static fn () => 'manual', [], true); - $this->registry->registerResourceTemplate($discoveredTemplate, static fn () => 'discovered'); + $this->registry->unregisterTool('test_tool'); - // Test that all elements exist - $this->registry->getTool('manual_tool'); - $this->registry->getResource('test://manual'); - $this->registry->getPrompt('manual_prompt'); - $this->registry->getResourceTemplate('manual://{id}'); - $this->registry->getTool('discovered_tool'); - $this->registry->getResource('test://discovered'); - $this->registry->getPrompt('discovered_prompt'); - $this->registry->getResourceTemplate('discovered://{id}'); - - $this->registry->clear(); - - // Manual elements should still exist - $this->registry->getTool('manual_tool'); - $this->registry->getResource('test://manual'); - $this->registry->getPrompt('manual_prompt'); - $this->registry->getResourceTemplate('manual://{id}'); - - // Test that all discovered elements throw exceptions $this->expectException(ToolNotFoundException::class); - $this->registry->getTool('discovered_tool'); - - $this->expectException(ResourceNotFoundException::class); - $this->registry->getResource('test://discovered'); - - $this->expectException(PromptNotFoundException::class); - $this->registry->getPrompt('discovered_prompt'); - - $this->expectException(ResourceNotFoundException::class); - $this->registry->getResourceTemplate('discovered://{id}'); + $this->registry->getTool('test_tool'); } - public function testClearLogsNothingWhenNoDiscoveredElements(): void + public function testUnregisterToolIsIdempotentForAbsentName(): void { - $manualTool = $this->createValidTool('manual_tool'); - $this->registry->registerTool($manualTool, static fn () => 'manual', true); + $this->registry->unregisterTool('never_registered'); - $this->logger - ->expects($this->never()) - ->method('debug'); + $this->assertFalse($this->registry->hasTools()); + } - $this->registry->clear(); + public function testUnregisterResourceRemovesRegisteredResource(): void + { + $resource = $this->createValidResource('test://resource'); + $this->registry->registerResource($resource, static fn () => 'content'); - $this->registry->getTool('manual_tool'); + $this->registry->unregisterResource('test://resource'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResource('test://resource', false); + } + + public function testUnregisterResourceTemplateRemovesRegisteredTemplate(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $this->registry->registerResourceTemplate($template, static fn () => 'content'); + + $this->registry->unregisterResourceTemplate('test://{id}'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResourceTemplate('test://{id}'); + } + + public function testUnregisterPromptRemovesRegisteredPrompt(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $this->registry->registerPrompt($prompt, static fn () => []); + + $this->registry->unregisterPrompt('test_prompt'); + + $this->expectException(PromptNotFoundException::class); + $this->registry->getPrompt('test_prompt'); } public function testRegisterToolHandlesStringHandler(): void From a820497b629075bf6b410a325d20154c9eb0655b Mon Sep 17 00:00:00 2001 From: Dayna Blackwell Date: Sun, 17 May 2026 05:10:31 -0700 Subject: [PATCH 037/143] Add title field to Resource and ResourceTemplate (#301) The MCP specification (2025-11-25) defines an optional title field for Resource and ResourceTemplate as a human-readable display label, distinct from name (a short identifier). Tool and Prompt already implement this field; Resource and ResourceTemplate were missing it. Changes: - Add title parameter to Resource and ResourceTemplate constructors - Add title to fromArray(), jsonSerialize(), and PHPStan type arrays - Add title to McpResource and McpResourceTemplate attributes - Pass title through Discoverer when constructing schema objects - Add tests for deserialization, serialization, and null omission Fixes #296 --- src/Capability/Attribute/McpResource.php | 14 ++++---- .../Attribute/McpResourceTemplate.php | 8 +++-- src/Capability/Discovery/Discoverer.php | 3 +- src/Schema/Resource.php | 18 ++++++---- src/Schema/ResourceTemplate.php | 18 +++++++--- tests/Unit/Schema/ResourceTemplateTest.php | 35 +++++++++++++++++++ tests/Unit/Schema/ResourceTest.php | 35 +++++++++++++++++++ 7 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 80f309c..0f51657 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -24,18 +24,20 @@ use Mcp\Schema\Icon; class McpResource { /** - * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. - * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. - * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param string $uri the specific URI identifying this resource instance + * @param ?string $name a short identifier for this resource; defaults to the method name + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description optional description; defaults to class DocBlock summary * @param ?string $mimeType the MIME type, if known and constant for this resource * @param ?int $size the size in bytes, if known and constant - * @param Annotations|null $annotations optional annotations describing the resource - * @param ?Icon[] $icons Optional list of icon URLs representing the resource - * @param ?array $meta Optional metadata + * @param ?Annotations $annotations optional annotations describing the resource + * @param ?Icon[] $icons optional icons representing the resource + * @param ?array $meta optional metadata */ public function __construct( public string $uri, public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 0269f93..6a2044d 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -24,15 +24,17 @@ class McpResourceTemplate { /** * @param string $uriTemplate the URI template string (RFC 6570) - * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. - * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $name a short identifier for the template type; defaults to the method name + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description optional description; defaults to class DocBlock summary * @param ?string $mimeType optional default MIME type for matching resources * @param ?Annotations $annotations optional annotations describing the resource template - * @param ?array $meta Optional metadata + * @param ?array $meta optional metadata */ public function __construct( public string $uriTemplate, public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 7e7df9e..9304c48 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -252,6 +252,7 @@ final class Discoverer implements DiscovererInterface $resource = new Resource( $instance->uri, $name, + $instance->title, $description, $instance->mimeType, $instance->annotations, @@ -291,7 +292,7 @@ final class Discoverer implements DiscovererInterface $mimeType = $instance->mimeType; $annotations = $instance->annotations; $meta = $instance->meta ?? null; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders); ++$discoveredCount['resourceTemplates']; diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index 28b8ae5..00fc267 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -22,6 +22,7 @@ use Mcp\Exception\InvalidArgumentException; * @phpstan-type ResourceData array{ * uri: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: AnnotationsData, @@ -47,19 +48,19 @@ class Resource implements \JsonSerializable /** * @param string $uri the URI of this resource - * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. - * @param ?string $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param string $name a short identifier for this resource + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. * @param ?string $mimeType the MIME type of this resource, if known * @param ?Annotations $annotations optional annotations for the client - * @param ?int $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @param ?int $size the size of the raw resource content, in bytes (before base64 encoding or any tokenization), if known * @param ?Icon[] $icons optional icons representing the resource - * @param ?array $meta Optional metadata - * - * This can be used by Hosts to display file sizes and estimate context window usage + * @param ?array $meta optional metadata */ public function __construct( public readonly string $uri, public readonly string $name, + public readonly ?string $title = null, public readonly ?string $description = null, public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, @@ -94,6 +95,7 @@ class Resource implements \JsonSerializable return new self( uri: $data['uri'], name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, @@ -107,6 +109,7 @@ class Resource implements \JsonSerializable * @return array{ * uri: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: Annotations, @@ -121,6 +124,9 @@ class Resource implements \JsonSerializable 'uri' => $this->uri, 'name' => $this->name, ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index a19d0c7..46f173c 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -21,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException; * @phpstan-type ResourceTemplateData array{ * uriTemplate: string, * name: string, + * title?: string, * description?: string|null, * mimeType?: string|null, * annotations?: AnnotationsData|null, @@ -44,15 +45,17 @@ class ResourceTemplate implements \JsonSerializable /** * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs - * @param string $name A human-readable name for the type of resource this template refers to. This can be used by clients to populate UI elements. - * @param string|null $description This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - * @param string|null $mimeType The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - * @param Annotations|null $annotations optional annotations for the client - * @param ?array $meta Optional metadata + * @param string $name a short identifier for this resource template type + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description a description to help the LLM understand available resources + * @param ?string $mimeType the MIME type for all resources that match this template, if uniform + * @param ?Annotations $annotations optional annotations for the client + * @param ?array $meta optional metadata */ public function __construct( public readonly string $uriTemplate, public readonly string $name, + public readonly ?string $title = null, public readonly ?string $description = null, public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, @@ -85,6 +88,7 @@ class ResourceTemplate implements \JsonSerializable return new self( uriTemplate: $data['uriTemplate'], name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, @@ -96,6 +100,7 @@ class ResourceTemplate implements \JsonSerializable * @return array{ * uriTemplate: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: Annotations, @@ -108,6 +113,9 @@ class ResourceTemplate implements \JsonSerializable 'uriTemplate' => $this->uriTemplate, 'name' => $this->name, ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index 54feaf4..3e2908d 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -75,10 +75,45 @@ class ResourceTemplateTest extends TestCase $this->assertInstanceOf(ResourceTemplate::class, $resource); $this->assertSame(self::VALID_URI, $resource->uriTemplate); $this->assertSame('list-books', $resource->name); + $this->assertNull($resource->title); $this->assertNull($resource->description); $this->assertNull($resource->meta); } + public function testTitleFromArray(): void + { + $resource = ResourceTemplate::fromArray([ + 'uriTemplate' => self::VALID_URI, + 'name' => 'list-books', + 'title' => 'Book Listing', + ]); + + $this->assertSame('Book Listing', $resource->title); + } + + public function testTitleSerialization(): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: 'list-books', + title: 'Book Listing', + ); + + $data = $resource->jsonSerialize(); + $this->assertSame('Book Listing', $data['title']); + } + + public function testTitleOmittedWhenNull(): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: 'list-books', + ); + + $data = $resource->jsonSerialize(); + $this->assertArrayNotHasKey('title', $data); + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceTest.php index c6c8562..7a2d9a1 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceTest.php @@ -77,10 +77,45 @@ class ResourceTest extends TestCase $this->assertInstanceOf(Resource::class, $resource); $this->assertSame(self::VALID_URI, $resource->uri); $this->assertSame('list-books', $resource->name); + $this->assertNull($resource->title); $this->assertNull($resource->description); $this->assertNull($resource->meta); } + public function testTitleFromArray(): void + { + $resource = Resource::fromArray([ + 'uri' => self::VALID_URI, + 'name' => 'list-books', + 'title' => 'Book Listing', + ]); + + $this->assertSame('Book Listing', $resource->title); + } + + public function testTitleSerialization(): void + { + $resource = new Resource( + uri: self::VALID_URI, + name: 'list-books', + title: 'Book Listing', + ); + + $data = $resource->jsonSerialize(); + $this->assertSame('Book Listing', $data['title']); + } + + public function testTitleOmittedWhenNull(): void + { + $resource = new Resource( + uri: self::VALID_URI, + name: 'list-books', + ); + + $data = $resource->jsonSerialize(); + $this->assertArrayNotHasKey('title', $data); + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { From 1300d2ee7db13c71ba433ce16f4d6a2fd4227885 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 18 May 2026 20:02:01 +0200 Subject: [PATCH 038/143] Add title parameter to Builder::addResource() and addResourceTemplate() (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #301 added the `title` field to the `Resource`/`ResourceTemplate` schema classes and the `McpResource`/`McpResourceTemplate` attributes, but the manual registration methods on `Builder` were never updated — so manual registration could not set a resource title, unlike tools and prompts. Add a `$title` parameter between `$name` and `$description` (matching `addTool()`/`addPrompt()`) and propagate it through `ArrayLoader` when constructing the `Resource`/`ResourceTemplate` objects. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 + .../Registry/Loader/ArrayLoader.php | 4 ++ src/Server/Builder.php | 8 +++ .../Loader/ArrayLoaderResourceTitleTest.php | 69 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e82b67..febbef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag. * [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly. * `Registry::register*()` semantics changed to plain last-write-wins (overwrites silently) and the methods now return the stored `*Reference`. The previous "discovered registration is ignored when a manual one already exists" precedence rule still applies, but is now enforced by `DiscoveryLoader` via reference-identity tracking — and still emits a debug log when a discovery is skipped due to a conflicting registration. +* Add optional `title` parameter to `Builder::addResource()` and `Builder::addResourceTemplate()` for MCP spec compliance +* [BC Break] `Builder::addResource()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* [BC Break] `Builder::addResourceTemplate()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. 0.5.0 ----- diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index bce3adc..8865b0f 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -56,6 +56,7 @@ final class ArrayLoader implements LoaderInterface * handler: Handler, * uri: string, * name: ?string, + * title: ?string, * description: ?string, * mimeType: ?string, * size: int|null, @@ -67,6 +68,7 @@ final class ArrayLoader implements LoaderInterface * handler: Handler, * uriTemplate: string, * name: ?string, + * title: ?string, * description: ?string, * mimeType: ?string, * annotations: ?Annotations, @@ -157,6 +159,7 @@ final class ArrayLoader implements LoaderInterface $resource = new Resource( uri: $data['uri'], name: $name, + title: $data['title'] ?? null, description: $description, mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, @@ -197,6 +200,7 @@ final class ArrayLoader implements LoaderInterface $template = new ResourceTemplate( uriTemplate: $data['uriTemplate'], name: $name, + title: $data['title'] ?? null, description: $description, mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 532f4fe..ba26d23 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -119,6 +119,7 @@ final class Builder * handler: Handler, * uri: string, * name: ?string, + * title: ?string, * description: ?string, * mimeType: ?string, * size: int|null, @@ -134,6 +135,7 @@ final class Builder * handler: Handler, * uriTemplate: string, * name: ?string, + * title: ?string, * description: ?string, * mimeType: ?string, * annotations: ?Annotations, @@ -433,6 +435,7 @@ final class Builder * Manually registers a resource handler. * * @param Handler $handler + * @param ?string $title Optional human-readable title for display in UI * @param ?Icon[] $icons * @param array|null $meta */ @@ -440,6 +443,7 @@ final class Builder \Closure|array|string $handler, string $uri, ?string $name = null, + ?string $title = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, @@ -451,6 +455,7 @@ final class Builder 'handler', 'uri', 'name', + 'title', 'description', 'mimeType', 'size', @@ -466,12 +471,14 @@ final class Builder * Manually registers a resource template handler. * * @param Handler $handler + * @param ?string $title Optional human-readable title for display in UI * @param array|null $meta */ public function addResourceTemplate( \Closure|array|string $handler, string $uriTemplate, ?string $name = null, + ?string $title = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null, @@ -481,6 +488,7 @@ final class Builder 'handler', 'uriTemplate', 'name', + 'title', 'description', 'mimeType', 'annotations', diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php new file mode 100644 index 0000000..bd01207 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php @@ -0,0 +1,69 @@ + static fn (): string => 'ok', + 'uri' => 'config://app/settings', + 'name' => 'app_settings', + 'title' => 'Application Settings', + 'description' => null, + 'mimeType' => null, + 'size' => null, + 'annotations' => null, + 'icons' => null, + 'meta' => null, + ], + ]; + + $loader = new ArrayLoader([], $resources); + $registry = new Registry(); + + $loader->load($registry); + + $resourceRef = $registry->getResource('config://app/settings'); + $this->assertSame('Application Settings', $resourceRef->resource->title); + } + + public function testLoadPropagatesResourceTemplateTitleToRegisteredTemplate(): void + { + $resourceTemplates = [ + [ + 'handler' => static fn (): string => 'ok', + 'uriTemplate' => 'user://{userId}/profile', + 'name' => 'user_profile', + 'title' => 'User Profile', + 'description' => null, + 'mimeType' => null, + 'annotations' => null, + 'meta' => null, + ], + ]; + + $loader = new ArrayLoader([], [], $resourceTemplates); + $registry = new Registry(); + + $loader->load($registry); + + $templateRef = $registry->getResourceTemplate('user://{userId}/profile'); + $this->assertSame('User Profile', $templateRef->resourceTemplate->title); + } +} From 3e45b1f486de5a087ee33cd8b29b00c8f0c94219 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 18 May 2026 20:02:26 +0200 Subject: [PATCH 039/143] Streamline README and add conformance score badges (#312) - Add a shields.io badge row to the README header (version, CI, PHP version, license, conformance scores, protocol/spec) to match the other official MCP SDKs. - Expand "PHP Libraries Using the MCP SDK" into a curated, alphabetically sorted list of downstream projects. - Add tests/Conformance/bin/score.php, a Symfony Console command that turns a conformance run's --output-dir into a shields.io endpoint JSON. - Extend the weekly conformance workflow to score each run and publish the client/server pass-rate to the orphan `badges` branch that the README badges read. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/conformance-weekly.yaml | 70 ++++++++++++- README.md | 20 +++- tests/Conformance/score.php | 116 ++++++++++++++++++++++ 3 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 tests/Conformance/score.php diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 685b939..d55ac81 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -4,13 +4,17 @@ name: conformance-weekly # @modelcontextprotocol/conformance release. The on:pull_request pipeline # pins to whatever version is available at PR time; this schedule catches # upstream releases that add scenarios between PRs. +# +# It also scores each run and publishes the client/server pass-rate as +# shields.io endpoint JSON to the orphan `badges` branch (consumed by the +# README); that branch is created on the first run. on: schedule: - cron: '0 6 * * 1' # Mondays 06:00 UTC workflow_dispatch: permissions: - contents: read + contents: write issues: write jobs: @@ -31,7 +35,10 @@ jobs: sleep 5 - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml + run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml --output-dir results + - name: Generate score badge + if: always() + run: php tests/Conformance/score.php server - name: Show docker logs on failure if: failure() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs @@ -43,6 +50,12 @@ jobs: path: | tests/Conformance/logs tests/Conformance/results + - name: Upload score badge + if: always() + uses: actions/upload-artifact@v4 + with: + name: server-badge + path: tests/Conformance/server-conformance.json client: name: conformance / client (latest) @@ -60,7 +73,10 @@ jobs: - run: mkdir -p tests/Conformance/logs - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml + run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results + - name: Generate score badge + if: always() + run: php tests/Conformance/score.php client - name: Upload conformance results if: failure() uses: actions/upload-artifact@v4 @@ -69,6 +85,12 @@ jobs: path: | tests/Conformance/logs tests/Conformance/results + - name: Upload score badge + if: always() + uses: actions/upload-artifact@v4 + with: + name: client-badge + path: tests/Conformance/client-conformance.json notify: name: Open issue on failure @@ -96,3 +118,45 @@ jobs: 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.yml\`." fi + + publish: + name: Publish conformance badges + runs-on: ubuntu-latest + needs: [server, client] + # Publish even when the suite regressed (the badge should reflect reality); + # skip on forks, which cannot push the `badges` branch. + if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }} + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: server-badge + path: badges-in + - uses: actions/download-artifact@v4 + with: + name: client-badge + path: badges-in + - name: Publish to badges branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --heads origin badges >/dev/null 2>&1; then + git fetch origin badges + git worktree add badges-wt badges + else + git worktree add --detach badges-wt + git -C badges-wt checkout --orphan badges + git -C badges-wt rm -rf --quiet . >/dev/null 2>&1 || true + fi + + cp badges-in/server-conformance.json badges-in/client-conformance.json badges-wt/ + + cd badges-wt + git add -A + if git diff --cached --quiet; then + echo "Conformance scores unchanged." + else + git commit -m "Update conformance score badges" + git push origin badges + fi diff --git a/README.md b/README.md index 37335b1..bc97892 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ # 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](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) +[![Client Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) + +
+ The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers and clients in PHP. @@ -277,9 +288,14 @@ $client->connect($transport); ## PHP Libraries Using the MCP SDK -- [pronskiy/mcp](https://github.com/pronskiy/mcp) — Additional developer experience layer +- [api-platform/mcp](https://github.com/api-platform/mcp) — MCP integration for API Platform +- [bnomei/kirby-mcp](https://github.com/bnomei/kirby-mcp) — MCP server for the Kirby CMS +- [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP plugin exposing application functionality over MCP +- [nette/mcp-inspector](https://github.com/nette/mcp-inspector) — MCP server for introspecting Nette applications +- [symfony/ai-mate](https://github.com/symfony/ai-mate) — AI development assistant MCP server for Symfony projects - [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle) — Symfony integration bundle -- [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP integration plugin + +Building something on top of the SDK? Open a pull request to add it to this list. ## Contributing diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php new file mode 100644 index 0000000..b33df91 --- /dev/null +++ b/tests/Conformance/score.php @@ -0,0 +1,116 @@ + + * + * The conformance CLI (run with `--output-dir results`) writes one + * `checks.json` per scenario into the `results/` directory next 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`. + */ + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; + +require_once dirname(__DIR__, 2).'/vendor/autoload.php'; + +(new SingleCommandApplication()) + ->setName('conformance-score') + ->setDescription('Generates a shields.io endpoint badge from the conformance results') + ->addArgument('suite', InputArgument::REQUIRED, 'Which conformance suite was run: "server" or "client"') + ->setCode(static function (InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + + $suite = $input->getArgument('suite'); + + if (!in_array($suite, ['server', 'client'], true)) { + $io->error(sprintf('Suite must be "server" or "client", got "%s".', $suite)); + + return Command::INVALID; + } + + $resultsDir = __DIR__.'/results'; + + if (!is_dir($resultsDir)) { + $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir results` first.', $resultsDir)); + + return Command::FAILURE; + } + + $total = 0; + $passed = 0; + $failures = []; + + foreach (Finder::create()->files()->name('checks.json')->in($resultsDir) as $file) { + $checks = json_decode($file->getContents(), true); + + if (!is_array($checks)) { + $io->warning(sprintf('Skipping unreadable result file "%s".', $file->getRelativePathname())); + + continue; + } + + ++$total; + + foreach ($checks as $check) { + if ('FAILURE' === ($check['status'] ?? null)) { + $failures[] = $file->getRelativePath(); + + continue 2; + } + } + + ++$passed; + } + + $pct = $total > 0 ? (int) round($passed / $total * 100) : 0; + + $badge = [ + 'schemaVersion' => 1, + 'label' => $suite.' conformance', + 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', + 'color' => match (true) { + 0 === $total => 'lightgrey', + $pct >= 95 => 'brightgreen', + $pct >= 80 => 'green', + $pct >= 60 => 'yellow', + default => 'orange', + }, + ]; + + $outputFile = __DIR__.'/'.$suite.'-conformance.json'; + + if (false === file_put_contents($outputFile, json_encode($badge, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n")) { + $io->error(sprintf('Could not write badge file "%s".', $outputFile)); + + return Command::FAILURE; + } + + if ($failures && $io->isVerbose()) { + $io->section('Failing scenarios'); + $io->listing($failures); + } + + $io->success(sprintf('%s: %s', $badge['label'], $badge['message'])); + + return Command::SUCCESS; + }) + ->run(); From f12ea0ab58bfdc8e405a979da0a508d974d573a6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 19 May 2026 14:40:17 +0200 Subject: [PATCH 040/143] Fix conformance score calculcation (#313) --- .gitignore | 2 ++ Makefile | 8 ++++++-- tests/Conformance/score.php | 19 +++++++++++-------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 6dc5d9e..5ea477c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ vendor examples/**/dev.log examples/**/cache examples/**/sessions +tests/Conformance/client-conformance.json +tests/Conformance/server-conformance.json tests/Conformance/results tests/Conformance/sessions tests/Conformance/logs/*.log diff --git a/Makefile b/Makefile index 49f0d9e..667aa04 100644 --- a/Makefile +++ b/Makefile @@ -27,11 +27,15 @@ conformance-server: docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d @echo "Waiting for server to start..." @sleep 5 - cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ || true + rm -rf tests/Conformance/results + cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --output-dir results || true + php tests/Conformance/score.php server docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: - cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml || true + rm -rf tests/Conformance/results + cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results || true + php tests/Conformance/score.php client coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php index b33df91..ddad90f 100644 --- a/tests/Conformance/score.php +++ b/tests/Conformance/score.php @@ -68,17 +68,20 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; continue; } - ++$total; - foreach ($checks as $check) { - if ('FAILURE' === ($check['status'] ?? null)) { - $failures[] = $file->getRelativePath(); - - continue 2; + switch ($check['status'] ?? null) { + case 'FAILURE': + $failures[] = $file->getRelativePath(); + break; + case 'SUCCESS': + ++$passed; + break; + default: + continue 2; } - } - ++$passed; + ++$total; + } } $pct = $total > 0 ? (int) round($passed / $total * 100) : 0; From d2fc743553a64eaae22521cee21c2d56708761ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 08:47:44 +0200 Subject: [PATCH 041/143] chore(deps): bump actions/upload-artifact from 4 to 7 (#331) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance-weekly.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index d55ac81..65018ac 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -44,7 +44,7 @@ jobs: run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs - name: Upload conformance results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: conformance-server-results path: | @@ -52,7 +52,7 @@ jobs: tests/Conformance/results - name: Upload score badge if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: server-badge path: tests/Conformance/server-conformance.json @@ -79,7 +79,7 @@ jobs: run: php tests/Conformance/score.php client - name: Upload conformance results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: conformance-client-results path: | @@ -87,7 +87,7 @@ jobs: tests/Conformance/results - name: Upload score badge if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: client-badge path: tests/Conformance/client-conformance.json From ee40ff523fddc9281ca83cd519ba2d71c2388cae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 08:48:16 +0200 Subject: [PATCH 042/143] chore(deps): bump actions/download-artifact from 4 to 8 (#330) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance-weekly.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 65018ac..5614100 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -128,11 +128,11 @@ jobs: if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }} steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: server-badge path: badges-in - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: client-badge path: badges-in From 293a5880e44b36c2120b114d175b6ddc4d7d1b06 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 25 May 2026 13:49:43 +0200 Subject: [PATCH 043/143] [Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Add CORS, DNS rebinding and protocol version middleware Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed through a public `StreamableHttpTransport::defaultMiddleware()` factory composed automatically when no middleware is passed. - `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`), configurable allowlist, reflects matching origin with `Vary: Origin` to protect shared caches. - `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against a hostname allowlist (localhost-only by default). - `ProtocolVersionMiddleware`: rejects requests carrying an unsupported `Mcp-Protocol-Version` header with `400 Bad Request`. The transport no longer applies CORS via an `instanceof + array_unshift` post-hook; the middleware parameter is nullable — `null` installs the secure defaults, `[]` disables them, and users compose by spreading `StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and `PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware can reuse them. BC breaks: - The `corsHeaders` constructor parameter is removed; the `middleware` parameter shifts one position. Positional callers passing the old `corsHeaders` argument must switch to named arguments or drop it. - Default `Access-Control-Allow-Origin` is no longer `*`. Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306 (protocol version validation). * [Server] Address review feedback on middleware defaults - CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on preflight per CORS spec; tokenize Vary header to avoid substring false-positives; add allowCredentials flag that emits Access-Control-Allow-Credentials and throws when combined with wildcard origin. - DnsRebindingProtectionMiddleware: return plain-text 403 body (HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST) for origin parsing; drop redundant unbracketed '::1' from default allowlist. - ProtocolVersionMiddleware: use Error::forInvalidParams instead of forInvalidRequest -- JSON-RPC -32602 fits header-value rejection better. - JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers choose the error code. - StreamableHttpTransport: emit a warning log when an explicit empty middleware list is passed, so operators can spot accidental bypass of the default security stack. * [Server] Fix CI: switch warning-log tests to PHPUnit mocks Replace the anonymous AbstractLogger fixture in StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)` so the test does not declare a `log()` signature at all. That sidesteps both the psr/log v1 LSP incompatibility (untyped parameter) and the phpstan `missingType.parameter` complaint in one move, and matches how the rest of the suite (CallToolHandlerTest etc.) fakes loggers. * Update src/Server/Transport/Http/Middleware/CorsMiddleware.php Co-authored-by: Antoine Bluchet --------- Co-authored-by: Antoine Bluchet --- CHANGELOG.md | 2 + docs/transports.md | 154 +++++++--- examples/server/oauth-keycloak/server.php | 7 +- examples/server/oauth-microsoft/server.php | 8 +- .../Transport/Http/JsonRpcErrorResponse.php | 41 +++ .../Http/Middleware/CorsMiddleware.php | 152 ++++++++++ .../DnsRebindingProtectionMiddleware.php | 111 +++++++ .../Middleware/ProtocolVersionMiddleware.php | 98 +++++++ .../Transport/StreamableHttpTransport.php | 76 ++--- tests/Conformance/conformance-baseline.yml | 3 +- .../Http/Middleware/CorsMiddlewareTest.php | 269 +++++++++++++++++ .../DnsRebindingProtectionMiddlewareTest.php | 145 ++++++++++ .../Http/Middleware/MiddlewareTestCase.php | 57 ++++ .../ProtocolVersionMiddlewareTest.php | 104 +++++++ .../Transport/StreamableHttpTransportTest.php | 271 +++++++++++++----- 15 files changed, 1342 insertions(+), 156 deletions(-) create mode 100644 src/Server/Transport/Http/JsonRpcErrorResponse.php create mode 100644 src/Server/Transport/Http/Middleware/CorsMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index febbef4..d72de1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add optional `title` parameter to `Builder::addResource()` and `Builder::addResourceTemplate()` for MCP spec compliance * [BC Break] `Builder::addResource()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `Builder::addResourceTemplate()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()` +* **[BC BREAK]** `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`). 0.5.0 ----- diff --git a/docs/transports.md b/docs/transports.md index a68875d..3708e35 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -110,8 +110,8 @@ $transport = new StreamableHttpTransport( - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. - **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. -- **`corsHeaders`** (optional): `array` - Custom CORS headers to override defaults. Merges with secure defaults. Defaults to `[]`. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. ### PSR-17 Auto-Discovery @@ -137,56 +137,109 @@ $psr17Factory = new Psr17Factory(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); ``` -### CORS Configuration +### Default Middleware -The transport sets secure CORS defaults that can be customized or disabled: +When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: + +| Order | Middleware | Purpose | +|-------|------------|---------| +| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | +| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | +| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | ```php -// Default CORS headers (backward compatible) -$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); +// Zero-config, secure-by-default — local servers get full protection automatically. +$transport = new StreamableHttpTransport($request); +``` -// Restrict to specific origin +The default stack can be inspected and recomposed via the public factory: + +```php +$middleware = StreamableHttpTransport::defaultMiddleware(); +``` + +### CORS Configuration + +CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it +in place of (or alongside) the defaults: + +```php +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; + +// Reflect a specific origin $transport = new StreamableHttpTransport( $request, - $responseFactory, - $streamFactory, - ['Access-Control-Allow-Origin' => 'https://myapp.com'] + middleware: [ + new CorsMiddleware(allowedOrigins: ['https://myapp.com']), + new DnsRebindingProtectionMiddleware(), + new ProtocolVersionMiddleware(), + ], ); -// Disable CORS for proxy scenarios +// Allow all origins (development only) $transport = new StreamableHttpTransport( $request, - $responseFactory, - $streamFactory, - ['Access-Control-Allow-Origin' => ''] -); - -// Custom headers with logger -$transport = new StreamableHttpTransport( - $request, - $responseFactory, - $streamFactory, - [ - 'Access-Control-Allow-Origin' => 'https://api.example.com', - 'Access-Control-Max-Age' => '86400' + middleware: [ + new CorsMiddleware(allowedOrigins: ['*']), + new DnsRebindingProtectionMiddleware(), + new ProtocolVersionMiddleware(), ], - $logger ); ``` -Default CORS headers: -- `Access-Control-Allow-Origin: *` -- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS` -- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept` +When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin` +so shared caches/CDNs do not serve a response generated for one origin to a request from another. -### PSR-15 Middleware +Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds +defaults when they are absent. -`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log, -enforce auth, or short-circuit with a response for any HTTP method. +> [!IMPORTANT] +> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying +> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves +> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. +> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. + +### DNS Rebinding Protection + +`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` +when `Origin` is absent). The default allowlist is localhost-only: + +```php +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; + +new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']); +``` + +If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain +or supply a permissive allowlist. + +### Protocol Version Validation + +`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported +set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some +legacy clients do not send it. + +```php +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; + +// Only accept the latest spec version +new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); +``` + +### Custom PSR-15 Middleware + +`StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append +your own middleware — the defaults stay outermost so CORS headers are applied to every response, including +short-circuited ones: ```php use Mcp\Server\Transport\StreamableHttpTransport; 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; @@ -197,7 +250,7 @@ final class AuthMiddleware implements MiddlewareInterface { } - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if (!$request->hasHeader('Authorization')) { return $this->responses->createResponse(401); @@ -209,15 +262,40 @@ final class AuthMiddleware implements MiddlewareInterface $transport = new StreamableHttpTransport( $request, - $responseFactory, - $streamFactory, - [], - $logger, - [new AuthMiddleware($responseFactory)], + logger: $logger, + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + new AuthMiddleware($responseFactory), + ], ); ``` -If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself. +To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list: + +```php +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; +use Mcp\Server\Transport\StreamableHttpTransport; + +$transport = new StreamableHttpTransport( + $request, + middleware: [ + ...array_filter( + StreamableHttpTransport::defaultMiddleware(), + fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware, + ), + new AuthMiddleware($responseFactory), + ], +); +``` + +Pass `middleware: []` to disable every default and run only your own chain: + +```php +$transport = new StreamableHttpTransport( + $request, + middleware: [new AuthMiddleware($responseFactory)], +); +``` ### Architecture diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index bdd22b9..fdaae7a 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -58,7 +58,12 @@ $server = Server::builder() $transport = new StreamableHttpTransport( (new Psr17Factory())->createServerRequestFromGlobals(), logger: logger(), - middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $metadataMiddleware, + $authMiddleware, + new OAuthRequestMetaMiddleware(), + ], ); $response = $server->run($transport); diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index 419817c..c4fae59 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -81,7 +81,13 @@ $server = Server::builder() $transport = new StreamableHttpTransport( (new Psr17Factory())->createServerRequestFromGlobals(), logger: logger(), - middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $oauthProxyMiddleware, + $metadataMiddleware, + $authMiddleware, + new OAuthRequestMetaMiddleware(), + ], ); $response = $server->run($transport); diff --git a/src/Server/Transport/Http/JsonRpcErrorResponse.php b/src/Server/Transport/Http/JsonRpcErrorResponse.php new file mode 100644 index 0000000..592d179 --- /dev/null +++ b/src/Server/Transport/Http/JsonRpcErrorResponse.php @@ -0,0 +1,41 @@ +createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($streamFactory->createStream($body)); + } +} diff --git a/src/Server/Transport/Http/Middleware/CorsMiddleware.php b/src/Server/Transport/Http/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..a32d4ca --- /dev/null +++ b/src/Server/Transport/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,152 @@ + + */ +final class CorsMiddleware implements MiddlewareInterface +{ + private readonly bool $isWildcard; + private readonly bool $varyOnOrigin; + private readonly string $allowedMethodsHeader; + private readonly string $allowedHeadersHeader; + private readonly ?string $exposedHeadersHeader; + + /** + * @param list $allowedOrigins Origins permitted for cross-origin requests. Empty disables `Access-Control-Allow-Origin`. Use `['*']` to allow any origin. + * @param list $allowedMethods Methods advertised via `Access-Control-Allow-Methods` (preflight only) + * @param list $allowedHeaders Headers advertised via `Access-Control-Allow-Headers` (preflight only) + * @param list $exposedHeaders Headers advertised via `Access-Control-Expose-Headers` + * @param bool $allowCredentials Whether to emit `Access-Control-Allow-Credentials: true`. Incompatible with `allowedOrigins: ['*']` — combining them throws. + */ + public function __construct( + private readonly array $allowedOrigins = [], + array $allowedMethods = ['GET', 'POST', 'DELETE'], + array $allowedHeaders = [ + 'Accept', + 'Authorization', + 'Content-Type', + 'Last-Event-ID', + StreamableHttpTransport::PROTOCOL_VERSION_HEADER, + StreamableHttpTransport::SESSION_HEADER, + ], + array $exposedHeaders = [StreamableHttpTransport::SESSION_HEADER], + private readonly bool $allowCredentials = false, + ) { + $this->isWildcard = \in_array('*', $allowedOrigins, true); + + if ($this->isWildcard && $allowCredentials) { + throw new InvalidArgumentException('Access-Control-Allow-Origin: * is incompatible with Access-Control-Allow-Credentials: true. Configure an explicit allowedOrigins list when credentialed requests are required.'); + } + + $this->varyOnOrigin = [] !== $allowedOrigins && !$this->isWildcard; + $this->allowedMethodsHeader = implode(', ', $allowedMethods); + $this->allowedHeadersHeader = implode(', ', $allowedHeaders); + $this->exposedHeadersHeader = [] === $exposedHeaders ? null : implode(', ', $exposedHeaders); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + $allowedOrigin = $this->resolveAllowedOrigin($request->getHeaderLine('Origin')); + if (null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Origin')) { + $response = $response->withHeader('Access-Control-Allow-Origin', $allowedOrigin); + } + + if ($this->allowCredentials && null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Credentials')) { + $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); + } + + if ($this->varyOnOrigin) { + $response = $this->ensureVaryOrigin($response); + } + + if ($this->isPreflight($request)) { + if (!$response->hasHeader('Access-Control-Allow-Methods')) { + $response = $response->withHeader('Access-Control-Allow-Methods', $this->allowedMethodsHeader); + } + + if (!$response->hasHeader('Access-Control-Allow-Headers')) { + $response = $response->withHeader('Access-Control-Allow-Headers', $this->allowedHeadersHeader); + } + } + + if (null !== $this->exposedHeadersHeader && !$response->hasHeader('Access-Control-Expose-Headers')) { + $response = $response->withHeader('Access-Control-Expose-Headers', $this->exposedHeadersHeader); + } + + return $response; + } + + private function isPreflight(ServerRequestInterface $request): bool + { + return 'OPTIONS' === $request->getMethod() + && $request->hasHeader('Access-Control-Request-Method'); + } + + private function resolveAllowedOrigin(string $origin): ?string + { + if ([] === $this->allowedOrigins) { + return null; + } + + if ($this->isWildcard) { + return '*'; + } + + if ('' !== $origin && \in_array($origin, $this->allowedOrigins, true)) { + return $origin; + } + + return null; + } + + private function ensureVaryOrigin(ResponseInterface $response): ResponseInterface + { + $current = $response->getHeaderLine('Vary'); + + if ('' === $current) { + return $response->withHeader('Vary', 'Origin'); + } + + if ('*' === trim($current)) { + return $response; + } + + $tokens = array_map('strtolower', array_map('trim', explode(',', $current))); + if (\in_array('origin', $tokens, true)) { + return $response; + } + + return $response->withHeader('Vary', $current.', Origin'); + } +} diff --git a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php new file mode 100644 index 0000000..490aa37 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php @@ -0,0 +1,111 @@ + + */ +final class DnsRebindingProtectionMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** @var list */ + private readonly array $allowedHosts; + + /** + * @param list $allowedHosts Hostnames (without port) that are permitted. Defaults to localhost variants. + * IPv6 addresses must be bracketed (e.g. `[::1]`) — that is the canonical form returned by `parse_url`. + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + array $allowedHosts = ['localhost', '127.0.0.1', '[::1]'], + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->allowedHosts = array_values(array_map('strtolower', $allowedHosts)); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $origin = $request->getHeaderLine('Origin'); + if ('' !== $origin) { + if (!$this->isAllowedOrigin($origin)) { + return $this->createForbiddenResponse('Forbidden: Invalid Origin header.'); + } + + return $handler->handle($request); + } + + $host = $request->getHeaderLine('Host'); + if ('' !== $host && !$this->isAllowedHost($host)) { + return $this->createForbiddenResponse('Forbidden: Invalid Host header.'); + } + + return $handler->handle($request); + } + + private function isAllowedOrigin(string $origin): bool + { + $host = parse_url($origin, \PHP_URL_HOST); + if (!\is_string($host) || '' === $host) { + return false; + } + + return \in_array(strtolower($host), $this->allowedHosts, true); + } + + private function isAllowedHost(string $host): bool + { + if (str_starts_with($host, '[')) { + $closingBracket = strpos($host, ']'); + if (false === $closingBracket) { + return false; + } + $hostname = substr($host, 0, $closingBracket + 1); + } else { + $hostname = explode(':', $host, 2)[0]; + } + + return \in_array(strtolower($hostname), $this->allowedHosts, true); + } + + private function createForbiddenResponse(string $message): ResponseInterface + { + return $this->responseFactory + ->createResponse(403) + ->withHeader('Content-Type', 'text/plain') + ->withBody($this->streamFactory->createStream($message)); + } +} diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php new file mode 100644 index 0000000..6d60e1a --- /dev/null +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -0,0 +1,98 @@ + + */ +final class ProtocolVersionMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** @var list */ + private readonly array $supportedVersions; + + /** + * @param list|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}. + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + ?array $supportedVersions = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $versions = $supportedVersions ?? ProtocolVersion::cases(); + $this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions)); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $headerValue = $request->getHeaderLine(StreamableHttpTransport::PROTOCOL_VERSION_HEADER); + + // Spec backwards-compat: when the header is absent, the server SHOULD assume + // protocol version 2025-03-26 — the release in which Streamable HTTP and the + // header itself were introduced. This is deliberately lower than the SDK's + // own default (V2025_06_18) so clients predating the header convention still + // get a deterministic protocol version applied. Servers that whitelist only + // newer versions in $supportedVersions will reject such requests with 400. + $version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue; + + if (\in_array($version, $this->supportedVersions, true)) { + return $handler->handle($request); + } + + $message = '' === $headerValue + ? \sprintf( + 'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.', + StreamableHttpTransport::PROTOCOL_VERSION_HEADER, + $version, + implode(', ', $this->supportedVersions), + ) + : \sprintf( + 'Unsupported %s header value: %s. Supported versions: %s.', + StreamableHttpTransport::PROTOCOL_VERSION_HEADER, + $headerValue, + implode(', ', $this->supportedVersions), + ); + + return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message)); + } +} diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 62e82ae..ab84b09 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -14,6 +14,9 @@ namespace Mcp\Server\Transport; use Http\Discovery\Psr17FactoryDiscovery; 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\Http\MiddlewareRequestHandler; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -30,16 +33,8 @@ use Symfony\Component\Uid\Uuid; */ class StreamableHttpTransport extends BaseTransport { - private const SESSION_HEADER = 'Mcp-Session-Id'; - - private const ALLOWED_HEADER = [ - 'Accept', - 'Authorization', - 'Content-Type', - 'Last-Event-ID', - 'Mcp-Protocol-Version', - self::SESSION_HEADER, - ]; + public const SESSION_HEADER = 'Mcp-Session-Id'; + public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; @@ -47,44 +42,48 @@ class StreamableHttpTransport extends BaseTransport private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; - /** @var array */ - private array $corsHeaders; - /** @var list */ - private array $middleware = []; + private array $middleware; /** - * @param array $corsHeaders - * @param iterable $middleware + * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware */ public function __construct( private ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, - array $corsHeaders = [], ?LoggerInterface $logger = null, - iterable $middleware = [], + ?iterable $middleware = null, ) { parent::__construct($logger); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - $this->corsHeaders = array_merge([ - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers' => implode(',', self::ALLOWED_HEADER), - 'Access-Control-Expose-Headers' => self::SESSION_HEADER, - ], $corsHeaders); - - foreach ($middleware as $m) { - if (!$m instanceof MiddlewareInterface) { - throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.'); + if (null === $middleware) { + $this->middleware = self::defaultMiddleware(); + } else { + $this->middleware = self::normalizeMiddleware($middleware); + if ([] === $this->middleware) { + $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); } - $this->middleware[] = $m; } } + /** + * Secure default middleware stack applied when no `$middleware` is provided to the constructor. + * + * @return list + */ + public static function defaultMiddleware(): array + { + return [ + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware(), + new ProtocolVersionMiddleware(), + ]; + } + public function send(string $data, array $context): void { $this->immediateResponse = $data; @@ -98,7 +97,7 @@ class StreamableHttpTransport extends BaseTransport \Closure::fromCallable([$this, 'handleRequest']), ); - return $this->withCorsHeaders($handler->handle($this->request)); + return $handler->handle($this->request); } protected function handleOptionsRequest(): ResponseInterface @@ -274,15 +273,22 @@ class StreamableHttpTransport extends BaseTransport return $response; } - protected function withCorsHeaders(ResponseInterface $response): ResponseInterface + /** + * @param iterable $middleware + * + * @return list + */ + private static function normalizeMiddleware(iterable $middleware): array { - foreach ($this->corsHeaders as $name => $value) { - if (!$response->hasHeader($name)) { - $response = $response->withHeader($name, $value); + $normalized = []; + foreach ($middleware as $m) { + if (!$m instanceof MiddlewareInterface) { + throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.'); } + $normalized[] = $m; } - return $response; + return $normalized; } private function handleRequest(ServerRequestInterface $request): ResponseInterface diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index 61f9783..efda80a 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,5 +1,4 @@ -server: - - dns-rebinding-protection +server: [] client: - elicitation-sep1034-client-defaults diff --git a/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php new file mode 100644 index 0000000..c38353f --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/CorsMiddlewareTest.php @@ -0,0 +1,269 @@ +factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); + // Non-preflight: Methods/Headers must NOT be emitted per CORS spec. + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + } + + #[TestDox('preflight request receives Access-Control-Allow-Methods and Access-Control-Allow-Headers')] + public function testPreflightReceivesMethodAndHeaderAdvertisements(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->preflightRequest('https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('GET, POST, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertNotSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); + } + + #[TestDox('non-preflight OPTIONS request does not receive Methods/Headers advertisements')] + public function testPlainOptionsIsNotTreatedAsPreflight(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['*']); + // OPTIONS without `Access-Control-Request-Method` is not a CORS preflight. + $request = $this->factory->createServerRequest('OPTIONS', 'https://example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers')); + } + + #[TestDox('wildcard allowedOrigins sets Access-Control-Allow-Origin to *')] + public function testWildcardOrigin(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['*']); + $request = $this->factory->createServerRequest('POST', 'https://example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + + #[TestDox('matching Origin is reflected back')] + public function testMatchingOriginIsReflected(): void + { + $middleware = new CorsMiddleware( + allowedOrigins: ['https://app.example.com', 'https://staging.example.com'], + ); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + + #[TestDox('non-matching Origin is not echoed')] + public function testNonMatchingOriginIsBlocked(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + + #[TestDox('does not overwrite headers set by inner middleware')] + public function testPreExistingHeadersAreNotOverwritten(): void + { + $inner = $this->handlerReturning(200, [ + 'Access-Control-Allow-Origin' => 'https://override.example.com', + 'Access-Control-Allow-Methods' => 'POST', + ]); + + $middleware = new CorsMiddleware(allowedOrigins: ['*']); + $request = $this->preflightRequest(); + + $response = $middleware->process($request, $inner); + + $this->assertSame('https://override.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame('POST', $response->getHeaderLine('Access-Control-Allow-Methods')); + } + + #[TestDox('exposed headers can be omitted')] + public function testEmptyExposedHeadersAreNotSet(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['*'], exposedHeaders: []); + $request = $this->factory->createServerRequest('POST', 'https://example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); + } + + #[TestDox('adds Vary: Origin when reflecting a specific origin to protect caches')] + public function testVaryOriginIsAddedForReflectedOrigin(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('adds Vary: Origin even when origin is rejected so caches do not poison')] + public function testVaryOriginIsAddedEvenWhenOriginDoesNotMatch(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertSame('Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('does not add Vary when Access-Control-Allow-Origin is wildcard')] + public function testVaryOriginIsNotAddedForWildcard(): void + { + $middleware = new CorsMiddleware(allowedOrigins: ['*']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Vary')); + } + + #[TestDox('does not add Vary when no allowed origins are configured')] + public function testVaryOriginIsNotAddedWhenAllowedOriginsEmpty(): void + { + $middleware = new CorsMiddleware(); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Vary')); + } + + #[TestDox('preserves existing Vary value when appending Origin')] + public function testVaryOriginAppendsToExistingVary(): void + { + $inner = $this->handlerReturning(200, ['Vary' => 'Accept-Encoding']); + + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $inner); + + $this->assertSame('Accept-Encoding, Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('does not duplicate Origin in existing Vary header')] + public function testVaryOriginIsNotDuplicated(): void + { + $inner = $this->handlerReturning(200, ['Vary' => 'Accept-Encoding, Origin']); + + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $inner); + + $this->assertSame('Accept-Encoding, Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('does not treat a substring match like Origin-Other as the Origin token')] + public function testVarySubstringDoesNotPreventAppending(): void + { + // `Origin-Resource-Policy` contains the substring "origin" but is a different token — + // tokenized comparison must still treat the response as missing the `Origin` value. + $inner = $this->handlerReturning(200, ['Vary' => 'Origin-Resource-Policy']); + + $middleware = new CorsMiddleware(allowedOrigins: ['https://app.example.com']); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $inner); + + $this->assertSame('Origin-Resource-Policy, Origin', $response->getHeaderLine('Vary')); + } + + #[TestDox('allowCredentials emits Access-Control-Allow-Credentials when an origin matches')] + public function testAllowCredentialsHeaderEmitted(): void + { + $middleware = new CorsMiddleware( + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, + ); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://app.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + } + + #[TestDox('allowCredentials does not emit credentials header when no origin matches')] + public function testAllowCredentialsSkippedWhenOriginUnmatched(): void + { + $middleware = new CorsMiddleware( + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, + ); + $request = $this->factory->createServerRequest('POST', 'https://example.com') + ->withHeader('Origin', 'https://evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + } + + #[TestDox('combining wildcard origin with allowCredentials throws')] + public function testWildcardWithCredentialsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new CorsMiddleware(allowedOrigins: ['*'], allowCredentials: true); + } + + private function preflightRequest(string $origin = 'https://app.example.com'): ServerRequestInterface + { + return $this->factory + ->createServerRequest('OPTIONS', 'https://example.com') + ->withHeader('Origin', $origin) + ->withHeader('Access-Control-Request-Method', 'POST') + ->withHeader('Access-Control-Request-Headers', 'Content-Type'); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php new file mode 100644 index 0000000..720a0f2 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php @@ -0,0 +1,145 @@ + ['http://localhost:8000']; + yield 'IPv4 loopback' => ['http://127.0.0.1:3000']; + yield 'IPv6 loopback (bracketed)' => ['http://[::1]:8000']; + } + + #[DataProvider('allowedOriginProvider')] + #[TestDox('allows request with localhost Origin variant: $origin')] + public function testAllowsLocalhostOrigin(string $origin): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', $origin); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('rejects non-allowed Origin with 403')] + public function testRejectsForeignOrigin(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', 'http://evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('text/plain', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('Origin', (string) $response->getBody()); + } + + #[TestDox('Origin header takes precedence over Host')] + public function testOriginPrecedenceOverHost(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', 'http://localhost:8000') + ->withHeader('Host', 'evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('validates Host header when Origin is absent')] + public function testFallbackToHostValidation(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil/') + ->withHeader('Host', 'evil.example.com'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(403, $response->getStatusCode()); + } + + #[TestDox('strips port from Host header when validating')] + public function testHostPortIsStripped(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost:8000'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('IPv6 Host with port is parsed correctly')] + public function testIpv6HostWithPort(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', '[::1]:8080'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('custom allowed hosts permit non-localhost names')] + public function testCustomAllowedHosts(): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedHosts: ['myapp.local'], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') + ->withHeader('Origin', 'http://myapp.local:3000'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('host comparison is case-insensitive')] + public function testCaseInsensitive(): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedHosts: ['MyApp.Local'], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') + ->withHeader('Origin', 'http://MYAPP.LOCAL:80'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('request without Origin or Host is allowed')] + public function testNoOriginNoHostPasses(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/')->withoutHeader('Host'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php b/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php new file mode 100644 index 0000000..3f5ba2f --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/MiddlewareTestCase.php @@ -0,0 +1,57 @@ +factory = new Psr17Factory(); + $this->passthroughHandler = $this->handlerReturning(200); + } + + /** + * @param array $headers extra headers to set on the response (already-set CORS headers etc.) + */ + protected function handlerReturning(int $status, array $headers = []): RequestHandlerInterface + { + return new class($this->factory, $status, $headers) implements RequestHandlerInterface { + /** @param array $headers */ + public function __construct( + private ResponseFactoryInterface $factory, + private int $status, + private array $headers, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = $this->factory->createResponse($this->status); + foreach ($this->headers as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $response; + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php new file mode 100644 index 0000000..ad216d5 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php @@ -0,0 +1,104 @@ +factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('rejects missing header when 2025-03-26 backwards-compat default is not in supportedVersions')] + public function testMissingHeaderRejectedByStrictServer(): void + { + $middleware = new ProtocolVersionMiddleware( + supportedVersions: [ProtocolVersion::V2025_11_25], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(400, $response->getStatusCode()); + } + + #[TestDox('accepts every version declared in the ProtocolVersion enum')] + public function testAcceptsSupportedVersions(): void + { + $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + + foreach (ProtocolVersion::cases() as $version) { + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(200, $response->getStatusCode(), 'Expected '.$version->value.' to be accepted.'); + } + } + + #[TestDox('rejects unsupported well-formed version with 400')] + public function testRejectsUnsupportedVersion(): void + { + $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, '1900-01-01'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + } + + #[TestDox('rejects malformed version with 400')] + public function testRejectsMalformedVersion(): void + { + $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, 'not-a-version'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(400, $response->getStatusCode()); + } + + #[TestDox('accepts only the supportedVersions whitelist when provided')] + public function testRestrictedSupportedVersions(): void + { + $middleware = new ProtocolVersionMiddleware( + supportedVersions: [ProtocolVersion::V2025_11_25], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + + $accepted = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, ProtocolVersion::V2025_11_25->value); + $rejected = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, ProtocolVersion::V2024_11_05->value); + + $this->assertSame(200, $middleware->process($accepted, $this->passthroughHandler)->getStatusCode()); + $this->assertSame(400, $middleware->process($rejected, $this->passthroughHandler)->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 7d9cd48..0f94960 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -11,9 +11,12 @@ namespace Mcp\Tests\Unit\Server\Transport; +use Mcp\Exception\InvalidArgumentException; +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 Nyholm\Psr7\Factory\Psr17Factory; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -21,120 +24,201 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerInterface; final class StreamableHttpTransportTest extends TestCase { - public static function corsHeaderProvider(): iterable + private Psr17Factory $factory; + + protected function setUp(): void { - yield 'GET (middleware returns 401)' => ['GET', false, 401]; - yield 'POST (middleware returns 401)' => ['POST', false, 401]; - yield 'DELETE (middleware returns 401)' => ['DELETE', false, 401]; - yield 'OPTIONS (middleware delegates -> transport handles preflight)' => ['OPTIONS', true, 204]; - yield 'GET (middleware delegates -> transport handles preflight)' => ['GET', true, 405]; - yield 'POST (middleware delegates -> transport handles preflight)' => ['POST', true, 202]; - yield 'DELETE (middleware delegates -> transport handles preflight)' => ['DELETE', true, 400]; + $this->factory = new Psr17Factory(); } - #[DataProvider('corsHeaderProvider')] - #[TestDox('CORS headers are always applied')] - public function testCorsHeader(string $method, bool $middlewareDelegatesToTransport, int $expectedStatusCode): void + #[TestDox('default middleware is applied when none is passed')] + public function testDefaultMiddlewareIsAppliedWhenOmitted(): void { - $factory = new Psr17Factory(); - $request = $factory->createServerRequest($method, 'https://example.com'); + // Preflight: OPTIONS + Access-Control-Request-Method — CorsMiddleware advertises Methods/Headers only on preflight. + $request = $this->factory + ->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Access-Control-Request-Method', 'POST'); - $middleware = new class($factory, $expectedStatusCode, $middlewareDelegatesToTransport) implements MiddlewareInterface { - public function __construct( - private ResponseFactoryInterface $responseFactory, - private int $expectedStatusCode, - private bool $middlewareDelegatesToTransport, - ) { - } + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - if ($this->middlewareDelegatesToTransport) { - return $handler->handle($request); - } + $response = $transport->listen(); - return $this->responseFactory->createResponse($this->expectedStatusCode); - } - }; + $this->assertSame(204, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); // secure-by-default + $this->assertSame('GET, POST, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertNotSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertNotSame('', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('default middleware blocks non-localhost Origin')] + public function testDefaultMiddlewareBlocksRebindingAttempt(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Origin', 'http://evil.example.com'); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(403, $response->getStatusCode()); + } + + #[TestDox('default middleware rejects unsupported MCP-Protocol-Version')] + public function testDefaultMiddlewareRejectsUnsupportedProtocolVersion(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, '1900-01-01'); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(400, $response->getStatusCode()); + } + + #[TestDox('explicit empty middleware list disables defaults and emits a warning log')] + public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'evil.example.com') + ->withHeader('Origin', 'http://evil.example.com'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('empty middleware list')); $transport = new StreamableHttpTransport( $request, - $factory, - $factory, + $this->factory, + $this->factory, + $logger, [], - null, - [$middleware], ); $response = $transport->listen(); - $this->assertSame($expectedStatusCode, $response->getStatusCode(), $response->getBody()->getContents()); - $this->assertTrue($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertTrue($response->hasHeader('Access-Control-Allow-Methods')); - $this->assertTrue($response->hasHeader('Access-Control-Allow-Headers')); - $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); - - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('GET, POST, DELETE, OPTIONS', $response->getHeaderLine('Access-Control-Allow-Methods')); - $this->assertSame( - 'Accept,Authorization,Content-Type,Last-Event-ID,Mcp-Protocol-Version,Mcp-Session-Id', - $response->getHeaderLine('Access-Control-Allow-Headers') - ); - $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + // No CORS, no DNS rebinding check — transport just answers. + $this->assertNotSame(403, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); } - #[TestDox('transport replaces existing CORS headers on the response')] - public function testCorsHeadersAreReplacedWhenAlreadyPresent(): void + #[TestDox('null middleware does not trigger the empty-list warning')] + public function testNullMiddlewareDoesNotWarn(): void { - $factory = new Psr17Factory(); - $request = $factory->createServerRequest('GET', 'https://example.com'); + $request = $this->factory + ->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost'); - $middleware = new class($factory) implements MiddlewareInterface { - public function __construct(private ResponseFactoryInterface $responses) - { - } + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - return $this->responses->createResponse(200) - ->withHeader('Access-Control-Allow-Origin', 'https://another.com'); - } - }; + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, $logger); + $transport->listen(); + } + + #[TestDox('custom middleware composes with default stack via spread')] + public function testDefaultsCanBeSpreadAndExtended(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost'); $transport = new StreamableHttpTransport( $request, - $factory, - $factory, - [], + $this->factory, + $this->factory, null, - [$middleware], + [ + ...StreamableHttpTransport::defaultMiddleware(), + $this->stubAuth401(), + ], ); $response = $transport->listen(); - $this->assertSame(200, $response->getStatusCode()); - - $this->assertSame('https://another.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('GET, POST, DELETE, OPTIONS', $response->getHeaderLine('Access-Control-Allow-Methods')); - $this->assertSame( - 'Accept,Authorization,Content-Type,Last-Event-ID,Mcp-Protocol-Version,Mcp-Session-Id', - $response->getHeaderLine('Access-Control-Allow-Headers') - ); + $this->assertSame(401, $response->getStatusCode()); + // CORS middleware is outermost — Expose-Headers is emitted on all responses, including 401. $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); } + #[TestDox('defaults can be filtered to drop DNS rebinding for proxy deployments')] + public function testDefaultsCanBeFilteredToDropDnsRebinding(): void + { + // Behind a reverse proxy: real Host is api.myapp.com, browser Origin is myapp.com. + // DnsRebindingProtectionMiddleware default (localhost-only) would 403 this — drop it. + $request = $this->factory + ->createServerRequest('POST', 'http://api.myapp.com/') + ->withHeader('Host', 'api.myapp.com') + ->withHeader('Origin', 'https://myapp.com'); + + $transport = new StreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [ + ...array_filter( + StreamableHttpTransport::defaultMiddleware(), + static fn (MiddlewareInterface $m): bool => !$m instanceof DnsRebindingProtectionMiddleware, + ), + $this->stubAuth401(), + ], + ); + + $response = $transport->listen(); + + // Auth short-circuits with 401 — proves DNS rebinding didn't reject the request first. + $this->assertSame(401, $response->getStatusCode()); + // CORS middleware is still in the chain — Expose-Headers attached to the 401. + $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('configured CorsMiddleware reflects matching Origin')] + public function testConfiguredCorsReflectsMatchingOrigin(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Origin', 'https://myapp.example.com'); + + $transport = new StreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [ + new CorsMiddleware(allowedOrigins: ['https://myapp.example.com']), + new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost']), + new ProtocolVersionMiddleware(), + ], + ); + + $response = $transport->listen(); + + $this->assertSame('https://myapp.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + #[TestDox('middleware runs before transport handles the request')] public function testMiddlewareRunsBeforeTransportHandlesRequest(): void { - $factory = new Psr17Factory(); - $request = $factory->createServerRequest('OPTIONS', 'https://example.com'); + $request = $this->factory->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost'); $state = new \stdClass(); $state->called = false; - $middleware = new class($state) implements MiddlewareInterface { + $spy = new class($state) implements MiddlewareInterface { public function __construct(private \stdClass $state) { } @@ -149,11 +233,10 @@ final class StreamableHttpTransportTest extends TestCase $transport = new StreamableHttpTransport( $request, - $factory, - $factory, - [], + $this->factory, + $this->factory, null, - [$middleware], + [$spy], ); $response = $transport->listen(); @@ -161,4 +244,34 @@ final class StreamableHttpTransportTest extends TestCase $this->assertTrue($state->called); $this->assertSame(204, $response->getStatusCode()); } + + #[TestDox('non-middleware entries are rejected')] + public function testInvalidMiddlewareEntryThrows(): void + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $this->expectException(InvalidArgumentException::class); + + new StreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [new \stdClass()], // @phpstan-ignore-line argument.type + ); + } + + private function stubAuth401(): MiddlewareInterface + { + return new class($this->factory) implements MiddlewareInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->factory->createResponse(401); + } + }; + } } From 0347dc85b4e577037f2fa177ac3fccd6aca7d8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Fri, 29 May 2026 07:16:12 +0200 Subject: [PATCH 044/143] [Server] Allow runtime-resolved element handlers (#294) * [Server] feat: allow runtime handlers Adds Mcp\Server\Handler\RunTimeHandlerInterface so integrators (e.g. configuration-driven element sources) can register handlers whose arguments and execution are resolved at runtime rather than via PHP reflection of a callable. ReferenceHandler::handle() dispatches instanceof RunTimeHandler ahead of the callable/array/string branches, calls filterArguments to narrow the argument map, and forwards execute() a ClientGateway built from the active session. The Registry-layer surface (ElementReference and the four *Reference subclasses, RegistryInterface, Registry::register*, ArrayLoader's handler-description helper) is widened to accept the new type. The Builder/ArrayLoader path keeps its narrow callable union; exposing runtime handlers there requires reworking HandlerResolver and is left for a follow-up. * feat(server): add runtime handler metadata API Extend RunTimeHandlerInterface with four nullable metadata accessors (getInputSchema, getOutputSchema, getPromptArguments, getCompletionProviders) so the loader can obtain element metadata without reflection. Add RunTimeHandlerTrait providing null defaults for all four accessors so single-purpose handlers can override only the methods relevant to their element kind. * feat(server): expose runtime handlers via Builder Widen Builder::addTool, addResource, addResourceTemplate, and addPrompt to accept RunTimeHandlerInterface instances alongside the existing closure/array/string handler forms. Branch ArrayLoader::load() on instanceof RunTimeHandlerInterface in each of the four registration loops so runtime handlers skip the reflection-based HandlerResolver/HandlerSchema path and pull metadata from the interface accessors instead. Require explicit name and description for runtime handlers and raise ConfigurationException when missing or when a runtime tool has no input schema source. Replace direct $data['handler'] use in error log context with getHandlerDescription() to avoid serializing large runtime handler object graphs into log records. Document the runtime handler API in docs/server-builder.md with a new Runtime Handlers subsection and add a cross-reference from docs/mcp-elements.md. * test(server): cover runtime handler Builder path Add ArrayLoaderRunTimeHandlerTest covering: - successful tool registration with handler-supplied input schema, with kwarg input schema (precedence over the handler), and the same precedence rule for output schema - ConfigurationException for tools missing name, description, or any input schema source - successful resource registration via Builder kwargs and the matching missing-name failure mode - successful resource template registration with completion providers sourced from the handler and the missing-description failure mode - successful prompt registration with arguments and completion providers sourced from the handler, plus the missing-name failure mode - a trait-only fixture verifying the four metadata accessors return null by default Update existing ReferenceHandlerTest anonymous classes to use RunTimeHandlerTrait so they satisfy the widened interface. * refactor(server): split runtime handler interface Splits RunTimeHandlerInterface into a minimal base contract (only execute()) plus element-specific subtypes: RunTimeToolHandlerInterface, RunTimePromptHandlerInterface, and RunTimeResourceTemplateHandlerInterface. Each subtype declares only the metadata accessors relevant to its element kind, so a tool handler no longer has to no-op out prompt or template methods. Drops the now-redundant RunTimeHandlerTrait and removes filterArguments; execute() receives the full argument map directly. Updates ArrayLoader, ReferenceHandler, and Builder type signatures to use the per-element interfaces, and rewrites the Runtime Handlers documentation accordingly. * refactor(server): rename runtime handler interfaces to Runtime casing Renames RunTimeHandlerInterface and its element-specific subtypes to Runtime casing, adds a dedicated RuntimeResourceHandlerInterface so addResource() rejects mismatched runtime handlers at the type level, and strips _session and _request from arguments forwarded to runtime handlers. Hardens ArrayLoader to re-throw ConfigurationException without wrapping, and extracts inline test fixtures into tests/Fixtures/Runtime/. * chore: please the linting gods * test(server): use Uuid::v4 for symfony/uid 5.4 compatibility Uuid::v7() was added in symfony/uid 6.2 and breaks the Symfony 5.4 matrix job. Match the convention used elsewhere in the suite. * refactor(server): split ArrayLoader registration Extract per-element preparation into dedicated private prepare* methods for tools, resources, templates, and prompts. Factor the shared required-field assertion and reflected name/description resolution into helpers. * refactor(server): non-null runtime handler types Drop nullable return types on RuntimeToolHandlerInterface:: getInputSchema(), RuntimePromptHandlerInterface:: getPromptArguments()/getCompletionProviders(), and RuntimeResourceTemplateHandlerInterface:: getCompletionProviders() in favor of plain arrays. ArrayLoader no longer needs the null-coalescing fallbacks, and NullSchemaToolHandler plus its ConfigurationException test become redundant. * doc: update docs/server-builder.md Co-authored-by: Christopher Hertel * feat: peer revie feedback Introduce a new `Builder::add(...)` pattern for a more generic runtime handler. * refactor(server): apply PR 294 review feedback Move per-interface handler dispatch from ReferenceHandler into ExplicitElementLoader by wrapping each ElementHandlerInterface in a Closure that satisfies the existing callable contract, revert the *Reference / Registry handler-type widening PR 294 introduced, throw Mcp\Exception\InvalidArgumentException (not \TypeError) on mismatched Builder::add() pairings, and rename two classes for clarity: * Mcp\Capability\Registry\Loader\ArrayLoader -> ReflectedElementLoader * Mcp\Schema\Resource -> Mcp\Schema\ResourceDefinition (no alias) ReferenceHandler::prepareArguments() now injects ClientGateway by type and binds an `array $arguments` / `array $variables` closure parameter from the leftover inbound arguments. This addresses Antoine's coupling concern (ReferenceHandler no longer knows about the four handler interfaces) and Chris's package-exception preference, and clears the phpdoc reserved-word collision Antoine flagged on the resource schema class. Existing tests are re-targeted to exercise the closure path through ExplicitElementLoader / ReferenceHandler. * doc: address PR 294 review feedback Apply Chris's line-level suggestions on docs/server-builder.md (rewrap to 120 chars, smoothed Drupal mcp module name-drop in the explicit-element registration section, exception name reads Mcp\Exception\InvalidArgumentException), rewrap docs/mcp-elements.md to 120 chars, and add 0.6.0 CHANGELOG entries covering the Builder::add() entry point, the new handler interfaces, the new ExplicitElementLoader, plus three BC breaks (Resource -> ResourceDefinition rename, ArrayLoader -> ReflectedElementLoader rename, mismatched-pairing exception swap). * refactor: address self-review feedback * CHANGELOG: drop spurious [BC Break] entry for the exception type Builder::add() raises (method is new in this PR). * docs/server-builder: revert wrap-only edits to pre-existing main content; keep 120ch wrapping only for new sections. * ExplicitElementLoader: trim verbose class docblock. * ReferenceHandler: rename SPLAT_PARAMETER_NAMES constant to ARGUMENT_BAG_PARAMETERS. * refactor: rename CallableHandler to Handler * style: fix phpdoc param alignment * refactor: inline $reference->handler access * fix: maintainer review feedback docs: mention ExplicitElementLoader in comment docs: move Drupal reference to README list docs: drop implementation-only changelog entry docs: revert rewrap noise from mcp-elements.md * style: fix phpdoc param alignment --------- Co-authored-by: Christopher Hertel Co-authored-by: soyuka --- CHANGELOG.md | 4 + README.md | 1 + docs/mcp-elements.md | 3 + docs/server-builder.md | 56 +++ src/Capability/Discovery/Discoverer.php | 4 +- src/Capability/Registry.php | 4 +- .../Registry/Loader/ExplicitElementLoader.php | 105 +++++ ...yLoader.php => ReflectedElementLoader.php} | 6 +- src/Capability/Registry/ReferenceHandler.php | 15 + src/Capability/Registry/ResourceReference.php | 4 +- src/Capability/RegistryInterface.php | 4 +- src/Schema/Page.php | 4 +- .../{Resource.php => ResourceDefinition.php} | 12 +- src/Schema/Result/ListResourcesResult.php | 14 +- src/Server/Builder.php | 82 +++- .../Handler/ElementHandlerInterface.php | 23 ++ src/Server/Handler/PromptHandlerInterface.php | 28 ++ .../Handler/ResourceHandlerInterface.php | 25 ++ .../ResourceTemplateHandlerInterface.php | 28 ++ src/Server/Handler/ToolHandlerInterface.php | 28 ++ .../Discovery/DiscoveryStateTest.php | 4 +- .../Registry/Loader/DiscoveryLoaderTest.php | 6 +- .../Loader/ExplicitElementLoaderTest.php | 384 ++++++++++++++++++ ...flectedElementLoaderResourceTitleTest.php} | 8 +- ...> ReflectedElementLoaderToolTitleTest.php} | 6 +- .../Registry/ReferenceHandlerTest.php | 144 +++++++ tests/Unit/Capability/RegistryTest.php | 10 +- ...rceTest.php => ResourceDefinitionTest.php} | 32 +- .../Request/ListResourcesHandlerTest.php | 4 +- .../Request/ReadResourceHandlerTest.php | 14 +- .../Handler/Request/ResourceSubscribeTest.php | 6 +- .../Request/ResourceUnsubscribeTest.php | 6 +- 32 files changed, 996 insertions(+), 78 deletions(-) create mode 100644 src/Capability/Registry/Loader/ExplicitElementLoader.php rename src/Capability/Registry/Loader/{ArrayLoader.php => ReflectedElementLoader.php} (98%) rename src/Schema/{Resource.php => ResourceDefinition.php} (95%) create mode 100644 src/Server/Handler/ElementHandlerInterface.php create mode 100644 src/Server/Handler/PromptHandlerInterface.php create mode 100644 src/Server/Handler/ResourceHandlerInterface.php create mode 100644 src/Server/Handler/ResourceTemplateHandlerInterface.php create mode 100644 src/Server/Handler/ToolHandlerInterface.php create mode 100644 tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php rename tests/Unit/Capability/Registry/Loader/{ArrayLoaderResourceTitleTest.php => ReflectedElementLoaderResourceTitleTest.php} (88%) rename tests/Unit/Capability/Registry/Loader/{ArrayLoaderToolTitleTest.php => ReflectedElementLoaderToolTitleTest.php} (87%) create mode 100644 tests/Unit/Capability/Registry/ReferenceHandlerTest.php rename tests/Unit/Schema/{ResourceTest.php => ResourceDefinitionTest.php} (81%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72de1f..1d94d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.6.0 ----- +* Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime. +* Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker. +* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. +* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`. * [BC Break] Bump default protocol version to `2025-11-25` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) diff --git a/README.md b/README.md index bc97892..8b3bccf 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ $client->connect($transport); - [api-platform/mcp](https://github.com/api-platform/mcp) — MCP integration for API Platform - [bnomei/kirby-mcp](https://github.com/bnomei/kirby-mcp) — MCP server for the Kirby CMS +- [drupal/mcp_server](https://www.drupal.org/project/mcp_server) — MCP server for Drupal exposing configuration and entities as MCP elements - [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP plugin exposing application functionality over MCP - [nette/mcp-inspector](https://github.com/nette/mcp-inspector) — MCP server for introspecting Nette applications - [symfony/ai-mate](https://github.com/symfony/ai-mate) — AI development assistant MCP server for Symfony projects diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f..2e4a151 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -42,6 +42,9 @@ Each capability can be registered using two methods: For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). +For runtime, config-driven elements whose shape is not known at compile time, see +[Explicit element registration](server-builder.md#explicit-element-registration) in the Server Builder docs. + ## Tools Tools are callable functions that perform actions and return results. diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96d..9b7dfa8 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -398,6 +398,61 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). +### Explicit element registration + +When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of +the four handler interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own +inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + ## Service Dependencies ### Container @@ -619,4 +674,5 @@ $server = Server::builder() | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | | `build()` | - | Create the server instance | diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 9304c48..dab6590 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -27,7 +27,7 @@ use Mcp\Exception\ExceptionInterface; use Mcp\Exception\RuntimeException; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\Log\LoggerInterface; @@ -249,7 +249,7 @@ final class Discoverer implements DiscovererInterface $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; - $resource = new Resource( + $resource = new ResourceDefinition( $instance->uri, $name, $instance->title, diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index c8fdca3..23167b6 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -26,7 +26,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\EventDispatcher\EventDispatcherInterface; @@ -83,7 +83,7 @@ final class Registry implements RegistryInterface return $reference; } - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference { $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; diff --git a/src/Capability/Registry/Loader/ExplicitElementLoader.php b/src/Capability/Registry/Loader/ExplicitElementLoader.php new file mode 100644 index 0000000..4733ab9 --- /dev/null +++ b/src/Capability/Registry/Loader/ExplicitElementLoader.php @@ -0,0 +1,105 @@ + + */ +final class ExplicitElementLoader implements LoaderInterface +{ + /** + * @param list $tools + * @param list $resources + * @param list}> $resourceTemplates + * @param list}> $prompts + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], + ) { + } + + public function load(RegistryInterface $registry): void + { + foreach ($this->tools as $entry) { + $handler = $entry['handler']; + $registry->registerTool($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $handler->execute($arguments, $gateway); + }, + )); + } + + foreach ($this->resources as $entry) { + $handler = $entry['handler']; + $registry->registerResource($entry['definition'], $this->boundClosure( + static fn (array $arguments): mixed => $handler->read( + $arguments['uri'], + new ClientGateway($arguments['_session']), + ), + )); + } + + foreach ($this->resourceTemplates as $entry) { + $handler = $entry['handler']; + $registry->registerResourceTemplate($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + $uri = $arguments['uri']; + unset($arguments['_session'], $arguments['_request'], $arguments['uri']); + + return $handler->read($uri, $arguments, $gateway); + }, + ), $entry['completionProviders']); + } + + foreach ($this->prompts as $entry) { + $handler = $entry['handler']; + $registry->registerPrompt($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $handler->get($arguments, $gateway); + }, + ), $entry['completionProviders']); + } + } + + private function boundClosure(\Closure $closure): \Closure + { + return \Closure::bind($closure, null, ReferenceHandler::class); + } +} diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php similarity index 98% rename from src/Capability/Registry/Loader/ArrayLoader.php rename to src/Capability/Registry/Loader/ReflectedElementLoader.php index 8865b0f..a87dd58 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -26,7 +26,7 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Icon; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; @@ -39,7 +39,7 @@ use Psr\Log\NullLogger; * * @phpstan-import-type Handler from ElementReference */ -final class ArrayLoader implements LoaderInterface +final class ReflectedElementLoader implements LoaderInterface { /** * @param array{ @@ -156,7 +156,7 @@ final class ArrayLoader implements LoaderInterface $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; } - $resource = new Resource( + $resource = new ResourceDefinition( uri: $data['uri'], name: $name, title: $data['title'] ?? null, diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cd..99e5844 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -32,6 +33,15 @@ final class ReferenceHandler implements ReferenceHandlerInterface */ public function handle(ElementReference $reference, array $arguments): mixed { + // Closures bound to this class as their scope consume the raw argument bag + // directly. Used by ExplicitElementLoader so reflection + name-based parameter + // mapping is bypassed for explicitly registered handler interfaces. + if ($reference->handler instanceof \Closure + && self::class === (new \ReflectionFunction($reference->handler))->getClosureScopeClass()?->getName() + ) { + return ($reference->handler)($arguments); + } + $session = $arguments['_session']; if (\is_string($reference->handler)) { @@ -102,6 +112,11 @@ final class ReferenceHandler implements ReferenceHandlerInterface $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); continue; } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + continue; + } } if (isset($arguments[$paramName])) { diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index bec8b9b..10d7837 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -13,7 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; /** * @phpstan-import-type Handler from ElementReference @@ -26,7 +26,7 @@ class ResourceReference extends ElementReference * @param Handler $handler */ public function __construct( - public readonly Resource $resource, + public readonly ResourceDefinition $resource, callable|array|string $handler, ) { parent::__construct($handler); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 124704b..bbbef76 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -21,7 +21,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; @@ -47,7 +47,7 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference; + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference; /** * Registers a resource template with its handler and completion providers. diff --git a/src/Schema/Page.php b/src/Schema/Page.php index bad46e9..3d54646 100644 --- a/src/Schema/Page.php +++ b/src/Schema/Page.php @@ -12,14 +12,14 @@ namespace Mcp\Schema; /** - * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|Resource + * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|ResourceDefinition * * @extends \ArrayObject */ final class Page extends \ArrayObject { /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition */ public function __construct( public readonly array $references, diff --git a/src/Schema/Resource.php b/src/Schema/ResourceDefinition.php similarity index 95% rename from src/Schema/Resource.php rename to src/Schema/ResourceDefinition.php index 00fc267..a399966 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/ResourceDefinition.php @@ -19,7 +19,7 @@ use Mcp\Exception\InvalidArgumentException; * @phpstan-import-type AnnotationsData from Annotations * @phpstan-import-type IconData from Icon * - * @phpstan-type ResourceData array{ + * @phpstan-type ResourceDefinitionData array{ * uri: string, * name: string, * title?: string, @@ -33,7 +33,7 @@ use Mcp\Exception\InvalidArgumentException; * * @author Kyrian Obikwelu */ -class Resource implements \JsonSerializable +class ResourceDefinition implements \JsonSerializable { /** * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. @@ -77,19 +77,19 @@ class Resource implements \JsonSerializable } /** - * @param ResourceData $data + * @param ResourceDefinitionData $data */ public static function fromArray(array $data): self { if (empty($data['uri']) || !\is_string($data['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceDefinition data.'); } if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } if (!empty($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in Resource data.'); + throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } return new self( diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 50fa9bf..0f338ca 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -14,20 +14,20 @@ namespace Mcp\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; -use Mcp\Schema\Resource as ResourceSchema; +use Mcp\Schema\ResourceDefinition; /** * The server's response to a resources/list request from the client. * - * @phpstan-import-type ResourceData from ResourceSchema + * @phpstan-import-type ResourceDefinitionData from ResourceDefinition * * @author Kyrian Obikwelu */ class ListResourcesResult implements ResultInterface { /** - * @param array $resources the list of resource definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * @param array $resources the list of resource definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. * * If present, there may be more results available. */ @@ -39,7 +39,7 @@ class ListResourcesResult implements ResultInterface /** * @param array{ - * resources: array, + * resources: array, * nextCursor?: string, * } $data */ @@ -50,14 +50,14 @@ class ListResourcesResult implements ResultInterface } return new self( - array_map(static fn (array $resource) => ResourceSchema::fromArray($resource), $data['resources']), + array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), $data['nextCursor'] ?? null ); } /** * @return array{ - * resources: array, + * resources: array, * nextCursor?: string, * } */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ba26d23..29add5a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -11,6 +11,7 @@ namespace Mcp\Server; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; @@ -18,10 +19,11 @@ use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; -use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\ChainLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\ExplicitElementLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; @@ -31,11 +33,20 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; +use Mcp\Schema\Prompt; +use Mcp\Schema\ResourceDefinition; +use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Handler\ElementHandlerInterface; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\PromptHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ResourceTemplateHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -107,6 +118,7 @@ final class Builder * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, @@ -148,6 +160,7 @@ final class Builder * @var array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -155,6 +168,26 @@ final class Builder */ private array $prompts = []; + /** + * @var list + */ + private array $explicitTools = []; + + /** + * @var list + */ + private array $explicitResources = []; + + /** + * @var list}> + */ + private array $explicitResourceTemplates = []; + + /** + * @var list}> + */ + private array $explicitPrompts = []; + private ?string $discoveryBasePath = null; /** @@ -518,6 +551,41 @@ final class Builder return $this; } + /** + * Registers an element using an explicit schema value object paired with a handler interface. + * + * Use this entry point when an element's name, schema, or description is only known at + * runtime (e.g. config-driven integrations). For statically-known elements, prefer + * `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from + * reflection of the handler. + * + * Mismatched pairings (e.g. a `Tool` with a `PromptHandlerInterface`) raise + * `Mcp\Exception\InvalidArgumentException`. Completion providers are only supported on + * `Prompt` and `ResourceTemplate` definitions; supplying them with `Tool` or + * `ResourceDefinition` raises the same exception. + * + * @param array $completionProviders Keyed by argument/variable name + */ + public function add( + Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, + ElementHandlerInterface $handler, + array $completionProviders = [], + ): self { + if ([] !== $completionProviders && ($definition instanceof Tool || $definition instanceof ResourceDefinition)) { + throw new InvalidArgumentException(\sprintf('Completion providers are only supported on Prompt and ResourceTemplate definitions, got %s.', $definition::class)); + } + + match (true) { + $definition instanceof Tool && $handler instanceof ToolHandlerInterface => $this->explicitTools[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceDefinition && $handler instanceof ResourceHandlerInterface => $this->explicitResources[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceTemplate && $handler instanceof ResourceTemplateHandlerInterface => $this->explicitResourceTemplates[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], + $definition instanceof Prompt && $handler instanceof PromptHandlerInterface => $this->explicitPrompts[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], + default => throw new InvalidArgumentException(\sprintf('%s definition cannot be paired with %s; expected the matching handler interface.', $definition::class, $handler::class)), + }; + + return $this; + } + /** * Register a single custom loader. */ @@ -556,11 +624,17 @@ final class Builder $this->gcDivisor, ); - // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's - // identity check then preserves them against same-name discovered entries. + // ExplicitElementLoader and ReflectedElementLoader run before DiscoveryLoader so manual entries are seen first; + // DiscoveryLoader's identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ExplicitElementLoader( + $this->explicitTools, + $this->explicitResources, + $this->explicitResourceTemplates, + $this->explicitPrompts, + ), + new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; if (null !== $this->discoveryBasePath) { diff --git a/src/Server/Handler/ElementHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php new file mode 100644 index 0000000..6b08955 --- /dev/null +++ b/src/Server/Handler/ElementHandlerInterface.php @@ -0,0 +1,23 @@ + + */ +interface ElementHandlerInterface +{ +} diff --git a/src/Server/Handler/PromptHandlerInterface.php b/src/Server/Handler/PromptHandlerInterface.php new file mode 100644 index 0000000..4143c97 --- /dev/null +++ b/src/Server/Handler/PromptHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface PromptHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function get(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceHandlerInterface.php b/src/Server/Handler/ResourceHandlerInterface.php new file mode 100644 index 0000000..7ca533a --- /dev/null +++ b/src/Server/Handler/ResourceHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface ResourceHandlerInterface extends ElementHandlerInterface +{ + public function read(string $uri, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceTemplateHandlerInterface.php b/src/Server/Handler/ResourceTemplateHandlerInterface.php new file mode 100644 index 0000000..892e439 --- /dev/null +++ b/src/Server/Handler/ResourceTemplateHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ResourceTemplateHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $variables + */ + public function read(string $uri, array $variables, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ToolHandlerInterface.php b/src/Server/Handler/ToolHandlerInterface.php new file mode 100644 index 0000000..4f2ee3e --- /dev/null +++ b/src/Server/Handler/ToolHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ToolHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function execute(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php index b5b60b1..e40c722 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php @@ -17,7 +17,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\TestCase; @@ -141,7 +141,7 @@ class DiscoveryStateTest extends TestCase private function resource(string $uri): ResourceReference { return new ResourceReference( - new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), + new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), static fn () => null, ); } diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php index eaab2df..dcb1d8d 100644 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -24,7 +24,7 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\MutableDiscoverer; @@ -267,9 +267,9 @@ class DiscoveryLoaderTest extends TestCase ); } - private function makeResource(string $uri): Resource + private function makeResource(string $uri): ResourceDefinition { - return new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); + return new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); } private function makePrompt(string $name): Prompt diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php new file mode 100644 index 0000000..0066a31 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -0,0 +1,384 @@ + 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], + description: 'A demo tool', + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('demo'); + $this->assertSame('demo', $reference->tool->name); + $this->assertSame('A demo tool', $reference->tool->description); + $this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], $reference->tool->inputSchema); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'foo' => 'bar', + ]); + + $this->assertSame('tool-ok', $result); + $this->assertSame(['foo' => 'bar'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceRegistersDefinitionAndDispatchesToHandler(): void + { + $resource = new ResourceDefinition( + uri: 'config://demo', + name: 'demo', + description: 'A demo resource', + mimeType: 'text/plain', + ); + $handler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'resource-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($resource, $handler)); + + $reference = $registry->getResource('config://demo', false); + $this->assertSame('config://demo', $reference->resource->uri); + $this->assertSame('demo', $reference->resource->name); + $this->assertSame('text/plain', $reference->resource->mimeType); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://demo', + ]); + + $this->assertSame(['contents' => 'resource-ok'], $result); + $this->assertSame('config://demo', $handler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceTemplateRegistersDefinitionAndDispatchesToHandler(): void + { + $template = new ResourceTemplate( + uriTemplate: 'config://{key}', + name: 'config_template', + description: 'A demo template', + ); + $handler = new class implements ResourceTemplateHandlerInterface { + public ?string $receivedUri = null; + /** @var array|null */ + public ?array $receivedVariables = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, array $variables, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedVariables = $variables; + $this->receivedGateway = $gateway; + + return ['contents' => 'template-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($template, $handler)); + + $reference = $registry->getResourceTemplate('config://{key}'); + $this->assertSame('config://{key}', $reference->resourceTemplate->uriTemplate); + $this->assertSame('config_template', $reference->resourceTemplate->name); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://abc', + 'key' => 'abc', + ]); + + $this->assertSame(['contents' => 'template-ok'], $result); + $this->assertSame('config://abc', $handler->receivedUri); + $this->assertSame(['key' => 'abc'], $handler->receivedVariables); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddPromptRegistersDefinitionAndDispatchesToHandler(): void + { + $prompt = new Prompt( + name: 'demo_prompt', + title: null, + description: 'A demo prompt', + ); + $handler = new class implements PromptHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function get(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'prompt-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); + + $reference = $registry->getPrompt('demo_prompt'); + $this->assertSame('demo_prompt', $reference->prompt->name); + $this->assertSame('A demo prompt', $reference->prompt->description); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'topic' => 'php', + ]); + + $this->assertSame('prompt-ok', $result); + $this->assertSame(['topic' => 'php'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddPromptForwardsCompletionProvidersToRegistry(): void + { + $prompt = new Prompt(name: 'greet', title: null, description: null); + $handler = new class implements PromptHandlerInterface { + public function get(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return ['alice', 'bob']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( + $prompt, + $handler, + ['name' => $provider], + )); + + $reference = $registry->getPrompt('greet'); + $this->assertSame(['name' => $provider], $reference->completionProviders); + } + + public function testAddResourceTemplateForwardsCompletionProvidersToRegistry(): void + { + $template = new ResourceTemplate( + uriTemplate: 'config://{key}', + name: 'config_template', + description: null, + ); + $handler = new class implements ResourceTemplateHandlerInterface { + public function read(string $uri, array $variables, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return ['alpha', 'beta']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( + $template, + $handler, + ['key' => $provider], + )); + + $reference = $registry->getResourceTemplate('config://{key}'); + $this->assertSame(['key' => $provider], $reference->completionProviders); + } + + public function testAddPromptWithoutCompletionProvidersDefaultsToEmptyArray(): void + { + $prompt = new Prompt(name: 'no_completion', title: null, description: null); + $handler = new class implements PromptHandlerInterface { + public function get(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); + + $this->assertSame([], $registry->getPrompt('no_completion')->completionProviders); + } + + public function testAddToolWithCompletionProvidersThrows(): void + { + $tool = new Tool( + name: 'noop', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], + description: null, + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($tool, $handler, ['foo' => $provider]); + } + + public function testAddResourceWithCompletionProvidersThrows(): void + { + $resource = new ResourceDefinition(uri: 'config://demo', name: 'demo'); + $handler = new class implements ResourceHandlerInterface { + public function read(string $uri, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($resource, $handler, ['foo' => $provider]); + } + + public function testMismatchedDefinitionAndHandlerThrowsInvalidArgumentException(): void + { + $prompt = new Prompt(name: 'mismatched', title: null, description: null); + $toolHandler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $this->expectException(InvalidArgumentException::class); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($prompt, $toolHandler); + } + + public function testLoaderRegistersClosuresRatherThanHandlerInstances(): void + { + $tool = new Tool( + name: 'closure_check', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], + description: null, + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('closure_check'); + $this->assertInstanceOf(\Closure::class, $reference->handler); + } + + /** + * @param callable(Server\Builder): Server\Builder $configure + */ + private function buildAndGetRegistry(callable $configure): RegistryInterface + { + $registry = new Registry(); + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry); + $configure($builder)->build(); + + return $registry; + } +} diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php similarity index 88% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php index bd01207..6dd0999 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderResourceTitleTest extends TestCase +class ReflectedElementLoaderResourceTitleTest extends TestCase { public function testLoadPropagatesResourceTitleToRegisteredResource(): void { @@ -34,7 +34,7 @@ class ArrayLoaderResourceTitleTest extends TestCase ], ]; - $loader = new ArrayLoader([], $resources); + $loader = new ReflectedElementLoader([], $resources); $registry = new Registry(); $loader->load($registry); @@ -58,7 +58,7 @@ class ArrayLoaderResourceTitleTest extends TestCase ], ]; - $loader = new ArrayLoader([], [], $resourceTemplates); + $loader = new ReflectedElementLoader([], [], $resourceTemplates); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php similarity index 87% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php index 68a1bfb..8537fcc 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderToolTitleTest extends TestCase +class ReflectedElementLoaderToolTitleTest extends TestCase { public function testLoadPropagatesToolTitleToRegisteredTool(): void { @@ -37,7 +37,7 @@ class ArrayLoaderToolTitleTest extends TestCase ], ]; - $loader = new ArrayLoader($tools); + $loader = new ReflectedElementLoader($tools); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 0000000..dadca9f --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,144 @@ +createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $toolHandler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $executedWith = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executedWith = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-result'; + } + }; + + $closure = \Closure::bind( + static function (array $arguments) use ($toolHandler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $toolHandler->execute($arguments, $gateway); + }, + null, + ReferenceHandler::class, + ); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'kept' => 'value', + 'other' => 'value2', + ]); + + $this->assertSame('tool-result', $result); + $this->assertSame( + ['kept' => 'value', 'other' => 'value2'], + $toolHandler->executedWith, + ); + $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); + } + + public function testHandleDispatchesToBoundResourceClosureWithRawArgumentBag(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $resourceHandler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'r-ok']; + } + }; + + $closure = \Closure::bind( + static fn (array $arguments): mixed => $resourceHandler->read( + $arguments['uri'], + new ClientGateway($arguments['_session']), + ), + null, + ReferenceHandler::class, + ); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://x', + ]); + + $this->assertSame(['contents' => 'r-ok'], $result); + $this->assertSame('config://x', $resourceHandler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); + } + + public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $captured = null; + $closure = static function (string $kept) use (&$captured): string { + $captured = $kept; + + return $kept; + }; + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'kept' => 'value', + ]); + + $this->assertSame('value', $result); + $this->assertSame('value', $captured); + } + + public function testHandleThrowsForStringHandlerThatIsNeitherFunctionNorClass(): void + { + $session = $this->createMock(SessionInterface::class); + + $reference = new ElementReference('definitely_not_a_function_or_class_xyz'); + + $this->expectException(InvalidArgumentException::class); + + (new ReferenceHandler())->handle($reference, ['_session' => $session]); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 5173aaf..a4d2780 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -21,7 +21,7 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; @@ -124,8 +124,8 @@ class RegistryTest extends TestCase $this->assertCount(2, $resources); $this->assertArrayHasKey('test://resource1', $resources->references); $this->assertArrayHasKey('test://resource2', $resources->references); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource1']); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource2']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource1']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource2']); } public function testGetResourceReturnsRegisteredResource(): void @@ -554,9 +554,9 @@ class RegistryTest extends TestCase ); } - private function createValidResource(string $uri): Resource + private function createValidResource(string $uri): ResourceDefinition { - return new Resource( + return new ResourceDefinition( uri: $uri, name: 'test_resource', description: 'Test resource', diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php similarity index 81% rename from tests/Unit/Schema/ResourceTest.php rename to tests/Unit/Schema/ResourceDefinitionTest.php index 7a2d9a1..ab39f41 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -12,11 +12,11 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -class ResourceTest extends TestCase +class ResourceDefinitionTest extends TestCase { private const VALID_URI = 'https://example.com/list-books'; @@ -24,12 +24,12 @@ class ResourceTest extends TestCase { $uri = self::VALID_URI; - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -40,7 +40,7 @@ class ResourceTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid resource URI: "/list-books" must be a valid URI with a scheme and optional path.'); - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); @@ -49,12 +49,12 @@ class ResourceTest extends TestCase #[DataProvider('provideValidUris')] public function testConstructorAcceptsUris(string $uri): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'test-resource', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -69,12 +69,12 @@ class ResourceTest extends TestCase public function testFromArrayValid(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', ]); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame(self::VALID_URI, $resource->uri); $this->assertSame('list-books', $resource->name); $this->assertNull($resource->title); @@ -84,7 +84,7 @@ class ResourceTest extends TestCase public function testTitleFromArray(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', 'title' => 'Book Listing', @@ -95,7 +95,7 @@ class ResourceTest extends TestCase public function testTitleSerialization(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', title: 'Book Listing', @@ -107,7 +107,7 @@ class ResourceTest extends TestCase public function testTitleOmittedWhenNull(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', ); @@ -122,15 +122,15 @@ class ResourceTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - Resource::fromArray($input); + ResourceDefinition::fromArray($input); } public static function provideInvalidResources(): iterable { - yield 'missing uri' => [[], 'Invalid or missing "uri" in Resource data.']; + yield 'missing uri' => [[], 'Invalid or missing "uri" in ResourceDefinition data.']; yield 'missing name' => [ ['uri' => self::VALID_URI], - 'Invalid or missing "name" in Resource data.', + 'Invalid or missing "name" in ResourceDefinition data.', ]; yield 'meta' => [ [ @@ -138,7 +138,7 @@ class ResourceTest extends TestCase 'name' => 'list-books', '_meta' => 'foo', ], - 'Invalid "_meta" in Resource data.', + 'Invalid "_meta" in ResourceDefinition data.', ]; } } diff --git a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php index dca1c31..3e385fe 100644 --- a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php @@ -14,7 +14,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Request\ListResourcesRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\Handler\Request\ListResourcesHandler; use Mcp\Server\Session\InMemorySessionStore; @@ -221,7 +221,7 @@ class ListResourcesHandlerTest extends TestCase private function addResourcesToRegistry(int $count): void { for ($i = 0; $i < $count; ++$i) { - $resource = new Resource( + $resource = new ResourceDefinition( uri: "resource://test/resource_$i", name: "resource_$i", description: "Test resource $i" diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 66a2296..efdc914 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -21,7 +21,7 @@ use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; @@ -66,7 +66,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -106,7 +106,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'image/png'), []]) ->getMock(); $this->registry @@ -150,7 +150,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$textContent, $blobContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'application/octet-stream'), []]) ->getMock(); $this->registry @@ -273,7 +273,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -318,7 +318,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -380,7 +380,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: $mimeType), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php index 02e2e5c..b7d491b 100644 --- a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceSubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -51,7 +51,7 @@ class ResourceSubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -77,7 +77,7 @@ class ResourceSubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php index d71dfb3..5586d05 100644 --- a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceUnsubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -53,7 +53,7 @@ class ResourceUnsubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -82,7 +82,7 @@ class ResourceUnsubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry From d150772e249760f017f59880edc1ca50fbe9a3aa Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 1 Jun 2026 09:35:54 +0100 Subject: [PATCH 045/143] [Schema][Server] Add MCP Apps extension (io.modelcontextprotocol/ui) support (#281) * feat: add MCP Apps extension (io.modelcontextprotocol/ui) support Adds support for the MCP Apps protocol extension, which lets servers expose interactive HTML UIs as resources rendered by spec-conforming clients (e.g. Goose) in sandboxed iframes. - Generic extension plumbing: ServerExtensionInterface and Builder::enableExtension(), with extensions advertised under capabilities.extensions during the initialize handshake. ClientCapabilities/ServerCapabilities gain an extensions field. - Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker, UiToolMeta, ToolVisibility enum, UiResourceContentMeta, UiResourceCsp, and UiResourcePermissions (with empty-object presence markers per spec). - A worked weather example under examples/server/mcp-apps/ with a minimal spec-conforming view (ui/initialize handshake, postMessage JSONRPCMessage objects, and ResizeObserver-driven size reporting). - docs/extensions.md guide with pointers from index.md and examples.md to the ext-apps repository for the TypeScript SDK and richer view-side patterns. - Unit and inspector snapshot test coverage. Spec: https://github.com/modelcontextprotocol/ext-apps * Type UiToolMeta visibility as ToolVisibility enum Also fix UiResourceContentMeta::jsonSerialize() return phpdoc. Co-Authored-By: Claude Opus 4.7 (1M context) * Address review nitpicks for MCP Apps extension Co-Authored-By: Claude Opus 4.7 (1M context) * Apply PR review feedback for MCP Apps extension Co-Authored-By: Claude Opus 4.7 (1M context) * Add regression tests and CHANGELOG entry for capabilities extensions - Pin the is_array() guards on extensions in ServerCapabilities and ClientCapabilities::fromArray() with explicit non-array payload tests. - Pin the isset()-rejects-null contract in UiResourcePermissions::fromArray(). - Surface the new extensions field on both capability classes in CHANGELOG. - Inline the ?: idiom in Builder::build() to match the surrounding style. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + composer.json | 1 + docs/examples.md | 12 + docs/extensions.md | 110 +++++++++ docs/index.md | 1 + examples/server/mcp-apps/WeatherApp.php | 67 +++++ examples/server/mcp-apps/server.php | 51 ++++ examples/server/mcp-apps/weather-app.html | 230 +++++++++++++++++ src/Schema/ClientCapabilities.php | 13 +- src/Schema/Extension/Apps/McpApps.php | 58 +++++ src/Schema/Extension/Apps/ToolVisibility.php | 26 ++ .../Extension/Apps/UiResourceContentMeta.php | 81 ++++++ src/Schema/Extension/Apps/UiResourceCsp.php | 82 +++++++ .../Extension/Apps/UiResourcePermissions.php | 76 ++++++ src/Schema/Extension/Apps/UiToolMeta.php | 64 +++++ .../Extension/ServerExtensionInterface.php | 40 +++ src/Schema/ServerCapabilities.php | 35 ++- src/Server/Builder.php | 35 +++ tests/Inspector/Stdio/StdioMcpAppsTest.php | 52 ++++ .../StdioMcpAppsTest-prompts_list.json | 3 + .../StdioMcpAppsTest-resources_list.json | 13 + ...psTest-resources_read-read_weather_ui.json | 22 ++ ...oMcpAppsTest-resources_templates_list.json | 3 + ...ppsTest-tools_call-get_weather_london.json | 9 + ...AppsTest-tools_call-get_weather_tokyo.json | 9 + .../StdioMcpAppsTest-tools_list.json | 28 +++ .../Schema/Extension/Apps/McpAppsTest.php | 223 +++++++++++++++++ .../Extension/CapabilitiesExtensionsTest.php | 232 ++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 57 +++++ 29 files changed, 1632 insertions(+), 3 deletions(-) create mode 100644 docs/extensions.md create mode 100644 examples/server/mcp-apps/WeatherApp.php create mode 100644 examples/server/mcp-apps/server.php create mode 100644 examples/server/mcp-apps/weather-app.html create mode 100644 src/Schema/Extension/Apps/McpApps.php create mode 100644 src/Schema/Extension/Apps/ToolVisibility.php create mode 100644 src/Schema/Extension/Apps/UiResourceContentMeta.php create mode 100644 src/Schema/Extension/Apps/UiResourceCsp.php create mode 100644 src/Schema/Extension/Apps/UiResourcePermissions.php create mode 100644 src/Schema/Extension/Apps/UiToolMeta.php create mode 100644 src/Schema/Extension/ServerExtensionInterface.php create mode 100644 tests/Inspector/Stdio/StdioMcpAppsTest.php create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json create mode 100644 tests/Unit/Schema/Extension/Apps/McpAppsTest.php create mode 100644 tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d94d50..cf056e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. * [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`. * [BC Break] Bump default protocol version to `2025-11-25` +* Add support for MCP Apps extension in schema and server +* Add `extensions` to `ServerCapabilities` and `ClientCapabilities` and `Builder::enableExtension()` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) * Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering. diff --git a/composer.json b/composer.json index d9f1507..b883603 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,7 @@ "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/", "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", diff --git a/docs/examples.md b/docs/examples.md index 4475f13..61b788e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -355,6 +355,18 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php 2. **confirm_action** - Simple boolean confirmation dialog 3. **collect_feedback** - Rating and comments form with optional fields +### MCP Apps + +**File**: `examples/server/mcp-apps/` + +A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` +HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to +the `get_weather` tool. The bundled `weather-app.html` performs the +`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, +and calls back into the server. See the +[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the +TypeScript SDK and richer view-side patterns. + ## Client Examples ### STDIO Discovery Calculator (Client) diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000..f181702 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,110 @@ +# Protocol Extensions + +MCP protocol extensions advertise additional, optional capabilities during the initialize handshake. +A server opts in via `Builder::enableExtension()`: + +```php +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->enableExtension(new McpApps()) + ->build(); +``` + +Pass one or more `ServerExtensionInterface` instances; multiple extensions can +be enabled in a single call. Enabling the same extension twice throws a +`LogicException`. + +> Note: extensions enabled via `enableExtension()` are merged into the +> `extensions` capability even when you supply your own `ServerCapabilities` via +> `setCapabilities()`. An enabled extension overrides any entry under the same +> id already present in those capabilities. + +## MCP Apps (`io.modelcontextprotocol/ui`) + +The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as +resources. Clients that support it render them in sandboxed iframes and bridge +tool calls between the iframe (the *View*) and the server via the host. + +A UI consists of two pieces wired together by `_meta.ui`: + +1. **A resource** with URI scheme `ui://` and MIME type + `text/html;profile=mcp-app`, returning the HTML body. +2. **A tool** linked to that resource via `UiToolMeta`, so the client knows to + open the UI when the tool is invoked. + +```php +use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Extension\Apps\ToolVisibility; +use Mcp\Schema\Extension\Apps\UiResourceContentMeta; +use Mcp\Schema\Extension\Apps\UiResourceCsp; +use Mcp\Schema\Extension\Apps\UiResourcePermissions; +use Mcp\Schema\Extension\Apps\UiToolMeta; + +$server = Server::builder() + ->enableExtension(new McpApps()) + ->addResource( + fn () => new TextResourceContents( + uri: 'ui://my-app', + mimeType: McpApps::MIME_TYPE, + text: file_get_contents(__DIR__.'/app.html'), + meta: ['ui' => new UiResourceContentMeta( + csp: new UiResourceCsp(connectDomains: ['https://api.example.com']), + permissions: new UiResourcePermissions(geolocation: true), + prefersBorder: true, + )], + ), + 'ui://my-app', + mimeType: McpApps::MIME_TYPE, + meta: ['ui' => McpApps::resourceMarker()], + ) + ->addTool( + $myToolHandler, + 'my_tool', + meta: ['ui' => new UiToolMeta( + resourceUri: 'ui://my-app', + visibility: [ToolVisibility::Model, ToolVisibility::App], + )], + ) + ->build(); +``` + +Note the two distinct `_meta.ui` shapes: the resource *descriptor* (its +`resources/list` entry) carries only an empty marker — `McpApps::resourceMarker()` — +flagging it as an MCP App, while the resource *content* returned by `resources/read` +carries the structured `UiResourceContentMeta` with the actual CSP and permission +configuration. + +### Server-side DTOs + +| Class | Purpose | +| --- | --- | +| `McpApps` | Extension marker; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME` constants. | +| `UiToolMeta` | Tool `_meta.ui` payload: `resourceUri` + `visibility`. | +| `ToolVisibility` | Enum: `Model`, `App`. | +| `UiResourceContentMeta` | Resource content `_meta.ui`: `csp`, `permissions`, `domain`, `prefersBorder`. | +| `UiResourceCsp` | CSP allow-lists: `connectDomains`, `resourceDomains`, `frameDomains`, `baseUriDomains`. | +| `UiResourcePermissions` | Sandbox permissions: `camera`, `microphone`, `geolocation`, `clipboardWrite`. | + +### Writing the HTML view + +The View and host exchange `JSONRPCMessage` **objects** (not JSON strings) via +`window.parent.postMessage`. Before the host forwards `tools/call`, +`tool-input`, or `tool-result`, the View must complete the spec-mandated +handshake: + +1. View → Host: `ui/initialize` request +2. Host → View: response with `hostCapabilities`, `hostInfo`, `hostContext` +3. View → Host: `ui/notifications/initialized` +4. View → Host: `ui/notifications/size-changed` whenever the iframe wants to + resize + +See the [`ext-apps` repository][ext-apps] for the full protocol, official +TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A +working minimal view is included in +[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). + +[ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/index.md b/docs/index.md index 86a3018..9ace7bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,4 +5,5 @@ - [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. +- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). - [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php new file mode 100644 index 0000000..5facead --- /dev/null +++ b/examples/server/mcp-apps/WeatherApp.php @@ -0,0 +1,67 @@ + $contentMeta], + ); + } + + public function getWeather(string $city): string + { + $weather = [ + 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'], + 'paris' => ['temp' => '18°C', 'condition' => 'Sunny', 'humidity' => '55%'], + 'tokyo' => ['temp' => '22°C', 'condition' => 'Partly Cloudy', 'humidity' => '65%'], + 'new york' => ['temp' => '12°C', 'condition' => 'Rainy', 'humidity' => '85%'], + 'lagos' => ['temp' => '30°C', 'condition' => 'Sunny', 'humidity' => '82%'], + 'stockholm' => ['temp' => '4°C', 'condition' => 'Cloudy', 'humidity' => '70%'], + 'berlin' => ['temp' => '9°C', 'condition' => 'Partly Cloudy', 'humidity' => '68%'], + 'sydney' => ['temp' => '26°C', 'condition' => 'Sunny', 'humidity' => '60%'], + 'buenos aires' => ['temp' => '24°C', 'condition' => 'Rainy', 'humidity' => '80%'], + ]; + + $key = strtolower($city); + $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%']; + + return \sprintf( + 'Weather in %s: %s, %s, Humidity: %s', + $city, + $data['temp'], + $data['condition'], + $data['humidity'], + ); + } +} diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php new file mode 100644 index 0000000..d8ca259 --- /dev/null +++ b/examples/server/mcp-apps/server.php @@ -0,0 +1,51 @@ +#!/usr/bin/env php +info('Starting MCP Apps Example Server...'); + +$server = Server::builder() + ->setServerInfo('MCP Apps Weather Example', '1.0.0') + ->setLogger(logger()) + ->enableExtension(new McpApps()) + ->addResource( + [WeatherApp::class, 'getWeatherApp'], + 'ui://weather-app', + 'weather-app', + description: 'Interactive weather dashboard', + mimeType: McpApps::MIME_TYPE, + meta: ['ui' => McpApps::resourceMarker()], + ) + ->addTool( + [WeatherApp::class, 'getWeather'], + 'get_weather', + description: 'Get current weather for a city', + meta: ['ui' => new UiToolMeta( + resourceUri: 'ui://weather-app', + visibility: [ToolVisibility::Model, ToolVisibility::App], + )], + ) + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/server/mcp-apps/weather-app.html b/examples/server/mcp-apps/weather-app.html new file mode 100644 index 0000000..1997a2c --- /dev/null +++ b/examples/server/mcp-apps/weather-app.html @@ -0,0 +1,230 @@ + + + + + + Weather Dashboard + + + +
+ + +
+
+
+
+
—
+
—
+
+
🌤️
+
+
—
+
+ Humidity — +
+
+
+ + + diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index bfe4e6e..3bb6081 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -20,7 +20,8 @@ namespace Mcp\Schema; class ClientCapabilities implements \JsonSerializable { /** - * @param array $experimental + * @param array $experimental + * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) */ public function __construct( public readonly ?bool $roots = false, @@ -28,6 +29,7 @@ class ClientCapabilities implements \JsonSerializable public readonly ?bool $sampling = null, public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, + public readonly ?array $extensions = null, ) { } @@ -39,6 +41,7 @@ class ClientCapabilities implements \JsonSerializable * sampling?: bool, * elicitation?: bool, * experimental?: array, + * extensions?: array, * } $data */ public static function fromArray(array $data): self @@ -68,7 +71,8 @@ class ClientCapabilities implements \JsonSerializable $rootsListChanged, $sampling, $elicitation, - $data['experimental'] ?? null + \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, + \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, ); } @@ -78,6 +82,7 @@ class ClientCapabilities implements \JsonSerializable * sampling?: object, * elicitation?: object, * experimental?: object, + * extensions?: object, * }|\stdClass */ public function jsonSerialize(): array|object @@ -102,6 +107,10 @@ class ClientCapabilities implements \JsonSerializable $data['experimental'] = (object) $this->experimental; } + if ($this->extensions) { + $data['extensions'] = (object) $this->extensions; + } + return $data ?: new \stdClass(); } } diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php new file mode 100644 index 0000000..bd702c8 --- /dev/null +++ b/src/Schema/Extension/Apps/McpApps.php @@ -0,0 +1,58 @@ + + */ +final class McpApps implements ServerExtensionInterface +{ + public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; + public const MIME_TYPE = 'text/html;profile=mcp-app'; + public const URI_SCHEME = 'ui'; + + public function getId(): string + { + return self::EXTENSION_ID; + } + + /** + * @return array{mimeTypes: string[]} + */ + public function getCapabilities(): array + { + return ['mimeTypes' => [self::MIME_TYPE]]; + } + + /** + * The marker value for the `_meta.ui` field on a UI resource *descriptor* + * (its `resources/list` entry), flagging the resource as an MCP App. + * + * The structured CSP/permissions metadata instead belongs on the resource + * *content* (the `resources/read` payload) via {@see UiResourceContentMeta}. + */ + public static function resourceMarker(): \stdClass + { + return new \stdClass(); + } +} diff --git a/src/Schema/Extension/Apps/ToolVisibility.php b/src/Schema/Extension/Apps/ToolVisibility.php new file mode 100644 index 0000000..bdb12ef --- /dev/null +++ b/src/Schema/Extension/Apps/ToolVisibility.php @@ -0,0 +1,26 @@ + + */ +enum ToolVisibility: string +{ + /** Visible to and callable by the LLM agent. */ + case Model = 'model'; + + /** Callable by the MCP App (HTML view) only, hidden from the model's tools/list. */ + case App = 'app'; +} diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php new file mode 100644 index 0000000..e507d80 --- /dev/null +++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php @@ -0,0 +1,81 @@ + + */ +final class UiResourceContentMeta implements \JsonSerializable +{ + public function __construct( + public readonly ?UiResourceCsp $csp = null, + public readonly ?UiResourcePermissions $permissions = null, + public readonly ?string $domain = null, + public readonly ?bool $prefersBorder = null, + ) { + } + + /** + * @param UiResourceContentMetaData $data + */ + public static function fromArray(array $data): self + { + return new self( + csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null, + permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null, + domain: $data['domain'] ?? null, + prefersBorder: $data['prefersBorder'] ?? null, + ); + } + + /** + * @return array{ + * csp?: UiResourceCsp, + * permissions?: UiResourcePermissions, + * domain?: string, + * prefersBorder?: bool + * } + */ + public function jsonSerialize(): array + { + $data = []; + + if (null !== $this->csp) { + $data['csp'] = $this->csp; + } + if (null !== $this->permissions) { + $data['permissions'] = $this->permissions; + } + if (null !== $this->domain) { + $data['domain'] = $this->domain; + } + if (null !== $this->prefersBorder) { + $data['prefersBorder'] = $this->prefersBorder; + } + + return $data; + } +} diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php new file mode 100644 index 0000000..d487d72 --- /dev/null +++ b/src/Schema/Extension/Apps/UiResourceCsp.php @@ -0,0 +1,82 @@ + + */ +final class UiResourceCsp implements \JsonSerializable +{ + /** + * @param ?string[] $connectDomains domains allowed for network requests (fetch, XHR, WebSocket) + * @param ?string[] $resourceDomains domains allowed for static resources (images, scripts, styles) + * @param ?string[] $frameDomains domains allowed for nested iframes + * @param ?string[] $baseUriDomains domains allowed for base URI origins + */ + public function __construct( + public readonly ?array $connectDomains = null, + public readonly ?array $resourceDomains = null, + public readonly ?array $frameDomains = null, + public readonly ?array $baseUriDomains = null, + ) { + } + + /** + * @param UiResourceCspData $data + */ + public static function fromArray(array $data): self + { + return new self( + connectDomains: $data['connectDomains'] ?? null, + resourceDomains: $data['resourceDomains'] ?? null, + frameDomains: $data['frameDomains'] ?? null, + baseUriDomains: $data['baseUriDomains'] ?? null, + ); + } + + /** + * @return UiResourceCspData + */ + public function jsonSerialize(): array + { + $data = []; + + // The MCP Apps spec (2026-01-26) defines "empty or omitted" identically + // for every CSP allow-list, so empty arrays are dropped, not emitted as `[]`. + if ($this->connectDomains) { + $data['connectDomains'] = $this->connectDomains; + } + if ($this->resourceDomains) { + $data['resourceDomains'] = $this->resourceDomains; + } + if ($this->frameDomains) { + $data['frameDomains'] = $this->frameDomains; + } + if ($this->baseUriDomains) { + $data['baseUriDomains'] = $this->baseUriDomains; + } + + return $data; + } +} diff --git a/src/Schema/Extension/Apps/UiResourcePermissions.php b/src/Schema/Extension/Apps/UiResourcePermissions.php new file mode 100644 index 0000000..bed14ba --- /dev/null +++ b/src/Schema/Extension/Apps/UiResourcePermissions.php @@ -0,0 +1,76 @@ +, + * microphone?: \stdClass|array, + * geolocation?: \stdClass|array, + * clipboardWrite?: \stdClass|array + * } + * + * @author Christopher Hertel + */ +final class UiResourcePermissions implements \JsonSerializable +{ + public function __construct( + public readonly bool $camera = false, + public readonly bool $microphone = false, + public readonly bool $geolocation = false, + public readonly bool $clipboardWrite = false, + ) { + } + + /** + * @param UiResourcePermissionsData $data + */ + public static function fromArray(array $data): self + { + // A permission is requested when its key is present with the spec's `{}` + // marker; isset() accepts that (array/object forms) and rejects a stray null. + return new self( + camera: isset($data['camera']), + microphone: isset($data['microphone']), + geolocation: isset($data['geolocation']), + clipboardWrite: isset($data['clipboardWrite']), + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = []; + + if ($this->camera) { + $data['camera'] = new \stdClass(); + } + if ($this->microphone) { + $data['microphone'] = new \stdClass(); + } + if ($this->geolocation) { + $data['geolocation'] = new \stdClass(); + } + if ($this->clipboardWrite) { + $data['clipboardWrite'] = new \stdClass(); + } + + return $data; + } +} diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php new file mode 100644 index 0000000..ecefa19 --- /dev/null +++ b/src/Schema/Extension/Apps/UiToolMeta.php @@ -0,0 +1,64 @@ + + */ +final class UiToolMeta implements \JsonSerializable +{ + /** + * @param ?string $resourceUri the ui:// URI of the linked UI resource + * @param ?list $visibility who can see/call this tool; when omitted the host + * defaults to both {@see ToolVisibility::Model} and {@see ToolVisibility::App} + */ + public function __construct( + public readonly ?string $resourceUri = null, + public readonly ?array $visibility = null, + ) { + } + + /** + * @param UiToolMetaData $data + */ + public static function fromArray(array $data): self + { + return new self( + resourceUri: $data['resourceUri'] ?? null, + visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null, + ); + } + + /** + * @return UiToolMetaData + */ + public function jsonSerialize(): array + { + $data = []; + + if (null !== $this->resourceUri) { + $data['resourceUri'] = $this->resourceUri; + } + if (null !== $this->visibility) { + $data['visibility'] = array_map(static fn (ToolVisibility $v): string => $v->value, $this->visibility); + } + + return $data; + } +} diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php new file mode 100644 index 0000000..8793a96 --- /dev/null +++ b/src/Schema/Extension/ServerExtensionInterface.php @@ -0,0 +1,40 @@ +]` in the initialize + * response. + * + * @author Christopher Hertel + */ +interface ServerExtensionInterface +{ + /** + * The reverse-DNS identifier used as the key under `capabilities.extensions`. + */ + public function getId(): string; + + /** + * The capability payload announced for this extension. + * + * The returned array is cast to an object and embedded under + * `capabilities.extensions[]` in the initialize response, so every value + * must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects). + * + * @return array + */ + public function getCapabilities(): array; +} diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 89eec18..0e47c61 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -30,6 +30,7 @@ class ServerCapabilities implements \JsonSerializable * @param ?bool $logging server emits structured log messages * @param ?bool $completions Server supports argument autocompletion * @param ?array $experimental experimental, non-standard features that the server supports + * @param ?array $extensions protocol extensions the server supports (e.g. io.modelcontextprotocol/ui) */ public function __construct( public readonly ?bool $tools = true, @@ -42,6 +43,7 @@ class ServerCapabilities implements \JsonSerializable public readonly ?bool $logging = false, public readonly ?bool $completions = false, public readonly ?array $experimental = null, + public readonly ?array $extensions = null, ) { } @@ -53,6 +55,7 @@ class ServerCapabilities implements \JsonSerializable * resources?: array{listChanged?: bool, subscribe?: bool}|object, * tools?: object|array{listChanged?: bool}, * experimental?: array, + * extensions?: array, * } $data */ public static function fromArray(array $data): self @@ -106,7 +109,32 @@ class ServerCapabilities implements \JsonSerializable promptsListChanged: $promptsListChanged, logging: $loggingEnabled, completions: $completionsEnabled, - experimental: $data['experimental'] ?? null, + experimental: \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, + extensions: \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, + ); + } + + /** + * Returns a copy with the given protocol extensions merged into the existing ones. + * + * Entries in $extensions override existing ones sharing the same id. + * + * @param array> $extensions + */ + public function withExtensions(array $extensions): self + { + return new self( + $this->tools, + $this->toolsListChanged, + $this->resources, + $this->resourcesSubscribe, + $this->resourcesListChanged, + $this->prompts, + $this->promptsListChanged, + $this->logging, + $this->completions, + $this->experimental, + [...$this->extensions ?? [], ...$extensions], ); } @@ -118,6 +146,7 @@ class ServerCapabilities implements \JsonSerializable * resources?: object, * tools?: object, * experimental?: object, + * extensions?: object, * } */ public function jsonSerialize(): array @@ -159,6 +188,10 @@ class ServerCapabilities implements \JsonSerializable $data['experimental'] = (object) $this->experimental; } + if ($this->extensions) { + $data['extensions'] = (object) $this->extensions; + } + return $data; } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 29add5a..198a349 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -28,9 +28,11 @@ use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\LogicException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ServerExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -207,6 +209,11 @@ final class Builder private ?ServerCapabilities $serverCapabilities = null; + /** + * @var array> + */ + private array $extensions = []; + /** * @var LoaderInterface[] */ @@ -263,6 +270,27 @@ final class Builder return $this; } + /** + * Enable one or more MCP protocol extensions, announced to clients under + * `capabilities.extensions` during the initialize handshake. + * + * @throws LogicException if the same extension is enabled more than once + */ + public function enableExtension(ServerExtensionInterface ...$extensions): self + { + foreach ($extensions as $extension) { + $id = $extension->getId(); + + if (isset($this->extensions[$id])) { + throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); + } + + $this->extensions[$id] = $extension->getCapabilities(); + } + + return $this; + } + /** * Register a single custom method handler. * @@ -661,8 +689,15 @@ final class Builder promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, logging: true, completions: true, + extensions: $this->extensions ?: null, ); + // Extensions enabled via enableExtension() are folded into caller-supplied + // capabilities too, so setCapabilities() does not silently drop them. + if (null !== $this->serverCapabilities && [] !== $this->extensions) { + $capabilities = $capabilities->withExtensions($this->extensions); + } + $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); diff --git a/tests/Inspector/Stdio/StdioMcpAppsTest.php b/tests/Inspector/Stdio/StdioMcpAppsTest.php new file mode 100644 index 0000000..b8f2a6f --- /dev/null +++ b/tests/Inspector/Stdio/StdioMcpAppsTest.php @@ -0,0 +1,52 @@ + [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'ui://weather-app', + ], + 'testName' => 'read_weather_ui', + ], + 'Get Weather Tool Call (London)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'get_weather', + 'toolArgs' => ['city' => 'London'], + ], + 'testName' => 'get_weather_london', + ], + 'Get Weather Tool Call (Tokyo)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'get_weather', + 'toolArgs' => ['city' => 'Tokyo'], + ], + 'testName' => 'get_weather_tokyo', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php'; + } +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json new file mode 100644 index 0000000..7292222 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json new file mode 100644 index 0000000..2ff24ba --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json @@ -0,0 +1,13 @@ +{ + "resources": [ + { + "name": "weather-app", + "uri": "ui://weather-app", + "description": "Interactive weather dashboard", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": {} + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json new file mode 100644 index 0000000..84e1751 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json @@ -0,0 +1,22 @@ +{ + "contents": [ + { + "uri": "ui://weather-app", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": { + "csp": { + "connectDomains": [ + "https://api.weather.example.com" + ] + }, + "permissions": { + "geolocation": {} + }, + "prefersBorder": true + } + }, + "text": "\n\n\n \n \n Weather Dashboard\n \n\n\n
\n \n \n
\n
\n
\n
\n
—
\n
—
\n
\n
🌤️
\n
\n
—
\n
\n Humidity —\n
\n
\n
\n \n\n\n" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json new file mode 100644 index 0000000..e867d9d --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json new file mode 100644 index 0000000..55cac88 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Weather in London: 15°C, Cloudy, Humidity: 78%" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json new file mode 100644 index 0000000..a79f501 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Weather in Tokyo: 22°C, Partly Cloudy, Humidity: 65%" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json new file mode 100644 index 0000000..dcb064a --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json @@ -0,0 +1,28 @@ +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + }, + "_meta": { + "ui": { + "resourceUri": "ui://weather-app", + "visibility": [ + "model", + "app" + ] + } + } + } + ] +} diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php new file mode 100644 index 0000000..ec0fd2b --- /dev/null +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -0,0 +1,223 @@ +assertSame('io.modelcontextprotocol/ui', $extension->getId()); + $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); + } + + public function testUiResourceCspSerialization(): void + { + $csp = new UiResourceCsp( + connectDomains: ['https://api.example.com'], + resourceDomains: ['https://cdn.example.com'], + frameDomains: ['https://embed.example.com'], + baseUriDomains: ['https://example.com'], + ); + + $serialized = $csp->jsonSerialize(); + + $this->assertSame(['https://api.example.com'], $serialized['connectDomains']); + $this->assertSame(['https://cdn.example.com'], $serialized['resourceDomains']); + $this->assertSame(['https://embed.example.com'], $serialized['frameDomains']); + $this->assertSame(['https://example.com'], $serialized['baseUriDomains']); + } + + public function testUiResourceCspOmitsNullFields(): void + { + $csp = new UiResourceCsp(connectDomains: ['https://api.example.com']); + + $serialized = $csp->jsonSerialize(); + + $this->assertArrayHasKey('connectDomains', $serialized); + $this->assertArrayNotHasKey('resourceDomains', $serialized); + $this->assertArrayNotHasKey('frameDomains', $serialized); + $this->assertArrayNotHasKey('baseUriDomains', $serialized); + } + + public function testUiResourceCspFromArray(): void + { + $csp = UiResourceCsp::fromArray([ + 'connectDomains' => ['https://api.example.com'], + 'frameDomains' => ['https://embed.example.com'], + ]); + + $this->assertSame(['https://api.example.com'], $csp->connectDomains); + $this->assertNull($csp->resourceDomains); + $this->assertSame(['https://embed.example.com'], $csp->frameDomains); + $this->assertNull($csp->baseUriDomains); + } + + public function testUiResourcePermissionsSerialization(): void + { + $perms = new UiResourcePermissions( + camera: true, + microphone: false, + geolocation: true, + clipboardWrite: false, + ); + + $serialized = $perms->jsonSerialize(); + + // Per spec, each requested permission is an empty object marker. + $this->assertEquals(new \stdClass(), $serialized['camera']); + $this->assertArrayNotHasKey('microphone', $serialized); + $this->assertEquals(new \stdClass(), $serialized['geolocation']); + $this->assertArrayNotHasKey('clipboardWrite', $serialized); + $this->assertSame('{"camera":{},"geolocation":{}}', json_encode($perms)); + } + + public function testUiResourcePermissionsOmitsUnrequestedFields(): void + { + $perms = new UiResourcePermissions(clipboardWrite: true); + + $serialized = $perms->jsonSerialize(); + + $this->assertArrayNotHasKey('camera', $serialized); + $this->assertArrayNotHasKey('microphone', $serialized); + $this->assertArrayNotHasKey('geolocation', $serialized); + $this->assertArrayHasKey('clipboardWrite', $serialized); + } + + public function testUiResourcePermissionsFromArray(): void + { + // Spec wire shape: presence indicates a request; the value is an empty object. + $perms = UiResourcePermissions::fromArray([ + 'camera' => [], + 'clipboardWrite' => [], + ]); + + $this->assertTrue($perms->camera); + $this->assertFalse($perms->microphone); + $this->assertFalse($perms->geolocation); + $this->assertTrue($perms->clipboardWrite); + } + + public function testUiResourcePermissionsFromArrayTreatsNullAsNotRequested(): void + { + // isset() rejects null values, so 'camera' => null must read as "not requested", + // not as "requested with a null marker". + // @phpstan-ignore-next-line — intentionally off-spec payload + $perms = UiResourcePermissions::fromArray(['camera' => null, 'geolocation' => []]); + + $this->assertFalse($perms->camera); + $this->assertTrue($perms->geolocation); + } + + public function testUiResourceContentMetaSerialization(): void + { + $meta = new UiResourceContentMeta( + csp: new UiResourceCsp(connectDomains: ['https://api.example.com']), + permissions: new UiResourcePermissions(clipboardWrite: true), + domain: 'example.com', + prefersBorder: true, + ); + + $serialized = $meta->jsonSerialize(); + + $this->assertArrayHasKey('csp', $serialized); + $this->assertArrayHasKey('permissions', $serialized); + $this->assertSame('example.com', $serialized['domain']); + $this->assertTrue($serialized['prefersBorder']); + } + + public function testUiResourceContentMetaOmitsNullFields(): void + { + $meta = new UiResourceContentMeta(prefersBorder: true); + + $serialized = $meta->jsonSerialize(); + + $this->assertArrayNotHasKey('csp', $serialized); + $this->assertArrayNotHasKey('permissions', $serialized); + $this->assertArrayNotHasKey('domain', $serialized); + $this->assertArrayHasKey('prefersBorder', $serialized); + } + + public function testUiResourceContentMetaFromArray(): void + { + $meta = UiResourceContentMeta::fromArray([ + 'csp' => ['connectDomains' => ['https://api.example.com']], + 'permissions' => ['clipboardWrite' => []], + 'domain' => 'example.com', + 'prefersBorder' => false, + ]); + + $this->assertInstanceOf(UiResourceCsp::class, $meta->csp); + $this->assertSame(['https://api.example.com'], $meta->csp->connectDomains); + $this->assertInstanceOf(UiResourcePermissions::class, $meta->permissions); + $this->assertTrue($meta->permissions->clipboardWrite); + $this->assertSame('example.com', $meta->domain); + $this->assertFalse($meta->prefersBorder); + } + + public function testUiToolMetaSerialization(): void + { + $meta = new UiToolMeta( + resourceUri: 'ui://my-app', + visibility: [ToolVisibility::Model, ToolVisibility::App], + ); + + $serialized = $meta->jsonSerialize(); + + $this->assertSame('ui://my-app', $serialized['resourceUri']); + $this->assertSame(['model', 'app'], $serialized['visibility']); + } + + public function testUiToolMetaOmitsNullFields(): void + { + $meta = new UiToolMeta(resourceUri: 'ui://my-app'); + + $serialized = $meta->jsonSerialize(); + + $this->assertArrayHasKey('resourceUri', $serialized); + $this->assertArrayNotHasKey('visibility', $serialized); + } + + public function testUiToolMetaFromArray(): void + { + $meta = UiToolMeta::fromArray([ + 'resourceUri' => 'ui://my-app', + 'visibility' => ['app'], + ]); + + $this->assertSame('ui://my-app', $meta->resourceUri); + $this->assertSame([ToolVisibility::App], $meta->visibility); + } + + public function testToolVisibilityEnum(): void + { + $this->assertSame('model', ToolVisibility::Model->value); + $this->assertSame('app', ToolVisibility::App->value); + } + + public function testResourceMarkerSerializesToEmptyObject(): void + { + $marker = McpApps::resourceMarker(); + + $this->assertEquals(new \stdClass(), $marker); + $this->assertSame('{}', json_encode($marker)); + } +} diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php new file mode 100644 index 0000000..d966111 --- /dev/null +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -0,0 +1,232 @@ + (new McpApps())->getCapabilities(), + ]; + + $caps = new ServerCapabilities( + tools: true, + resources: true, + extensions: $extensions, + ); + + $this->assertSame($extensions, $caps->extensions); + } + + public function testServerCapabilitiesExtensionsDefaultNull(): void + { + $caps = new ServerCapabilities(); + + $this->assertNull($caps->extensions); + } + + public function testServerCapabilitiesJsonSerializeWithExtensions(): void + { + $caps = new ServerCapabilities( + tools: true, + resources: true, + prompts: false, + extensions: [ + McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), + ], + ); + + $json = $caps->jsonSerialize(); + + $this->assertArrayHasKey('extensions', $json); + $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); + } + + public function testServerCapabilitiesJsonSerializeWithoutExtensions(): void + { + $caps = new ServerCapabilities(tools: true); + + $json = $caps->jsonSerialize(); + + $this->assertArrayNotHasKey('extensions', $json); + } + + public function testServerCapabilitiesFromArrayWithExtensions(): void + { + $data = [ + 'tools' => new \stdClass(), + 'extensions' => [ + McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']], + ], + ]; + + $caps = ServerCapabilities::fromArray($data); + + $this->assertTrue($caps->tools); + $this->assertNotNull($caps->extensions); + $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions); + $this->assertSame(['text/html;profile=mcp-app'], $caps->extensions[McpApps::EXTENSION_ID]['mimeTypes']); + } + + public function testServerCapabilitiesFromArrayWithoutExtensions(): void + { + $caps = ServerCapabilities::fromArray(['tools' => new \stdClass()]); + + $this->assertNull($caps->extensions); + } + + public function testServerCapabilitiesFromArrayRejectsNonArrayExtensions(): void + { + // @phpstan-ignore-next-line — intentionally malformed payload + $caps = ServerCapabilities::fromArray(['tools' => new \stdClass(), 'extensions' => 'not-an-array']); + + $this->assertNull($caps->extensions); + } + + public function testServerCapabilitiesWithExtensionsMerges(): void + { + $caps = new ServerCapabilities( + tools: true, + extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]], + ); + + $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); + + $this->assertSame(['x' => 1], $merged->extensions['a']); + $this->assertSame(['y' => 99], $merged->extensions['b'], 'new entry overrides existing id'); + $this->assertSame(['z' => 3], $merged->extensions['c']); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); + } + + public function testClientCapabilitiesWithExtensions(): void + { + $extensions = [ + McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), + ]; + + $caps = new ClientCapabilities( + extensions: $extensions, + ); + + $this->assertSame($extensions, $caps->extensions); + } + + public function testClientCapabilitiesExtensionsDefaultNull(): void + { + $caps = new ClientCapabilities(); + + $this->assertNull($caps->extensions); + } + + public function testClientCapabilitiesJsonSerializeWithExtensions(): void + { + $caps = new ClientCapabilities( + extensions: [ + McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(), + ], + ); + + $json = $caps->jsonSerialize(); + + $this->assertArrayHasKey('extensions', $json); + $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); + } + + public function testClientCapabilitiesJsonSerializeWithoutExtensions(): void + { + $caps = new ClientCapabilities(); + + $json = $caps->jsonSerialize(); + + // ClientCapabilities returns \stdClass when empty (so it serializes as `{}`, not `[]`). + if (\is_array($json)) { + $this->assertArrayNotHasKey('extensions', $json); + } else { + $this->assertObjectNotHasProperty('extensions', $json); + } + } + + public function testClientCapabilitiesFromArrayWithExtensions(): void + { + $data = [ + 'roots' => ['listChanged' => true], + 'extensions' => [ + McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']], + ], + ]; + + $caps = ClientCapabilities::fromArray($data); + + $this->assertTrue($caps->roots); + $this->assertTrue($caps->rootsListChanged); + $this->assertNotNull($caps->extensions); + $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions); + } + + public function testClientCapabilitiesFromArrayWithoutExtensions(): void + { + $caps = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); + + $this->assertNull($caps->extensions); + } + + public function testClientCapabilitiesFromArrayRejectsNonArrayExtensions(): void + { + // @phpstan-ignore-next-line — intentionally malformed payload + $caps = ClientCapabilities::fromArray(['roots' => ['listChanged' => true], 'extensions' => 'not-an-array']); + + $this->assertNull($caps->extensions); + } + + public function testBackwardCompatibilityServerCapabilities(): void + { + $caps = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false, + logging: false, + completions: false, + experimental: null, + ); + + $this->assertNull($caps->extensions); + + $json = $caps->jsonSerialize(); + $this->assertArrayNotHasKey('extensions', $json); + } + + public function testBackwardCompatibilityClientCapabilities(): void + { + $caps = new ClientCapabilities( + roots: true, + rootsListChanged: true, + sampling: true, + elicitation: true, + experimental: null, + ); + + $this->assertNull($caps->extensions); + + $json = $caps->jsonSerialize(); + $this->assertArrayNotHasKey('extensions', $json); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 8a92d59..4435639 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -13,11 +13,15 @@ namespace Mcp\Tests\Unit\Server; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\Handler\Request\CallToolHandler; +use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -79,6 +83,59 @@ final class BuilderTest extends TestCase $this->assertSame('intercepted', $result); } + #[TestDox('enableExtension() registers an extension and announces its capability payload')] + public function testEnableExtensionRegistersExtension(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertNotNull($capabilities->extensions); + $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); + $this->assertSame(['mimeTypes' => [McpApps::MIME_TYPE]], $capabilities->extensions[McpApps::EXTENSION_ID]); + } + + #[TestDox('enableExtension() throws when the same extension is enabled twice')] + public function testEnableExtensionRejectsDuplicate(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(McpApps::EXTENSION_ID); + + Server::builder()->enableExtension(new McpApps(), new McpApps()); + } + + #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] + public function testEnableExtensionMergesIntoCustomCapabilities(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setCapabilities(new ServerCapabilities(tools: true)) + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertNotNull($capabilities->extensions); + $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); + } + + private function extractServerCapabilities(Server $server): ServerCapabilities + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof InitializeHandler) { + return $handler->configuration->capabilities; + } + } + + $this->fail('InitializeHandler not found in request handlers'); + } + private function callTool(Server $server, string $toolName): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); From 433c84b58af346dd32f15f9909679e96a46ebe23 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 2 Jun 2026 16:47:04 +0100 Subject: [PATCH 046/143] Document v0.6.0 changes in CHANGELOG and docs (#311) Wrap-up for the v0.6.0 release: capture features/BC breaks merged since v0.5.0 that were missing from documentation. - CHANGELOG: add the `title` field on `Resource`/`ResourceTemplate` (#301), a feature plus three positional-argument BC breaks. - mcp-elements.md: add the missing `title` parameter to the McpTool, McpResource, McpResourceTemplate and McpPrompt attribute reference sections; fix a missing closing paren in an `#[McpPrompt]` example. - server-builder.md: add `title?` to the `addPrompt()` row of the method reference table (stale since v0.5.0). - client.md: bump the protocol-version example to `V2025_11_25` to match the new default. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 +++++- README.md | 2 ++ docs/client.md | 2 +- docs/index.md | 2 ++ docs/mcp-elements.md | 10 +++++++--- docs/server-builder.md | 6 +++--- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf056e7..8db034b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `extensions` to `ServerCapabilities` and `ClientCapabilities` and `Builder::enableExtension()` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) +* Add optional `title` field to `ResourceDefinition` and `ResourceTemplate` for MCP spec compliance * Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering. * Add `RegistryInterface::unregisterTool()`, `unregisterResource()`, `unregisterResourceTemplate()`, `unregisterPrompt()` — idempotent removals. * Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks. @@ -26,7 +27,10 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `Builder::addResource()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `Builder::addResourceTemplate()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()` -* **[BC BREAK]** `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`). +* [BC BREAK] `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`). +* [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. 0.5.0 ----- diff --git a/README.md b/README.md index 8b3bccf..3f1a377 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,8 @@ $client->connect($transport); - **[Transports](docs/transports.md)** — STDIO and HTTP transport setup and usage - **[MCP Elements](docs/mcp-elements.md)** — Creating tools, resources, prompts, and templates - **[Server-Client Communication](docs/server-client-communication.md)** — Sampling, logging, progress, and notifications +- **[Protocol Extensions](docs/extensions.md)** — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources) +- **[Authorization](docs/authorization.md)** — OAuth and authorization setup for HTTP transport - **[Events](docs/events.md)** — Hooking into server lifecycle with events ### Learning & Examples diff --git a/docs/client.md b/docs/client.md index 304fad7..680d823 100644 --- a/docs/client.md +++ b/docs/client.md @@ -83,7 +83,7 @@ Specify the MCP protocol version (defaults to latest): use Mcp\Schema\Enum\ProtocolVersion; $client = Client::builder() - ->setProtocolVersion(ProtocolVersion::V2025_06_18) + ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->build(); ``` diff --git a/docs/index.md b/docs/index.md index 9ace7bd..9116229 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,4 +6,6 @@ - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. - [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). +- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport. +- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events. - [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 2e4a151..0f8c9eb 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -74,6 +74,7 @@ class Calculator ### Parameters - **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. - **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. - **`annotations`** (optional): `ToolAnnotations` object for additional metadata. - **`icons`** (optional): Array of `Icon` objects for visual representation. @@ -219,7 +220,8 @@ class ConfigProvider ### Parameters - **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -- **`name`** (optional): Human-readable name. Defaults to method name if not provided. +- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. - **`description`** (optional): Resource description. Defaults to docblock summary if not provided. - **`mimeType`** (optional): MIME type of the resource content. - **`size`** (optional): Size in bytes if known. @@ -367,7 +369,8 @@ class UserProvider ### Parameters - **`uriTemplate`** (required): URI template with `{variables}` using RFC 6570 syntax. Must comply with RFC 3986. -- **`name`** (optional): Human-readable name. Defaults to method name if not provided. +- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. - **`description`** (optional): Template description. Defaults to docblock summary if not provided. - **`mimeType`** (optional): MIME type of the resource content. - **`annotations`** (optional): Additional metadata. @@ -393,7 +396,7 @@ class PromptGenerator /** * Generates a code review request prompt. */ - #[McpPrompt(name: 'code_review'] + #[McpPrompt(name: 'code_review')] public function reviewCode(string $language, string $code, string $focus = 'general'): array { return [ @@ -407,6 +410,7 @@ class PromptGenerator ### Parameters - **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. - **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. - **`icons`** (optional): Array of `Icon` objects for visual representation. - **`meta`** (optional): Arbitrary key-value pairs for custom metadata. diff --git a/docs/server-builder.md b/docs/server-builder.md index 9b7dfa8..9836bee 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -671,8 +671,8 @@ $server = Server::builder() | `addNotificationHandler()` | handler | Prepend a single custom notification handler | | `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | | `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | -| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | -| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | -| `addPrompt()` | handler, name?, description? | Register prompt | +| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | +| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | +| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | | `add()` | definition, handler | Register an element from a schema VO + handler pair | | `build()` | - | Create the server instance | From ca849aa75b9b46f47b39077eb8549406c192874b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 19 Jun 2026 13:48:48 +0200 Subject: [PATCH 047/143] Preserve custom domain on docs deploy (#374) --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6243cac..f263510 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,3 +33,4 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./.phpdoc/build enable_jekyll: false + cname: php.sdk.modelcontextprotocol.io From 30e520a019169237e693b8d12c510ca60e2304d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:18:40 +0200 Subject: [PATCH 048/143] chore(deps): bump actions/checkout from 6 to 7 (#385) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance-weekly.yaml | 6 +++--- .github/workflows/docs.yml | 2 +- .github/workflows/pipeline.yaml | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 5614100..fadd976 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -22,7 +22,7 @@ jobs: name: conformance / server (latest) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '22' @@ -61,7 +61,7 @@ jobs: name: conformance / client (latest) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' @@ -127,7 +127,7 @@ jobs: # skip on forks, which cannot push the `badges` branch. if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: server-badge diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f263510..a1c3c83 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 957d6f9..194ac11 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node uses: actions/setup-node@v6 @@ -128,7 +128,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -168,7 +168,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 From 00a568e8d90ee50b26498b58c091a5790b241388 Mon Sep 17 00:00:00 2001 From: Nariman Gardi <110671428+NarimanGardi@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:09:45 +0300 Subject: [PATCH 049/143] [Server] Fix completion/complete crash for plain ref/resource (#388) A non-template resource resolves to a ResourceReference, which has no completionProviders property; only prompts and resource templates do. Read providers from those instead, returning an empty completion result when a resource has none. --- .../Request/CompletionCompleteHandler.php | 18 +++- .../Request/CompletionCompleteHandlerTest.php | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index 29c41fb..b3c4d04 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Completion\ProviderInterface; +use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; @@ -56,12 +57,11 @@ final class CompletionCompleteHandler implements RequestHandlerInterface $value = $request->argument['value'] ?? ''; try { - $reference = match (true) { - $request->ref instanceof PromptReference => $this->registry->getPrompt($request->ref->name), - $request->ref instanceof ResourceReference => $this->registry->getResource($request->ref->uri), + $providers = match (true) { + $request->ref instanceof PromptReference => $this->registry->getPrompt($request->ref->name)->completionProviders, + $request->ref instanceof ResourceReference => $this->resourceCompletionProviders($request->ref->uri), }; - $providers = $reference->completionProviders; $provider = $providers[$name] ?? null; if (null === $provider) { return new Response($request->getId(), new CompletionCompleteResult([])); @@ -90,4 +90,14 @@ final class CompletionCompleteHandler implements RequestHandlerInterface return Error::forInternalError('Error while handling completion request', $request->getId()); } } + + /** + * @return array + */ + private function resourceCompletionProviders(string $uri): array + { + $reference = $this->registry->getResource($uri); + + return $reference instanceof ResourceTemplateReference ? $reference->completionProviders : []; + } } diff --git a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php new file mode 100644 index 0000000..63eefba --- /dev/null +++ b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php @@ -0,0 +1,97 @@ +registry = $this->createMock(RegistryInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new CompletionCompleteHandler($this->registry); + } + + public function testReturnsEmptyCompletionForResourceWithoutTemplate(): void + { + $uri = 'file://static/readme.txt'; + $request = $this->createCompletionRequest($uri, ['name' => 'arg', 'value' => 'a']); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($this->createMock(ResourceReference::class)); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(new CompletionCompleteResult([]), $response->result); + } + + public function testReturnsCompletionsForResourceTemplate(): void + { + $uri = 'file://users/alice'; + $request = $this->createCompletionRequest($uri, ['name' => 'id', 'value' => 'al']); + + $templateReference = new ResourceTemplateReference( + new ResourceTemplate('file://users/{id}', 'user'), + static fn () => null, + ['id' => new CompletionProviderFixture()], + ); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($templateReference); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(new CompletionCompleteResult(['alpha'], 1, false), $response->result); + } + + /** + * @param array{ name: string, value: string } $argument + */ + private function createCompletionRequest(string $uri, array $argument): CompletionCompleteRequest + { + return CompletionCompleteRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CompletionCompleteRequest::getMethod(), + 'id' => 'test-completion-'.uniqid(), + 'params' => [ + 'ref' => ['type' => 'ref/resource', 'uri' => $uri], + 'argument' => $argument, + ], + ]); + } +} From df5379c04761b33fb7c7f5e6b52b45f3510b617a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 6 Jul 2026 00:10:13 +0200 Subject: [PATCH 050/143] [Server] Harden JSON-RPC input parsing against malformed input and resource exhaustion (#383) * fix: harden JSON-RPC input parsing against malformed input and DoS MessageFactory::create() detected batches via $input[0] === '{', which mishandled valid-but-non-object JSON: a scalar ("5") or leading-whitespace object silently returned [] (with a PHP warning) or could reach a foreach over a non-array. It also had no bound on batch size, letting one small request expand into arbitrarily many operations (amplification). - Detect single-vs-batch from the decoded JSON type (object vs list array) instead of the raw first byte; reject scalars and empty payloads as InvalidInputMessageException rather than crashing or no-oping. - Cap batch size (maxBatchSize, default 100) and reject oversized batches. - Reject non-object batch elements as InvalidInputMessageException instead of letting them hit a TypeError. - Bound the StreamableHttpTransport POST body (maxBodyBytes, default 4 MiB), returning 413, with an incremental read so unknown-size/chunked bodies cannot exhaust memory. * docs: document input-hardening limits in CHANGELOG and transports guide Add CHANGELOG entries and docs/transports.md sections for the JSON-RPC batch size cap (maxBatchSize) and the StreamableHttpTransport request body size cap (maxBodyBytes), plus the type-based single-vs-batch message detection. --- CHANGELOG.md | 3 + docs/transports.md | 35 ++++++++ src/JsonRpc/MessageFactory.php | 46 +++++++++-- .../Transport/StreamableHttpTransport.php | 51 +++++++++++- tests/Unit/JsonRpc/MessageFactoryTest.php | 82 +++++++++++++++++++ .../Transport/StreamableHttpTransportTest.php | 36 ++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db034b..5ecccb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. +* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. +* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. 0.5.0 ----- diff --git a/docs/transports.md b/docs/transports.md index 3708e35..049ca2d 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -112,6 +112,7 @@ $transport = new StreamableHttpTransport( - **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. - **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. +- **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). ### PSR-17 Auto-Discovery @@ -230,6 +231,40 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); ``` +### Request Body Size Limit + +`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or +unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches +message parsing. + +```php +use Mcp\Server\Transport\StreamableHttpTransport; + +// Raise the cap to 16 MiB +$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024); +``` + +When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with +unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot +exhaust memory. A value below `1` throws `InvalidArgumentException`. + +### JSON-RPC Batch Size Limit + +A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on +`MessageFactory`: + +```php +use Mcp\JsonRpc\MessageFactory; + +$factory = MessageFactory::make(maxBatchSize: 50); +``` + +Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array +is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` +entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws +`InvalidArgumentException`. + ### Custom PSR-15 Middleware `StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 1bb82db..bb2f2f0 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -67,12 +67,24 @@ final class MessageFactory Schema\Request\SetLogLevelRequest::class, ]; + /** + * Upper bound on the number of messages accepted in a single batch, guarding + * against amplification where one small request expands into many operations. + */ + public const DEFAULT_MAX_BATCH_SIZE = 100; + /** * @param list|class-string> $registeredMessages + * @param int $maxBatchSize Maximum number of messages accepted in a single JSON-RPC batch */ public function __construct( private readonly array $registeredMessages, + private readonly int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, ) { + if ($this->maxBatchSize < 1) { + throw new InvalidArgumentException('maxBatchSize must be at least 1.'); + } + foreach ($this->registeredMessages as $messageClass) { if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) { throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class)); @@ -83,9 +95,9 @@ final class MessageFactory /** * Creates a new Factory instance with all the protocol's default messages. */ - public static function make(): self + public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self { - return new self(self::REGISTERED_MESSAGES); + return new self(self::REGISTERED_MESSAGES, $maxBatchSize); } /** @@ -102,13 +114,37 @@ final class MessageFactory { $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); - if ('{' === $input[0]) { - $data = [$data]; + // A JSON-RPC payload is a single message (JSON object) or a batch (JSON + // array). Anything else (scalar, null) is invalid input rather than a + // parse error, and must not reach the per-message loop below. + if (!\is_array($data)) { + return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object or a batch array.')]; + } + + // json_decode(assoc: true) maps both objects and arrays to PHP arrays. A + // list is a batch; a non-list (string keys) is a single message. An empty + // array is ambiguous ({} vs []) and invalid as either, so reject it. + if ([] === $data) { + return [new InvalidInputMessageException('A JSON-RPC message must not be empty.')]; + } + + if (array_is_list($data)) { + if (\count($data) > $this->maxBatchSize) { + return [new InvalidInputMessageException(\sprintf('JSON-RPC batch size %d exceeds the maximum allowed batch size of %d.', \count($data), $this->maxBatchSize))]; + } + + $batch = $data; + } else { + $batch = [$data]; } $messages = []; - foreach ($data as $message) { + foreach ($batch as $message) { try { + if (!\is_array($message)) { + throw new InvalidInputMessageException('A JSON-RPC message must be a JSON object.'); + } + $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { $messages[] = $e; diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index ab84b09..a5f757c 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -22,6 +22,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; @@ -36,6 +37,12 @@ class StreamableHttpTransport extends BaseTransport public const SESSION_HEADER = 'Mcp-Session-Id'; public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; + /** + * Upper bound on the request body read for a POST, guarding against memory + * exhaustion from an oversized (or unbounded chunked) payload. + */ + public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; + private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; @@ -54,9 +61,14 @@ class StreamableHttpTransport extends BaseTransport ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, ?iterable $middleware = null, + private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, ) { parent::__construct($logger); + if ($this->maxBodyBytes < 1) { + throw new InvalidArgumentException('maxBodyBytes must be at least 1.'); + } + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -107,7 +119,13 @@ class StreamableHttpTransport extends BaseTransport protected function handlePostRequest(): ResponseInterface { - $body = $this->request->getBody()->getContents(); + $body = $this->readBody($this->request->getBody()); + if (null === $body) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); + } + $this->handleMessage($body, $this->sessionId); if (null !== $this->immediateResponse) { @@ -273,6 +291,37 @@ class StreamableHttpTransport extends BaseTransport return $response; } + /** + * Reads the request body, bounded by {@see self::$maxBodyBytes}. + * + * Returns the body contents, or `null` when the payload exceeds the cap. When + * the stream advertises a size we reject up-front; otherwise (e.g. chunked + * transfer with unknown size) we read incrementally and stop at the cap so an + * unbounded stream cannot exhaust memory. + */ + private function readBody(StreamInterface $body): ?string + { + $size = $body->getSize(); + if (null !== $size && $size > $this->maxBodyBytes) { + return null; + } + + $contents = ''; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ('' === $chunk) { + break; + } + + $contents .= $chunk; + if (\strlen($contents) > $this->maxBodyBytes) { + return null; + } + } + + return $contents; + } + /** * @param iterable $middleware * diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index d38aabe..4a07bab 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\JsonRpc; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; @@ -400,4 +401,85 @@ final class MessageFactoryTest extends TestCase $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); $this->assertStringContainsString('message', $results[0]->getMessage()); } + + public function testScalarJsonIsRejected(): void + { + $results = $this->factory->create('5'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testStringJsonIsRejected(): void + { + $results = $this->factory->create('"hello"'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testEmptyBatchIsRejected(): void + { + $results = $this->factory->create('[]'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testBatchElementMustBeObject(): void + { + $results = $this->factory->create('[1, 2]'); + + $this->assertCount(2, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } + + public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void + { + $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + } + + public function testBatchSizeExceedingMaxIsRejected(): void + { + $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "ping", "id": 2}, + {"jsonrpc": "2.0", "method": "ping", "id": 3} + ]'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('batch', $results[0]->getMessage()); + } + + public function testBatchSizeWithinMaxIsAccepted(): void + { + $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "ping", "id": 2} + ]'; + + $results = $factory->create($json); + + $this->assertCount(2, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(PingRequest::class, $results[1]); + } + + public function testNonPositiveMaxBatchSizeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + new MessageFactory([PingRequest::class], maxBatchSize: 0); + } } diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 0f94960..32479a0 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -261,6 +261,42 @@ final class StreamableHttpTransportTest extends TestCase ); } + public function testPostBodyExceedingMaxBytesReturns413(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream(str_repeat('a', 64))); + + // Empty middleware bypasses the default security stack to isolate body-size handling. + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 16); + + $response = $transport->listen(); + + $this->assertSame(413, $response->getStatusCode()); + } + + public function testPostBodyWithinMaxBytesIsNotRejected(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream('{}')); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 1024); + + $response = $transport->listen(); + + $this->assertNotSame(413, $response->getStatusCode()); + } + + public function testNonPositiveMaxBodyBytesThrows(): void + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $this->expectException(InvalidArgumentException::class); + + new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); + } + private function stubAuth401(): MiddlewareInterface { return new class($this->factory) implements MiddlewareInterface { From 1587ac5bd240d2e8ac454c0c86143dbd8df52fc4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 6 Jul 2026 00:37:19 +0200 Subject: [PATCH 051/143] docs(adr): scope OAuth to resource server, not authorization server (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record ADR 0001: the MCP server is an OAuth 2.1 Resource Server that MAY delegate to an upstream authorization server, and will NOT issue tokens or act as an Identity Provider. Gives a single citable ruling to decline the recurring "native authorization server" PRs (e.g. #373). Also remove the surface signal that invites them: reframe the OAuthProxyMiddleware class docblock to lead with its delegation intent and state explicit non-goals (never mints/signs/stores/rotates tokens), and add a "Scope" section to docs/authorization.md describing the three OAuth roles and what the SDK does not do. No behavior change — docblock, docs, and ADR only. --- ...oauth-authorization-server-out-of-scope.md | 97 +++++++++++++++++++ adr/README.md | 10 ++ docs/authorization.md | 20 +++- .../Http/Middleware/OAuthProxyMiddleware.php | 19 ++-- 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 adr/0001-oauth-authorization-server-out-of-scope.md create mode 100644 adr/README.md diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md new file mode 100644 index 0000000..fa5b091 --- /dev/null +++ b/adr/0001-oauth-authorization-server-out-of-scope.md @@ -0,0 +1,97 @@ +# 0001 — The MCP server is an OAuth Resource Server, not an Authorization Server + +- Status: Accepted +- Date: 2026-06-15 + +## Context + +OAuth 2.1 defines three distinct roles: + +| Role | What it does | Status in this SDK | Scope | +|------|--------------|--------------------|-------| +| Resource Server | Validates incoming bearer tokens, serves Protected Resource Metadata (RFC 9728), emits `WWW-Authenticate` | Shipped (`AuthorizationMiddleware`, `JwtTokenValidator`, `ProtectedResourceMetadata`) | **IN scope** | +| Delegation / proxy to an upstream AS | Forwards `/authorize` and `/token` to your existing IdP | Shipped (`OAuthProxyMiddleware`) | **IN scope — delegation ONLY** | +| Authorization Server / Identity Provider (IdP) | Mints its own tokens, registers clients, runs login and consent | Absent | **OUT of scope** | + +The SDK repeatedly receives pull requests that move it toward becoming a full OAuth 2.1 +**authorization server** — asking the MCP server to mint its own access and refresh tokens, +register clients, and run login and consent flows, that is, to become an Identity Provider. +The most explicit example is [#373](https://github.com/modelcontextprotocol/php-sdk/pull/373) +("[Server] Add native OAuth 2.1 authorization server", ~3,400 lines across 50+ files), but the +direction has crept in incrementally rather than in one PR. + +A contributing factor is that the SDK already exposes the *authorization-server endpoints in +proxy form*, and that surface has grown: +[#221](https://github.com/modelcontextprotocol/php-sdk/pull/221) added the OAuth resource-server +middleware, and [#269](https://github.com/modelcontextprotocol/php-sdk/pull/269) added Dynamic +Client Registration (RFC 7591). Today `OAuthProxyMiddleware` answers `/authorize`, `/token`, and +`/.well-known/oauth-authorization-server`, and DCR endpoints exist. The shape of that surface +invites contributors to "finish the job" by backing those endpoints with a real token issuer. +It is not scaffolding to be completed — it is a delegating proxy, and that is the whole of its +intent. + +## Decision + +**The MCP server is an OAuth 2.1 Resource Server that MAY delegate to an upstream +authorization server. It will NOT issue tokens or act as an Identity Provider.** + +Concretely: + +- The SDK validates bearer tokens, serves RFC 9728 Protected Resource Metadata, and emits + `WWW-Authenticate` challenges. This is the Resource Server role and is fully supported. +- The SDK MAY delegate `/authorize` and `/token` to an upstream authorization server via + `OAuthProxyMiddleware`. This is delegation only: the middleware redirects the browser to + the upstream `/authorize` endpoint and proxies `/token` requests to the upstream token + endpoint. It never mints, signs, stores, or rotates tokens of its own. +- The SDK will NOT implement an authorization server: no token issuance, no token signing or + key management, no login UI, no consent UI, no authorization-code or refresh-token storage, + no first-party Dynamic Client Registration acting as an issuer. + +Pull requests that add authorization-server / IdP behavior are declined by reference to this +ADR. + +## Rationale + +- **Security liability.** Issuing tokens means owning signing-key generation, storage, and + rotation; authorization-code and refresh-token persistence; refresh-token rotation and + replay detection; and consent. A defect in any of these is a credential-issuance + vulnerability affecting every consumer of the SDK. This is precisely the surface an MCP SDK + should not own. +- **RFC footprint.** A correct authorization server must implement and keep current with + RFC 6749 (OAuth 2.0), RFC 7591 (Dynamic Client Registration), RFC 8414 (Authorization + Server Metadata), PKCE (RFC 7636), and refresh-token rotation guidance, among others. That + is an open-ended maintenance and conformance burden far outside the SDK's purpose. +- **Mature implementations already exist.** Token issuance is a solved problem. + `league/oauth2-server` provides it as a PHP library, and every production IdP — Keycloak, + Auth0, Microsoft Entra ID, Okta — provides it as a service. Re-implementing it inside an + MCP SDK adds risk without adding value. + +## Boundary statement + +`OAuthProxyMiddleware` **delegates** to an upstream IdP. It is **not** authorization-server +scaffolding to be completed. Backing its `/authorize` and `/token` endpoints with a +first-party token issuer is out of scope and will be declined. + +## Consequences + +- The supported authorization architecture is: an external authorization server (your IdP or + `league/oauth2-server` running in your own application) issues tokens; the MCP server + validates them as a Resource Server and optionally proxies the OAuth flow to that upstream. +- Contributors get a single, citable ruling for why authorization-server PRs are declined, + reducing repeated large-PR churn. +- Resource Server and proxy/delegation features remain welcome and supported. + +## Alternatives / what to do instead + +If you need an authorization server (token issuance, client registration, login, consent), do +**not** add it to this SDK. Instead: + +- **Front the MCP server with an existing IdP** — Keycloak, Auth0, Microsoft Entra ID, or + Okta. Point `JwtTokenValidator` and `ProtectedResourceMetadata` at that issuer, and + optionally use `OAuthProxyMiddleware` to delegate `/authorize` and `/token` to it. +- **Run `league/oauth2-server` in your own application**, behind the SDK's existing proxy and + validator seams. The MCP server validates the tokens it issues; it does not issue them + itself. + +See [`../docs/authorization.md`](../docs/authorization.md) for the supported Resource Server +and delegation setup. diff --git a/adr/README.md b/adr/README.md new file mode 100644 index 0000000..d8ab0de --- /dev/null +++ b/adr/README.md @@ -0,0 +1,10 @@ +# Architecture Decision Records + +This directory holds Architecture Decision Records (ADRs): short documents that capture a +significant architectural decision, the context that led to it, and its consequences. They +exist so a decision can be cited once and referenced thereafter — for example, when declining +a pull request that conflicts with an accepted decision. + +## Records + +- [0001 — The MCP server is an OAuth Resource Server, not an Authorization Server](0001-oauth-authorization-server-out-of-scope.md) diff --git a/docs/authorization.md b/docs/authorization.md index 5b77b37..184eb7e 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -5,6 +5,7 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, im ## Table of Contents +- [Scope: what this SDK does and does not do](#scope-what-this-sdk-does-and-does-not-do) - [Overview](#overview) - [Quick Start](#quick-start) - [Components](#components) @@ -14,13 +15,30 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, im - [Scope-Based Access Control](#scope-based-access-control) - [Examples](#examples) +## Scope: what this SDK does and does not do + +The MCP server is an OAuth 2.1 **Resource Server**. It validates the tokens it receives and may +delegate the OAuth flow to your upstream Identity Provider. **It is not an authorization server +and it does not issue tokens.** + +| Role | What it does | Status | Scope | +|------|--------------|--------|-------| +| Resource Server | Validates incoming bearer tokens, serves Protected Resource Metadata (RFC 9728), emits `WWW-Authenticate` | Supported (`AuthorizationMiddleware`, `JwtTokenValidator`, `ProtectedResourceMetadata`) | **In scope** | +| Delegation / proxy to an upstream AS | Forwards `/authorize` and `/token` to your existing IdP | Supported (`OAuthProxyMiddleware`) | **In scope — delegation only** | +| Authorization Server / Identity Provider | Mints its own tokens, registers clients, runs login and consent | Not implemented | **Out of scope: being an authorization server / issuing tokens is out of scope** | + +To issue tokens, front the MCP server with an external IdP (Keycloak, Auth0, Microsoft Entra +ID, Okta) or run `league/oauth2-server` in your own application, and let the MCP server +validate those tokens as a Resource Server. See +[adr/0001-oauth-authorization-server-out-of-scope.md](../adr/0001-oauth-authorization-server-out-of-scope.md). + ## Overview Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides: - **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication - **ProtectedResourceMetadataMiddleware** - Serves RFC 9728 metadata at well-known endpoints -- **OAuthProxyMiddleware** - Proxies OAuth flows to upstream authorization servers +- **OAuthProxyMiddleware** - Delegates OAuth flows (`/authorize`, `/token`) to your upstream IdP; the SDK never issues tokens itself - **OAuthRequestMetaMiddleware** - Bridges HTTP OAuth attributes to JSON-RPC request meta - **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers - **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index 9274852..f59a6dc 100644 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -26,15 +26,20 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * Proxies OAuth authorization requests to an upstream authorization server. + * Delegates OAuth flows to an upstream authorization server so you do NOT have to build one. * - * This middleware implements the OAuth proxy pattern for MCP servers that - * delegate authorization to third-party OAuth providers (Microsoft, Keycloak, etc.). + * This middleware exists precisely so the MCP server does not become an authorization server: + * it forwards the OAuth endpoints to your existing upstream Identity Provider (Microsoft Entra + * ID, Keycloak, Auth0, Okta, league/oauth2-server, etc.) instead of issuing tokens itself. * - * It handles: - * - /authorize: Redirects to the upstream authorization server - * - /token: Proxies token requests to the upstream token endpoint - * - /.well-known/oauth-authorization-server: Serves authorization server metadata + * It delegates: + * - /authorize: Redirects the user agent to the upstream authorization endpoint + * - /token: Proxies the token request to the upstream token endpoint and returns its response + * - /.well-known/oauth-authorization-server: Serves metadata pointing at this delegating proxy + * + * Non-goals: this middleware never issues, mints, signs, stores, or rotates tokens, and the SDK + * is not an OAuth authorization server / Identity Provider. See + * adr/0001-oauth-authorization-server-out-of-scope.md. * * @author Volodymyr Panivko */ From 55f7884368e722a56dc0c0faade6f3cf8504476b Mon Sep 17 00:00:00 2001 From: Valery Gutu <60061959+valeriudev@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:13:41 +0300 Subject: [PATCH 052/143] [Server] Allow pre-built instance handlers in element registration (#375) * Allow pre-built instance handlers in element registration `HandlerResolver::resolve()` rejected `[$instance, 'methodName']` array handlers even though the `Handler` type alias and the runtime invoker already support them, so `Server::builder()->addTool(handler: [$obj, 'bar'])` threw "Invalid array handler format" at build time. Relax the array-shape guard to accept an object in slot 0 and normalize it to its class name for reflection (the same idiom getHandlerDescription() already uses). This unblocks dependency-injected handler objects that the container-less `new $className()` fallback cannot build. Fixes #369 * Use 0.6.1 changelog heading and test instance handler end-to-end This is a bugfix with no new public API or BC break, so the changelog entry belongs under 0.6.1 rather than a new 0.7.0 section. Add an end-to-end test that registers a pre-built instance handler with a constructor dependency the container-less new $className() fallback cannot satisfy, then drives a real tools/call through CallToolHandler and asserts the injected instance is the one invoked. * Address review: reject Closure array handlers, fix abstract-method message - HandlerResolver: extract the array-handler guard into a readable $hasValidTarget check that accepts strings and objects but excludes \Closure, so [$closure, 'method'] falls through to the format error instead of the misleading "method not found in class Closure". - Fix the abstract-method rejection message ("must be abstract" -> "must not be abstract"), a pre-existing typo in the same branch. - Tighten the CHANGELOG entry wording. - Cover Closure rejection with a regression test. * Move instance-handler entry under 0.7.0 changelog heading Per review: registering pre-built instance handlers is a feature, not a patch, so it belongs in a minor release heading. --- CHANGELOG.md | 5 +++ src/Capability/Discovery/HandlerResolver.php | 23 ++++++++---- .../Discovery/HandlerResolverTest.php | 22 ++++++++++-- tests/Unit/Server/BuilderTest.php | 36 +++++++++++++++++++ 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecccb1..a69e827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.7.0 +----- + +* Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build. + 0.6.0 ----- diff --git a/src/Capability/Discovery/HandlerResolver.php b/src/Capability/Discovery/HandlerResolver.php index cd78aaf..8cadcd3 100644 --- a/src/Capability/Discovery/HandlerResolver.php +++ b/src/Capability/Discovery/HandlerResolver.php @@ -11,11 +11,14 @@ namespace Mcp\Capability\Discovery; +use Mcp\Capability\Registry\ElementReference; use Mcp\Exception\InvalidArgumentException; /** * Utility class to validate and resolve MCP element handlers. * + * @phpstan-import-type Handler from ElementReference + * * @author Kyrian Obikwelu */ class HandlerResolver @@ -25,11 +28,12 @@ class HandlerResolver * * A handler can be: * - A Closure: function() { ... } - * - An array: [ClassName::class, 'methodName'] (instance method) + * - An array: [ClassName::class, 'methodName'] (resolved on a new or container-provided instance) + * - An array: [$instance, 'methodName'] (method on a pre-built object instance) * - An array: [ClassName::class, 'staticMethod'] (static method, if callable) * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * - * @param \Closure|array{0: string, 1: string}|string $handler the handler to resolve + * @param Handler $handler the handler to resolve * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, * or the method is unsuitable (e.g., private, abstract). @@ -41,10 +45,17 @@ class HandlerResolver } if (\is_array($handler)) { - if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !\is_string($handler[0]) || !\is_string($handler[1])) { - throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); + // A Closure in slot 0 must fall through to the format error rather than + // be treated as an instance, where "$closure::class" would yield the + // misleading class name "Closure". + $target = $handler[0] ?? null; + $hasValidTarget = (\is_string($target) || \is_object($target)) && !$target instanceof \Closure; + + if (2 !== \count($handler) || !$hasValidTarget || !isset($handler[1]) || !\is_string($handler[1])) { + throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); } - [$className, $methodName] = $handler; + [$classOrObject, $methodName] = $handler; + $className = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; if (!class_exists($className)) { throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className)); } @@ -70,7 +81,7 @@ class HandlerResolver throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be public.', $className, $methodName)); } if ($reflectionMethod->isAbstract()) { - throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be abstract.', $className, $methodName)); + throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must not be abstract.', $className, $methodName)); } if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" cannot be a constructor or destructor.', $className, $methodName)); diff --git a/tests/Unit/Capability/Discovery/HandlerResolverTest.php b/tests/Unit/Capability/Discovery/HandlerResolverTest.php index 6a87a0b..9076a7e 100644 --- a/tests/Unit/Capability/Discovery/HandlerResolverTest.php +++ b/tests/Unit/Capability/Discovery/HandlerResolverTest.php @@ -38,6 +38,15 @@ class HandlerResolverTest extends TestCase $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); } + public function testResolvesValidInstanceArrayHandler(): void + { + $handler = [new ValidHandlerClass(), 'publicMethod']; + $resolved = HandlerResolver::resolve($handler); + $this->assertInstanceOf(\ReflectionMethod::class, $resolved); + $this->assertEquals('publicMethod', $resolved->getName()); + $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); + } + public function testResolvesValidInvokableClassStringHandler(): void { $handler = ValidInvokableClass::class; @@ -59,17 +68,24 @@ class HandlerResolverTest extends TestCase public function testThrowsForInvalidArrayHandlerFormatCount(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */ } public function testThrowsForInvalidArrayHandlerFormatTypes(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */ } + public function testThrowsForClosureInArrayHandler(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); + HandlerResolver::resolve([static fn () => null, 'method']); + } + public function testThrowsForNonExistentClassInArrayHandler(): void { $this->expectException(InvalidArgumentException::class); @@ -129,7 +145,7 @@ class HandlerResolverTest extends TestCase public function testThrowsForAbstractMethodHandler(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.'); + $this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must not be abstract.'); HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 4435639..1930879 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -83,6 +83,25 @@ final class BuilderTest extends TestCase $this->assertSame('intercepted', $result); } + #[TestDox('A pre-built instance handler with constructor dependencies is registered and invoked on that instance')] + public function testPreBuiltInstanceHandlerIsInvokedOnTheGivenInstance(): void + { + // The handler's constructor requires an argument the container-less + // `new $className()` fallback can never satisfy. If the tool call + // succeeds, it can only be because the pre-built instance — carrying + // its injected dependency — was the one invoked. + $handler = new GreetingService('World'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addTool(handler: [$handler, 'greet'], name: 'greet', description: 'Greets using the injected name') + ->build(); + + $result = $this->callTool($server, 'greet'); + + $this->assertSame('Hello, World', $result); + } + #[TestDox('enableExtension() registers an extension and announces its capability payload')] public function testEnableExtensionRegistersExtension(): void { @@ -166,3 +185,20 @@ final class BuilderTest extends TestCase $this->fail('CallToolHandler not found in request handlers'); } } + +/** + * A handler whose constructor dependency cannot be satisfied by the + * container-less `new $className()` fallback, so it must be registered as a + * pre-built instance. + */ +final class GreetingService +{ + public function __construct(private string $name) + { + } + + public function greet(): string + { + return 'Hello, '.$this->name; + } +} From 12f4a78813deed0c2858d4740a32d5a0fb075a24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:03:48 +0200 Subject: [PATCH 053/143] chore(deps): bump actions/setup-node from 6 to 7 (#394) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance-weekly.yaml | 4 ++-- .github/workflows/pipeline.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index fadd976..f12f379 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '22' - run: composer install --prefer-dist --no-progress --no-interaction @@ -66,7 +66,7 @@ jobs: with: php-version: '8.4' coverage: none - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '22' - run: composer install --prefer-dist --no-progress --no-interaction diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 194ac11..9b76066 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -62,7 +62,7 @@ jobs: coverage: "none" - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -80,7 +80,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -137,7 +137,7 @@ jobs: coverage: "none" - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' From 206aa9da527e56302c2784b0aec127fd7bc08002 Mon Sep 17 00:00:00 2001 From: Dev <94041411+DevkumarPatel@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:41:51 +0800 Subject: [PATCH 054/143] [Schema] Fix Implementation::fromArray rejecting falsy-but-valid name/version (#393) * [Schema] Fix Implementation::fromArray rejecting falsy-but-valid name/version `empty()` treats the string "0" as empty, so a valid clientInfo/serverInfo with `"version": "0"` (or `"name": "0"`) was rejected with an InvalidArgumentException. During initialize this aborted request parsing and surfaced as a misleading "A valid session id is REQUIRED" error. Replace the `empty()` guards with explicit isset/is_string/empty-string checks so genuinely missing, non-string, and empty values are still rejected while "0" is accepted. Adds ImplementationTest covering the regression and the existing validation paths. Fixes #392 Co-Authored-By: Claude Opus 4.8 (1M context) * [Schema] Suppress PHPStan argument.type on invalid-input tests --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/Schema/Implementation.php | 4 +- tests/Unit/Schema/ImplementationTest.php | 177 +++++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Schema/ImplementationTest.php diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 22d58a6..0216f05 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -45,10 +45,10 @@ class Implementation implements \JsonSerializable */ public static function fromArray(array $data): self { - if (empty($data['name']) || !\is_string($data['name'])) { + if (!isset($data['name']) || !\is_string($data['name']) || '' === $data['name']) { throw new InvalidArgumentException('Invalid or missing "name" in Implementation data.'); } - if (empty($data['version']) || !\is_string($data['version'])) { + if (!isset($data['version']) || !\is_string($data['version']) || '' === $data['version']) { throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); } diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php new file mode 100644 index 0000000..1e19dea --- /dev/null +++ b/tests/Unit/Schema/ImplementationTest.php @@ -0,0 +1,177 @@ +assertSame('app', $implementation->name); + $this->assertSame('dev', $implementation->version); + $this->assertNull($implementation->description); + $this->assertNull($implementation->icons); + $this->assertNull($implementation->websiteUrl); + } + + public function testFromArrayWithMinimalData(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '1.2.3', + ]); + + $this->assertSame('my-client', $implementation->name); + $this->assertSame('1.2.3', $implementation->version); + $this->assertNull($implementation->description); + $this->assertNull($implementation->icons); + $this->assertNull($implementation->websiteUrl); + } + + public function testFromArrayWithAllFields(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '1.2.3', + 'description' => 'A test client', + 'icons' => [['src' => 'https://example.com/icon.png']], + 'websiteUrl' => 'https://example.com', + ]); + + $this->assertSame('my-client', $implementation->name); + $this->assertSame('1.2.3', $implementation->version); + $this->assertSame('A test client', $implementation->description); + $this->assertIsArray($implementation->icons); + $this->assertCount(1, $implementation->icons); + $this->assertSame('https://example.com', $implementation->websiteUrl); + } + + /** + * Regression test for #392: falsy-but-valid version strings such as "0" + * were rejected because empty('0') === true. + */ + public function testFromArrayAcceptsZeroStringVersion(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '0', + ]); + + $this->assertSame('0', $implementation->version); + } + + /** + * Regression test for #392: a name of "0" is a valid string and must be accepted. + */ + public function testFromArrayAcceptsZeroStringName(): void + { + $implementation = Implementation::fromArray([ + 'name' => '0', + 'version' => '1.0.0', + ]); + + $this->assertSame('0', $implementation->name); + } + + public function testFromArrayThrowsOnMissingName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['version' => '1.0.0']); + } + + public function testFromArrayThrowsOnEmptyName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); + + Implementation::fromArray(['name' => '', 'version' => '1.0.0']); + } + + public function testFromArrayThrowsOnNonStringName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 123, 'version' => '1.0.0']); + } + + public function testFromArrayThrowsOnMissingVersion(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client']); + } + + public function testFromArrayThrowsOnEmptyVersion(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); + + Implementation::fromArray(['name' => 'my-client', 'version' => '']); + } + + public function testFromArrayThrowsOnNonStringVersion(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => 1]); + } + + public function testFromArrayThrowsOnNonArrayIcons(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "icons" in Implementation data; expected an array.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); + } + + public function testJsonSerializeRoundTrip(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '0', + 'description' => 'A test client', + 'websiteUrl' => 'https://example.com', + ]); + + $this->assertSame([ + 'name' => 'my-client', + 'version' => '0', + 'description' => 'A test client', + 'websiteUrl' => 'https://example.com', + ], $implementation->jsonSerialize()); + } + + public function testJsonSerializeOmitsNullOptionalFields(): void + { + $implementation = new Implementation('my-client', '1.0.0'); + + $this->assertSame([ + 'name' => 'my-client', + 'version' => '1.0.0', + ], $implementation->jsonSerialize()); + } +} From e4a8a224ffbd665158090a9fb10bb6d58039c33b Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 14 Jul 2026 23:55:06 +0200 Subject: [PATCH 055/143] [Client] Add client-side elicitation support (#371) * [Client] Add client-side elicitation support The SDK could send elicitation/create requests from a server tool via ClientGateway::elicit(), but the client could not receive them: there was no request handler, and ElicitRequest was not registered in MessageFactory, so incoming elicitation/create messages failed to parse. This adds the client half, mirroring the existing Sampling pattern: - Register ElicitRequest in MessageFactory so elicitation/create parses. - ElicitationRequestHandler wraps a user callback and returns an ElicitResult (accept/decline/cancel) or an Error. - ElicitationCallbackInterface defines the callback contract. - ElicitationException forwards a specific message to the server; any other throwable returns a generic error. Includes unit tests, a runnable client example against the elicitation demo server, and client docs/README updates. * [Client] Simplify elicitation example labelFor() per review Type the parameter as AbstractSchemaDefinition and read ->title directly instead of matching on each concrete definition type. title is a required non-null string on the base class, so the match and the $name fallback were redundant. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- README.md | 10 ++ ROADMAP.md | 2 +- docs/client.md | 66 ++++++++ examples/client/stdio_elicitation.php | 144 ++++++++++++++++++ .../Request/ElicitationCallbackInterface.php | 26 ++++ .../Request/ElicitationRequestHandler.php | 68 +++++++++ src/Exception/ElicitationException.php | 24 +++ src/JsonRpc/MessageFactory.php | 1 + .../Request/ElicitationRequestHandlerTest.php | 138 +++++++++++++++++ tests/Unit/JsonRpc/MessageFactoryTest.php | 17 +++ 10 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 examples/client/stdio_elicitation.php create mode 100644 src/Client/Handler/Request/ElicitationCallbackInterface.php create mode 100644 src/Client/Handler/Request/ElicitationRequestHandler.php create mode 100644 src/Exception/ElicitationException.php create mode 100644 tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php diff --git a/README.md b/README.md index 3f1a377..e088563 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ $client->disconnect(); - **Resource Access**: Read static and dynamic resources - **Prompt Management**: List and retrieve prompt templates - **Completion Support**: Request argument completion suggestions +- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests ### Advanced Features @@ -233,6 +234,15 @@ $client = Client::builder() ->build(); ``` +- **Elicitation Support**: Respond to server requests for user input +```php +$elicitationHandler = new ElicitationRequestHandler($myCallback); +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($elicitationHandler) + ->build(); +``` + - **Logging Notifications**: Receive server log messages ```php $loggingHandler = new LoggingNotificationHandler($myCallback); diff --git a/ROADMAP.md b/ROADMAP.md index 03e4751..5b67e14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ This roadmap is a living document that outlines the planned features and improve ## Goals for the First Major Release - **Server** -- [ ] Implement full support for elicitations +- [x] Implement full support for elicitations - [ ] Implement OAuth2 authentication for server - **Client** - [x] Implement client-side support diff --git a/docs/client.md b/docs/client.md index 680d823..494edf1 100644 --- a/docs/client.md +++ b/docs/client.md @@ -534,6 +534,72 @@ $client = Client::builder() > throw new \RuntimeException('Rate limit exceeded'); > ``` +### Elicitation (User Input Requests) + +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: + +```php +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // Present $request->requestedSchema->properties to the user and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->build(); +``` + +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +> [!IMPORTANT] +> **Error Handling in Elicitation Callbacks:** +> +> - **Throw `ElicitationException`** to forward a specific error message to the server +> - **Any other exception** is logged but returns a generic error to the server +> +> ```php +> // Good: Server receives "No interactive console available" message +> throw new ElicitationException('No interactive console available'); +> +> // Bad: Server receives generic "Error while processing elicitation" message +> throw new \RuntimeException('No interactive console available'); +> ``` + +See `examples/client/stdio_elicitation.php` for a runnable example against the +elicitation demo server. + ## Error Handling The client throws exceptions for various error conditions: diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php new file mode 100644 index 0000000..0780a8e --- /dev/null +++ b/examples/client/stdio_elicitation.php @@ -0,0 +1,144 @@ +message}\n"; + + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $default = $this->defaultFor($definition); + $label = $this->labelFor($definition); + + if (null !== $default) { + $display = is_bool($default) ? ($default ? 'true' : 'false') : (string) $default; + echo " {$label} [{$display}]: "; + } else { + echo " {$label}: "; + } + + $rawInput = fgets(\STDIN); + $input = false === $rawInput ? '' : trim($rawInput); + $value = '' === $input ? $default : $this->cast($definition, $input); + + $content[$name] = $value; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } + + private function defaultFor(object $definition): mixed + { + return match (true) { + $definition instanceof EnumSchemaDefinition => $definition->default ?? $definition->enum[0], + $definition instanceof NumberSchemaDefinition => $definition->default ?? $definition->minimum ?? ($definition->integerOnly ? 1 : 1.0), + $definition instanceof BooleanSchemaDefinition => $definition->default ?? false, + $definition instanceof StringSchemaDefinition => $definition->default ?? ('date' === $definition->format ? date('Y-m-d') : ''), + default => null, + }; + } + + private function labelFor(AbstractSchemaDefinition $definition): string + { + return $definition->title; + } + + private function cast(object $definition, string $input): mixed + { + return match (true) { + $definition instanceof BooleanSchemaDefinition => filter_var($input, \FILTER_VALIDATE_BOOLEAN), + $definition instanceof NumberSchemaDefinition => $definition->integerOnly ? (int) $input : (float) $input, + default => $input, + }; + } +}); + +$client = Client::builder() + ->setClientInfo('STDIO Elicitation Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($elicitationRequestHandler) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/elicitation/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + + echo "Calling 'book_restaurant'...\n"; + $result = $client->callTool( + name: 'book_restaurant', + arguments: ['restaurantName' => 'The Test Kitchen'], + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } + + echo "\nCalling 'confirm_action'...\n"; + $result = $client->callTool( + name: 'confirm_action', + arguments: ['actionDescription' => 'Delete all temporary files'], + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } + } +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + $client->disconnect(); +} diff --git a/src/Client/Handler/Request/ElicitationCallbackInterface.php b/src/Client/Handler/Request/ElicitationCallbackInterface.php new file mode 100644 index 0000000..fdaea14 --- /dev/null +++ b/src/Client/Handler/Request/ElicitationCallbackInterface.php @@ -0,0 +1,26 @@ + + * + * @author Johannes Wachter + */ +class ElicitationRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly ElicitationCallbackInterface $callback, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request): Response|Error + { + \assert($request instanceof ElicitRequest); + + try { + $result = $this->callback->__invoke($request); + + return new Response($request->getId(), $result); + } catch (ElicitationException $e) { + $this->logger->error('Elicitation failed: '.$e->getMessage(), ['exception' => $e]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error during elicitation', ['exception' => $e]); + + return Error::forInternalError('Error while processing elicitation', $request->getId()); + } + } +} diff --git a/src/Exception/ElicitationException.php b/src/Exception/ElicitationException.php new file mode 100644 index 0000000..0ef784e --- /dev/null +++ b/src/Exception/ElicitationException.php @@ -0,0 +1,24 @@ + + */ +final class ElicitationException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index bb2f2f0..97bac3e 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -53,6 +53,7 @@ final class MessageFactory Schema\Request\CallToolRequest::class, Schema\Request\CompletionCompleteRequest::class, Schema\Request\CreateSamplingMessageRequest::class, + Schema\Request\ElicitRequest::class, Schema\Request\GetPromptRequest::class, Schema\Request\InitializeRequest::class, Schema\Request\ListPromptsRequest::class, diff --git a/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php new file mode 100644 index 0000000..6735871 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php @@ -0,0 +1,138 @@ +callbackReturning( + new ElicitResult(ElicitAction::Decline), + )); + + $this->assertTrue($handler->supports($this->createElicitRequest())); + } + + public function testDoesNotSupportOtherRequests(): void + { + $handler = new ElicitationRequestHandler($this->callbackReturning( + new ElicitResult(ElicitAction::Decline), + )); + + $ping = PingRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => PingRequest::getMethod(), + 'id' => 'ping-1', + ]); + + $this->assertFalse($handler->supports($ping)); + } + + public function testHandleReturnsResponseOnAccept(): void + { + $result = new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']); + $handler = new ElicitationRequestHandler($this->callbackReturning($result)); + + $request = $this->createElicitRequest(); + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($request->getId(), $response->id); + $this->assertSame($result, $response->result); + } + + public function testHandleReturnsErrorOnElicitationException(): void + { + $handler = new ElicitationRequestHandler($this->callbackThrowing( + new ElicitationException('user input unavailable'), + )); + + $request = $this->createElicitRequest(); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame($request->getId(), $response->getId()); + $this->assertSame('user input unavailable', $response->message); + } + + public function testHandleReturnsGenericErrorOnThrowable(): void + { + $handler = new ElicitationRequestHandler($this->callbackThrowing( + new \RuntimeException('boom'), + )); + + $request = $this->createElicitRequest(); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame($request->getId(), $response->getId()); + $this->assertSame('Error while processing elicitation', $response->message); + } + + private function createElicitRequest(): ElicitRequest + { + return ElicitRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ElicitRequest::getMethod(), + 'id' => 'elicit-'.uniqid(), + 'params' => [ + 'message' => 'Please provide your name.', + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + ], + 'required' => ['name'], + ], + ], + ]); + } + + private function callbackReturning(ElicitResult $result): ElicitationCallbackInterface + { + return new class($result) implements ElicitationCallbackInterface { + public function __construct(private readonly ElicitResult $result) + { + } + + public function __invoke(ElicitRequest $request): ElicitResult + { + return $this->result; + } + }; + } + + private function callbackThrowing(\Throwable $exception): ElicitationCallbackInterface + { + return new class($exception) implements ElicitationCallbackInterface { + public function __construct(private readonly \Throwable $exception) + { + } + + public function __invoke(ElicitRequest $request): ElicitResult + { + throw $this->exception; + } + }; + } +} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 4a07bab..291b6c5 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\CancelledNotification; use Mcp\Schema\Notification\InitializedNotification; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\PingRequest; use PHPUnit\Framework\TestCase; @@ -336,6 +337,22 @@ final class MessageFactoryTest extends TestCase $this->assertInstanceOf(PingRequest::class, $results[0]); } + public function testMakeFactoryParsesElicitationCreate(): void + { + $factory = MessageFactory::make(); + $json = '{"jsonrpc": "2.0", "method": "elicitation/create", "id": 1, "params": {"message": "Your name?", "requestedSchema": {"type": "object", "properties": {"name": {"type": "string", "title": "Name"}}, "required": ["name"]}}}'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + /** @var ElicitRequest $result */ + $result = $results[0]; + $this->assertInstanceOf(ElicitRequest::class, $result); + $this->assertSame('elicitation/create', $result::getMethod()); + $this->assertSame('Your name?', $result->message); + $this->assertSame(1, $result->getId()); + } + public function testResponseWithInvalidIdType(): void { $json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}'; From ff91ca6d603444780c24d7e7c4ba8805913baeb3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 14 Jul 2026 23:55:31 +0200 Subject: [PATCH 056/143] [Server] Defer element loading to first registry read (#389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder::build() runs all loaders eagerly and snapshots capabilities from the resulting registry. Both are computed once, when the server is built. Under a persistent runtime (e.g. FrankenPHP worker mode) the server is built a single time, so a loader whose data source is not yet ready at that moment (cold cache, un-warmed metadata) leaves the registry empty for the whole process — tools/list stays empty while tools/call still works. Make deferred loading the default behavior of Registry itself: it takes a loader and runs it on the first read (has*/get*), moving loading to request time when the application is initialized. The load runs once; the loaded flag is set only after a successful load, so a transient failure is retried on the next read. A re-entrancy guard lets loaders that read the registry during their own run (e.g. discovery's identity check) observe the partial state instead of recursing. Registrations made before the first read survive it. Expose the switch on Builder via setLazyLoading() (default on) so callers can opt back into eager loading at build time. In lazy mode capabilities are advertised from the configured sources so the initialize handshake does not force a load; in eager mode (or for a registry supplied via setRegistry(), which is always loaded eagerly) they are read from the loaded registry. --- src/Capability/Registry.php | 62 ++++++++++ src/Server/Builder.php | 92 ++++++++++++--- .../Loader/ExplicitElementLoaderTest.php | 6 +- tests/Unit/Capability/RegistryTest.php | 109 ++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 65 +++++++++++ 5 files changed, 315 insertions(+), 19 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 23167b6..f90a4ca 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,6 +11,7 @@ namespace Mcp\Capability; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; @@ -60,13 +61,42 @@ final class Registry implements RegistryInterface */ private array $resourceTemplates = []; + private bool $loaded = false; + + private bool $loading = false; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), private readonly NameValidator $nameValidator = new NameValidator(), + private readonly ?LoaderInterface $loader = null, ) { } + /** + * Runs the configured loader once, on demand. Reads trigger this automatically, so element + * loading is deferred to the first read (request time) rather than eager at build time — under a + * persistent runtime a source not yet ready at build no longer freezes the registry empty. + * + * `loaded` is set only after success, so a transient failure is retried on the next read. The + * `loading` guard lets a loader read the registry during its own run (e.g. discovery's identity + * check) without re-entering the load. + */ + public function load(): void + { + if ($this->loaded || $this->loading || null === $this->loader) { + return; + } + + $this->loading = true; + try { + $this->loader->load($this); + $this->loaded = true; + } finally { + $this->loading = false; + } + } + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference { if (!$this->nameValidator->isValid($tool->name)) { @@ -165,31 +195,43 @@ final class Registry implements RegistryInterface public function hasTool(string $name): bool { + $this->load(); + return isset($this->tools[$name]); } public function hasResource(string $uri): bool { + $this->load(); + return isset($this->resources[$uri]); } public function hasResourceTemplate(string $uriTemplate): bool { + $this->load(); + return isset($this->resourceTemplates[$uriTemplate]); } public function hasPrompt(string $name): bool { + $this->load(); + return isset($this->prompts[$name]); } public function hasTools(): bool { + $this->load(); + return [] !== $this->tools; } public function getTools(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $tools = []; foreach ($this->tools as $toolReference) { $tools[$toolReference->tool->name] = $toolReference->tool; @@ -212,16 +254,22 @@ final class Registry implements RegistryInterface public function getTool(string $name): ToolReference { + $this->load(); + return $this->tools[$name] ?? throw new ToolNotFoundException($name); } public function hasResources(): bool { + $this->load(); + return [] !== $this->resources; } public function getResources(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $resources = []; foreach ($this->resources as $resourceReference) { $resources[$resourceReference->resource->uri] = $resourceReference->resource; @@ -246,6 +294,8 @@ final class Registry implements RegistryInterface string $uri, bool $includeTemplates = true, ): ResourceReference|ResourceTemplateReference { + $this->load(); + $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; @@ -266,11 +316,15 @@ final class Registry implements RegistryInterface public function hasResourceTemplates(): bool { + $this->load(); + return [] !== $this->resourceTemplates; } public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $templates = []; foreach ($this->resourceTemplates as $templateReference) { $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate; @@ -293,16 +347,22 @@ final class Registry implements RegistryInterface public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference { + $this->load(); + return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate); } public function hasPrompts(): bool { + $this->load(); + return [] !== $this->prompts; } public function getPrompts(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $prompts = []; foreach ($this->prompts as $promptReference) { $prompts[$promptReference->prompt->name] = $promptReference->prompt; @@ -325,6 +385,8 @@ final class Registry implements RegistryInterface public function getPrompt(string $name): PromptReference { + $this->load(); + return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 198a349..c53fa62 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -219,6 +219,10 @@ final class Builder */ private array $loaders = []; + private bool $hasCustomRegistry = false; + + private bool $lazyLoading = true; + /** * Sets the server's identity. Required. * @@ -344,6 +348,21 @@ final class Builder public function setRegistry(RegistryInterface $registry): self { $this->registry = $registry; + $this->hasCustomRegistry = true; + + return $this; + } + + /** + * Controls when configured loaders (manual elements, discovery, custom loaders) run. + * + * Lazy (the default) defers loading to the first registry read so a persistent runtime does not + * freeze the registry to a source not yet ready at build time. Disable to load eagerly at build. + * A registry supplied via setRegistry() is always loaded eagerly. + */ + public function setLazyLoading(bool $lazyLoading = true): self + { + $this->lazyLoading = $lazyLoading; return $this; } @@ -643,7 +662,6 @@ final class Builder { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), @@ -674,23 +692,24 @@ final class Builder } } - $loader = new ChainLoader($loaders); - $loader->load($registry); + $chainLoader = new ChainLoader($loaders); + + if ($this->hasCustomRegistry) { + // Builder can't inject the loader into an already-constructed instance, so load it eagerly. + $registry = $this->registry; + $chainLoader->load($registry); + $eagerlyLoaded = true; + } else { + $registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader); + if (!$this->lazyLoading) { + $registry->load(); + } + $eagerlyLoaded = !$this->lazyLoading; + } $messageFactory = MessageFactory::make(); - $capabilities = $this->serverCapabilities ?? new ServerCapabilities( - tools: $registry->hasTools(), - toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - resources: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - prompts: $registry->hasPrompts(), - promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); + $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); // Extensions enabled via enableExtension() are folded into caller-supplied // capabilities too, so setCapabilities() does not silently drop them. @@ -734,6 +753,49 @@ final class Builder return new Server($protocol, $logger); } + /** + * When loaded, capabilities are read from the registry. When deferred, reading it would force + * the load, so they are advertised from the configured sources instead — opaque sources (custom + * loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics. + */ + private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities + { + $listChanged = $this->eventDispatcher instanceof EventDispatcherInterface; + + if ($eagerlyLoaded) { + $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); + + return new ServerCapabilities( + tools: $registry->hasTools(), + toolsListChanged: $listChanged, + resources: $hasResources, + resourcesSubscribe: $hasResources, + resourcesListChanged: $listChanged, + prompts: $registry->hasPrompts(), + promptsListChanged: $listChanged, + logging: true, + completions: true, + extensions: $this->extensions ?: null, + ); + } + + $hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath; + $hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources; + + return new ServerCapabilities( + tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources, + toolsListChanged: $listChanged, + resources: $hasResources, + resourcesSubscribe: $hasResources, + resourcesListChanged: $listChanged, + prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources, + promptsListChanged: $listChanged, + logging: true, + completions: true, + extensions: $this->extensions ?: null, + ); + } + private function createDiscoverer(LoggerInterface $logger): DiscovererInterface { $discoverer = new Discoverer($logger, null, $this->schemaGenerator); diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php index 0066a31..dad2de6 100644 --- a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -373,11 +373,9 @@ final class ExplicitElementLoaderTest extends TestCase */ private function buildAndGetRegistry(callable $configure): RegistryInterface { + // A caller-supplied registry is loaded eagerly at build, so it is populated once build() returns. $registry = new Registry(); - $builder = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setRegistry($registry); - $configure($builder)->build(); + $configure(Server::builder()->setServerInfo('test', '1.0.0')->setRegistry($registry))->build(); return $registry; } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index a4d2780..c8aa5a7 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -13,10 +13,12 @@ namespace Mcp\Tests\Unit\Capability; use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\RegistryInterface; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; @@ -534,6 +536,113 @@ class RegistryTest extends TestCase ], $structuredContent); } + public function testConfiguredLoaderIsNotRunUntilFirstRead(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->never())->method('load'); + + // Constructing (and registering) must not trigger the loader. + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->registerTool($this->createValidTool('manual'), 'handler'); + } + + public function testConfiguredLoaderRunsOnFirstReadAndPopulatesTheRegistry(): void + { + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry(null, $this->logger, loader: $loader); + + $this->assertTrue($registry->hasTools()); + $this->assertArrayHasKey('loaded', $registry->getTools()->references); + } + + public function testConfiguredLoaderRunsExactlyOnceAcrossManyReads(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->hasTools(); + $registry->getTools(); + $registry->hasResources(); + $registry->getPrompts(); + } + + public function testRuntimeRegistrationsSurviveTheDeferredLoad(): void + { + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry(null, $this->logger, loader: $loader); + // Registered before the first read; the deferred load must be additive, not replacing. + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + + $tools = $registry->getTools()->references; + $this->assertArrayHasKey('runtime', $tools); + $this->assertArrayHasKey('loaded', $tools); + } + + public function testConfiguredLoaderRetriesAfterAFailedLoad(): void + { + $tool = $this->createValidTool('loaded'); + $loader = new class($tool) implements LoaderInterface { + private int $calls = 0; + + public function __construct(private readonly Tool $tool) + { + } + + public function load(RegistryInterface $registry): void + { + ++$this->calls; + if (1 === $this->calls) { + throw new \RuntimeException('data source not ready'); + } + + $registry->registerTool($this->tool, 'handler'); + } + }; + + $registry = new Registry(null, $this->logger, loader: $loader); + + try { + $registry->hasTools(); + $this->fail('Expected the first load to throw.'); + } catch (\RuntimeException $e) { + $this->assertSame('data source not ready', $e->getMessage()); + } + + $this->assertArrayHasKey('loaded', $registry->getTools()->references); + } + + public function testLoadRunsTheConfiguredLoaderEagerly(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->load(); + } + + public function testLoadIsANoopWithoutAConfiguredLoader(): void + { + $registry = new Registry(null, $this->logger); + $registry->load(); + + $this->assertFalse($registry->hasTools()); + } + + private function toolLoader(Tool $tool): LoaderInterface + { + return new class($tool) implements LoaderInterface { + public function __construct(private readonly Tool $tool) + { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, 'handler'); + } + }; + } + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 1930879..255a142 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; @@ -19,6 +21,7 @@ use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Server; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; @@ -141,6 +144,68 @@ final class BuilderTest extends TestCase $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); } + #[TestDox('build() advertises tools capability for a pre-populated registry set via setRegistry()')] + public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void + { + $registry = new Registry(); + $registry->registerTool( + new Tool(name: 'test_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: 'A test tool', annotations: null), + static fn (): string => 'result', + ); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertTrue($capabilities->tools); + } + + #[TestDox('setLazyLoading() returns the builder for fluent chaining')] + public function testSetLazyLoadingReturnsSelf(): void + { + $builder = Server::builder(); + + $this->assertSame($builder, $builder->setLazyLoading(false)); + } + + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] + public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->never())->method('load'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addLoader($loader) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + // A custom loader is opaque, so its presence advertises tools even though it never ran. + $this->assertTrue($capabilities->tools); + } + + #[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')] + public function testEagerLoadingAdvertisesFromLoadedRegistry(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setLazyLoading(false) + ->addLoader($loader) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + // The loader ran but registered nothing, so the loaded registry advertises no tools. + $this->assertFalse($capabilities->tools); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); From 8e18bfbdf119e9891099e647ae6b3031ed164ab6 Mon Sep 17 00:00:00 2001 From: Valeriu Gutu <60061959+valeriudev@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:56:50 +0300 Subject: [PATCH 057/143] [Server] Always emit items for array tool parameter schemas (#378) Array tool parameters without an inferable element type generated `{"type":"array"}` with no `items`, which strict MCP clients (VS Code / Copilot) reject. Always emit `items` (`{}` when the element type is unknown) and recover the element type for nullable typed arrays such as `string[]|null`. Fixes #151 --- CHANGELOG.md | 1 + src/Capability/Discovery/SchemaGenerator.php | 36 +++++++++++++++++-- .../HttpComplexToolSchemaTest-tools_list.json | 5 ++- .../Discovery/SchemaGeneratorFixture.php | 21 +++++++++++ .../Discovery/SchemaGeneratorTest.php | 31 +++++++++++++--- 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69e827..d09422f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build. +* Always emit an `items` schema for array tool parameters: untyped arrays get `items: {}` and nullable typed arrays (e.g. `string[]|null`) keep their element type. Fixes strict clients rejecting tools with "array type must have items" (#151). 0.6.0 ----- diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 04ee08f..a4efdd6 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -223,7 +223,7 @@ final class SchemaGenerator implements SchemaGeneratorInterface private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array { if ($paramInfo['is_variadic']) { - return $this->buildVariadicParameterSchema($paramInfo); + return $this->ensureArrayItems($this->buildVariadicParameterSchema($paramInfo)); } $inferredSchema = $this->buildInferredParameterSchema($paramInfo); @@ -240,7 +240,34 @@ final class SchemaGenerator implements SchemaGeneratorInterface $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); } - return $mergedSchema; + // Run after all merges so that when a Schema attribute reshapes the parameter + // (e.g. to `object`), the array `items` invariant is only enforced on what is + // genuinely still an array. + return $this->ensureArrayItems($mergedSchema); + } + + /** + * Guarantees an array-typed schema always declares `items`. + * + * `items` is optional in JSON Schema, but some strict clients reject an array schema + * without it. When no element type could be inferred, default to the empty schema `{}` + * (matches anything) — represented as `new \stdClass()` so it serializes to `{}` rather + * than `[]`. + * + * @param array $schema + * + * @return array + */ + private function ensureArrayItems(array $schema): array + { + $type = $schema['type'] ?? null; + $isArray = 'array' === $type || (\is_array($type) && \in_array('array', $type, true)); + + if ($isArray && !isset($schema['items'])) { + $schema['items'] = new \stdClass(); + } + + return $schema; } /** @@ -693,6 +720,11 @@ final class SchemaGenerator implements SchemaGeneratorInterface { $normalizedType = trim($phpTypeString); + // Strip a top-level nullable union (e.g. `string[]|null`, `null|int[]`) so the + // element type is still recovered; array nullability is handled separately via + // `allows_null`. Internal unions such as `array` are left untouched. + $normalizedType = trim((string) preg_replace('/^null\s*\|\s*|\s*\|\s*null$/i', '', $normalizedType)); + // Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.) if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) { $itemType = strtolower($matches[2]); diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json index 2a7698a..0e0bbe9 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json @@ -48,7 +48,10 @@ "null" ], "description": "an optional list of attendee email addresses", - "default": null + "default": null, + "items": { + "type": "string" + } }, "sendInvites": { "type": "boolean", diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 0d40026..d695917 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -266,6 +266,18 @@ class SchemaGeneratorFixture ): void { } + /** + * Nullable typed arrays should still recover their element type. + * + * @param string[]|null $nullableStrings Nullable list of strings + * @param array|null $nullableInts Nullable list of integers + */ + public function nullableTypedArrays( + ?array $nullableStrings, + ?array $nullableInts = null, + ): void { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** @@ -308,6 +320,15 @@ class SchemaGeneratorFixture { } + /** + * Variadic parameter without a type hint. + * + * @param mixed ...$values Variadic values + */ + public function untypedVariadic(...$values): void + { + } + /** * Variadic with Schema constraints. * diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c92121e..0c223cb 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -44,7 +44,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['type' => 'string'], $schema['properties']['name']); $this->assertEquals(['type' => 'integer'], $schema['properties']['age']); $this->assertEquals(['type' => 'boolean'], $schema['properties']['active']); - $this->assertEquals(['type' => 'array'], $schema['properties']['tags']); + $this->assertEquals(['type' => 'array', 'items' => new \stdClass()], $schema['properties']['tags']); $this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']); $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); } @@ -56,7 +56,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']); $this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']); $this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']); - $this->assertEquals(['type' => 'array', 'description' => 'Some data'], $schema['properties']['data']); + $this->assertEquals(['type' => 'array', 'description' => 'Some data', 'items' => new \stdClass()], $schema['properties']['data']); $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']); } @@ -206,10 +206,13 @@ final class SchemaGeneratorTest extends TestCase { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); $schema = $this->schemaGenerator->generate($method); - $this->assertEquals(['type' => 'array', 'description' => 'Generic array'], $schema['properties']['genericArray']); + $this->assertEquals(['type' => 'array', 'description' => 'Generic array', 'items' => new \stdClass()], $schema['properties']['genericArray']); + // An untyped array must still declare `items`, serialized as the empty schema `{}` + // (not `[]`) so strict clients accept it. + $this->assertSame('{}', json_encode($schema['properties']['genericArray']['items'])); $this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']); $this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']); - $this->assertEquals(['type' => 'array', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']); + $this->assertEquals(['type' => 'array', 'description' => 'Mixed array map', 'items' => new \stdClass()], $schema['properties']['mixedMap']); $this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']); $this->assertEquals('object', $schema['properties']['objectLikeArray']['type']); $this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']); @@ -218,6 +221,16 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); } + public function testRecoversItemsTypeForNullableTypedArrays(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableTypedArrays'); + $schema = $this->schemaGenerator->generate($method); + // A `|null` suffix must not erase the element type: `string[]|null` keeps `items: {type: string}`. + $this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of strings', 'items' => ['type' => 'string']], $schema['properties']['nullableStrings']); + $this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of integers', 'default' => null, 'items' => ['type' => 'integer']], $schema['properties']['nullableInts']); + $this->assertEqualsCanonicalizing(['nullableStrings'], $schema['required']); + } + public function testHandlesNullableTypeHintsAndOptionalParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); @@ -226,7 +239,7 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']); $this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']); $this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']); - $this->assertEquals(['type' => 'array', 'default' => []], $schema['properties']['optionalArray']); + $this->assertEquals(['type' => 'array', 'default' => [], 'items' => new \stdClass()], $schema['properties']['optionalArray']); $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']); } @@ -255,6 +268,14 @@ final class SchemaGeneratorTest extends TestCase $this->assertArrayNotHasKey('required', $schema); } + public function testUntypedVariadicStillDeclaresItems(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'untypedVariadic'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'array', 'description' => 'Variadic values', 'items' => new \stdClass()], $schema['properties']['values']); + $this->assertSame('{}', json_encode($schema['properties']['values']['items'])); + } + public function testHandlesMixedTypeHintsOmittingExplicitType(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); From d187002d875e11e5a3708cfd9e4a634f88f8815d Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 15 Jul 2026 06:20:43 +0800 Subject: [PATCH 058/143] [Server] Reject malformed MCP session ID headers (#384) * [Server] Reject malformed MCP session ID headers * [Server] Narrow invalid session ID exception handling * [Server] Keep UUID catch compatible with supported Symfony --- .../Transport/StreamableHttpTransport.php | 15 +++++++-- .../Transport/StreamableHttpTransportTest.php | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index a5f757c..cce2bd5 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -343,8 +343,19 @@ class StreamableHttpTransport extends BaseTransport private function handleRequest(ServerRequestInterface $request): ResponseInterface { $this->request = $request; - $sessionIdString = $request->getHeaderLine(self::SESSION_HEADER); - $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + $sessionIdHeaders = $request->getHeader(self::SESSION_HEADER); + if (\count($sessionIdHeaders) > 1) { + return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400); + } + + $sessionIdString = $sessionIdHeaders[0] ?? ''; + + try { + $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + // Symfony UID 5.4/6.4 throw the global parent; newer versions throw a namespaced subclass. + } catch (\InvalidArgumentException) { + return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must be a valid UUID.'), 400); + } return match ($request->getMethod()) { 'OPTIONS' => $this->handleOptionsRequest(), diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 32479a0..22b7fef 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -85,6 +85,39 @@ final class StreamableHttpTransportTest extends TestCase $this->assertSame(400, $response->getStatusCode()); } + #[TestDox('malformed MCP session IDs are rejected as bad requests')] + public function testMalformedSessionIdHeaderReturnsBadRequest(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader(StreamableHttpTransport::SESSION_HEADER, '{"not":"a-token"}'); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString(StreamableHttpTransport::SESSION_HEADER, (string) $response->getBody()); + } + + #[TestDox('duplicate MCP session ID headers are rejected as bad requests')] + public function testDuplicateSessionIdHeadersReturnBadRequest(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader(StreamableHttpTransport::SESSION_HEADER, '2fb587fc-593f-47ce-9d9a-9c06f2b907a3') + ->withAddedHeader(StreamableHttpTransport::SESSION_HEADER, '5e583da8-a677-4446-b723-4ddbe00fda62'); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('must not be repeated', (string) $response->getBody()); + } + #[TestDox('explicit empty middleware list disables defaults and emits a warning log')] public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void { From 1fed089d711d5acc99ba1e3122c806a4ba3c6046 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 15 Jul 2026 00:23:20 +0200 Subject: [PATCH 059/143] [Examples] Demo pre-built instance handlers + inspector coverage (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Bump MCP Inspector to 0.22.0 for 2025-11-25 protocol The pinned inspector 0.17.2 predates the protocol version bump to 2025-11-25 (v0.6.0) and rejects the handshake with "Server's protocol version is not supported", failing every inspector snapshot test. 0.22.0 negotiates the current protocol; the full inspector suite passes against it (95 tests, 7 pre-existing skips). Co-Authored-By: Claude Opus 4.8 (1M context) * [Examples] Demo pre-built instance handler in combined-registration Extends the combined-registration example with a third handler style: a pre-built object instance `[$instance, 'method']`, alongside the existing discovered and manual class-string handlers. PreconfiguredGreeter takes a scalar constructor argument neither the container-less `new $className()` fallback nor the auto-wiring container can build, so it can only be registered as an already-constructed instance — exercising the resolver path added in the parent branch. Adds an inspector snapshot case (tools/call instance_greeter) and updates the tools/list snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/examples.md | 14 ++++-- docs/server-builder.md | 4 +- .../PreconfiguredGreeter.php | 46 +++++++++++++++++++ .../server/combined-registration/server.php | 7 +++ .../Http/HttpCombinedRegistrationTest.php | 8 ++++ ...ationTest-tools_call-instance_greeter.json | 9 ++++ ...tpCombinedRegistrationTest-tools_list.json | 16 +++++++ tests/Inspector/InspectorSnapshotTestCase.php | 2 +- 8 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 examples/server/combined-registration/PreconfiguredGreeter.php create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json diff --git a/docs/examples.md b/docs/examples.md index 61b788e..14e97fd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -217,14 +217,20 @@ npx @modelcontextprotocol/inspector http://localhost:8000 **What it demonstrates:** - Mixing attribute discovery with manual registration - HTTP server with both discovered and manual capabilities -- Flexible registration patterns +- All three handler styles: discovered, `[Class::class, 'method']`, and a pre-built `[$instance, 'method']` +- Pre-built instance handlers for classes the container cannot auto-wire (e.g. constructor scalars) **Key Features:** ```php +// Built here so its constructor dependencies are injected before registration; +// the SDK invokes this very instance instead of constructing one itself. +$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); + $server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual registration - ->addResource([ManualHandlers::class, 'getPriorityConfig'], 'config://priority') + ->setDiscovery(__DIR__, ['.']) // Automatic discovery + ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual class-string handler + ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') // Pre-built instance handler + ->addResource([ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority') ``` ### Complex Tool Schema diff --git a/docs/server-builder.md b/docs/server-builder.md index 9836bee..5016526 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -277,8 +277,8 @@ Register MCP elements programmatically without using attributes. The handler is **Handler** can be any PHP callable: 1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` -2. **Class and method name pair**: `[ClassName::class, 'methodName']` - class must be constructable through the container -3. **Class instance and method name**: `[$instance, 'methodName']` +2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) +3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime 4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method ### Manual Tool Registration diff --git a/examples/server/combined-registration/PreconfiguredGreeter.php b/examples/server/combined-registration/PreconfiguredGreeter.php new file mode 100644 index 0000000..8d55abd --- /dev/null +++ b/examples/server/combined-registration/PreconfiguredGreeter.php @@ -0,0 +1,46 @@ +addTool([new PreconfiguredGreeter('...', ...), 'greet'], 'instance_greeter')`. + * + * Neither the container-less `new $className()` fallback nor the auto-wiring + * container can build this class, since the required `string $greeting` has no + * default and is not a resolvable service. + */ +final class PreconfiguredGreeter +{ + public function __construct( + private readonly string $greeting, + private readonly LoggerInterface $logger, + ) { + } + + /** + * A tool registered as a pre-built object instance. + * + * @param string $name the name to greet + * + * @return string greeting + */ + public function greet(string $name): string + { + $this->logger->info("Instance tool 'instance_greeter' called for {$name}"); + + return "{$this->greeting}, {$name}!"; + } +} diff --git a/examples/server/combined-registration/server.php b/examples/server/combined-registration/server.php index cf61d55..475ed41 100644 --- a/examples/server/combined-registration/server.php +++ b/examples/server/combined-registration/server.php @@ -14,9 +14,15 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); use Mcp\Example\Server\CombinedRegistration\ManualHandlers; +use Mcp\Example\Server\CombinedRegistration\PreconfiguredGreeter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +// Built here so its constructor dependencies (a scalar the container cannot +// auto-wire) are injected before registration; the SDK invokes this very +// instance instead of trying to construct one itself. +$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); + $server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') ->setLogger(logger()) @@ -24,6 +30,7 @@ $server = Server::builder() ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__) ->addTool([ManualHandlers::class, 'manualGreeter']) + ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') ->addResource( [ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority', diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php index 8077839..ff47592 100644 --- a/tests/Inspector/Http/HttpCombinedRegistrationTest.php +++ b/tests/Inspector/Http/HttpCombinedRegistrationTest.php @@ -25,6 +25,14 @@ final class HttpCombinedRegistrationTest extends HttpInspectorSnapshotTestCase ], 'testName' => 'manual_greeter', ], + 'Instance Greeter Tool (pre-built object handler)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'instance_greeter', + 'toolArgs' => ['name' => 'HTTP Test User'], + ], + 'testName' => 'instance_greeter', + ], 'Discovered Status Check Tool' => [ 'method' => 'tools/call', 'options' => [ diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json new file mode 100644 index 0000000..4b62f1b --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Willkommen, HTTP Test User!" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json index 04d8ea1..0812699 100644 --- a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json @@ -16,6 +16,22 @@ ] } }, + { + "name": "instance_greeter", + "description": "A tool registered as a pre-built object instance.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name to greet" + } + }, + "required": [ + "name" + ] + } + }, { "name": "discovered_status_check", "description": "A tool discovered via attributes.", diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 18bb7f4..35d08e1 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ use Symfony\Component\Process\Process; abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.17.2'; + private const INSPECTOR_VERSION = '0.22.0'; /** @param array $options */ #[DataProvider('provideMethods')] From 0058d93086ce88331768419c2e35d0b984461126 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 15 Jul 2026 00:24:04 +0200 Subject: [PATCH 060/143] [Server] Expose OAuth protected resource metadata as a reusable request handler (#391) * [Server] Expose OAuth protected resource metadata as a reusable request handler * docs: show handler DI wiring in Symfony/Laravel controller examples * Focus PR on transport refactor: drop docs and example changes * docs: add changelog entry for ProtectedResourceMetadataHandler --- CHANGELOG.md | 1 + .../ProtectedResourceMetadataMiddleware.php | 18 ++-- .../ProtectedResourceMetadataHandler.php | 60 ++++++++++++ .../ProtectedResourceMetadataHandlerTest.php | 93 +++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d09422f..c52aa49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. * Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. * Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. +* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). 0.5.0 ----- diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php index 315f585..67a0cdc 100644 --- a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -11,8 +11,8 @@ namespace Mcp\Server\Transport\Http\Middleware; -use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadataHandler; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -23,22 +23,25 @@ use Psr\Http\Server\RequestHandlerInterface; /** * Serves OAuth 2.0 Protected Resource Metadata (RFC 9728) at well-known endpoints. * + * This is a thin path-guard adapter: it decides *when* the metadata endpoint applies + * (a GET to one of the configured well-known paths) and delegates the *what* to + * {@see ProtectedResourceMetadataHandler}, the reusable request handler that can also be + * mounted directly as a framework controller. + * * @see https://datatracker.ietf.org/doc/html/rfc9728 * * @author Volodymyr Panivko */ final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface { - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; + private ProtectedResourceMetadataHandler $metadataHandler; public function __construct( private readonly ProtectedResourceMetadata $metadata, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->metadataHandler = new ProtectedResourceMetadataHandler($metadata, $responseFactory, $streamFactory); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -47,10 +50,7 @@ final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface return $handler->handle($request); } - return $this->responseFactory - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + return $this->metadataHandler->handle($request); } private function isMetadataRequest(ServerRequestInterface $request): bool diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php new file mode 100644 index 0000000..0b0cf7f --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php @@ -0,0 +1,60 @@ + + */ +final class ProtectedResourceMetadataHandler implements RequestHandlerInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly ProtectedResourceMetadata $metadata, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php new file mode 100644 index 0000000..b751df4 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandlerTest.php @@ -0,0 +1,93 @@ + + */ +class ProtectedResourceMetadataHandlerTest extends TestCase +{ + #[TestDox('handle returns protected resource metadata JSON')] + public function testHandleReturnsMetadataJson(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://mcp.example.com/docs', + localizedHumanReadable: [ + 'resource_name#uk' => 'Pryklad MCP API', + ], + ); + + $handler = new ProtectedResourceMetadataHandler( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); + $this->assertSame('Example MCP API', $payload['resource_name']); + $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); + $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); + } + + #[TestDox('handle serves metadata regardless of request path or method')] + public function testHandleIgnoresRoutingConcerns(): void + { + $factory = new Psr17Factory(); + + $handler = new ProtectedResourceMetadataHandler( + metadata: new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + ), + responseFactory: $factory, + streamFactory: $factory, + ); + + // The handler is the "controller action" — the caller (middleware or framework + // router) owns path/method matching, so an arbitrary request still yields metadata. + $request = $factory->createServerRequest('POST', 'https://mcp.example.com/anything'); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + } +} From a6f415578fa789783d010274d11c922e97659a8a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 15 Jul 2026 00:58:00 +0200 Subject: [PATCH 061/143] Fix v0.7.0 CHANGELOG entries (#396) --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52aa49..01bf9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,16 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.7.0 ----- +* Add client-side elicitation support: `ElicitationCallbackInterface`, `ElicitationRequestHandler`, and `ElicitationException` let clients respond to server elicitation requests. +* Defer element loading to the first registry read: loaders now run at request time (first `has*`/`get*` call) instead of eagerly at `Builder::build()`, fixing empty registries under persistent runtimes (e.g. FrankenPHP worker mode) where a loader's data source is not ready at build time. Adds `Builder::setLazyLoading()` (default on), a public `Registry::load()`, and an optional `LoaderInterface` constructor argument on `Registry`. +* [BC Break] Element loading is lazy by default: loader failures now surface on the first request rather than at `Builder::build()`, and `initialize` advertises capabilities from the configured sources rather than the loaded registry. Call `Builder::setLazyLoading(false)` to restore eager build-time loading. * Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build. * Always emit an `items` schema for array tool parameters: untyped arrays get `items: {}` and nullable typed arrays (e.g. `string[]|null`) keep their element type. Fixes strict clients rejecting tools with "array type must have items" (#151). +* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. +* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. +* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. +* Reject malformed `Mcp-Session-Id` headers with a `400` response: a repeated header or a value that is not a valid UUID is now rejected up front instead of surfacing as an uncaught `Uuid::fromString()` error. +* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). 0.6.0 ----- @@ -37,10 +45,6 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. -* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. -* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. -* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. -* Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). 0.5.0 ----- From 055593f2592e1d7f5f48587c999ca7068a3be90d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jul 2026 11:15:43 +0200 Subject: [PATCH 062/143] Merge commit from fork processSSEStream() appended every 4 KiB chunk to $sseBuffer and only flushed on the "\n\n" SSE event delimiter. A server streaming data without ever sending the delimiter grew the buffer without bound, exhausting client memory (GHSA-7m52-jw36-44r3). Cap the buffer (default 8 MiB, configurable via the constructor). When appending the next chunk would exceed the cap before a complete event is seen, abort the stream and resolve the in-flight request with an error immediately, so the caller fails fast instead of spinning until its request timeout. Reimplements tonghuaroot's advisory fix: makes the cap configurable rather than a hardcoded constant, fails the pending request fast instead of relying on the timeout, and adds regression tests. Co-authored-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com> --- src/Client/Transport/HttpTransport.php | 65 +++++++++- .../Client/Transport/HttpTransportTest.php | 117 ++++++++++++++++++ 2 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Client/Transport/HttpTransportTest.php diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 33869f7..dd3255f 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -14,6 +14,7 @@ namespace Mcp\Client\Transport; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\ConnectionException; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Http\Client\ClientInterface; @@ -52,11 +53,24 @@ class HttpTransport extends BaseTransport private string $sseBuffer = ''; /** - * @param string $endpoint The MCP server endpoint URL - * @param array $headers Additional headers to send - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * Default cap on the bytes buffered while waiting for a complete SSE event. + */ + public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; + + private readonly int $maxSseBufferBytes; + + /** + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param int $maxSseBufferBytes Maximum bytes buffered while waiting for a complete + * SSE event. A server that never sends the "\n\n" event + * delimiter would otherwise grow the buffer without bound + * and exhaust client memory; reaching the cap aborts the + * stream instead. Raise it for servers that legitimately + * emit single events larger than the default. */ public function __construct( private readonly string $endpoint, @@ -65,9 +79,15 @@ class HttpTransport extends BaseTransport ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, + int $maxSseBufferBytes = self::DEFAULT_MAX_SSE_BUFFER_BYTES, ) { parent::__construct($logger); + if ($maxSseBufferBytes < 1) { + throw new InvalidArgumentException(\sprintf('The maximum SSE buffer size must be a positive number of bytes, got %d.', $maxSseBufferBytes)); + } + + $this->maxSseBufferBytes = $maxSseBufferBytes; $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -200,6 +220,12 @@ class HttpTransport extends BaseTransport if (!$this->activeStream->eof()) { $chunk = $this->activeStream->read(4096); if ('' !== $chunk) { + if (\strlen($this->sseBuffer) + \strlen($chunk) > $this->maxSseBufferBytes) { + $this->abortSseStream(\sprintf('buffered %d bytes without a complete event, exceeding the %d byte limit', \strlen($this->sseBuffer) + \strlen($chunk), $this->maxSseBufferBytes)); + + return; + } + $this->sseBuffer .= $chunk; } } @@ -218,6 +244,35 @@ class HttpTransport extends BaseTransport } } + /** + * Tear down the active SSE stream and fail any in-flight request. + * + * The waiting fiber is resolved with an error immediately so the caller + * fails fast, rather than spinning until the request timeout elapses. + */ + private function abortSseStream(string $reason): void + { + $bufferedBytes = \strlen($this->sseBuffer); + $this->sseBuffer = ''; + $this->activeStream = null; + + $this->logger->warning('Aborting SSE stream: '.$reason, [ + 'session_id' => $this->sessionId, + 'buffered_bytes' => $bufferedBytes, + 'max_sse_buffer_bytes' => $this->maxSseBufferBytes, + ]); + + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $error = Error::forInternalError('SSE stream aborted: '.$reason, $requestId); + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + } + /** * Parse a single SSE event and handle the message. */ diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php new file mode 100644 index 0000000..100107d --- /dev/null +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -0,0 +1,117 @@ +factory = new Psr17Factory(); + } + + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] + public function testSseBufferIsBoundedByConfiguredCap(): void + { + $transport = $this->createTransport(maxSseBufferBytes: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + // A server that streams data without ever sending the "\n\n" delimiter. + $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); + + $this->invokeProcessSseStream($transport); + + self::assertSame('', $this->readPrivate($transport, 'sseBuffer'), 'buffer must be cleared on abort'); + self::assertNull($this->readPrivate($transport, 'activeStream'), 'stream must be released on abort'); + } + + #[TestDox('aborting the SSE stream fails the in-flight request immediately instead of waiting for its timeout')] + public function testAbortFailsPendingRequestFast(): void + { + $transport = $this->createTransport(maxSseBufferBytes: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); + + $this->invokeProcessSseStream($transport); + + $response = $state->consumeResponse(1); + self::assertInstanceOf(Error::class, $response); + self::assertSame(Error::INTERNAL_ERROR, $response->code); + self::assertSame(1, $response->id); + } + + #[TestDox('well-formed delimited events are parsed and dispatched')] + public function testWellFormedEventsStillParse(): void + { + $transport = $this->createTransport(); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $this->setActiveStream($transport, $this->factory->createStream("data: hello\n\ndata: world\n\n")); + + $this->invokeProcessSseStream($transport); + + self::assertSame(['hello', 'world'], $messages); + } + + #[TestDox('the buffer cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createTransport(maxSseBufferBytes: 0); + } + + private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport + { + return new HttpTransport( + endpoint: 'https://example.test/mcp', + httpClient: $this->createMock(ClientInterface::class), + requestFactory: $this->factory, + streamFactory: $this->factory, + maxSseBufferBytes: $maxSseBufferBytes, + ); + } + + private function setActiveStream(HttpTransport $transport, StreamInterface $stream): void + { + (new \ReflectionProperty($transport, 'activeStream'))->setValue($transport, $stream); + } + + private function invokeProcessSseStream(HttpTransport $transport): void + { + (new \ReflectionMethod($transport, 'processSSEStream'))->invoke($transport); + } + + private function readPrivate(HttpTransport $transport, string $property): mixed + { + return (new \ReflectionProperty($transport, $property))->getValue($transport); + } +} From 529099f463915606363d164248d398b2b20adfa3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jul 2026 11:16:14 +0200 Subject: [PATCH 063/143] Merge commit from fork processInput() appended every 8 KiB stdout chunk to $inputBuffer and only drained it on a "\n". A spawned server that streams stdout without ever emitting a newline grew the buffer without bound, exhausting client memory (GHSA-6vrr-g4hw-f3hj). Cap the buffer (default 4 MiB, configurable via the constructor). When appending the next chunk would exceed the cap before a newline is seen, abort the read and resolve the in-flight request with an error immediately, so the caller fails fast instead of spinning until its request timeout. Based on localden's suggested fix in the advisory: makes the cap configurable, but resolves the pending request with an error (keeping runRequest's Response|Error contract) instead of throwing a TransportException out of the read loop, and leaves connection teardown to the caller. Adds regression tests. Co-authored-by: localden <53200638+localden@users.noreply.github.com> --- src/Client/Transport/StdioTransport.php | 58 +++++++++- .../Client/Transport/StdioTransportTest.php | 107 ++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Client/Transport/StdioTransportTest.php diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 4807461..f102961 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Transport; use Mcp\Exception\ConnectionException; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Log\LoggerInterface; @@ -45,6 +46,11 @@ class StdioTransport extends BaseTransport private string $inputBuffer = ''; + /** + * Default cap on the bytes buffered while waiting for a complete line. + */ + public const DEFAULT_MAX_BUFFER_SIZE = 4 * 1024 * 1024; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; @@ -52,10 +58,16 @@ class StdioTransport extends BaseTransport private $activeProgressCallback; /** - * @param string $command The command to run - * @param array $args Command arguments - * @param string|null $cwd Working directory - * @param array|null $env Environment variables + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables + * @param int $maxBufferSize Maximum bytes buffered while waiting for a complete line. The + * buffer is only drained on a "\n"; a spawned server that streams + * stdout without ever emitting a newline would otherwise grow it + * without bound and exhaust client memory. Reaching the cap aborts + * the read instead. Raise it for servers that emit single frames + * larger than the default. */ public function __construct( private readonly string $command, @@ -63,8 +75,13 @@ class StdioTransport extends BaseTransport private readonly ?string $cwd = null, private readonly ?array $env = null, ?LoggerInterface $logger = null, + private readonly int $maxBufferSize = self::DEFAULT_MAX_BUFFER_SIZE, ) { parent::__construct($logger); + + if ($maxBufferSize < 1) { + throw new InvalidArgumentException(\sprintf('The maximum buffer size must be a positive number of bytes, got %d.', $maxBufferSize)); + } } public function connect(): void @@ -223,6 +240,12 @@ class StdioTransport extends BaseTransport $data = fread($this->stdout, 8192); if (false !== $data && '' !== $data) { + if (\strlen($this->inputBuffer) + \strlen($data) > $this->maxBufferSize) { + $this->abortInput(\sprintf('buffered %d bytes without a newline, exceeding the %d byte limit', \strlen($this->inputBuffer) + \strlen($data), $this->maxBufferSize)); + + return; + } + $this->inputBuffer .= $data; } @@ -237,6 +260,33 @@ class StdioTransport extends BaseTransport } } + /** + * Discard the input buffer and fail any in-flight request. + * + * The waiting fiber is resolved with an error immediately so the caller + * fails fast, rather than spinning until the request timeout elapses. + */ + private function abortInput(string $reason): void + { + $bufferedBytes = \strlen($this->inputBuffer); + $this->inputBuffer = ''; + + $this->logger->warning('Aborting stdio input: '.$reason, [ + 'buffered_bytes' => $bufferedBytes, + 'max_buffer_size' => $this->maxBufferSize, + ]); + + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $error = Error::forInternalError('stdio input aborted: '.$reason, $requestId); + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + } + private function processFiber(): void { if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php new file mode 100644 index 0000000..ecc46b8 --- /dev/null +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -0,0 +1,107 @@ +addPendingRequest(1, 30); + $transport->setState($state); + + // A server that floods stdout without ever emitting a newline. + $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); + + $this->invokeProcessInput($transport); + + self::assertSame('', $this->readPrivate($transport, 'inputBuffer'), 'buffer must be cleared on abort'); + } + + #[TestDox('aborting the input fails the in-flight request immediately')] + public function testAbortFailsPendingRequestFast(): void + { + $transport = new StdioTransport(command: 'true', maxBufferSize: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); + + $this->invokeProcessInput($transport); + + $response = $state->consumeResponse(1); + self::assertInstanceOf(Error::class, $response); + self::assertSame(Error::INTERNAL_ERROR, $response->code); + self::assertSame(1, $response->id); + } + + #[TestDox('newline-delimited frames within the cap are parsed and dispatched')] + public function testWellFormedFramesStillParse(): void + { + $transport = new StdioTransport(command: 'true'); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $this->setStdout($transport, $this->stream('{"a":1}'."\n".'{"b":2}'."\n")); + + $this->invokeProcessInput($transport); + + self::assertSame(['{"a":1}', '{"b":2}'], $messages); + } + + #[TestDox('the buffer cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + new StdioTransport(command: 'true', maxBufferSize: 0); + } + + /** + * @return resource + */ + private function stream(string $contents) + { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + return $stream; + } + + private function setStdout(StdioTransport $transport, mixed $stream): void + { + (new \ReflectionProperty($transport, 'stdout'))->setValue($transport, $stream); + } + + private function invokeProcessInput(StdioTransport $transport): void + { + (new \ReflectionMethod($transport, 'processInput'))->invoke($transport); + } + + private function readPrivate(StdioTransport $transport, string $property): mixed + { + return (new \ReflectionProperty($transport, $property))->getValue($transport); + } +} From 9aa18f391a0d90791c2a57e85eb2c543d1637d0a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jul 2026 11:50:31 +0200 Subject: [PATCH 064/143] Merge commit from fork processInput() called fgets($this->input) with no length argument. PHP fgets() reads until a newline or EOF, so a peer that streams bytes without ever sending a newline forced the whole stream into a single allocation and exhausted the process memory limit (GHSA-vm75-qjc7-xh6w). Bound each read with fgets($this->input, $this->maxLineBytes) (default 4 MiB, configurable via the constructor). When a read fills the cap with no trailing newline the line exceeds the limit: discard it and keep discarding one bounded chunk per tick until its terminating newline, rather than buffering it. The discard is stateful so the run loop keeps servicing fibers and outgoing messages instead of blocking on a drain loop, and the over-length line is logged once. Reimplements tonghuaroot's advisory fix: makes the cap configurable rather than a hardcoded constant, drains the over-length line without an inner blocking loop (responsive run loop, single log line), and adds regression tests. Co-authored-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com> --- src/Server/Transport/StdioTransport.php | 43 +++++++- .../Server/Transport/StdioTransportTest.php | 101 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Server/Transport/StdioTransportTest.php diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 4da7f2a..565f7da 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Transport; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Transport\Stdio\RunnerControl; use Mcp\Server\Transport\Stdio\RunnerControlInterface; @@ -24,17 +25,34 @@ use Psr\Log\LoggerInterface; */ class StdioTransport extends BaseTransport { + /** + * Default cap on the bytes read for a single input line. + */ + public const DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024; + + /** Whether the current over-length line is still being drained and discarded. */ + private bool $discardingLine = false; + /** * @param resource $input * @param resource $output + * @param int $maxLineBytes Maximum bytes read for a single input line. fgets() with no length reads until a + * newline or EOF, so a peer that never sends a newline would buffer the whole stream + * into one allocation and exhaust memory; a line exceeding this cap is discarded + * instead. */ public function __construct( private $input = \STDIN, private $output = \STDOUT, ?LoggerInterface $logger = null, private readonly RunnerControlInterface $runnerControl = new RunnerControl(), + private readonly int $maxLineBytes = self::DEFAULT_MAX_LINE_BYTES, ) { parent::__construct($logger); + + if ($maxLineBytes < 1) { + throw new InvalidArgumentException(\sprintf('The maximum line size must be a positive number of bytes, got %d.', $maxLineBytes)); + } } public function send(string $data, array $context): void @@ -68,13 +86,36 @@ class StdioTransport extends BaseTransport protected function processInput(): void { - $line = fgets($this->input); + $line = fgets($this->input, $this->maxLineBytes); if (false === $line) { usleep(50000); // 50ms return; } + $lineComplete = str_ends_with($line, "\n"); + + // A previous over-length line is still being drained: keep discarding + // one bounded chunk per tick until its terminating newline is reached, + // so the run loop stays responsive instead of blocking on a drain loop. + if ($this->discardingLine) { + $this->discardingLine = !$lineComplete; + + return; + } + + // fgets() reads at most maxLineBytes - 1 bytes; a full read with no + // trailing newline means the line exceeds the cap. Discard it rather + // than buffering it, and keep discarding the remainder on later ticks. + if (!$lineComplete && \strlen($line) >= $this->maxLineBytes - 1) { + $this->discardingLine = true; + $this->logger->warning('StdioTransport discarded an input line exceeding the maximum length.', [ + 'max_line_bytes' => $this->maxLineBytes, + ]); + + return; + } + $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->handleMessage($trimmedLine, $this->sessionId); diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php new file mode 100644 index 0000000..252454d --- /dev/null +++ b/tests/Unit/Server/Transport/StdioTransportTest.php @@ -0,0 +1,101 @@ +createTransport(str_repeat('a', 100)."\n", $messages, maxLineBytes: 16); + + $this->pumpToEof($transport); + + self::assertSame([], $messages, 'the over-length line must never be dispatched'); + } + + #[TestDox('processing resumes with the next line after an over-length line is discarded')] + public function testRecoversAfterOverlongLine(): void + { + $messages = []; + $transport = $this->createTransport(str_repeat('a', 100)."\n".'{"valid":1}'."\n", $messages, maxLineBytes: 16); + + $this->pumpToEof($transport); + + self::assertSame(['{"valid":1}'], $messages); + } + + #[TestDox('a normal line within the cap is dispatched')] + public function testNormalLineIsDispatched(): void + { + $messages = []; + $transport = $this->createTransport('{"jsonrpc":"2.0","id":1}'."\n", $messages); + + $this->pumpToEof($transport); + + self::assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); + } + + #[TestDox('the line byte cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + new StdioTransport(input: $this->stream(''), output: $this->stream(''), maxLineBytes: 0); + } + + /** + * @param list $messages + */ + private function createTransport(string $input, array &$messages, int $maxLineBytes = 4 * 1024 * 1024): StdioTransport + { + $transport = new StdioTransport( + input: $this->stream($input), + output: $this->stream(''), + maxLineBytes: $maxLineBytes, + ); + + $transport->onMessage(static function ($transport, string $payload) use (&$messages): void { + $messages[] = $payload; + }); + + return $transport; + } + + /** + * @return resource + */ + private function stream(string $contents) + { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + return $stream; + } + + private function pumpToEof(StdioTransport $transport): void + { + $processInput = new \ReflectionMethod($transport, 'processInput'); + $input = (new \ReflectionProperty($transport, 'input'))->getValue($transport); + + for ($i = 0; $i < 1000 && !feof($input); ++$i) { + $processInput->invoke($transport); + } + } +} From 9a6d24c417154ff531906a3962061560736b0d5a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 27 Jul 2026 12:16:10 +0200 Subject: [PATCH 065/143] Fix codestyle pipeline for 0.7.1 (#402) --- tests/Unit/Client/Transport/HttpTransportTest.php | 12 ++++++------ tests/Unit/Client/Transport/StdioTransportTest.php | 10 +++++----- tests/Unit/Server/Transport/StdioTransportTest.php | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 100107d..408980d 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -43,8 +43,8 @@ final class HttpTransportTest extends TestCase $this->invokeProcessSseStream($transport); - self::assertSame('', $this->readPrivate($transport, 'sseBuffer'), 'buffer must be cleared on abort'); - self::assertNull($this->readPrivate($transport, 'activeStream'), 'stream must be released on abort'); + $this->assertSame('', $this->readPrivate($transport, 'sseBuffer'), 'buffer must be cleared on abort'); + $this->assertNull($this->readPrivate($transport, 'activeStream'), 'stream must be released on abort'); } #[TestDox('aborting the SSE stream fails the in-flight request immediately instead of waiting for its timeout')] @@ -60,9 +60,9 @@ final class HttpTransportTest extends TestCase $this->invokeProcessSseStream($transport); $response = $state->consumeResponse(1); - self::assertInstanceOf(Error::class, $response); - self::assertSame(Error::INTERNAL_ERROR, $response->code); - self::assertSame(1, $response->id); + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INTERNAL_ERROR, $response->code); + $this->assertSame(1, $response->id); } #[TestDox('well-formed delimited events are parsed and dispatched')] @@ -78,7 +78,7 @@ final class HttpTransportTest extends TestCase $this->invokeProcessSseStream($transport); - self::assertSame(['hello', 'world'], $messages); + $this->assertSame(['hello', 'world'], $messages); } #[TestDox('the buffer cap must be a positive number of bytes')] diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index ecc46b8..fb31408 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -33,7 +33,7 @@ final class StdioTransportTest extends TestCase $this->invokeProcessInput($transport); - self::assertSame('', $this->readPrivate($transport, 'inputBuffer'), 'buffer must be cleared on abort'); + $this->assertSame('', $this->readPrivate($transport, 'inputBuffer'), 'buffer must be cleared on abort'); } #[TestDox('aborting the input fails the in-flight request immediately')] @@ -49,9 +49,9 @@ final class StdioTransportTest extends TestCase $this->invokeProcessInput($transport); $response = $state->consumeResponse(1); - self::assertInstanceOf(Error::class, $response); - self::assertSame(Error::INTERNAL_ERROR, $response->code); - self::assertSame(1, $response->id); + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INTERNAL_ERROR, $response->code); + $this->assertSame(1, $response->id); } #[TestDox('newline-delimited frames within the cap are parsed and dispatched')] @@ -67,7 +67,7 @@ final class StdioTransportTest extends TestCase $this->invokeProcessInput($transport); - self::assertSame(['{"a":1}', '{"b":2}'], $messages); + $this->assertSame(['{"a":1}', '{"b":2}'], $messages); } #[TestDox('the buffer cap must be a positive number of bytes')] diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php index 252454d..dbfcec2 100644 --- a/tests/Unit/Server/Transport/StdioTransportTest.php +++ b/tests/Unit/Server/Transport/StdioTransportTest.php @@ -26,7 +26,7 @@ final class StdioTransportTest extends TestCase $this->pumpToEof($transport); - self::assertSame([], $messages, 'the over-length line must never be dispatched'); + $this->assertSame([], $messages, 'the over-length line must never be dispatched'); } #[TestDox('processing resumes with the next line after an over-length line is discarded')] @@ -37,7 +37,7 @@ final class StdioTransportTest extends TestCase $this->pumpToEof($transport); - self::assertSame(['{"valid":1}'], $messages); + $this->assertSame(['{"valid":1}'], $messages); } #[TestDox('a normal line within the cap is dispatched')] @@ -48,7 +48,7 @@ final class StdioTransportTest extends TestCase $this->pumpToEof($transport); - self::assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); + $this->assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); } #[TestDox('the line byte cap must be a positive number of bytes')] From 785fc3b9b7006ecc8a73322c939d96a4a7154345 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:09:23 +1200 Subject: [PATCH 066/143] Fix 'notifcation' typo in docs (#410) --- docs/server-client-communication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index b37ff63..f54294b 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -51,7 +51,7 @@ The `sample` method accepts four arguments: ## Logging The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers -to send structured log messages as notifcation to clients: +to send structured log messages as notification to clients: ```php use Mcp\Schema\Enum\LoggingLevel; From d9b3c0ba4e9d25727912b217294b201a85643a57 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 08:22:20 +0200 Subject: [PATCH 067/143] [Client] Reject non-positive timeouts in Configuration (#415) * [Client] Reject non-positive timeouts in Configuration * [Client] Drop redundant comments on timeout validation --- src/Client/Configuration.php | 8 +++ tests/Unit/Client/ConfigurationTest.php | 92 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/Unit/Client/ConfigurationTest.php diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index 8c7e909..98ad2d8 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -11,6 +11,7 @@ namespace Mcp\Client; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; @@ -30,5 +31,12 @@ class Configuration public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, ) { + if ($initTimeout < 1) { + throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout)); + } + + if ($requestTimeout < 1) { + throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout)); + } } } diff --git a/tests/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php new file mode 100644 index 0000000..ae50a49 --- /dev/null +++ b/tests/Unit/Client/ConfigurationTest.php @@ -0,0 +1,92 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $seconds)); + + $this->createConfiguration(initTimeout: $seconds); + } + + #[TestDox('a non-positive request timeout of $seconds seconds is rejected')] + #[DataProvider('provideNonPositiveTimeouts')] + public function testNonPositiveRequestTimeoutIsRejected(int $seconds): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The request timeout must be a positive number of seconds, got %d.', $seconds)); + + $this->createConfiguration(requestTimeout: $seconds); + } + + /** + * @return iterable + */ + public static function provideNonPositiveTimeouts(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + } + + #[TestDox('the builder rejects a non-positive initialization timeout')] + public function testBuilderRejectsNonPositiveInitTimeout(): void + { + $builder = Client::builder()->setInitTimeout(0); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('the builder rejects a non-positive request timeout')] + public function testBuilderRejectsNonPositiveRequestTimeout(): void + { + $builder = Client::builder()->setRequestTimeout(-5); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('positive timeouts are accepted')] + public function testPositiveTimeoutsAreAccepted(): void + { + $config = $this->createConfiguration(initTimeout: 1, requestTimeout: 1); + + $this->assertSame(1, $config->initTimeout); + $this->assertSame(1, $config->requestTimeout); + } + + private function createConfiguration(int $initTimeout = 30, int $requestTimeout = 120): Configuration + { + return new Configuration( + clientInfo: new Implementation('test-client', '1.0.0'), + capabilities: new ClientCapabilities(), + initTimeout: $initTimeout, + requestTimeout: $requestTimeout, + ); + } +} From a987a3a24895e9386a5ac0f2a5a13e1d5f9dc585 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 21:29:26 +0200 Subject: [PATCH 068/143] [Server] Throw the SDK's own RuntimeException from FileSessionStore (#404) The constructor guarded its unwritable-directory case with the global `\RuntimeException`, the only throw in `src/` still outside `Mcp\Exception\ExceptionInterface`. A consumer wrapping server setup in a `catch (ExceptionInterface)` missed it and got an unhandled SPL exception on a misconfigured session directory instead. `Mcp\Exception\RuntimeException` extends `\RuntimeException`, so this is additive: existing `catch (\RuntimeException)` blocks keep working. The constructor had no test coverage at all, so this adds one for the throw plus the directory-creation and write/read paths around it. --- src/Server/Session/FileSessionStore.php | 3 +- .../Server/Session/FileSessionStoreTest.php | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Server/Session/FileSessionStoreTest.php diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index aa99aba..9716e3d 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Session; +use Mcp\Exception\RuntimeException; use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; use Symfony\Component\Uid\Uuid; @@ -31,7 +32,7 @@ class FileSessionStore implements SessionStoreInterface } if (!is_dir($this->directory) || !is_writable($this->directory)) { - throw new \RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); + throw new RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); } } diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php new file mode 100644 index 0000000..1e0caa7 --- /dev/null +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -0,0 +1,90 @@ +directory = sys_get_temp_dir().'/mcp-file-session-store-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (!is_dir($this->directory)) { + return; + } + + @chmod($this->directory, 0775); + + foreach (glob($this->directory.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->directory); + } + + #[TestDox('creates the session directory when it does not exist yet')] + public function testCreatesMissingDirectory(): void + { + new FileSessionStore($this->directory); + + $this->assertDirectoryExists($this->directory); + } + + #[TestDox('round-trips a session payload through the filesystem')] + public function testWriteThenRead(): void + { + $store = new FileSessionStore($this->directory); + $id = new UuidV4(); + + $store->write($id, 'payload'); + + $this->assertTrue($store->exists($id)); + $this->assertSame('payload', $store->read($id)); + } + + #[TestDox('rejects an unwritable directory with the SDK\'s own exception')] + public function testUnwritableDirectoryThrowsPackageException(): void + { + mkdir($this->directory, 0775, true); + 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 store's only throw must stay inside the package hierarchy, so a + // consumer catching ExceptionInterface sees it rather than a bare SPL + // RuntimeException escaping the SDK. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Session directory "%s" is not writable.', $this->directory)); + + try { + new FileSessionStore($this->directory); + } catch (RuntimeException $e) { + $this->assertInstanceOf(ExceptionInterface::class, $e); + + throw $e; + } + } +} From 9236824b6dabd5e4657f97214b3b5886e0d22dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Meyer?= Date: Fri, 14 Aug 2026 21:50:08 +0200 Subject: [PATCH 069/143] [Schema] Always emit {} for empty tool schema properties (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Schema] Always emit {} for empty tool schema properties Normalize empty properties arrays (including nested object schemas and outputSchema) in Tool::__construct so tools/list never serializes invalid JSON Schema properties: []. Fixes #405 * [Schema] Normalize every empty sub-schema, not only properties Walking into `items` and `additionalProperties` without normalizing an empty sub-schema at those positions left the `[]`-instead-of-`{}` bug intact one level down. Most notably it regressed #151 on a `Tool::fromArray()` round-trip: `SchemaGenerator` emits `items: {}` for untyped array parameters, which decodes to `[]` and was re-serialized as `[]` — the exact schema strict clients reject. Empty sub-schemas are now replaced with a `\stdClass` at every schema position, and the keyword list is completed to cover draft-07 through 2020-12: `$defs`, `definitions`, `dependentSchemas`, `patternProperties`, `propertyNames`, `contains`, `not`, `if`/`then`/`else`, `additionalItems`, and `unevaluatedItems`/`unevaluatedProperties`. Keywords holding plain JSON arrays (`allOf: []`, `enum: []`, `dependentRequired`) keep encoding as `[]`. --------- Co-authored-by: Christopher Hertel --- CHANGELOG.md | 5 + src/Schema/Tool.php | 142 +++++++++++++++++++++--- tests/Unit/Schema/ToolTest.php | 192 +++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a9..01665a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.8.0 +----- + +* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. + 0.7.0 ----- diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 1817804..83a83f8 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -21,14 +21,14 @@ use Mcp\Exception\InvalidArgumentException; * * @phpstan-type ToolInputSchema array{ * type: 'object', - * properties: array, + * properties: array|\stdClass, * required: string[]|null * } * @phpstan-type ToolOutputSchema array{ * type: 'object', - * properties?: array, + * properties?: array|\stdClass, * required?: string[]|null, - * additionalProperties?: bool|array, + * additionalProperties?: bool|array|\stdClass, * description?: string * } * @phpstan-type ToolData array{ @@ -46,6 +46,53 @@ use Mcp\Exception\InvalidArgumentException; */ class Tool implements \JsonSerializable { + /** + * JSON Schema keywords whose value is a single sub-schema. + */ + private const SUB_SCHEMA_KEYWORDS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + ]; + + /** + * JSON Schema keywords whose value maps names to sub-schemas. + */ + private const SUB_SCHEMA_MAP_KEYWORDS = [ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties', + ]; + + /** + * JSON Schema keywords whose value is a list of sub-schemas. + */ + private const SUB_SCHEMA_LIST_KEYWORDS = [ + 'allOf', + 'anyOf', + 'oneOf', + 'prefixItems', + ]; + + /** + * @var ToolInputSchema + */ + public readonly array $inputSchema; + + /** + * @var ToolOutputSchema|null + */ + public readonly ?array $outputSchema; + /** * @param string $name the name of the tool * @param ?string $title Optional human-readable title for display in UI @@ -61,16 +108,21 @@ class Tool implements \JsonSerializable public function __construct( public readonly string $name, public readonly ?string $title, - public readonly array $inputSchema, + array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, public readonly ?array $icons = null, public readonly ?array $meta = null, - public readonly ?array $outputSchema = null, + ?array $outputSchema = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); } + + // Always normalize here so every construction path emits `{}` for empty + // sub-schemas — not only SchemaGenerator / fromArray. + $this->inputSchema = self::normalizeSchema($inputSchema); + $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; } /** @@ -87,20 +139,19 @@ class Tool implements \JsonSerializable if (!isset($data['inputSchema']['type']) || 'object' !== $data['inputSchema']['type']) { throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); } - $inputSchema = self::normalizeSchemaProperties($data['inputSchema']); $outputSchema = null; if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); } - $outputSchema = self::normalizeSchemaProperties($data['outputSchema']); + $outputSchema = $data['outputSchema']; } return new self( name: $data['name'], title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, - inputSchema: $inputSchema, + inputSchema: $data['inputSchema'], description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, @@ -148,18 +199,85 @@ class Tool implements \JsonSerializable } /** - * Normalize schema properties: convert an empty properties array to stdClass. + * Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`. + * + * Once JSON is decoded into associative arrays, PHP cannot tell the empty object `{}` + * from the empty array `[]` — both are `[]`. Re-encoding then produces `[]`, which is + * invalid wherever a schema is expected (`properties`, `items`, `additionalProperties`, + * …), and strict clients reject it. Every empty sub-schema is therefore replaced with a + * `\stdClass` before serialization. + * + * The walk is recursive and covers the schema keywords of draft-07 through 2020-12, so + * nested object parameters, `$defs`, combinators, and `outputSchema` are all covered — + * not only the top-level `properties` map. * * @param array $schema * * @return array */ - private static function normalizeSchemaProperties(array $schema): array + private static function normalizeSchema(array $schema): array { - if (isset($schema['properties']) && \is_array($schema['properties']) && empty($schema['properties'])) { - $schema['properties'] = new \stdClass(); + foreach (self::SUB_SCHEMA_KEYWORDS as $keyword) { + if (isset($schema[$keyword]) && \is_array($schema[$keyword])) { + $schema[$keyword] = self::normalizeSubSchema($schema[$keyword]); + } + } + + foreach (self::SUB_SCHEMA_MAP_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { + continue; + } + + if ([] === $schema[$keyword]) { + $schema[$keyword] = new \stdClass(); + continue; + } + + foreach ($schema[$keyword] as $name => $subSchema) { + if (\is_array($subSchema)) { + $schema[$keyword][$name] = self::normalizeSubSchema($subSchema); + } + } + } + + foreach (self::SUB_SCHEMA_LIST_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { + continue; + } + + // An empty list stays a list — `allOf: []` is already valid JSON. + foreach ($schema[$keyword] as $index => $subSchema) { + if (\is_array($subSchema)) { + $schema[$keyword][$index] = self::normalizeSubSchema($subSchema); + } + } + } + + if (isset($schema['items']) && \is_array($schema['items'])) { + // `items` is a single sub-schema, or a list of them in draft-07 tuple form. + // An empty array is read as the empty schema `{}` — what an `items: {}` from + // SchemaGenerator decodes to — rather than as an empty tuple. + if ([] !== $schema['items'] && array_is_list($schema['items'])) { + foreach ($schema['items'] as $index => $itemSchema) { + if (\is_array($itemSchema)) { + $schema['items'][$index] = self::normalizeSubSchema($itemSchema); + } + } + } else { + $schema['items'] = self::normalizeSubSchema($schema['items']); + } } return $schema; } + + /** + * @param array $schema + * + * @return array|\stdClass + */ + private static function normalizeSubSchema(array $schema): array|\stdClass + { + return [] === $schema ? new \stdClass() : self::normalizeSchema($schema); + } } diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index dd6861c..dc71189 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -97,4 +97,196 @@ class ToolTest extends TestCase $this->assertSame($original->name, $restored->name); $this->assertSame($original->description, $restored->description); } + + public function testConstructorNormalizesEmptyInputSchemaPropertiesToObject(): void + { + $tool = new Tool( + name: 'no_params', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertSame('{"name":"no_params","inputSchema":{"type":"object","properties":{},"required":null}}', json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesAfterJsonDecodeRoundTrip(): void + { + /** @var array{type: 'object', properties: array, required: null} $schema */ + $schema = json_decode('{"type":"object","properties":{},"required":null}', true); + $this->assertSame([], $schema['properties']); + + $tool = new Tool('t', null, $schema, null, null); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool)); + } + + public function testFromArrayNormalizesNestedEmptyPropertiesRecursively(): void + { + $tool = Tool::fromArray([ + 'name' => 't', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'filter' => ['type' => 'object', 'properties' => []], + ], + 'required' => null, + ], + ]); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['filter']['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool->inputSchema['properties']['filter'])); + } + + public function testConstructorNormalizesEmptyOutputSchemaProperties(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: ['type' => 'object', 'properties' => ['q' => ['type' => 'string']], 'required' => null], + description: null, + annotations: null, + outputSchema: ['type' => 'object', 'properties' => []], + ); + + $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties']); + $this->assertStringContainsString('"outputSchema":{"type":"object","properties":{}}', (string) json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'rows' => [ + 'type' => 'array', + 'items' => ['type' => 'object', 'properties' => []], + ], + ], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); + } + + /** + * Each case is a schema that is already valid JSON: decoding it collapses every `{}` + * to `[]`, and normalization has to restore it verbatim. + * + * @return iterable + */ + public static function emptySubSchemaProvider(): iterable + { + yield 'top-level properties' => ['{"type":"object","properties":{}}']; + yield 'nested properties' => ['{"type":"object","properties":{"filter":{"type":"object","properties":{}}}}']; + yield 'property schema' => ['{"type":"object","properties":{"anything":{}}}']; + yield 'items schema' => ['{"type":"object","properties":{"tags":{"type":"array","items":{}}}}']; + yield 'tuple items schema' => ['{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"object","properties":{}},{}]}}}']; + yield 'additionalItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","additionalItems":{}}}}']; + yield 'additionalProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","additionalProperties":{}}}}']; + yield 'propertyNames schema' => ['{"type":"object","properties":{"map":{"type":"object","propertyNames":{}}}}']; + yield 'contains schema' => ['{"type":"object","properties":{"tags":{"type":"array","contains":{}}}}']; + yield 'unevaluatedItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","unevaluatedItems":{}}}}']; + yield 'unevaluatedProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","unevaluatedProperties":{}}}}']; + yield 'not schema' => ['{"type":"object","properties":{"a":{"not":{}}}}']; + yield 'if/then/else schemas' => ['{"type":"object","properties":{"a":{"if":{},"then":{"type":"object","properties":{}},"else":{}}}}']; + yield '$defs entry' => ['{"type":"object","properties":{"a":{"$ref":"#/$defs/E"}},"$defs":{"E":{"type":"object","properties":{}}}}']; + yield 'definitions entry' => ['{"type":"object","properties":{"a":{"$ref":"#/definitions/E"}},"definitions":{"E":{}}}']; + yield 'patternProperties entry' => ['{"type":"object","properties":{"map":{"type":"object","patternProperties":{"^x":{}}}}}']; + yield 'dependentSchemas entry' => ['{"type":"object","properties":{"a":{"type":"string"}},"dependentSchemas":{"a":{}}}']; + yield 'combinator branches' => ['{"type":"object","properties":{"a":{"anyOf":[{},{"type":"object","properties":{}}],"oneOf":[{}],"allOf":[{}]}}}']; + yield 'prefixItems entry' => ['{"type":"object","properties":{"a":{"type":"array","prefixItems":[{},{"type":"object","properties":{}}]}}}']; + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemas(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemasInOutputSchema(string $schemaJson): void + { + /** @var array{type: 'object', properties?: array} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool( + name: 't', + title: null, + inputSchema: self::validInputSchema(), + description: null, + annotations: null, + outputSchema: $schema, + ); + + $this->assertSame($schemaJson, json_encode($tool->outputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * @return iterable + */ + public static function preservedEmptyArrayProvider(): iterable + { + yield 'empty combinator list' => ['{"type":"object","properties":{},"allOf":[]}']; + yield 'empty prefixItems list' => ['{"type":"object","properties":{},"prefixItems":[]}']; + yield 'empty required list' => ['{"type":"object","properties":{},"required":[]}']; + yield 'empty enum list' => ['{"type":"object","properties":{"a":{"enum":[]}}}']; + yield 'empty dependentRequired list' => ['{"type":"object","properties":{"a":{}},"dependentRequired":{"a":[]}}']; + } + + /** + * Keywords that hold JSON arrays — not sub-schemas — must keep encoding as `[]`. + */ + #[DataProvider('preservedEmptyArrayProvider')] + public function testConstructorLeavesNonSchemaEmptyArraysAlone(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * Regression test for #151: `SchemaGenerator` emits `items: {}` for untyped arrays, + * but a client decoding that payload gets `items: []` back — re-serializing it used to + * hand strict clients the very schema #151 fixed. + */ + public function testFromArrayRoundTripPreservesEmptyItemsSchema(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['tags' => ['type' => 'array', 'items' => new \stdClass()]], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $wire = (string) json_encode($tool); + $this->assertStringContainsString('"items":{}', $wire); + + /** @var array{name: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}} $decoded */ + $decoded = json_decode($wire, true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame($wire, json_encode(Tool::fromArray($decoded))); + } } From c8ed07bba251ec3748f548773bfb47280092fa42 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 22:34:01 +0200 Subject: [PATCH 070/143] [Server] Handle malformed JSON-RPC input graceful (#412) * Never let a throwable escape Protocol::processInput() * Validate payload types in the schema hydration methods * Stop leaking handler exception messages to the client --- src/JsonRpc/MessageFactory.php | 4 + src/Schema/Annotations.php | 33 +++- src/Schema/Content/AudioContent.php | 9 +- src/Schema/Content/BlobResourceContents.php | 7 + src/Schema/Content/EmbeddedResource.php | 2 +- src/Schema/Content/PromptMessage.php | 9 +- src/Schema/Content/SamplingMessage.php | 8 +- src/Schema/Content/TextContent.php | 2 +- src/Schema/Content/TextResourceContents.php | 7 + src/Schema/Elicitation/ElicitationSchema.php | 7 + .../Extension/Apps/UiResourceContentMeta.php | 15 ++ src/Schema/Extension/Apps/UiResourceCsp.php | 8 + src/Schema/Extension/Apps/UiToolMeta.php | 20 ++- src/Schema/Icon.php | 30 +++- src/Schema/Implementation.php | 9 +- src/Schema/ModelPreferences.php | 29 +++- .../Notification/CancelledNotification.php | 4 + .../LoggingMessageNotification.php | 9 +- .../Notification/ProgressNotification.php | 17 +- src/Schema/Prompt.php | 21 ++- src/Schema/PromptArgument.php | 7 + .../Request/CompletionCompleteRequest.php | 16 +- .../Request/CreateSamplingMessageRequest.php | 29 +++- src/Schema/Request/InitializeRequest.php | 12 +- src/Schema/Request/ListPromptsRequest.php | 5 + .../Request/ListResourceTemplatesRequest.php | 5 + src/Schema/Request/ListResourcesRequest.php | 5 + src/Schema/Request/ListToolsRequest.php | 5 + src/Schema/Request/SetLogLevelRequest.php | 6 +- src/Schema/ResourceDefinition.php | 17 +- src/Schema/ResourceTemplate.php | 10 +- src/Schema/Result/CallToolResult.php | 19 ++- .../Result/CompletionCompleteResult.php | 12 ++ .../Result/CreateSamplingMessageResult.php | 5 +- src/Schema/Result/ElicitResult.php | 5 +- src/Schema/Result/GetPromptResult.php | 8 + src/Schema/Result/InitializeResult.php | 7 + src/Schema/Result/ListPromptsResult.php | 15 +- .../Result/ListResourceTemplatesResult.php | 15 +- src/Schema/Result/ListResourcesResult.php | 15 +- src/Schema/Result/ListToolsResult.php | 15 +- src/Schema/Root.php | 4 + src/Schema/Tool.php | 2 +- src/Schema/ToolAnnotations.php | 12 ++ src/Server/Protocol.php | 95 ++++++++++- tests/Unit/Fixtures/ThrowingRequest.php | 38 +++++ tests/Unit/JsonRpc/MalformedInputTest.php | 83 +++++++++ tests/Unit/JsonRpc/MessageFactoryTest.php | 36 ++++ tests/Unit/Schema/Result/ElicitResultTest.php | 3 +- tests/Unit/Server/ProtocolTest.php | 160 +++++++++++++++++- 50 files changed, 858 insertions(+), 58 deletions(-) create mode 100644 tests/Unit/Fixtures/ThrowingRequest.php create mode 100644 tests/Unit/JsonRpc/MalformedInputTest.php diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 97bac3e..d9a895e 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -177,6 +177,10 @@ final class MessageFactory throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.'); } + if (!\is_string($data['method'])) { + throw new InvalidInputMessageException('Invalid JSON-RPC message: "method" must be a string.'); + } + $messageClass = $this->findMessageClassByMethod($data['method']); return $messageClass::fromArray($data); diff --git a/src/Schema/Annotations.php b/src/Schema/Annotations.php index a448ba5..ec64e7a 100644 --- a/src/Schema/Annotations.php +++ b/src/Schema/Annotations.php @@ -60,7 +60,20 @@ class Annotations implements \JsonSerializable { $audience = null; if (isset($data['audience']) && \is_array($data['audience'])) { - $audience = array_map(static fn (string $r) => Role::from($r), $data['audience']); + $audience = array_map( + static function (mixed $role): Role { + if (!\is_string($role) || null === $case = Role::tryFrom($role)) { + throw new InvalidArgumentException('Each entry in "audience" must be a valid role.'); + } + + return $case; + }, + $data['audience'], + ); + } + + if (isset($data['priority']) && !\is_float($data['priority']) && !\is_int($data['priority'])) { + throw new InvalidArgumentException('Invalid "priority" in Annotations data; expected a number.'); } return new self( @@ -69,6 +82,24 @@ class Annotations implements \JsonSerializable ); } + /** + * Hydrates an optional "annotations" field, rejecting a value that is present but not an object. + * + * @param string $context the surrounding schema type, used for the error message + */ + public static function tryFromArray(mixed $data, string $context): ?self + { + if (null === $data) { + return null; + } + + if (!\is_array($data)) { + throw new InvalidArgumentException(\sprintf('Invalid "annotations" in %s data; expected an array.', $context)); + } + + return self::fromArray($data); + } + /** * @return AnnotationsData */ diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php index a23db24..cf7418c 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -44,14 +44,17 @@ class AudioContent extends Content */ public static function fromArray(array $data): self { - if (!isset($data['data']) || !isset($data['mimeType'])) { - throw new InvalidArgumentException('Invalid or missing "data" or "mimeType" in AudioContent data.'); + if (!isset($data['data']) || !\is_string($data['data'])) { + throw new InvalidArgumentException('Missing or invalid "data" in AudioContent data.'); + } + if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Missing or invalid "mimeType" in AudioContent data.'); } return new self( $data['data'], $data['mimeType'], - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + Annotations::tryFromArray($data['annotations'] ?? null, 'AudioContent') ); } diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index 0d6016d..ee126fb 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -54,6 +54,13 @@ class BlobResourceContents extends ResourceContents throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" for BlobResourceContents.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" for BlobResourceContents.'); + } + return new self($data['uri'], $data['mimeType'] ?? null, $data['blob'], $data['_meta'] ?? null); } diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php index eb84985..76c4d67 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -62,7 +62,7 @@ class EmbeddedResource extends Content return new self( $resourceInstance, - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + Annotations::tryFromArray($data['annotations'] ?? null, 'EmbeddedResource'), ); } diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index 075d113..923459d 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -58,6 +58,9 @@ class PromptMessage extends Content $contentData = $data['content']; $contentType = $contentData['type'] ?? null; + if (!\is_string($contentType)) { + throw new InvalidArgumentException('Missing or invalid content "type" for PromptMessage.'); + } $content = match ($contentType) { 'text' => TextContent::fromArray($contentData), @@ -67,7 +70,11 @@ class PromptMessage extends Content default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)), }; - return new self(Role::from($data['role']), $content); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in PromptMessage data.', $data['role'])); + } + + return new self($role, $content); } /** diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 48aaa71..18c8877 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -45,9 +45,15 @@ class SamplingMessage extends Content throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.'); } - $role = Role::from($data['role']); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in SamplingMessage data.', $data['role'])); + } + $contentData = $data['content']; $contentType = $contentData['type'] ?? null; + if (!\is_string($contentType)) { + throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.'); + } $contentInstance = match ($contentType) { 'text' => TextContent::fromArray($contentData), diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php index 14a6402..b743af5 100644 --- a/src/Schema/Content/TextContent.php +++ b/src/Schema/Content/TextContent.php @@ -56,7 +56,7 @@ class TextContent extends Content return new self( $data['text'], - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + Annotations::tryFromArray($data['annotations'] ?? null, 'TextContent') ); } diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php index 47ee31f..9beff81 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -54,6 +54,13 @@ class TextResourceContents extends ResourceContents throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" for TextResourceContents.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" for TextResourceContents.'); + } + return new self($data['uri'], $data['mimeType'] ?? null, $data['text'], $data['_meta'] ?? null); } diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index 1e4486c..36d130a 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -35,6 +35,9 @@ final class ElicitationSchema implements \JsonSerializable } foreach ($required as $name) { + if (!\is_string($name)) { + throw new InvalidArgumentException('Each entry in "required" must be a string.'); + } if (!\array_key_exists($name, $properties)) { throw new InvalidArgumentException(\sprintf('Required property "%s" is not defined in properties.', $name)); } @@ -68,6 +71,10 @@ final class ElicitationSchema implements \JsonSerializable $properties[$name] = self::createSchemaDefinition($propertyData); } + if (isset($data['required']) && !\is_array($data['required'])) { + throw new InvalidArgumentException('Invalid "required" for elicitation schema; expected an array.'); + } + return new self( properties: $properties, required: $data['required'] ?? [], diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php index e507d80..666a7e9 100644 --- a/src/Schema/Extension/Apps/UiResourceContentMeta.php +++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Metadata for the _meta.ui field on resource content in a resources/read response. * @@ -43,6 +45,19 @@ final class UiResourceContentMeta implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['csp']) && !\is_array($data['csp'])) { + throw new InvalidArgumentException('Invalid "csp" in UiResourceContentMeta data; expected an array.'); + } + if (isset($data['permissions']) && !\is_array($data['permissions'])) { + throw new InvalidArgumentException('Invalid "permissions" in UiResourceContentMeta data; expected an array.'); + } + if (isset($data['domain']) && !\is_string($data['domain'])) { + throw new InvalidArgumentException('Invalid "domain" in UiResourceContentMeta data.'); + } + if (isset($data['prefersBorder']) && !\is_bool($data['prefersBorder'])) { + throw new InvalidArgumentException('Invalid "prefersBorder" in UiResourceContentMeta data.'); + } + return new self( csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null, permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null, diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php index d487d72..e3550a6 100644 --- a/src/Schema/Extension/Apps/UiResourceCsp.php +++ b/src/Schema/Extension/Apps/UiResourceCsp.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Content Security Policy configuration for MCP App resources. * @@ -47,6 +49,12 @@ final class UiResourceCsp implements \JsonSerializable */ public static function fromArray(array $data): self { + foreach (['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'] as $key) { + if (isset($data[$key]) && !\is_array($data[$key])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in UiResourceCsp data; expected an array.', $key)); + } + } + return new self( connectDomains: $data['connectDomains'] ?? null, resourceDomains: $data['resourceDomains'] ?? null, diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php index ecefa19..235df2a 100644 --- a/src/Schema/Extension/Apps/UiToolMeta.php +++ b/src/Schema/Extension/Apps/UiToolMeta.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Metadata for the _meta.ui field on a Tool, linking it to a UI resource. * @@ -39,9 +41,25 @@ final class UiToolMeta implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['resourceUri']) && !\is_string($data['resourceUri'])) { + throw new InvalidArgumentException('Invalid "resourceUri" in UiToolMeta data.'); + } + if (isset($data['visibility']) && !\is_array($data['visibility'])) { + throw new InvalidArgumentException('Invalid "visibility" in UiToolMeta data; expected an array.'); + } + return new self( resourceUri: $data['resourceUri'] ?? null, - visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null, + visibility: isset($data['visibility']) ? array_map( + static function (mixed $entry): ToolVisibility { + if (!\is_string($entry) || null === $case = ToolVisibility::tryFrom($entry)) { + throw new InvalidArgumentException('Each entry in "visibility" of UiToolMeta data must be a valid tool visibility.'); + } + + return $case; + }, + $data['visibility'], + ) : null, ); } diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index b3cdd72..13929e0 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -65,8 +65,36 @@ class Icon implements \JsonSerializable if (empty($data['src']) || !\is_string($data['src'])) { throw new InvalidArgumentException('Invalid or missing "src" in Icon data.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in Icon data.'); + } + if (isset($data['sizes']) && !\is_array($data['sizes'])) { + throw new InvalidArgumentException('Invalid "sizes" in Icon data.'); + } - return new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null); + return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null); + } + + /** + * Hydrates an "icons" list, rejecting entries that are not objects. + * + * @param array $icons + * @param string $context the surrounding schema type, used for the error message + * + * @return self[] + */ + public static function listFromArray(array $icons, string $context): array + { + return array_map( + static function (mixed $icon) use ($context): self { + if (!\is_array($icon)) { + throw new InvalidArgumentException(\sprintf('Each entry in "icons" of %s data must be an array.', $context)); + } + + return self::fromArray($icon); + }, + $icons, + ); } /** diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 0216f05..214a38f 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -57,7 +57,14 @@ class Implementation implements \JsonSerializable throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); } - $data['icons'] = array_map(Icon::fromArray(...), $data['icons']); + $data['icons'] = Icon::listFromArray($data['icons'], 'Implementation'); + } + + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in Implementation data.'); + } + if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) { + throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.'); } return new self( diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php index f2f30db..88cf558 100644 --- a/src/Schema/ModelPreferences.php +++ b/src/Schema/ModelPreferences.php @@ -11,6 +11,8 @@ namespace Mcp\Schema; +use Mcp\Exception\InvalidArgumentException; + /** * The server's preferences for model selection, requested of the client during sampling. * @@ -61,14 +63,35 @@ class ModelPreferences implements \JsonSerializable */ public static function fromArray(array $preferences): self { + if (isset($preferences['hints']) && !\is_array($preferences['hints'])) { + throw new InvalidArgumentException('Invalid "hints" in ModelPreferences data.'); + } + return new self( $preferences['hints'] ?? null, - $preferences['costPriority'] ?? null, - $preferences['speedPriority'] ?? null, - $preferences['intelligencePriority'] ?? null, + self::priority($preferences, 'costPriority'), + self::priority($preferences, 'speedPriority'), + self::priority($preferences, 'intelligencePriority'), ); } + /** + * @param array $preferences + */ + private static function priority(array $preferences, string $key): ?float + { + if (!isset($preferences[$key])) { + return null; + } + + // JSON numbers decode to int when they have no fractional part. + if (!\is_float($preferences[$key]) && !\is_int($preferences[$key])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in ModelPreferences data; expected a number.', $key)); + } + + return (float) $preferences[$key]; + } + /** * @return ModelPreferencesData */ diff --git a/src/Schema/Notification/CancelledNotification.php b/src/Schema/Notification/CancelledNotification.php index 14d4e59..4ae3257 100644 --- a/src/Schema/Notification/CancelledNotification.php +++ b/src/Schema/Notification/CancelledNotification.php @@ -48,6 +48,10 @@ class CancelledNotification extends Notification throw new InvalidArgumentException('Invalid or missing "requestId" parameter for "notifications/cancelled" notification.'); } + if (isset($params['reason']) && !\is_string($params['reason'])) { + throw new InvalidArgumentException('Invalid "reason" parameter for "notifications/cancelled" notification.'); + } + return new self($params['requestId'], $params['reason'] ?? null); } diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php index 29e00fc..3088f49 100644 --- a/src/Schema/Notification/LoggingMessageNotification.php +++ b/src/Schema/Notification/LoggingMessageNotification.php @@ -47,7 +47,14 @@ class LoggingMessageNotification extends Notification throw new InvalidArgumentException('Missing "data" parameter for "notifications/message" notification.'); } - $level = LoggingLevel::from($params['level']); + if (null === $level = LoggingLevel::tryFrom($params['level'])) { + throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "notifications/message" notification.', $params['level'])); + } + + if (isset($params['logger']) && !\is_string($params['logger'])) { + throw new InvalidArgumentException('Invalid "logger" parameter for "notifications/message" notification.'); + } + $data = \is_string($params['data']) ? $params['data'] : json_encode($params['data']); return new self($level, $data, $params['logger'] ?? null); diff --git a/src/Schema/Notification/ProgressNotification.php b/src/Schema/Notification/ProgressNotification.php index d3365a9..15fc50a 100644 --- a/src/Schema/Notification/ProgressNotification.php +++ b/src/Schema/Notification/ProgressNotification.php @@ -44,18 +44,27 @@ class ProgressNotification extends Notification protected static function fromParams(?array $params): Notification { - if (!isset($params['progressToken']) || !\is_string($params['progressToken'])) { + // JSON numbers decode to int when they have no fractional part. + if (!isset($params['progressToken']) || !\is_string($params['progressToken']) && !\is_int($params['progressToken'])) { throw new InvalidArgumentException('Missing or invalid "progressToken" parameter for "notifications/progress" notification.'); } - if (!isset($params['progress']) || !\is_float($params['progress'])) { + if (!isset($params['progress']) || !\is_float($params['progress']) && !\is_int($params['progress'])) { throw new InvalidArgumentException('Missing or invalid "progress" parameter for "notifications/progress" notification.'); } + if (isset($params['total']) && !\is_float($params['total']) && !\is_int($params['total'])) { + throw new InvalidArgumentException('Invalid "total" parameter for "notifications/progress" notification.'); + } + + if (isset($params['message']) && !\is_string($params['message'])) { + throw new InvalidArgumentException('Invalid "message" parameter for "notifications/progress" notification.'); + } + return new self( $params['progressToken'], - $params['progress'], - $params['total'] ?? null, + (float) $params['progress'], + isset($params['total']) ? (float) $params['total'] : null, $params['message'] ?? null, ); } diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 68ae773..1f33e41 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -67,19 +67,34 @@ class Prompt implements \JsonSerializable } $arguments = null; if (isset($data['arguments']) && \is_array($data['arguments'])) { - $arguments = array_map(static fn (array $argData) => PromptArgument::fromArray($argData), $data['arguments']); + $arguments = array_map( + static function (mixed $argData): PromptArgument { + if (!\is_array($argData)) { + throw new InvalidArgumentException('Each entry in "arguments" of Prompt data must be an array.'); + } + + return PromptArgument::fromArray($argData); + }, + $data['arguments'], + ); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in Prompt data.'); } + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in Prompt data.'); + } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in Prompt data.'); + } return new self( name: $data['name'], title: $data['title'] ?? null, description: $data['description'] ?? null, arguments: $arguments, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Prompt') : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/PromptArgument.php b/src/Schema/PromptArgument.php index 3ef45ca..38fe3dc 100644 --- a/src/Schema/PromptArgument.php +++ b/src/Schema/PromptArgument.php @@ -47,6 +47,13 @@ class PromptArgument implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in PromptArgument data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in PromptArgument data.'); + } + if (isset($data['required']) && !\is_bool($data['required'])) { + throw new InvalidArgumentException('Invalid "required" in PromptArgument data.'); + } + return new self( name: $data['name'], description: $data['description'] ?? null, diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php index 467bb97..7ad0332 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -45,8 +45,8 @@ final class CompletionCompleteRequest extends Request } $ref = match ($params['ref']['type'] ?? null) { - 'ref/prompt' => new PromptReference($params['ref']['name']), - 'ref/resource' => new ResourceReference($params['ref']['uri']), + 'ref/prompt' => new PromptReference(self::refString($params['ref'], 'name')), + 'ref/resource' => new ResourceReference(self::refString($params['ref'], 'uri')), default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), }; @@ -57,6 +57,18 @@ final class CompletionCompleteRequest extends Request return new self($ref, $params['argument']); } + /** + * @param array $ref + */ + private static function refString(array $ref, string $key): string + { + if (!isset($ref[$key]) || !\is_string($ref[$key])) { + throw new InvalidArgumentException(\sprintf('Missing or invalid "ref.%s" parameter for completion/complete.', $key)); + } + + return $ref[$key]; + } + /** * @return array{ * ref: PromptReference|ResourceReference, diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 3014405..71a7b27 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -87,6 +87,9 @@ final class CreateSamplingMessageRequest extends Request $preferences = null; if (isset($params['preferences'])) { + if (!\is_array($params['preferences'])) { + throw new InvalidArgumentException('Invalid "preferences" parameter for sampling/createMessage.'); + } $preferences = ModelPreferences::fromArray($params['preferences']); } @@ -95,13 +98,37 @@ final class CreateSamplingMessageRequest extends Request $includeContext = SamplingContext::tryFrom($params['includeContext']); } + if (isset($params['systemPrompt']) && !\is_string($params['systemPrompt'])) { + throw new InvalidArgumentException('Invalid "systemPrompt" parameter for sampling/createMessage.'); + } + + if (isset($params['temperature']) && !\is_float($params['temperature']) && !\is_int($params['temperature'])) { + throw new InvalidArgumentException('Invalid "temperature" parameter for sampling/createMessage.'); + } + + if (isset($params['stopSequences'])) { + if (!\is_array($params['stopSequences'])) { + throw new InvalidArgumentException('Invalid "stopSequences" parameter for sampling/createMessage.'); + } + + foreach ($params['stopSequences'] as $stopSequence) { + if (!\is_string($stopSequence)) { + throw new InvalidArgumentException('Each entry in "stopSequences" must be a string for sampling/createMessage.'); + } + } + } + + if (isset($params['metadata']) && !\is_array($params['metadata'])) { + throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); + } + return new self( $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, $includeContext, - $params['temperature'] ?? null, + isset($params['temperature']) ? (float) $params['temperature'] : null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, ); diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php index 04db317..f7b7460 100644 --- a/src/Schema/Request/InitializeRequest.php +++ b/src/Schema/Request/InitializeRequest.php @@ -42,17 +42,17 @@ final class InitializeRequest extends Request protected static function fromParams(?array $params): static { - if (!isset($params['protocolVersion'])) { - throw new InvalidArgumentException('protocolVersion is required'); + if (!isset($params['protocolVersion']) || !\is_string($params['protocolVersion'])) { + throw new InvalidArgumentException('Missing or invalid "protocolVersion" parameter for initialize.'); } - if (!isset($params['capabilities'])) { - throw new InvalidArgumentException('capabilities is required'); + if (!isset($params['capabilities']) || !\is_array($params['capabilities'])) { + throw new InvalidArgumentException('Missing or invalid "capabilities" parameter for initialize.'); } $capabilities = ClientCapabilities::fromArray($params['capabilities']); - if (!isset($params['clientInfo'])) { - throw new InvalidArgumentException('clientInfo is required'); + if (!isset($params['clientInfo']) || !\is_array($params['clientInfo'])) { + throw new InvalidArgumentException('Missing or invalid "clientInfo" parameter for initialize.'); } $clientInfo = Implementation::fromArray($params['clientInfo']); diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php index 8c62799..3f1cff0 100644 --- a/src/Schema/Request/ListPromptsRequest.php +++ b/src/Schema/Request/ListPromptsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListPromptsRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for prompts/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php index 4ce4dfd..1822c57 100644 --- a/src/Schema/Request/ListResourceTemplatesRequest.php +++ b/src/Schema/Request/ListResourceTemplatesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListResourceTemplatesRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/templates/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php index 30c0041..a8fe772 100644 --- a/src/Schema/Request/ListResourcesRequest.php +++ b/src/Schema/Request/ListResourcesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListResourcesRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php index 00af286..83cf887 100644 --- a/src/Schema/Request/ListToolsRequest.php +++ b/src/Schema/Request/ListToolsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListToolsRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for tools/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index eb83e1c..1a441cc 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -43,7 +43,11 @@ final class SetLogLevelRequest extends Request throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); } - return new self(LoggingLevel::from($params['level'])); + if (null === $level = LoggingLevel::tryFrom($params['level'])) { + throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "logging/setLevel".', $params['level'])); + } + + return new self($level); } /** diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index a399966..ca9d0e6 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -88,9 +88,18 @@ class ResourceDefinition implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceDefinition data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceDefinition data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceDefinition data; expected an integer.'); + } return new self( uri: $data['uri'], @@ -98,9 +107,9 @@ class ResourceDefinition implements \JsonSerializable title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceDefinition'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceDefinition') : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46f173c..a26e8b9 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -81,9 +81,15 @@ class ResourceTemplate implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceTemplate data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceTemplate data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceTemplate data.'); + } return new self( uriTemplate: $data['uriTemplate'], @@ -91,7 +97,7 @@ class ResourceTemplate implements \JsonSerializable title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceTemplate'), meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index bfbc9fa..c82a6dd 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -95,15 +95,30 @@ class CallToolResult implements ResultInterface $contents = []; foreach ($data['content'] as $item) { - $contents[] = match ($item['type'] ?? null) { + $type = \is_array($item) ? $item['type'] ?? null : null; + if (!\is_string($type)) { + throw new InvalidArgumentException('Missing or invalid content "type" in CallToolResult data.'); + } + + $contents[] = match ($type) { 'text' => TextContent::fromArray($item), 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), - default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $item['type'] ?? null)), + default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $type)), }; } + if (isset($data['isError']) && !\is_bool($data['isError'])) { + throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); + } + if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { + throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); + } + return new self( $contents, $data['isError'] ?? false, diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index d7fa4c4..43c9fed 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -67,6 +67,18 @@ class CompletionCompleteResult implements ResultInterface public static function fromArray(array $data): self { $completion = $data['completion'] ?? []; + if (!\is_array($completion)) { + throw new InvalidArgumentException('Invalid "completion" in CompletionCompleteResult data.'); + } + if (isset($completion['values']) && !\is_array($completion['values'])) { + throw new InvalidArgumentException('Invalid "completion.values" in CompletionCompleteResult data.'); + } + if (isset($completion['total']) && !\is_int($completion['total'])) { + throw new InvalidArgumentException('Invalid "completion.total" in CompletionCompleteResult data.'); + } + if (isset($completion['hasMore']) && !\is_bool($completion['hasMore'])) { + throw new InvalidArgumentException('Invalid "completion.hasMore" in CompletionCompleteResult data.'); + } return new self( $completion['values'] ?? [], diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 986d629..8eb4b13 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -58,7 +58,10 @@ class CreateSamplingMessageResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.'); } - $role = Role::from($data['role']); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in CreateSamplingMessageResult data.', $data['role'])); + } + $contentPayload = $data['content']; $content = self::hydrateContent($contentPayload); diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index d667374..7041595 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -44,7 +44,10 @@ final class ElicitResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); } - $action = ElicitAction::from($data['action']); + if (null === $action = ElicitAction::tryFrom($data['action'])) { + throw new InvalidArgumentException(\sprintf('Invalid "action" value "%s" in ElicitResult data.', $data['action'])); + } + $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; if (ElicitAction::Accept === $action && null === $content) { diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php index 40277f8..0083a83 100644 --- a/src/Schema/Result/GetPromptResult.php +++ b/src/Schema/Result/GetPromptResult.php @@ -51,8 +51,16 @@ class GetPromptResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "messages" array in GetPromptResult data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in GetPromptResult data.'); + } + $messages = []; foreach ($data['messages'] as $message) { + if (!\is_array($message)) { + throw new InvalidArgumentException('Each entry in "messages" of GetPromptResult data must be an array.'); + } + $messages[] = PromptMessage::fromArray($message); } diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index 5c184d6..e80c28c 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -64,6 +64,13 @@ class InitializeResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "serverInfo".'); } + if (isset($data['instructions']) && !\is_string($data['instructions'])) { + throw new InvalidArgumentException('Invalid "instructions" in InitializeResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in InitializeResult data.'); + } + return new self( ServerCapabilities::fromArray($data['capabilities']), Implementation::fromArray($data['serverInfo']), diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php index 7b1b382..fd164c4 100644 --- a/src/Schema/Result/ListPromptsResult.php +++ b/src/Schema/Result/ListPromptsResult.php @@ -49,8 +49,21 @@ class ListPromptsResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "prompts" array in ListPromptsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListPromptsResult data.'); + } + return new self( - array_map(static fn (array $prompt) => Prompt::fromArray($prompt), $data['prompts']), + array_map( + static function (mixed $entry): Prompt { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "prompts" of ListPromptsResult data must be an array.'); + } + + return Prompt::fromArray($entry); + }, + $data['prompts'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php index 1b8ddb2..e0b7114 100644 --- a/src/Schema/Result/ListResourceTemplatesResult.php +++ b/src/Schema/Result/ListResourceTemplatesResult.php @@ -49,8 +49,21 @@ class ListResourceTemplatesResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "resourceTemplates" array in ListResourceTemplatesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourceTemplatesResult data.'); + } + return new self( - array_map(static fn (array $resourceTemplate) => ResourceTemplate::fromArray($resourceTemplate), $data['resourceTemplates']), + array_map( + static function (mixed $entry): ResourceTemplate { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resourceTemplates" of ListResourceTemplatesResult data must be an array.'); + } + + return ResourceTemplate::fromArray($entry); + }, + $data['resourceTemplates'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 0f338ca..63066b0 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -49,8 +49,21 @@ class ListResourcesResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "resources" array in ListResourcesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourcesResult data.'); + } + return new self( - array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), + array_map( + static function (mixed $entry): ResourceDefinition { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resources" of ListResourcesResult data must be an array.'); + } + + return ResourceDefinition::fromArray($entry); + }, + $data['resources'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php index 7b08a0d..c6ba6c8 100644 --- a/src/Schema/Result/ListToolsResult.php +++ b/src/Schema/Result/ListToolsResult.php @@ -49,8 +49,21 @@ class ListToolsResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "tools" array in ListToolsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListToolsResult data.'); + } + return new self( - array_map(static fn (array $tool) => Tool::fromArray($tool), $data['tools']), + array_map( + static function (mixed $entry): Tool { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "tools" of ListToolsResult data must be an array.'); + } + + return Tool::fromArray($entry); + }, + $data['tools'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 5f6862d..3c20e9e 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -54,6 +54,10 @@ class Root implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "uri" in Root data.'); } + if (isset($data['name']) && !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid "name" in Root data.'); + } + return new self($data['uri'], $data['name'] ?? null); } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 83a83f8..c4062cf 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -154,7 +154,7 @@ class Tool implements \JsonSerializable inputSchema: $data['inputSchema'], description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Tool') : null, meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, outputSchema: $outputSchema, ); diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php index 783d959..c4f2774 100644 --- a/src/Schema/ToolAnnotations.php +++ b/src/Schema/ToolAnnotations.php @@ -11,6 +11,8 @@ namespace Mcp\Schema; +use Mcp\Exception\InvalidArgumentException; + /** * Additional properties describing a Tool to clients. * NOTE: all properties in ToolAnnotations are hints. @@ -48,6 +50,16 @@ class ToolAnnotations implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in ToolAnnotations data.'); + } + + foreach (['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as $hint) { + if (isset($data[$hint]) && !\is_bool($data[$hint])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in ToolAnnotations data; expected a boolean.', $hint)); + } + } + return new self( $data['title'] ?? null, $data['readOnlyHint'] ?? null, diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index d9af4e4..5a4e358 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -61,6 +61,12 @@ class Protocol public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; + /** + * Deliberately generic: unexpected throwables carry internal details such as file paths, class + * names and argument types, which must not be handed to the peer. The full exception is logged. + */ + private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; + /** * @param array>> $requestHandlers * @param array $notificationHandlers @@ -107,6 +113,66 @@ class Protocol * @param TransportInterface $transport */ public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void + { + // Last line of defense: a malformed message must never escape as a PHP error and take the + // server process down. + try { + $this->doProcessInput($transport, $input, $sessionId); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered. Replying to a notification would violate JSON-RPC, + // and the failure has already been logged. + if (null === $id = self::findResponseId($input)) { + return; + } + + try { + $this->sendResponse($transport, Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), null); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Failed to send internal error response: %s', $e->getMessage()), ['exception' => $e]); + } + } + } + + /** + * Determines the id an unprocessable input has to be answered under. + * + * Returns null when the input carries no request at all, in which case it consists of + * notifications only and JSON-RPC forbids answering it. A batch resolves to the empty id + * because its failure cannot be attributed to one of its requests. + */ + private static function findResponseId(string $input): string|int|null + { + try { + $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!\is_array($data)) { + return null; + } + + if (!array_is_list($data)) { + $id = $data['id'] ?? null; + + return \is_string($id) || \is_int($id) ? $id : null; + } + + foreach ($data as $message) { + if (\is_array($message) && isset($message['id'])) { + return ''; + } + } + + return null; + } + + /** + * @param TransportInterface $transport + */ + private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void { $this->logger->info('Received message to process.', ['message' => $input]); @@ -128,14 +194,25 @@ class Protocol } foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->handleInvalidMessage($transport, $message, $session); - } elseif ($message instanceof Request) { - $this->handleRequest($transport, $message, $session); - } elseif ($message instanceof Response || $message instanceof Error) { - $this->handleResponse($message, $session); - } elseif ($message instanceof Notification) { - $this->handleNotification($message, $session); + // Guarded per message so one faulty message cannot suppress the rest of a batch. + try { + if ($message instanceof InvalidInputMessageException) { + $this->handleInvalidMessage($transport, $message, $session); + } elseif ($message instanceof Request) { + $this->handleRequest($transport, $message, $session); + } elseif ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message, $session); + } elseif ($message instanceof Notification) { + $this->handleNotification($message, $session); + } + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while handling message: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered; a notification or a response must not produce one. + if ($message instanceof Request) { + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $message->getId()); + $this->sendResponse($transport, $error, $session); + } } } @@ -236,7 +313,7 @@ class Protocol } catch (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - $error = Error::forInternalError($e->getMessage(), $request->getId()); + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId()); $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); $error = $errorEvent->getError(); diff --git a/tests/Unit/Fixtures/ThrowingRequest.php b/tests/Unit/Fixtures/ThrowingRequest.php new file mode 100644 index 0000000..d5b4136 --- /dev/null +++ b/tests/Unit/Fixtures/ThrowingRequest.php @@ -0,0 +1,38 @@ + + */ +final class ThrowingRequest extends Request +{ + public static function getMethod(): string + { + return 'test/throwing'; + } + + protected static function fromParams(?array $params): static + { + throw new \TypeError('Internal detail that must not leak to the client.'); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/tests/Unit/JsonRpc/MalformedInputTest.php b/tests/Unit/JsonRpc/MalformedInputTest.php new file mode 100644 index 0000000..cb8d1ad --- /dev/null +++ b/tests/Unit/JsonRpc/MalformedInputTest.php @@ -0,0 +1,83 @@ + + */ +final class MalformedInputTest extends TestCase +{ + /** + * @return iterable + */ + public static function provideMalformedPayloads(): iterable + { + yield 'method is an object' => ['{"jsonrpc":"2.0","id":1,"method":{"evil":true}}']; + yield 'method is an array' => ['{"jsonrpc":"2.0","id":1,"method":[1,2,3]}']; + + yield 'initialize with array protocolVersion' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":[1],"capabilities":{},"clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string capabilities' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":"x","clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string clientInfo' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":"x"}}']; + yield 'initialize with non-array icons entry' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1","icons":["x"]}}}']; + + yield 'completion ref/prompt with array name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":[1]},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/prompt without name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt"},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/resource with object uri' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":{}},"argument":{"name":"x","value":"y"}}}']; + + yield 'setLevel with unknown enum value' => ['{"jsonrpc":"2.0","id":1,"method":"logging/setLevel","params":{"level":"not-a-real-level"}}']; + yield 'logging notification with unknown enum value' => ['{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"nope","data":"x"}}']; + + yield 'tools/list with array cursor' => ['{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":[1]}}']; + yield 'cancelled notification with array reason' => ['{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":[1]}}']; + yield 'progress notification with array total' => ['{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1,"total":[1]}}']; + + yield 'sampling with string preferences' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"preferences":"x"}}']; + yield 'sampling with array systemPrompt' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"systemPrompt":[1]}}']; + yield 'sampling with unknown role' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"nope","content":{"type":"text","text":"x"}}],"maxTokens":1}}']; + yield 'sampling with array content type' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":[1],"text":"x"}}],"maxTokens":1}}']; + yield 'sampling with non-string stopSequence' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"stopSequences":[[]]}}']; + + yield 'elicitation with string required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":"a"}}}']; + yield 'elicitation with array in required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":[[]]}}}']; + } + + #[DataProvider('provideMalformedPayloads')] + #[TestDox('Malformed payload is reported as invalid input: $_dataName')] + public function testMalformedPayloadIsReportedAsInvalidInput(string $payload): void + { + $results = MessageFactory::make()->create($payload); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + #[TestDox('A malformed message in a batch does not discard the valid ones')] + public function testMalformedMessageInBatchDoesNotDiscardValidMessages(): void + { + $payload = '[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":{}}]'; + + $results = MessageFactory::make()->create($payload); + + $this->assertCount(2, $results); + $this->assertInstanceOf(\Mcp\Schema\Request\ListToolsRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } +} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 291b6c5..441a500 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\PingRequest; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -452,6 +453,41 @@ final class MessageFactoryTest extends TestCase $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); } + /** + * @return iterable + */ + public static function provideNonStringMethods(): iterable + { + yield 'object' => ['{"evil": true}']; + yield 'array' => ['[1, 2, 3]']; + yield 'int' => ['5']; + yield 'bool' => ['true']; + } + + #[DataProvider('provideNonStringMethods')] + public function testNonStringMethodIsRejected(string $method): void + { + $results = $this->factory->create(\sprintf('{"jsonrpc": "2.0", "id": 1, "method": %s}', $method)); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('"method" must be a string', $results[0]->getMessage()); + } + + public function testBatchWithNonStringMethodStillYieldsTheValidMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": {}, "id": 2} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(2, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } + public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void { $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 6ffd15c..62091a5 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -85,7 +85,8 @@ final class ElicitResultTest extends TestCase public function testFromArrayWithInvalidAction(): void { - $this->expectException(\ValueError::class); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "action" value "invalid"'); ElicitResult::fromArray(['action' => 'invalid']); } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index f1d1c83..b5f836e 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -21,12 +21,14 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Request\CallToolRequest; +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\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; +use Mcp\Tests\Unit\Fixtures\ThrowingRequest; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -349,6 +351,161 @@ final class ProtocolTest extends TestCase $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); } + #[TestDox('An unexpected throwable while creating a message returns an internal error under its id')] + public function testUnexpectedThrowableWhileCreatingMessagesReturnsInternalError(): void + { + $sent = null; + $this->transport->expects($this->once()) + ->method('send') + ->willReturnCallback(static function ($data) use (&$sent) { + $sent = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "id": 1, "method": "test/throwing"}', + Uuid::v4() + ); + + $decoded = json_decode((string) $sent, true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame(1, $decoded['id'], 'The peer must be able to correlate the failure with its request.'); + $this->assertStringNotContainsString('must not leak', $decoded['error']['message']); + } + + #[TestDox('A batch that fails to hydrate is answered once, under the empty id')] + public function testBatchThatFailsToHydrateIsAnsweredOnce(): void + { + $sent = []; + $this->transport->method('send')->willReturnCallback(static function ($data) use (&$sent) { + $sent[] = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([PingRequest::class, ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "id": 1, "method": "ping"}, {"jsonrpc": "2.0", "id": 2, "method": "test/throwing"}]', + Uuid::v4() + ); + + $this->assertCount(1, $sent); + + $decoded = json_decode($sent[0], true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame('', $decoded['id'], 'The failure cannot be attributed to one request of the batch.'); + } + + #[TestDox('A batch holding only notifications is not answered when processing fails')] + public function testBatchOfNotificationsIsNotAnsweredWhenProcessingFails(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "method": "notifications/initialized"}]', + Uuid::v4() + ); + } + + #[TestDox('An unexpected throwable while saving the session does not answer a notification')] + public function testUnexpectedThrowableWhileSavingSessionDoesNotEscape(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + // JSON-RPC forbids answering a notification, so the failure is only logged. + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + Uuid::v4() + ); + } + + #[TestDox('A failing notification event listener does not produce a response')] + public function testFailingNotificationListenerDoesNotProduceResponse(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $queue = []; + $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { + return '_mcp.outgoing_queue' === $key ? $queue : $default; + }); + $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->method('dispatch')->willReturnCallback(static function ($event) { + if ($event instanceof NotificationEvent) { + throw new \RuntimeException('listener blew up'); + } + + return $event; + }); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + eventDispatcher: $dispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->assertSame([], $protocol->consumeOutgoingMessages($sessionId)); + } + #[TestDox('Request without handler returns method not found error')] public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void { @@ -513,7 +670,8 @@ final class ProtocolTest extends TestCase $message = json_decode($outgoing[0]['message'], true); $this->assertArrayHasKey('error', $message); $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); - $this->assertStringContainsString('Unexpected error', $message['error']['message']); + $this->assertSame('Internal server error.', $message['error']['message']); + $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); } #[TestDox('Notification handler exceptions are caught and logged')] From 071476ebd8108a75994706807914bff0aac584b6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho <12901868+cbcoutinho@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:34:23 +0200 Subject: [PATCH 071/143] [Client] Parse CRLF- and CR-delimited SSE events (#401) HttpTransport looked for an event boundary with strpos($buffer, "\n\n") and split event lines on "\n". Servers that terminate SSE lines with CRLF therefore never produced a recognised event: the response accumulated in the buffer, no message was ever dispatched, and connect() failed with "Initialization failed: Request timed out". This affects every MCP server built on the Python SDK. FastMCP's streamable_http_app() returns sse_starlette's EventSourceResponse without a sep argument, and its DEFAULT_SEPARATOR is "\r\n". The bug is invisible from within this repository because the PHP server implementation emits LF, so client and server agree in the test suite while disagreeing with most servers in the wild. The SSE specification requires a parser to accept CRLF, LF or CR, so the previous behaviour was non-compliant rather than merely unlucky. Event extraction now accepts all three terminators, choosing whichever appears first so a buffer split mid-sequence is handled correctly, and line splitting uses the same set. A stream that ends without a trailing blank line now dispatches its final event instead of discarding it. Adds the first tests for src/Client/Transport/, covering LF, CRLF and CR framing, an id field, a comment preamble, and a missing trailing blank line. Without the fix four of the six fail with the timeout above. Co-authored-by: Claude Opus 5 (1M context) --- src/Client/Transport/HttpTransport.php | 46 +++++++++++-- .../Client/Transport/HttpTransportTest.php | 64 +++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index dd3255f..ddb662f 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -230,16 +230,19 @@ class HttpTransport extends BaseTransport } } - while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) { - $event = substr($this->sseBuffer, 0, $pos); - $this->sseBuffer = substr($this->sseBuffer, $pos + 2); - + while (null !== ($event = $this->extractSSEEvent())) { if (!empty(trim($event))) { $this->processSSEEvent($event); } } - if ($this->activeStream->eof() && empty($this->sseBuffer)) { + if ($this->activeStream->eof()) { + // The stream ended without a trailing blank line: dispatch what is left. + if (!empty(trim($this->sseBuffer))) { + $this->processSSEEvent($this->sseBuffer); + } + + $this->sseBuffer = ''; $this->activeStream = null; } } @@ -273,6 +276,37 @@ class HttpTransport extends BaseTransport } } + /** + * Take the next complete event off the buffer, or null if none is complete yet. + * + * Per the SSE specification, lines are terminated by CRLF, LF or CR, so an + * event is delimited by any pair of those. Servers built on sse-starlette + * (the MCP Python SDK) use CRLF. + */ + private function extractSSEEvent(): ?string + { + $position = null; + $length = 0; + + foreach (["\r\n\r\n", "\n\n", "\r\r"] as $delimiter) { + $found = strpos($this->sseBuffer, $delimiter); + + if (false !== $found && (null === $position || $found < $position)) { + $position = $found; + $length = \strlen($delimiter); + } + } + + if (null === $position) { + return null; + } + + $event = substr($this->sseBuffer, 0, $position); + $this->sseBuffer = substr($this->sseBuffer, $position + $length); + + return $event; + } + /** * Parse a single SSE event and handle the message. */ @@ -280,7 +314,7 @@ class HttpTransport extends BaseTransport { $data = ''; - foreach (explode("\n", $event) as $line) { + foreach (preg_split("/\r\n|\r|\n/", $event) ?: [] as $line) { if (str_starts_with($line, 'data:')) { $data .= trim(substr($line, 5)); } diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 408980d..6c6119e 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -11,14 +11,19 @@ namespace Mcp\Tests\Unit\Client\Transport; +use Mcp\Client; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; final class HttpTransportTest extends TestCase @@ -30,6 +35,65 @@ final class HttpTransportTest extends TestCase $this->factory = new Psr17Factory(); } + /** + * @return iterable + */ + public static function frameProvider(): iterable + { + yield 'LF line endings' => ["event: message\ndata: %s\n\n"]; + // sse-starlette (and therefore every MCP Python SDK server) defaults to CRLF. + yield 'CRLF line endings' => ["event: message\r\ndata: %s\r\n\r\n"]; + yield 'CR line endings' => ["event: message\rdata: %s\r\r"]; + yield 'with an id field' => ["id: 1\r\nevent: message\r\ndata: %s\r\n\r\n"]; + yield 'no trailing blank line' => ["event: message\ndata: %s\n"]; + yield 'preceded by a comment' => [": ping\n\nevent: message\ndata: %s\n\n"]; + } + + #[DataProvider('frameProvider')] + #[TestDox('initialization succeeds for an SSE response framed as: $_dataName')] + public function testInitializeParsesSseFraming(string $frame): void + { + $httpClient = new class($frame) implements ClientInterface { + public function __construct(private readonly string $frame) + { + } + + 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' => 'text/event-stream', + 'Mcp-Session-Id' => 'abc123', + ], \sprintf($this->frame, $payload)); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(1) + ->build(); + + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + + $this->assertTrue($client->isConnected()); + $this->assertSame('test-server', $client->getServerInfo()?->name); + } + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] public function testSseBufferIsBoundedByConfiguredCap(): void { From 8bae3c054e5d7253657f422bf7af1f0dc81e8c6c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 22:47:26 +0200 Subject: [PATCH 072/143] [Capability] Preserve optional content fields in PromptResultFormatter (#406) * Preserve optional content fields in PromptResultFormatter The formatter's typed-content path rebuilt each Content object from only its required fields, silently dropping everything else a prompt returned (annotations on every type, _meta and explicit mimeType on embedded resource contents). Delegate to the schema classes' fromArray() instead, keeping the historical mimeType defaulting and RuntimeException-with- index error contract. ImageContent was the only content class without annotations support, so it gains the same constructor/fromArray()/jsonSerialize() handling as TextContent and AudioContent. Co-Authored-By: Claude Fable 5 * Reword CHANGELOG entry to be user-facing --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 2 + .../Formatter/PromptResultFormatter.php | 95 +++--------- src/Schema/Content/ImageContent.php | 46 ++++-- .../Formatter/PromptResultFormatterTest.php | 142 ++++++++++++++++++ .../Unit/Schema/Content/ImageContentTest.php | 87 +++++++++++ 5 files changed, 283 insertions(+), 89 deletions(-) create mode 100644 tests/Unit/Schema/Content/ImageContentTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 01665a1..715b220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. +* Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. +* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. 0.7.0 ----- diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index 871bded..92ae78c 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -11,15 +11,14 @@ namespace Mcp\Capability\Formatter; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\AudioContent; -use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Enum\Role; /** @@ -178,6 +177,9 @@ final class PromptResultFormatter /** * Formats typed content arrays into Content objects. * + * Delegates to the schema classes' fromArray() so optional fields + * (annotations, mimeType, _meta, ...) carry over instead of being dropped. + * * @param array $content */ private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource @@ -185,83 +187,22 @@ final class PromptResultFormatter $indexStr = null !== $index ? " at index {$index}" : ''; $type = $content['type']; - return match ($type) { - 'text' => $this->formatTextContent($content, $indexStr), - 'image' => $this->formatImageContent($content, $indexStr), - 'audio' => $this->formatAudioContent($content, $indexStr), - 'resource' => $this->formatResourceContent($content, $indexStr), - default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), - }; - } - - /** - * @param array $content - */ - private function formatTextContent(array $content, string $indexStr): TextContent - { - if (!isset($content['text']) || !\is_string($content['text'])) { - throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); + if ('resource' === $type && isset($content['resource']) && \is_array($content['resource']) && !isset($content['resource']['mimeType'])) { + // EmbeddedResource::fromArray() leaves a missing mimeType unset; this + // formatter has always defaulted it, so keep that for compatibility. + $content['resource']['mimeType'] = isset($content['resource']['text']) ? 'text/plain' : 'application/octet-stream'; } - return new TextContent($content['text']); - } - - /** - * @param array $content - */ - private function formatImageContent(array $content, string $indexStr): ImageContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + try { + return match ($type) { + 'text' => TextContent::fromArray($content), + 'image' => ImageContent::fromArray($content), + 'audio' => AudioContent::fromArray($content), + 'resource' => EmbeddedResource::fromArray($content), + default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), + }; + } catch (InvalidArgumentException $e) { + throw new RuntimeException("Invalid '{$type}' content{$indexStr}: {$e->getMessage()}", 0, $e); } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new ImageContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatAudioContent(array $content, string $indexStr): AudioContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); - } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new AudioContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatResourceContent(array $content, string $indexStr): EmbeddedResource - { - if (!isset($content['resource']) || !\is_array($content['resource'])) { - throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (!isset($resource['uri']) || !\is_string($resource['uri'])) { - throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); - } - - if (isset($resource['text']) && \is_string($resource['text'])) { - $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); - } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { - $resourceObj = new BlobResourceContents( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - $resource['blob'] - ); - } else { - throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); - } - - return new EmbeddedResource($resourceObj); } } diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php index b64d007..9e2cbff 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -12,12 +12,16 @@ namespace Mcp\Schema\Content; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Annotations; /** + * @phpstan-import-type AnnotationsData from Annotations + * * @phpstan-type ImageContentData array{ * type: 'image', * data: string, - * mimeType: string + * mimeType: string, + * annotations?: AnnotationsData, * } * * @author Kyrian Obikwelu @@ -27,12 +31,14 @@ class ImageContent extends Content /** * Create a new ImageContent instance. * - * @param string $data Base64-encoded image data - * @param string $mimeType The MIME type of the image + * @param string $data Base64-encoded image data + * @param string $mimeType The MIME type of the image + * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( public readonly string $data, public readonly string $mimeType, + public readonly ?Annotations $annotations = null, ) { parent::__construct('image'); } @@ -49,18 +55,23 @@ class ImageContent extends Content throw new InvalidArgumentException('Missing or invalid "mimeType" in ImageContent data.'); } - return new self($data['data'], $data['mimeType']); + return new self( + $data['data'], + $data['mimeType'], + isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + ); } /** * Create a new ImageContent from a file path. * - * @param string $path Path to the image file - * @param string|null $mimeType Optional MIME type override + * @param string $path Path to the image file + * @param string|null $mimeType Optional MIME type override + * @param ?Annotations $annotations Optional annotations describing the content * * @throws InvalidArgumentException If the file doesn't exist */ - public static function fromFile(string $path, ?string $mimeType = null): self + public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self { if (!file_exists($path)) { throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path)); @@ -69,25 +80,36 @@ class ImageContent extends Content $data = base64_encode(file_get_contents($path)); $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png'; - return new self($data, $detectedMime); + return new self($data, $detectedMime, $annotations); } - public static function fromString(string $data, string $mimeType): self + public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self { - return new self(base64_encode($data), $mimeType); + return new self(base64_encode($data), $mimeType, $annotations); } /** * Convert the content to an array. * - * @return ImageContentData + * @return array{ + * type: 'image', + * data: string, + * mimeType: string, + * annotations?: Annotations, + * } */ public function jsonSerialize(): array { - return [ + $result = [ 'type' => $this->type, 'data' => $this->data, 'mimeType' => $this->mimeType, ]; + + if (null !== $this->annotations) { + $result['annotations'] = $this->annotations; + } + + return $result; } } diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 53d6dc7..85c443c 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -12,6 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Formatter; use Mcp\Capability\Formatter\PromptResultFormatter; +use Mcp\Exception\RuntimeException; +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\EmbeddedResource; +use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; @@ -46,4 +50,142 @@ class PromptResultFormatterTest extends TestCase $this->assertCount(1, $result); $this->assertSame(Role::User, $result[0]->role); } + + public function testFormatTypedTextContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Hello', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(TextContent::class, $content); + $this->assertSame('Hello', $content->text); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + } + + public function testFormatTypedImageContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'image', + 'data' => base64_encode('binary'), + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['assistant']], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(ImageContent::class, $content); + $this->assertSame('image/png', $content->mimeType); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::Assistant], $content->annotations->audience); + } + + public function testFormatTypedAudioContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'audio', + 'data' => base64_encode('binary'), + 'mimeType' => 'audio/mpeg', + 'annotations' => ['priority' => 1.0], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(AudioContent::class, $content); + $this->assertNotNull($content->annotations); + $this->assertSame(1.0, $content->annotations->priority); + } + + public function testFormatTypedResourceContentPreservesOptionalFields(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => [ + 'uri' => 'file://data.json', + 'mimeType' => 'application/json', + 'text' => '{"key": "value"}', + '_meta' => ['origin' => 'test'], + ], + 'annotations' => ['audience' => ['user']], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(EmbeddedResource::class, $content); + $this->assertSame('application/json', $content->resource->mimeType); + $this->assertSame(['origin' => 'test'], $content->resource->meta); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + } + + public function testFormatTypedTextResourceContentDefaultsMimeType(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => ['uri' => 'file://a.txt', 'text' => 'plain'], + ], + ], + ]); + + $this->assertSame('text/plain', $result[0]->content->resource->mimeType); + } + + public function testFormatTypedBlobResourceContentDefaultsMimeType(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => ['uri' => 'file://a.bin', 'blob' => base64_encode('binary')], + ], + ], + ]); + + $this->assertSame('application/octet-stream', $result[0]->content->resource->mimeType); + } + + public function testFormatTypedContentRejectsInvalidDataWithIndexContext(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Invalid 'text' content at index 0"); + + (new PromptResultFormatter())->format([ + ['role' => 'user', 'content' => ['type' => 'text']], + ]); + } + + public function testFormatTypedContentRejectsUnknownType(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Invalid content type 'video' at index 0."); + + (new PromptResultFormatter())->format([ + ['role' => 'user', 'content' => ['type' => 'video']], + ]); + } } diff --git a/tests/Unit/Schema/Content/ImageContentTest.php b/tests/Unit/Schema/Content/ImageContentTest.php new file mode 100644 index 0000000..b167202 --- /dev/null +++ b/tests/Unit/Schema/Content/ImageContentTest.php @@ -0,0 +1,87 @@ +assertNull($content->annotations); + } + + public function testConstructorAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $this->assertSame($annotations, $content->annotations); + } + + public function testFromArrayDeserializesAnnotations(): void + { + $content = ImageContent::fromArray([ + 'type' => 'image', + 'data' => base64_encode('binary'), + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + ]); + + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + } + + public function testJsonSerializeOmitsNullAnnotations(): void + { + $content = new ImageContent(base64_encode('binary'), 'image/png'); + + $this->assertArrayNotHasKey('annotations', $content->jsonSerialize()); + } + + public function testJsonSerializeIncludesAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $data = $content->jsonSerialize(); + + $this->assertSame($annotations, $data['annotations']); + } + + public function testRoundTripWithAnnotations(): void + { + $original = new ImageContent(base64_encode('binary'), 'image/png', new Annotations([Role::User], 0.5)); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ImageContent::fromArray($decoded); + + $this->assertSame($original->data, $rehydrated->data); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromStringAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User]); + $content = ImageContent::fromString('binary', 'image/png', $annotations); + + $this->assertSame(base64_encode('binary'), $content->data); + $this->assertSame($annotations, $content->annotations); + } +} From bdc9d86671b84fe4aecbf42a4f8148348ca550d8 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 14 Aug 2026 23:04:25 +0200 Subject: [PATCH 073/143] [Client][Server] Add Roots support (#395) * [Client][Server] Add Roots support Wire up the MCP "roots" capability on both sides: Client (php-sdk as client): - RootsCallbackInterface + ListRootsRequestHandler answer server roots/list requests, mirroring the Sampling handler. - Client::sendRootsListChanged() emits notifications/roots/list_changed. Server (php-sdk as server): - ClientGateway::listRoots() requests the client's roots, and supportsRoots() reports whether the client advertised the capability, normalizing the stored capabilities to an array so an empty capabilities object (\stdClass) doesn't cause a TypeError. - Add ListRootsResult::fromArray() (used by listRoots()). - ListRootsRequestHandler forwards RootsException messages to the server (new Mcp\Exception\RootsException), mirroring SamplingRequestHandler. - Client::sendRootsListChanged() throws when the client did not advertise the roots.listChanged capability. Examples, docs and tests: - examples/README/docs advertise `new ClientCapabilities(roots: true, rootsListChanged: true)` before sending notifications/roots/list_changed. - CHANGELOG entry for Roots (unreleased 0.7.0 section at the time). - Unit tests for capability serialization/round-trip, sendRootsListChanged gating, and RootsException message forwarding. phpunit, php-cs-fixer and phpstan green. * [Client][Server] Address review: schema validation, changelog section, connection guard, working example Responds to chr-hertel's review of the Roots support: - ListRootsResult::fromArray() rejects non-array root elements instead of accepting malformed entries silently. - CHANGELOG entry moved to the next unreleased section as the release train advanced past it (0.7.0 -> 0.8.0). - Client::sendRootsListChanged() went straight to Protocol::sendNotification(), which sends via `$this->transport?->send()` -- on a client that is not connected the notification was silently dropped. It now throws a ConnectionException like sendRequest() does. - The stdio example only advertised the roots capability, so the roots/list handler never actually ran. The client-communication demo server gets an `inspect_workspace_roots` tool that calls ClientGateway::supportsRoots()/listRoots(); the example calls it, so the server actually issues roots/list and the client answers it. Dropped the try/catch wrapper from the example. - ClientTest's connected-client mock now goes through setState() so isConnected() reflects a real handshake instead of a stubbed flag. phpunit, php-cs-fixer and phpstan green. --- CHANGELOG.md | 1 + README.md | 9 ++ docs/client.md | 45 +++++++ examples/client/stdio_roots.php | 85 ++++++++++++++ .../ClientAwareService.php | 36 ++++++ src/Client.php | 23 ++++ .../Request/ListRootsRequestHandler.php | 68 +++++++++++ .../Request/RootsCallbackInterface.php | 28 +++++ src/Exception/RootsException.php | 24 ++++ src/Schema/Result/ListRootsResult.php | 27 +++++ src/Server/ClientGateway.php | 47 +++++++- tests/Unit/Client/ClientTest.php | 79 +++++++++++++ .../Request/ListRootsRequestHandlerTest.php | 105 +++++++++++++++++ tests/Unit/Schema/ClientCapabilitiesTest.php | 71 +++++++++++ .../Schema/Result/ListRootsResultTest.php | 110 ++++++++++++++++++ tests/Unit/Server/ClientGatewayTest.php | 109 +++++++++++++++++ 16 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 examples/client/stdio_roots.php create mode 100644 src/Client/Handler/Request/ListRootsRequestHandler.php create mode 100644 src/Client/Handler/Request/RootsCallbackInterface.php create mode 100644 src/Exception/RootsException.php create mode 100644 tests/Unit/Client/ClientTest.php create mode 100644 tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesTest.php create mode 100644 tests/Unit/Schema/Result/ListRootsResultTest.php create mode 100644 tests/Unit/Server/ClientGatewayTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 715b220..5ed92e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. +* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. 0.7.0 ----- diff --git a/README.md b/README.md index e088563..4e1a868 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,15 @@ $client = Client::builder() ->build(); ``` +- **Roots Support**: Expose `file://` workspace folders to the server +```php +$rootsHandler = new ListRootsRequestHandler($myCallback); +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler($rootsHandler) + ->build(); +``` + - **Logging Notifications**: Receive server log messages ```php $loggingHandler = new LoggingNotificationHandler($myCallback); diff --git a/docs/client.md b/docs/client.md index 494edf1..c227649 100644 --- a/docs/client.md +++ b/docs/client.md @@ -600,6 +600,51 @@ Only the `Accept` action carries content. See `examples/client/stdio_elicitation.php` for a runnable example against the elicitation demo server. +### Roots + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: + +```php +$client->sendRootsListChanged(); +``` + +See `examples/client/stdio_roots.php` for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. + ## Error Handling The client throws exceptions for various error conditions: diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php new file mode 100644 index 0000000..b10e743 --- /dev/null +++ b/examples/client/stdio_roots.php @@ -0,0 +1,85 @@ +setClientInfo('STDIO Roots Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler($rootsRequestHandler) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/client-communication/server.php'], +); + +echo "Connecting to MCP server...\n"; +$client->connect($transport); + +$serverInfo = $client->getServerInfo(); +echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + +// The server tool asks the client for its roots, which triggers the handler above. +echo "Calling 'inspect_workspace_roots'...\n"; +$result = $client->callTool(name: 'inspect_workspace_roots'); + +echo "\nResult:\n"; +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } +} + +// Whenever the client's workspace folders change, notify the server so it can +// request an updated list via roots/list. +echo "\nNotifying the server that the roots list changed...\n"; +$client->sendRootsListChanged(); + +$client->disconnect(); diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index 2a3d834..1e895c4 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -25,6 +25,42 @@ final class ClientAwareService $this->logger->info('SamplingTool instantiated for sampling example.'); } + /** + * Ask the client which workspace folders the server is allowed to operate on. + * + * Demonstrates the server side of the "roots" client capability: the tool + * issues a roots/list request that the client answers from its own handler. + * + * @return array{status: string, message: string, roots?: list} + */ + #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] + public function inspectWorkspaceRoots(RequestContext $context): array + { + $clientGateway = $context->getClientGateway(); + + if (!$clientGateway->supportsRoots()) { + return [ + 'status' => 'unsupported', + 'message' => 'Client does not expose roots. Advertise the "roots" capability and register a ListRootsRequestHandler to let the server discover your workspace folders.', + ]; + } + + $result = $clientGateway->listRoots(); + + $roots = []; + foreach ($result->roots as $root) { + $roots[] = ['uri' => $root->uri, 'name' => $root->name]; + } + + $clientGateway->log(LoggingLevel::Info, \sprintf('Client exposed %d root(s).', \count($roots))); + + return [ + 'status' => 'ok', + 'message' => \sprintf('Client exposed %d root(s).', \count($roots)), + 'roots' => $roots, + ]; + } + /** * @return array{incident: string, recommended_actions: string, model: string} */ diff --git a/src/Client.php b/src/Client.php index dd29069..7df6e3d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,11 +17,13 @@ use Mcp\Client\Protocol; use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; +use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\RootsListChangedNotification; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; @@ -244,6 +246,27 @@ class Client $this->sendRequest($request); } + /** + * Notify the server that the client's list of roots has changed. + * + * The server should react by requesting an updated list via roots/list. + * + * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability + * @throws ConnectionException if the client is not connected + */ + public function sendRootsListChanged(): void + { + if (true !== $this->config->capabilities->rootsListChanged) { + throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).'); + } + + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + + $this->protocol->sendNotification(new RootsListChangedNotification()); + } + /** * Send a request to the server and wait for response. * diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php new file mode 100644 index 0000000..eb18961 --- /dev/null +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -0,0 +1,68 @@ + + * + * @author Johannes Wachter + */ +class ListRootsRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RootsCallbackInterface $callback, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListRootsRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request): Response|Error + { + \assert($request instanceof ListRootsRequest); + + try { + $result = $this->callback->__invoke($request); + + return new Response($request->getId(), $result); + } catch (RootsException $e) { + $this->logger->error('Listing roots failed: '.$e->getMessage(), ['exception' => $e]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error while listing roots', ['exception' => $e]); + + return Error::forInternalError('Error while listing roots', $request->getId()); + } + } +} diff --git a/src/Client/Handler/Request/RootsCallbackInterface.php b/src/Client/Handler/Request/RootsCallbackInterface.php new file mode 100644 index 0000000..80677f1 --- /dev/null +++ b/src/Client/Handler/Request/RootsCallbackInterface.php @@ -0,0 +1,28 @@ + + */ +interface RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult; +} diff --git a/src/Exception/RootsException.php b/src/Exception/RootsException.php new file mode 100644 index 0000000..8d12cb0 --- /dev/null +++ b/src/Exception/RootsException.php @@ -0,0 +1,24 @@ + + */ +final class RootsException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 3abe2e0..b2088dd 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Root; @@ -33,6 +34,32 @@ class ListRootsResult implements ResultInterface ) { } + /** + * @param array{ + * roots: array, + * _meta?: ?array + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['roots']) || !\is_array($data['roots'])) { + throw new InvalidArgumentException('Missing or invalid "roots" in ListRootsResult data.'); + } + + $roots = []; + foreach ($data['roots'] as $root) { + if (!\is_array($root)) { + throw new InvalidArgumentException('Invalid root in ListRootsResult data, expected an array.'); + } + + $roots[] = Root::fromArray($root); + } + + $meta = isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null; + + return new self($roots, $meta); + } + /** * @return array{ * roots: Root[], diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 4686071..d7d55eb 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -32,8 +32,10 @@ use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Notification\ProgressNotification; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\ListRootsResult; use Mcp\Server\Session\SessionInterface; /** @@ -188,6 +190,49 @@ class ClientGateway return ElicitResult::fromArray($response->result); } + /** + * Request the list of filesystem roots exposed by the client. + * + * Roots are the client's "workspace folders" — the directories or files the + * server is allowed to operate on. The client answers the roots/list request + * with a list of file:// URIs. + * + * @param int $timeout The timeout in seconds + * + * @return ListRootsResult The roots exposed by the client + * + * @throws ClientException if the client request results in an error message + */ + public function listRoots(int $timeout = 120): ListRootsResult + { + $request = new ListRootsRequest(); + + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return ListRootsResult::fromArray($response->result); + } + + /** + * Check if the connected client supports roots. + * + * Roots allow servers to ask the client for the set of directories or files + * it is permitted to operate on. This method checks the client's advertised + * capabilities to determine if roots/list requests are supported. + * + * @return bool True if the client supports roots, false otherwise + */ + public function supportsRoots(): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('roots', $capabilities); + } + /** * Check if the connected client supports elicitation. * @@ -199,7 +244,7 @@ class ClientGateway */ public function supportsElicitation(): bool { - $capabilities = $this->session->get('client_capabilities', []); + $capabilities = (array) $this->session->get('client_capabilities', []); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('elicitation', $capabilities); diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php new file mode 100644 index 0000000..29d23ff --- /dev/null +++ b/tests/Unit/Client/ClientTest.php @@ -0,0 +1,79 @@ +createMock(TransportInterface::class); + $transport->method('send')->willReturnCallback(static function (string $data) use (&$sent): void { + $sent[] = $data; + }); + // Stand in for a completed initialize handshake, which the transport drives. + $transport->method('setState')->willReturnCallback(static function (ClientStateInterface $state): void { + $state->setInitialized(true); + }); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $client->connect($transport); + $client->sendRootsListChanged(); + + $this->assertCount(1, $sent); + $decoded = json_decode($sent[0], true); + $this->assertSame('notifications/roots/list_changed', $decoded['method']); + } + + public function testSendRootsListChangedThrowsWhenCapabilityNotDeclared(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->never())->method('send'); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true)) + ->build(); + + $client->connect($transport); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('roots.listChanged'); + + $client->sendRootsListChanged(); + } + + public function testSendRootsListChangedThrowsWhenNotConnected(): void + { + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Client is not connected.'); + + $client->sendRootsListChanged(); + } +} diff --git a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php new file mode 100644 index 0000000..c101b16 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php @@ -0,0 +1,105 @@ +createCallback(new ListRootsResult([]))); + + $this->assertTrue($handler->supports(new ListRootsRequest())); + } + + public function testDoesNotSupportOtherRequests(): void + { + $handler = new ListRootsRequestHandler($this->createCallback(new ListRootsResult([]))); + + $this->assertFalse($handler->supports(new PingRequest())); + } + + public function testHandleReturnsRootsFromCallback(): void + { + $result = new ListRootsResult([ + new Root('file:///home/user/project', 'project'), + new Root('file:///tmp'), + ]); + + $handler = new ListRootsRequestHandler($this->createCallback($result)); + + $request = (new ListRootsRequest())->withId('req-1'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('req-1', $response->getId()); + $this->assertSame($result, $response->result); + } + + public function testHandleReturnsErrorWhenCallbackThrows(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new \RuntimeException('boom'); + } + }); + + $request = (new ListRootsRequest())->withId('req-2'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-2', $response->getId()); + $this->assertSame('Error while listing roots', $response->message); + } + + public function testHandleForwardsRootsExceptionMessage(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new RootsException('permission denied'); + } + }); + + $request = (new ListRootsRequest())->withId('req-3'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-3', $response->getId()); + $this->assertSame('permission denied', $response->message); + } + + private function createCallback(ListRootsResult $result): RootsCallbackInterface + { + return new class($result) implements RootsCallbackInterface { + public function __construct(private readonly ListRootsResult $result) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return $this->result; + } + }; + } +} diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php new file mode 100644 index 0000000..3c62e00 --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -0,0 +1,71 @@ +assertArrayHasKey('roots', $data); + $this->assertSame([], $data['roots']); + } + + public function testSerializesRootsWithListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + + $this->assertSame(['listChanged' => true], $data['roots']); + } + + public function testSerializesEmptyCapabilitiesAsObject(): void + { + $capabilities = new ClientCapabilities(); + + $this->assertSame('{}', json_encode($capabilities)); + } + + public function testFromArrayReadsRootsListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); + + $this->assertTrue($capabilities->roots); + $this->assertTrue($capabilities->rootsListChanged); + } + + public function testFromArrayRootsWithoutListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => []]); + + $this->assertTrue($capabilities->roots); + $this->assertNull($capabilities->rootsListChanged); + } + + public function testRoundTripPreservesRootsListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + $restored = ClientCapabilities::fromArray($data); + + $this->assertTrue($restored->roots); + $this->assertTrue($restored->rootsListChanged); + } +} diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php new file mode 100644 index 0000000..46729fb --- /dev/null +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -0,0 +1,110 @@ +assertSame($roots, $result->roots); + $this->assertNull($result->meta); + } + + public function testFromArray(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + ]); + + $this->assertCount(2, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + $this->assertSame('project', $result->roots[0]->name); + $this->assertSame('file:///tmp', $result->roots[1]->uri); + $this->assertNull($result->roots[1]->name); + $this->assertNull($result->meta); + } + + public function testFromArrayWithMeta(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [['uri' => 'file:///tmp']], + '_meta' => ['requestId' => 'abc'], + ]); + + $this->assertSame(['requestId' => 'abc'], $result->meta); + } + + public function testFromArrayWithMissingRoots(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "roots"'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([]); + } + + public function testFromArrayWithNonArrayRoot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid root in ListRootsResult data, expected an array.'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([ + 'roots' => ['file:///tmp'], + ]); + } + + public function testFromArrayRejectsNonFileUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must start with "file://"'); + + ListRootsResult::fromArray([ + 'roots' => [['uri' => 'https://example.com']], + ]); + } + + public function testFromArrayRoundTrip(): void + { + $data = [ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + '_meta' => ['foo' => 'bar'], + ]; + + $result = ListRootsResult::fromArray($data); + + $this->assertSame($data, json_decode(json_encode($result), true)); + } + + public function testJsonSerializeWithoutMeta(): void + { + $result = new ListRootsResult([new Root('file:///tmp')]); + + $this->assertSame([ + 'roots' => [['uri' => 'file:///tmp']], + ], json_decode(json_encode($result), true)); + } +} diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php new file mode 100644 index 0000000..409669e --- /dev/null +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -0,0 +1,109 @@ +createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); + + $gateway = new ClientGateway($session); + + $this->assertTrue($gateway->supportsRoots()); + } + + public function testSupportsRootsReturnsFalseWhenNotAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); + + $gateway = new ClientGateway($session); + + $this->assertFalse($gateway->supportsRoots()); + } + + public function testListRootsReturnsRootsFromClient(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $response = $this->response([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ], + ]); + + $result = $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $response); + + $this->assertInstanceOf(ListRootsResult::class, $result); + $this->assertCount(1, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + } + + public function testListRootsThrowsClientExceptionOnError(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $error = Error::forInternalError('nope', '1'); + + $this->expectException(ClientException::class); + + $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $error); + } + + /** + * Runs the gateway call inside a Fiber, asserts it suspends with a roots/list + * request, then resumes it with the given client response. + * + * @param Response>|Error $response + */ + private function runInFiber(\Closure $call, Response|Error $response): mixed + { + $fiber = new \Fiber($call); + $suspend = $fiber->start(); + + $this->assertIsArray($suspend); + $this->assertSame('request', $suspend['type']); + $this->assertInstanceOf(ListRootsRequest::class, $suspend['request']); + + $fiber->resume($response); + + return $fiber->getReturn(); + } + + /** + * @param array $result + * + * @return Response> + */ + private function response(array $result): Response + { + return new Response('1', $result); + } +} From fc456125e265c00ecdc4291231628766a535a9f8 Mon Sep 17 00:00:00 2001 From: Alex Rothberg Date: Fri, 14 Aug 2026 17:20:56 -0400 Subject: [PATCH 074/143] [Schema] Add resource_link content block (#400) * Add resource_link content block The spec's `resource_link` content type (protocol revision 2025-06-18, still present in 2025-11-25) had no corresponding schema class: it lets a tool result or prompt message reference a resource by URI/name without embedding its full contents, the way `EmbeddedResource` does. Without it, tools returning many or large resource references (e.g. search results over a DAM) are stuck smuggling URIs through text/structured content. Add `Mcp\Schema\Content\ResourceLink`, mirroring the spec's `Resource` shape (uri, name, title, description, mimeType, annotations, size, icons, _meta) with a fixed `type: "resource_link"`, following the conventions of the sibling `EmbeddedResource`/`ResourceDefinition` classes. Register it everywhere `resource` content blocks are (de)serialized by type per the spec's `ContentBlock` union: - `CallToolResult::fromArray()` - `PromptMessage` (constructor type, `fromArray()`, phpstan types) - `PromptResultFormatter` (typed-array and direct-instance paths) `SamplingMessage`/`CreateSamplingMessageResult` are intentionally left unchanged: the spec's `SamplingMessageContentBlock` does not include `ResourceLink`. Fixes #399 Co-Authored-By: Claude Fable 5 * Add resource_link example coverage to discovery-userprofile Add a lookup_user tool to the discovery-userprofile example: given a userId, it returns a short text summary plus a resource_link pointing at the existing user://{userId}/profile resource template, instead of embedding the profile. This mirrors the PR's motivating use case (a tool referencing many/large resources by URI rather than inlining them) and exercises resource_link through the example's own inspector snapshot tests (tools_call-lookup_user[_not_found], tools_list). Wiring this up surfaced a real bug: ToolReference::extractStructuredContent() passed any raw array return value straight through as structuredContent, even when it held Content instances (e.g. the TextContent/ResourceLink pair above). That serializes to a JSON list, which fails the spec's requirement that structuredContent be an object - the inspector's MCP client rejected the tool call outright until fixed. Skip structuredContent extraction whenever the raw result contains a Content item, since those are already represented in `content` and were never meant to double as structured data. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Alex Rothberg <51059+cancan101@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + docs/mcp-elements.md | 13 +- .../discovery-userprofile/McpElements.php | 41 ++++ .../Formatter/PromptResultFormatter.php | 11 +- src/Capability/Registry/ToolReference.php | 11 + src/Schema/Content/PromptMessage.php | 12 +- src/Schema/Content/ResourceLink.php | 146 +++++++++++ src/Schema/Result/CallToolResult.php | 2 + .../Http/HttpDiscoveryUserProfileTest.php | 16 ++ ...serProfileTest-tools_call-lookup_user.json | 16 ++ ...Test-tools_call-lookup_user_not_found.json | 9 + ...tpDiscoveryUserProfileTest-tools_list.json | 16 ++ .../Formatter/PromptResultFormatterTest.php | 24 ++ tests/Unit/Capability/RegistryTest.php | 16 ++ .../Unit/Schema/Content/PromptMessageTest.php | 84 +++++++ .../Unit/Schema/Content/ResourceLinkTest.php | 231 ++++++++++++++++++ .../Unit/Schema/Result/CallToolResultTest.php | 112 +++++++++ 17 files changed, 751 insertions(+), 10 deletions(-) create mode 100644 src/Schema/Content/ResourceLink.php create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json create mode 100644 tests/Unit/Schema/Content/PromptMessageTest.php create mode 100644 tests/Unit/Schema/Content/ResourceLinkTest.php create mode 100644 tests/Unit/Schema/Result/CallToolResultTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed92e5..70c9bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. +* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. 0.7.0 ----- diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 0f8c9eb..3617a2f 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -107,7 +107,7 @@ public function returnVoid(): void { /* no return */ } // Empty conten For fine control over output formatting: ```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, EmbeddedResource}; +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; public function getFormattedCode(): TextContent { @@ -142,6 +142,17 @@ public function getEmbeddedResource(): EmbeddedResource resource: ['uri' => 'file://data.json', 'text' => 'File content'] ); } + +public function getResourceLink(): ResourceLink +{ + // Reference a resource by URI without embedding its contents, e.g. when + // a tool result would otherwise need to inline many or large resources. + return new ResourceLink( + uri: 'file://data.json', + name: 'data.json', + mimeType: 'application/json' + ); +} ``` #### Multiple Content Items diff --git a/examples/server/discovery-userprofile/McpElements.php b/examples/server/discovery-userprofile/McpElements.php index 8ab6c7d..c853254 100644 --- a/examples/server/discovery-userprofile/McpElements.php +++ b/examples/server/discovery-userprofile/McpElements.php @@ -18,6 +18,9 @@ use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\PromptGetException; use Mcp\Exception\ResourceReadException; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; use Psr\Log\LoggerInterface; /** @@ -87,6 +90,44 @@ final class McpElements return array_keys($this->users); } + /** + * Looks up a user and returns a reference to their profile resource. + * + * Rather than embedding the full profile (as `resources/read` on + * `user://{userId}/profile` would), this returns a `resource_link` block + * pointing at that resource template so the caller can fetch it + * separately if needed. This mirrors how a tool like a search returning + * many hits would reference each matching resource by URI instead of + * inlining every one of them. + * + * @param string $userId the ID of the user to look up + * + * @return Content[] a short summary plus a resource_link to the user's profile + */ + #[McpTool(name: 'lookup_user')] + public function lookupUser( + #[CompletionProvider(values: ['101', '102', '103'])] + string $userId, + ): array { + $this->logger->info('Executing tool: lookup_user', ['userId' => $userId]); + + if (!isset($this->users[$userId])) { + return [new TextContent("User ID {$userId} not found.")]; + } + + $user = $this->users[$userId]; + + return [ + new TextContent("Found user {$user['name']} ({$user['role']})."), + new ResourceLink( + uri: "user://{$userId}/profile", + name: 'user_profile', + description: "Full profile for {$user['name']}.", + mimeType: 'application/json', + ), + ]; + } + /** * Sends a welcome message to a user. * (This is a placeholder - in a real app, it might queue an email). diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index 92ae78c..207c7e2 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -18,6 +18,7 @@ use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; @@ -141,18 +142,19 @@ final class PromptResultFormatter /** * Formats content into a proper Content object. */ - private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; if ($content instanceof Content) { if ( $content instanceof TextContent || $content instanceof ImageContent - || $content instanceof AudioContent || $content instanceof EmbeddedResource + || $content instanceof AudioContent || $content instanceof ResourceLink + || $content instanceof EmbeddedResource ) { return $content; } - throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, ResourceLink, or EmbeddedResource."); } if (\is_string($content)) { @@ -182,7 +184,7 @@ final class PromptResultFormatter * * @param array $content */ - private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; $type = $content['type']; @@ -199,6 +201,7 @@ final class PromptResultFormatter 'image' => ImageContent::fromArray($content), 'audio' => AudioContent::fromArray($content), 'resource' => EmbeddedResource::fromArray($content), + 'resource_link' => ResourceLink::fromArray($content), default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), }; } catch (InvalidArgumentException $e) { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 36c0835..beec182 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -68,6 +68,17 @@ class ToolReference extends ElementReference public function extractStructuredContent(mixed $toolExecutionResult): ?array { if (\is_array($toolExecutionResult)) { + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + // Content items are already reflected in the result's `content` + // array; a raw array holding one or more of them isn't + // structured data and, if it were serialized as-is, could + // produce a `structuredContent` value that isn't a JSON object + // (e.g. a list), which the spec doesn't allow. + return null; + } + } + return $toolExecutionResult; } diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index 923459d..d47f5ec 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -20,11 +20,12 @@ use Mcp\Schema\Enum\Role; * @phpstan-import-type TextContentData from TextContent * @phpstan-import-type ImageContentData from ImageContent * @phpstan-import-type AudioContentData from AudioContent + * @phpstan-import-type ResourceLinkData from ResourceLink * @phpstan-import-type EmbeddedResourceData from EmbeddedResource * * @phpstan-type PromptMessageData array{ * role: string, - * content: TextContentData|ImageContentData|AudioContentData|EmbeddedResourceData, + * content: TextContentData|ImageContentData|AudioContentData|ResourceLinkData|EmbeddedResourceData, * } * * @author Kyrian Obikwelu @@ -34,12 +35,12 @@ class PromptMessage extends Content /** * Create a new PromptMessage instance. * - * @param Role $role The role of the message - * @param TextContent|ImageContent|AudioContent|EmbeddedResource $content The content of the message + * @param Role $role The role of the message + * @param TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content The content of the message */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content, + public readonly TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content, ) { parent::__construct('prompt'); } @@ -67,6 +68,7 @@ class PromptMessage extends Content 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), 'resource' => EmbeddedResource::fromArray($contentData), + 'resource_link' => ResourceLink::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)), }; @@ -82,7 +84,7 @@ class PromptMessage extends Content * * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent|EmbeddedResource + * content: TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource * } */ public function jsonSerialize(): array diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php new file mode 100644 index 0000000..874cc85 --- /dev/null +++ b/src/Schema/Content/ResourceLink.php @@ -0,0 +1,146 @@ +, + * } + * + * @author Alex Rothberg + */ +class ResourceLink extends Content +{ + /** + * @param string $uri the URI of this resource + * @param string $name a short identifier for this resource + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description a description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources + * @param ?string $mimeType the MIME type of this resource, if known + * @param ?Annotations $annotations optional annotations for the client + * @param ?int $size the size of the raw resource content, in bytes (before base64 encoding or any tokenization), if known + * @param ?Icon[] $icons optional icons representing the resource + * @param ?array $meta optional metadata + */ + public function __construct( + public readonly string $uri, + public readonly string $name, + public readonly ?string $title = null, + public readonly ?string $description = null, + public readonly ?string $mimeType = null, + public readonly ?Annotations $annotations = null, + public readonly ?int $size = null, + public readonly ?array $icons = null, + public readonly ?array $meta = null, + ) { + parent::__construct('resource_link'); + } + + /** + * @param ResourceLinkData $data + */ + public static function fromArray(array $data): self + { + if (($data['type'] ?? null) !== 'resource_link') { + throw new InvalidArgumentException('Invalid type for ResourceLink.'); + } + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceLink data.'); + } + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in ResourceLink data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.'); + } + + return new self( + uri: $data['uri'], + name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, + description: $data['description'] ?? null, + mimeType: $data['mimeType'] ?? null, + annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + size: isset($data['size']) ? (int) $data['size'] : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: $data['_meta'] ?? null, + ); + } + + /** + * @return array{ + * type: 'resource_link', + * uri: string, + * name: string, + * title?: string, + * description?: string, + * mimeType?: string, + * annotations?: Annotations, + * size?: int, + * icons?: Icon[], + * _meta?: array, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'uri' => $this->uri, + 'name' => $this->name, + ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + if (null !== $this->size) { + $data['size'] = $this->size; + } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index c82a6dd..c661839 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -16,6 +16,7 @@ use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\ResultInterface; @@ -105,6 +106,7 @@ class CallToolResult implements ResultInterface 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), + 'resource_link' => ResourceLink::fromArray($item), default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $type)), }; } diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index 985b01b..353230a 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -17,6 +17,22 @@ final class HttpDiscoveryUserProfileTest extends HttpInspectorSnapshotTestCase { return [ ...parent::provideMethods(), + 'Lookup User Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"101"'], + ], + 'testName' => 'lookup_user', + ], + 'Lookup User Tool (Not Found)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"999"'], + ], + 'testName' => 'lookup_user_not_found', + ], 'Send Welcome Tool' => [ 'method' => 'tools/call', 'options' => [ diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json new file mode 100644 index 0000000..1399cd2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json @@ -0,0 +1,16 @@ +{ + "content": [ + { + "type": "text", + "text": "Found user Alice (admin)." + }, + { + "name": "user_profile", + "uri": "user://101/profile", + "description": "Full profile for Alice.", + "mimeType": "application/json", + "type": "resource_link" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json new file mode 100644 index 0000000..211521c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "User ID 999 not found." + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json index 205ad0c..08b8198 100644 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json @@ -23,6 +23,22 @@ ] } }, + { + "name": "lookup_user", + "description": "Looks up a user and returns a reference to their profile resource.\n\nRather than embedding the full profile (as `resources/read` on\n`user://{userId}/profile` would), this returns a `resource_link` block\npointing at that resource template so the caller can fetch it\nseparately if needed. This mirrors how a tool like a search returning\nmany hits would reference each matching resource by URI instead of\ninlining every one of them.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user to look up" + } + }, + "required": [ + "userId" + ] + } + }, { "name": "send_welcome", "description": "Sends a welcome message to a user.\n\n(This is a placeholder - in a real app, it might queue an email).", diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 85c443c..eedba5b 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -17,6 +17,7 @@ use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; use PHPUnit\Framework\TestCase; @@ -31,6 +32,29 @@ class PromptResultFormatterTest extends TestCase $this->assertSame($message, $result[0]); } + public function testFormatPromptMessageWithResourceLinkContent(): void + { + $message = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png')); + $result = (new PromptResultFormatter())->format($message); + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + public function testFormatRoleContentArrayWithResourceLinkContent(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ], + ]); + $this->assertCount(1, $result); + $this->assertSame(Role::User, $result[0]->role); + $this->assertInstanceOf(ResourceLink::class, $result[0]->content); + $this->assertSame('file:///a.png', $result[0]->content->uri); + $this->assertSame('a.png', $result[0]->content->name); + } + public function testFormatUserAssistantShorthand(): void { $result = (new PromptResultFormatter())->format([ diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index c8aa5a7..3b2d7d7 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -22,6 +22,8 @@ use Mcp\Capability\RegistryInterface; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; @@ -536,6 +538,20 @@ class RegistryTest extends TestCase ], $structuredContent); } + public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void + { + $tool = $this->createValidTool('lookup_thing', null); + $toolReturnValue = [ + new TextContent('Found it.'), + new ResourceLink(uri: 'thing://1', name: 'thing_1'), + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('lookup_thing'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + } + public function testConfiguredLoaderIsNotRunUntilFirstRead(): void { $loader = $this->createMock(LoaderInterface::class); diff --git a/tests/Unit/Schema/Content/PromptMessageTest.php b/tests/Unit/Schema/Content/PromptMessageTest.php new file mode 100644 index 0000000..8808d41 --- /dev/null +++ b/tests/Unit/Schema/Content/PromptMessageTest.php @@ -0,0 +1,84 @@ + 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], + ]); + + $this->assertSame(Role::User, $message->role); + $this->assertInstanceOf(ResourceLink::class, $message->content); + $this->assertSame('file:///project/src/main.rs', $message->content->uri); + $this->assertSame('main.rs', $message->content->name); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $message = new PromptMessage(Role::Assistant, new ResourceLink('file:///a.png', 'a.png')); + + $this->assertSame([ + 'role' => 'assistant', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + ], + ], json_decode(json_encode($message), true)); + } + + public function testRoundTripWithResourceLink(): void + { + $original = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png')); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = PromptMessage::fromArray($decoded); + + $this->assertSame(Role::User, $rehydrated->role); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content); + $this->assertSame('file:///a.png', $rehydrated->content->uri); + $this->assertSame('image/png', $rehydrated->content->mimeType); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + PromptMessage::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'not-a-real-type'], + ]); + } + + public function testConstructorAcceptsResourceLinkContent(): void + { + $link = new ResourceLink('file:///a.png', 'a.png'); + $message = new PromptMessage(Role::User, $link); + + $this->assertSame($link, $message->content); + } +} diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php new file mode 100644 index 0000000..3e64377 --- /dev/null +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -0,0 +1,231 @@ +assertSame('resource_link', $link->type); + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->description); + $this->assertNull($link->mimeType); + $this->assertNull($link->annotations); + $this->assertNull($link->size); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testConstructorWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertSame($annotations, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertSame($icons, $link->icons); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testJsonSerializeMinimal(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ], $link->jsonSerialize()); + } + + public function testJsonSerializeWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $data = $link->jsonSerialize(); + + $this->assertSame('resource_link', $data['type']); + $this->assertSame(self::VALID_URI, $data['uri']); + $this->assertSame('main.rs', $data['name']); + $this->assertSame('Main Source File', $data['title']); + $this->assertSame('Primary application entry point', $data['description']); + $this->assertSame('text/x-rust', $data['mimeType']); + $this->assertSame($annotations, $data['annotations']); + $this->assertSame(1024, $data['size']); + $this->assertSame($icons, $data['icons']); + $this->assertSame(['origin' => 'test'], $data['_meta']); + } + + public function testOptionalFieldsOmittedWhenNull(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + $data = $link->jsonSerialize(); + + $this->assertArrayNotHasKey('title', $data); + $this->assertArrayNotHasKey('description', $data); + $this->assertArrayNotHasKey('mimeType', $data); + $this->assertArrayNotHasKey('annotations', $data); + $this->assertArrayNotHasKey('size', $data); + $this->assertArrayNotHasKey('icons', $data); + $this->assertArrayNotHasKey('_meta', $data); + } + + public function testFromArrayMinimal(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->annotations); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testFromArrayWithAllFields(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + 'title' => 'Main Source File', + 'description' => 'Primary application entry point', + 'mimeType' => 'text/x-rust', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + 'size' => 1024, + 'icons' => [['src' => 'https://example.com/icon.png']], + '_meta' => ['origin' => 'test'], + ]); + + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertInstanceOf(Annotations::class, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertCount(1, $link->icons); + $this->assertInstanceOf(Icon::class, $link->icons[0]); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testRoundTripThroughJsonSerializeAndFromArray(): void + { + $original = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: new Annotations([Role::User], 0.5), + size: 1024, + icons: [new Icon('https://example.com/icon.png')], + meta: ['origin' => 'test'], + ); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ResourceLink::fromArray($decoded); + + $this->assertSame($original->uri, $rehydrated->uri); + $this->assertSame($original->name, $rehydrated->name); + $this->assertSame($original->title, $rehydrated->title); + $this->assertSame($original->description, $rehydrated->description); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertSame($original->size, $rehydrated->size); + $this->assertSame($original->meta, $rehydrated->meta); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromArrayRejectsWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid type for ResourceLink.'); + + /* @phpstan-ignore argument.type */ + ResourceLink::fromArray([ + 'type' => 'resource', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + } + + #[DataProvider('provideInvalidData')] + public function testFromArrayRejectsInvalidData(array $input, string $expectedExceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + ResourceLink::fromArray($input); + } + + public static function provideInvalidData(): iterable + { + yield 'missing uri' => [ + ['type' => 'resource_link', 'name' => 'main.rs'], + 'Invalid or missing "uri" in ResourceLink data.', + ]; + yield 'missing name' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI], + 'Invalid or missing "name" in ResourceLink data.', + ]; + yield 'invalid _meta' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], + 'Invalid "_meta" in ResourceLink data.', + ]; + } +} diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php new file mode 100644 index 0000000..f7c2b6f --- /dev/null +++ b/tests/Unit/Schema/Result/CallToolResultTest.php @@ -0,0 +1,112 @@ + [ + [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + 'mimeType' => 'text/x-rust', + ], + ], + 'isError' => false, + ]); + + $this->assertCount(1, $result->content); + $this->assertInstanceOf(ResourceLink::class, $result->content[0]); + $this->assertSame('file:///project/src/main.rs', $result->content[0]->uri); + $this->assertSame('main.rs', $result->content[0]->name); + $this->assertSame('text/x-rust', $result->content[0]->mimeType); + } + + public function testFromArrayDeserializesMixedContentTypes(): void + { + $result = CallToolResult::fromArray([ + 'content' => [ + ['type' => 'text', 'text' => 'search results'], + ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ['type' => 'resource_link', 'uri' => 'file:///b.png', 'name' => 'b.png'], + ], + ]); + + $this->assertCount(3, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ResourceLink::class, $result->content[1]); + $this->assertInstanceOf(ResourceLink::class, $result->content[2]); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + CallToolResult::fromArray([ + 'content' => [ + ['type' => 'not-a-real-type'], + ], + ]); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $result = new CallToolResult([ + new ResourceLink('file:///project/src/main.rs', 'main.rs'), + ]); + + $data = $result->jsonSerialize(); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], $data['content'][0]->jsonSerialize()); + } + + public function testRoundTripWithResourceLinkAlongsideOtherContentTypes(): void + { + $original = new CallToolResult([ + new TextContent('25 results found'), + new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png'), + new ImageContent(base64_encode('binary'), 'image/png'), + new AudioContent(base64_encode('binary'), 'audio/mpeg'), + EmbeddedResource::fromText('file:///readme.txt', 'hello'), + ]); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = CallToolResult::fromArray($decoded); + + $this->assertCount(5, $rehydrated->content); + $this->assertInstanceOf(TextContent::class, $rehydrated->content[0]); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content[1]); + $this->assertInstanceOf(ImageContent::class, $rehydrated->content[2]); + $this->assertInstanceOf(AudioContent::class, $rehydrated->content[3]); + $this->assertInstanceOf(EmbeddedResource::class, $rehydrated->content[4]); + + $this->assertSame('file:///a.png', $rehydrated->content[1]->uri); + $this->assertSame('a.png', $rehydrated->content[1]->name); + $this->assertSame('image/png', $rehydrated->content[1]->mimeType); + } +} From eb20453f499eda914071a559278e3abc281037f3 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 01:41:19 +0200 Subject: [PATCH 075/143] [Client][Schema][Server] Implement protocol version negotiation (#403) * [Client][Docs][Schema][Server] Implement protocol version negotiation The `initialize` handler never looked at the version the client asked for: it always answered with the server's configured version (or the SDK default), so a client offering a revision the server supports could still be told to speak a different one. The spec requires echoing back a supported request and only counter-offering when the requested version cannot be honoured. Turns ProtocolVersion into the registry the other SDKs converged on (typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py): `latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered comparison through `isAtLeast()`. Comparison goes through declaration order rather than string collation, so an unrecognized peer string compares conservatively instead of accidentally sorting above a known revision. The one throw the enum can produce is the SDK's own LogicException, so a consumer catching `Mcp\Exception\ExceptionInterface` does not miss it. Adds 2026-07-28 as a known revision and splits the set into two eras: handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern (2026-07-28+, per-request `_meta`). The lists are kept apart so a modern revision can never leak into an `initialize` exchange, which has no `initialize` at all. Serving the modern era is follow-up work; this only teaches the SDK that the revision exists and must not be mis-negotiated. Deliberately absent is any "newest revision in any era" accessor, which would hand back the one revision the handshake must never offer. Server: negotiates and stores the result on the session. A version pinned in Configuration still pins the handshake to exactly that revision. Client: validates the counter-offer and fails the handshake instead of continuing on a revision it cannot speak; records the negotiated version on the client state, readable through the new Client::getProtocolVersion(). The HTTP middleware now defaults to the handshake versions rather than every declared case, so it stops accepting a modern version the server cannot yet serve. When the header is absent it falls back to ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that introduced both Streamable HTTP and the header itself. The negotiation behaviour is one provideNegotiationTable() data provider whose rows are the documented table -- supported revision echoed back, unknown or malformed counter-offered, modern counter-offered -- so the docs and the suite cannot drift apart. The supported-revision rows iterate handshakeVersions() rather than a literal list, so declaring a new handshake revision extends the table automatically, and the "never answer with a modern revision" assertion runs on every row. Documented in server-builder.md (the enum registry, how the server answers a handshake, what pinning changes), transports.md (the header check) and client.md (the client's side), all linking the spec's versioning section. * [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated * Log a warning instead of silently falling back when a modern revision is configured on the client or pinned on the server builder. * Document that setProtocolVersion() does not restrict the MCP-Protocol-Version header check, which validates against a fixed set. * Pin every revision's era in a test, so a new case declared past the modern boundary fails instead of dropping out of the handshake set. * Add CHANGELOG entry. * [Client][Docs][Schema][Server] Rename version fallback constant and trim comments --- CHANGELOG.md | 1 + docs/client.md | 15 +- docs/server-builder.md | 69 ++++++ docs/transports.md | 11 + src/Client.php | 13 ++ src/Client/Protocol.php | 36 +++- src/Client/State/ClientState.php | 12 ++ src/Client/State/ClientStateInterface.php | 13 ++ src/Schema/Enum/ProtocolVersion.php | 102 ++++++++- src/Server/Builder.php | 7 + .../Handler/Request/InitializeHandler.php | 47 +++- .../Middleware/ProtocolVersionMiddleware.php | 15 +- tests/Unit/Client/ProtocolTest.php | 202 ++++++++++++++++++ .../Unit/Schema/Enum/ProtocolVersionTest.php | 134 ++++++++++++ .../Handler/Request/InitializeHandlerTest.php | 154 +++++++++++-- .../ProtocolVersionMiddlewareTest.php | 19 +- 16 files changed, 824 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/Client/ProtocolTest.php create mode 100644 tests/Unit/Schema/Enum/ProtocolVersionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c9bc2..35fcaf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. +* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. 0.7.0 ----- diff --git a/docs/client.md b/docs/client.md index c227649..9d585dd 100644 --- a/docs/client.md +++ b/docs/client.md @@ -77,7 +77,7 @@ $client = Client::builder() ### Protocol Version -Specify the MCP protocol version (defaults to latest): +Specify the MCP protocol version to offer during the handshake (defaults to the latest): ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -87,6 +87,19 @@ $client = Client::builder() ->build(); ``` +This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as +described in the specification's +[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK +cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed +on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. + +Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. +Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning +saying so. + +See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange. + ### Capabilities Declare client capabilities to enable server features: diff --git a/docs/server-builder.md b/docs/server-builder.md index 5016526..6e1cee7 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -8,6 +8,7 @@ various aspects of the server behavior. - [Basic Usage](#basic-usage) - [Server Configuration](#server-configuration) +- [Protocol Version Negotiation](#protocol-version-negotiation) - [Discovery Configuration](#discovery-configuration) - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) @@ -90,6 +91,73 @@ $server = Server::builder() ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); ``` +### Protocol Version + +By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do +not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that +negotiation resolves, and for what `setProtocolVersion()` changes: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$server = Server::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18); +``` + +## Protocol Version Negotiation + +MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in +its `initialize` request, and the server answers with the revision the connection will actually use. Both sides +disconnect if they cannot agree. This follows the +[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) +section of the specification. + +The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` +ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first +ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true +``` + +Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, +but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. + +### How the server answers + +| Client requests | Server responds with | +| --- | --- | +| A revision the server supports | That same revision | +| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | +| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | + +A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the +connection. The negotiated revision is stored on the session under `protocol_version`. + +The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through +this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions +would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to +mis-negotiate it. + +This table is mirrored by the `provideNegotiationTable()` data provider in +`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so +a newly declared revision is covered automatically. + +### Pinning a revision + +`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The +pin wins over the client's request, so a client asking for anything else receives the pinned revision as a +counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. + +> [!NOTE] +> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header, +> which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport +> builds the middleware without access to the server configuration, so the header keeps being accepted for every +> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the +> same revision — see [Protocol Version Validation](transports.md#protocol-version-validation). + ## Discovery Configuration **Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. @@ -661,6 +729,7 @@ $server = Server::builder() | `setServerInfo()` | name, version, description? | Set server identity | | `setPaginationLimit()` | limit | Set max items per page | | `setInstructions()` | instructions | Set usage instructions | +| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | | `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | | `setLogger()` | logger | Set PSR-3 logger | diff --git a/docs/transports.md b/docs/transports.md index 049ca2d..4caee51 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -231,6 +231,17 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); ``` +The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over +`initialize`, rather than every revision the enum declares. A request without the header is treated as +`ProtocolVersion::DEFAULT_HEADER_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and the +header itself, so a header-less request cannot be newer than that. + +This header check is separate from, and happens after, the handshake itself. See +[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the +first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against +the set it was constructed with, not against the revision a given session negotiated, so a server that pins the +handshake has to pass that revision here as well. + ### Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or diff --git a/src/Client.php b/src/Client.php index 7df6e3d..18ec54f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -19,6 +19,7 @@ use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -113,6 +114,18 @@ class Client return $this->protocol->getState()->getInstructions(); } + /** + * Protocol revision negotiated during the handshake. + * + * This is the version the server answered with, which is not necessarily the + * one configured on the builder: a server that cannot speak the requested + * revision counter-offers one it supports. Null until the handshake completed. + */ + public function getProtocolVersion(): ?ProtocolVersion + { + return $this->protocol->getState()->getProtocolVersion(); + } + /** * Send a ping request to the server. */ diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 7564845..50c51f1 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -18,6 +18,7 @@ use Mcp\Client\State\ClientState; use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -98,8 +99,20 @@ class Protocol */ public function initialize(Configuration $config): Response|Error { + $offered = $config->protocolVersion; + if ($offered->isModern()) { + // Only handshake era spec versions need the initialize call, so if we + // end up here, we fall back to the latest handshake version. + $offered = ProtocolVersion::latestHandshake(); + + $this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [ + 'configured' => $config->protocolVersion->value, + 'offered' => $offered->value, + ]); + } + $request = new InitializeRequest( - $config->protocolVersion->value, + $offered->value, $config->capabilities, $config->clientInfo, ); @@ -108,6 +121,26 @@ class Protocol if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); + + // A counter-offer this SDK cannot speak leaves nothing to fall back to, + // so the handshake fails rather than continuing on a revision neither + // side agrees on. + $negotiated = $initResult->protocolVersion; + if (null === $negotiated || $negotiated->isModern()) { + // fromArray() above already rejected a missing or non-string revision. + $counterOffer = (string) $response->result['protocolVersion']; + + return Error::forInvalidParams(\sprintf( + 'Server responded with unsupported protocol version "%s". Supported versions: %s.', + $counterOffer, + implode(', ', array_map( + static fn (ProtocolVersion $v): string => $v->value, + ProtocolVersion::handshakeVersions(), + )), + ), $response->id); + } + + $this->state->setProtocolVersion($negotiated); $this->state->setServerInfo($initResult->serverInfo); $this->state->setInstructions($initResult->instructions); $this->state->setInitialized(true); @@ -116,6 +149,7 @@ class Protocol $this->logger->info('Initialization complete', [ 'server' => $initResult->serverInfo->name, + 'protocolVersion' => $negotiated->value, ]); } diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php index b3e6ba8..a1e1f89 100644 --- a/src/Client/State/ClientState.php +++ b/src/Client/State/ClientState.php @@ -11,6 +11,7 @@ namespace Mcp\Client\State; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -28,6 +29,7 @@ class ClientState implements ClientStateInterface { private int $requestIdCounter = 1; private bool $initialized = false; + private ?ProtocolVersion $protocolVersion = null; private ?Implementation $serverInfo = null; private ?string $instructions = null; @@ -96,6 +98,16 @@ class ClientState implements ClientStateInterface return $this->initialized; } + public function setProtocolVersion(ProtocolVersion $protocolVersion): void + { + $this->protocolVersion = $protocolVersion; + } + + public function getProtocolVersion(): ?ProtocolVersion + { + return $this->protocolVersion; + } + public function setServerInfo(Implementation $serverInfo): void { $this->serverInfo = $serverInfo; diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php index 5be054d..a5de42d 100644 --- a/src/Client/State/ClientStateInterface.php +++ b/src/Client/State/ClientStateInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Client\State; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -76,6 +77,18 @@ interface ClientStateInterface */ public function isInitialized(): bool; + /** + * Store the protocol version negotiated during initialization. + */ + public function setProtocolVersion(ProtocolVersion $protocolVersion): void; + + /** + * Get the protocol version negotiated during initialization. + * + * Null until the handshake has completed. + */ + public function getProtocolVersion(): ?ProtocolVersion; + /** * Store the server info from initialization. */ diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index b580e70..9b127e1 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -11,15 +11,115 @@ namespace Mcp\Schema\Enum; +use Mcp\Exception\LogicException; + /** - * Available protocol versions for MCP. + * Registry of the MCP protocol revisions this SDK knows about. + * + * Cases are declared oldest to newest, and that declaration order — not the + * lexicographic order of the values — is what every comparison here relies on: + * revision identifiers are an enumerated set, not an ordered scalar, and a + * future one is not guaranteed to be date-shaped. + * + * The revisions split into two eras: + * + * - **handshake** (`2025-11-25` and earlier) negotiate a version through the + * `initialize` round-trip and keep session state for the connection; + * - **modern** ({@see self::FIRST_MODERN_VERSION} and later) drop `initialize` + * entirely — every request carries its own version in `_meta`, and servers + * advertise what they speak through `server/discover`. + * + * @see https://modelcontextprotocol.io/specification/draft/basic/versioning * * @author Illia Vasylevskyi */ enum ProtocolVersion: string { + // Declaration order is also the era boundary: everything declared from + // FIRST_MODERN_VERSION onwards is modern. A new handshake-era revision has + // to be inserted above that case — appending it here would silently drop it + // out of the handshake negotiation. case V2024_11_05 = '2024-11-05'; case V2025_03_26 = '2025-03-26'; case V2025_06_18 = '2025-06-18'; case V2025_11_25 = '2025-11-25'; + case V2026_07_28 = '2026-07-28'; + + /** + * First revision of the modern era, in which the `initialize` handshake was + * replaced by per-request metadata. + */ + public const FIRST_MODERN_VERSION = self::V2026_07_28; + + /** + * Version a server assumes when a client omits the `MCP-Protocol-Version` + * header on the Streamable HTTP transport. + * + * This is the revision that introduced both Streamable HTTP and the header + * itself, so a request without the header cannot be newer than this. + */ + public const DEFAULT_HEADER_VERSION = self::V2025_03_26; + + /** + * Newest revision reachable through the `initialize` handshake. + * + * This is what a server counter-offers when it cannot honour the version a + * client asked for, and what a handshake-era client offers by default. + */ + public static function latestHandshake(): self + { + $versions = self::handshakeVersions(); + + return $versions[\count($versions) - 1]; + } + + /** + * Revisions reachable through the `initialize` handshake, oldest to newest. + * + * @return non-empty-list + */ + public static function handshakeVersions(): array + { + return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern())); + } + + /** + * Revisions using the per-request metadata envelope, oldest to newest. + * + * @return non-empty-list + */ + public static function modernVersions(): array + { + return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern())); + } + + /** + * Whether this revision belongs to the modern, per-request-metadata era. + */ + public function isModern(): bool + { + return $this->isAtLeast(self::FIRST_MODERN_VERSION); + } + + /** + * Whether this revision is at least as new as $minimum. + */ + public function isAtLeast(self $minimum): bool + { + return $this->position() >= $minimum->position(); + } + + /** + * Index of this revision in the chronological declaration order. + */ + private function position(): int + { + foreach (self::cases() as $index => $case) { + if ($case === $this) { + return $index; + } + } + + throw new LogicException(\sprintf('Protocol version "%s" is not a declared case.', $this->value)); + } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index c53fa62..fbce3d9 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -717,6 +717,13 @@ final class Builder $capabilities = $capabilities->withExtensions($this->extensions); } + if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) { + $logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [ + 'configured' => $this->protocolVersion->value, + 'negotiable' => array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + ]); + } + $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index f7c7eac..4461ae4 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -47,6 +48,9 @@ final class InitializeHandler implements RequestHandlerInterface $session->set('client_info', $request->clientInfo->jsonSerialize()); $session->set('client_capabilities', $request->capabilities->jsonSerialize()); + $negotiated = $this->negotiate($request->protocolVersion); + $session->set('protocol_version', $negotiated->value); + return new Response( $request->getId(), new InitializeResult( @@ -54,8 +58,49 @@ final class InitializeHandler implements RequestHandlerInterface $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, null, - $this->configuration?->protocolVersion, + $negotiated, ), ); } + + /** + * Picks the protocol version to answer an `initialize` handshake with. + * + * If the client asked for a version this server supports, the spec requires + * responding with that exact version. Otherwise the server counter-offers + * the newest version it does support and leaves it to the client to decide + * whether it can continue on that revision or must disconnect. + */ + private function negotiate(string $requested): ProtocolVersion + { + $supported = $this->supportedVersions(); + $version = ProtocolVersion::tryFrom($requested); + + if (null !== $version && \in_array($version, $supported, true)) { + return $version; + } + + return $supported[\count($supported) - 1]; + } + + /** + * Versions this server is willing to negotiate over `initialize`. + * + * A version configured on the server pins the handshake to exactly that + * revision. Modern revisions are never offered here: they have no + * `initialize` at all, so a client that reached this handler cannot speak + * one, and answering with it would leave the connection unusable. + * + * @return non-empty-list + */ + private function supportedVersions(): array + { + $configured = $this->configuration?->protocolVersion; + + if (null !== $configured && !$configured->isModern()) { + return [$configured]; + } + + return ProtocolVersion::handshakeVersions(); + } } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index 6d60e1a..a4fb76b 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -35,6 +35,9 @@ use Psr\Http\Server\RequestHandlerInterface; * the `initialize` round-trip does not carry the header, and legacy clients * that omit it are tolerated. * + * Validation is against a fixed set, not against the version a particular session + * negotiated: PSR-15 middleware runs before the transport resolves the session. + * * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header * * @author Volodymyr Panivko @@ -48,7 +51,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface private readonly array $supportedVersions; /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ @@ -57,7 +60,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $versions = $supportedVersions ?? ProtocolVersion::cases(); + $versions = $supportedVersions ?? ProtocolVersion::handshakeVersions(); $this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions)); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -70,10 +73,10 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface // Spec backwards-compat: when the header is absent, the server SHOULD assume // protocol version 2025-03-26 — the release in which Streamable HTTP and the // header itself were introduced. This is deliberately lower than the SDK's - // own default (V2025_06_18) so clients predating the header convention still - // get a deterministic protocol version applied. Servers that whitelist only - // newer versions in $supportedVersions will reject such requests with 400. - $version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue; + // own default so clients predating the header convention still get a + // deterministic protocol version applied. Servers that whitelist only newer + // versions in $supportedVersions will reject such requests with 400. + $version = '' === $headerValue ? ProtocolVersion::DEFAULT_HEADER_VERSION->value : $headerValue; if (\in_array($version, $this->supportedVersions, true)) { return $handler->handle($request); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php new file mode 100644 index 0000000..7545f73 --- /dev/null +++ b/tests/Unit/Client/ProtocolTest.php @@ -0,0 +1,202 @@ +value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_06_18)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); + } + + #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] + public function testDoesNotOfferModernVersionOverHandshake(): void + { + $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); + $protocol = new Protocol(logger: $logger = new CollectingLogger()); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); + $this->assertSame([[ + 'configured' => ProtocolVersion::V2026_07_28->value, + 'offered' => ProtocolVersion::latestHandshake()->value, + ]], $logger->warnings); + } + + #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] + public function testAcceptsHandshakeCounterOffer(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2024_11_05->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(ProtocolVersion::V2024_11_05, $protocol->getState()->getProtocolVersion()); + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('fails the handshake when the server answers with a version the SDK cannot speak')] + #[DataProvider('provideUnusableCounterOffers')] + public function testRejectsUnusableCounterOffer(string $counterOffer): void + { + $transport = new RecordingTransport($counterOffer); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString($counterOffer, $result->message); + $this->assertNull($protocol->getState()->getProtocolVersion()); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + /** + * @return iterable + */ + public static function provideUnusableCounterOffers(): iterable + { + yield 'unknown revision' => ['2099-01-01']; + // The modern era has no `initialize`, so a server answering the handshake + // with one has produced a connection neither side can actually use. + yield 'modern revision' => [ProtocolVersion::V2026_07_28->value]; + } + + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration + { + return new Configuration( + clientInfo: new Implementation('client-app', '1.0.0'), + capabilities: new ClientCapabilities(), + protocolVersion: $protocolVersion, + ); + } +} + +/** + * Transport that answers the `initialize` request inline with a canned + * `protocolVersion`, so the handshake resolves without a Fiber round-trip. + */ +final class RecordingTransport implements TransportInterface +{ + public ?string $offeredVersion = null; + + private ClientStateInterface $state; + + public function __construct(private readonly string $counterOffer) + { + } + + public function send(string $data): void + { + /** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */ + $message = json_decode($data, true); + + if ('initialize' !== ($message['method'] ?? null)) { + return; + } + + $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + + $this->state->storeResponse($message['id'], [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $message['id'], + 'result' => [ + 'protocolVersion' => $this->counterOffer, + 'capabilities' => [], + 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], + ], + ]); + } + + public function setState(ClientStateInterface $state): void + { + $this->state = $state; + } + + public function connect(): void + { + } + + public function close(): void + { + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + throw new LogicException('Not used in these tests.'); + } + + public function onInitialize(callable $callback): void + { + } + + public function onMessage(callable $callback): void + { + } + + public function onError(callable $callback): void + { + } + + public function onClose(callable $callback): void + { + } +} + +/** + * Logger that keeps the context of every warning, so a silent fallback can be + * told apart from one the caller was told about. + */ +final class CollectingLogger 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[] = $context; + } + } +} diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php new file mode 100644 index 0000000..1870a79 --- /dev/null +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -0,0 +1,134 @@ + $version) { + $this->assertTrue( + $version->isAtLeast($version), + \sprintf('%s should be at least itself.', $version->value), + ); + + foreach (\array_slice($cases, $index + 1) as $newer) { + $this->assertTrue( + $newer->isAtLeast($version), + \sprintf('%s is declared after %s and should compare newer.', $newer->value, $version->value), + ); + $this->assertFalse( + $version->isAtLeast($newer), + \sprintf('%s should not compare newer than %s.', $version->value, $newer->value), + ); + } + } + } + + #[TestDox('splits the known revisions into a handshake and a modern era')] + public function testEraSplitCoversEveryCaseExactlyOnce(): void + { + $handshake = ProtocolVersion::handshakeVersions(); + $modern = ProtocolVersion::modernVersions(); + + // A partition: every revision in exactly one era, in declaration order. + $this->assertSame(ProtocolVersion::cases(), [...$handshake, ...$modern]); + } + + #[TestDox('every known revision is assigned to an era on purpose')] + public function testEveryRevisionIsClassifiedExplicitly(): void + { + // Hand-maintained on purpose: a revision appended below FIRST_MODERN_VERSION + // is classified modern by accident, and every other assertion in this file + // stays green while it vanishes from the handshake negotiation. + $eras = [ + '2024-11-05' => false, + '2025-03-26' => false, + '2025-06-18' => false, + '2025-11-25' => false, + '2026-07-28' => true, + ]; + + $this->assertSame( + array_keys($eras), + array_map(static fn (ProtocolVersion $version): string => $version->value, ProtocolVersion::cases()), + 'A revision was added or removed: list it above with the era it belongs to.', + ); + + foreach (ProtocolVersion::cases() as $version) { + $this->assertSame( + $eras[$version->value], + $version->isModern(), + \sprintf('%s is in the wrong era; a handshake revision must be declared above %s.', $version->value, ProtocolVersion::FIRST_MODERN_VERSION->value), + ); + } + } + + #[TestDox('2026-07-28 opens the modern era')] + public function testModernEraStartsAt20260728(): void + { + $this->assertSame(ProtocolVersion::V2026_07_28, ProtocolVersion::FIRST_MODERN_VERSION); + + $this->assertFalse(ProtocolVersion::V2025_11_25->isModern()); + $this->assertTrue(ProtocolVersion::V2026_07_28->isModern()); + } + + #[TestDox('latestHandshake() stops short of the modern era')] + public function testLatestHandshakeStopsBeforeTheModernEra(): void + { + $this->assertSame(ProtocolVersion::V2025_11_25, ProtocolVersion::latestHandshake()); + $this->assertFalse(ProtocolVersion::latestHandshake()->isModern()); + } + + #[TestDox('handshake versions never contain a modern revision')] + public function testHandshakeVersionsExcludeModernRevisions(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertFalse($version->isModern(), \sprintf('%s leaked into the handshake era.', $version->value)); + } + } + + #[TestDox('compares by declaration order, not by string value')] + #[DataProvider('provideVersionComparisons')] + public function testIsAtLeast(ProtocolVersion $version, ProtocolVersion $minimum, bool $expected): void + { + $this->assertSame($expected, $version->isAtLeast($minimum)); + } + + /** + * @return iterable + */ + public static function provideVersionComparisons(): iterable + { + yield 'equal' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, true]; + yield 'newer' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18, true]; + yield 'older' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_03_26, false]; + yield 'across eras' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2024_11_05, true]; + yield 'oldest against newest' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28, false]; + } + + #[TestDox('the header-absent default is the revision that introduced the header')] + public function testDefaultHeaderVersion(): void + { + $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION); + } +} diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index c56eac4..25df23b 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -20,6 +20,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -39,29 +40,18 @@ class InitializeHandlerTest extends TestCase $handler = new InitializeHandler($configuration); $session = $this->createMock(SessionInterface::class); - $session->expects($this->exactly(2)) + $session->expects($this->exactly(3)) ->method('set') ->willReturnCallback(function (string $key, mixed $value): void { match ($key) { 'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value), 'client_capabilities' => $this->assertEquals(new \stdClass(), $value), + 'protocol_version' => $this->assertSame(ProtocolVersion::V2024_11_05->value, $value), default => $this->fail("Unexpected session key: {$key}"), }; }); - $request = InitializeRequest::fromArray([ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => 'request-1', - 'method' => InitializeRequest::getMethod(), - 'params' => [ - 'protocolVersion' => ProtocolVersion::V2024_11_05->value, - 'capabilities' => [], - 'clientInfo' => [ - 'name' => 'client-app', - 'version' => '1.0.0', - ], - ], - ]); + $request = $this->createInitializeRequest(ProtocolVersion::V2024_11_05->value); $response = $handler->handle($request, $session); @@ -76,4 +66,140 @@ class InitializeHandlerTest extends TestCase $result->jsonSerialize()['protocolVersion'] ); } + + #[TestDox('answers an unpinned handshake per the negotiation table')] + #[DataProvider('provideNegotiationTable')] + public function testNegotiationTable(string $requested, ProtocolVersion $expected): void + { + $handler = new InitializeHandler($this->createConfiguration()); + + $response = $handler->handle( + $this->createInitializeRequest($requested), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertNotNull($response->result->protocolVersion); + $this->assertSame($expected, $response->result->protocolVersion); + + // No row may resolve to a modern revision, whatever the client sent — that + // era has no `initialize`, so the answer would be unusable for both sides. + $this->assertFalse($response->result->protocolVersion->isModern()); + } + + /** + * The negotiation table from docs/server-builder.md, one data set per case a + * client can present. Keeping the rows in a single provider is what stops the + * documented table and the tested behaviour from drifting apart. + * + * @return iterable + */ + public static function provideNegotiationTable(): iterable + { + $counterOffer = ProtocolVersion::latestHandshake(); + + // Driven off the enum rather than a literal list, so a new revision is + // covered by the era it is declared in the moment it lands. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('supported %s -> echoed back', $version->value) => [$version->value, $version]; + } + + yield 'unknown future revision -> counter-offer' => ['2099-01-01', $counterOffer]; + yield 'not a revision at all -> counter-offer' => ['banana', $counterOffer]; + yield 'empty -> counter-offer' => ['', $counterOffer]; + + foreach (ProtocolVersion::modernVersions() as $version) { + yield \sprintf('modern %s -> counter-offer', $version->value) => [$version->value, $counterOffer]; + } + } + + #[TestDox('a modern version pinned in configuration cannot leak into the handshake')] + public function testModernConfiguredVersionFallsBackToHandshakeSet(): void + { + $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); + } + + #[TestDox('a pinned version wins over a different version requested by the client')] + public function testPinnedVersionOverridesClientRequest(): void + { + $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2025_03_26)); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_11_25->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertSame(ProtocolVersion::V2025_03_26, $response->result->protocolVersion); + } + + #[TestDox('stores the negotiated version on the session')] + public function testStoresNegotiatedVersionOnSession(): void + { + $handler = new InitializeHandler($this->createConfiguration()); + + $stored = []; + $session = $this->createMock(SessionInterface::class); + $session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$stored): void { + $stored[$key] = $value; + }); + + $handler->handle($this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), $session); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $stored['protocol_version'] ?? null); + } + + #[TestDox('falls back to empty defaults when constructed without a configuration')] + public function testHandlesMissingConfiguration(): void + { + // The reads in handle() sit on the left of ??, which has isset semantics and + // therefore tolerates the null without a nullsafe operator. This test is what + // proves that, rather than the shape of the accessor. + $handler = new InitializeHandler(); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertEquals(new ServerCapabilities(), $response->result->capabilities); + $this->assertEquals(new Implementation(), $response->result->serverInfo); + $this->assertNull($response->result->instructions); + $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); + } + + private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration + { + return new Configuration( + serverInfo: new Implementation('server', '1.2.3'), + capabilities: new ServerCapabilities(), + protocolVersion: $protocolVersion, + ); + } + + private function createInitializeRequest(string $protocolVersion): InitializeRequest + { + return InitializeRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => InitializeRequest::getMethod(), + 'params' => [ + 'protocolVersion' => $protocolVersion, + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'client-app', + 'version' => '1.0.0', + ], + ], + ]); + } } diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php index ad216d5..143b432 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php @@ -44,12 +44,12 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase $this->assertSame(400, $response->getStatusCode()); } - #[TestDox('accepts every version declared in the ProtocolVersion enum')] + #[TestDox('accepts every handshake-era version by default')] public function testAcceptsSupportedVersions(): void { $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - foreach (ProtocolVersion::cases() as $version) { + foreach (ProtocolVersion::handshakeVersions() as $version) { $request = $this->factory->createServerRequest('POST', 'http://localhost/') ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); @@ -59,6 +59,21 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase } } + #[TestDox('rejects modern-era versions by default, since the server cannot serve them yet')] + public function testRejectsModernVersionsByDefault(): void + { + $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + + foreach (ProtocolVersion::modernVersions() as $version) { + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(400, $response->getStatusCode(), 'Expected '.$version->value.' to be rejected.'); + } + } + #[TestDox('rejects unsupported well-formed version with 400')] public function testRejectsUnsupportedVersion(): void { From c4b163b4c46580cb68ad85785058969b7bed1342 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 02:11:15 +0200 Subject: [PATCH 076/143] [Server] Add ClientGateway::supportsSampling() (#418) Sampling was the one client capability a tool could not check before asking. `supportsRoots()` and `supportsElicitation()` both read the capabilities the client advertised during the handshake; sampling is stored in that same session data but had no accessor, so a tool had to issue the request and catch the refusal. --- CHANGELOG.md | 1 + src/Server/ClientGateway.php | 17 +++++++++++++++++ tests/Unit/Server/ClientGatewayTest.php | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fcaf2..162c180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. +* Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index d7d55eb..5205a61 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -250,6 +250,23 @@ class ClientGateway return \array_key_exists('elicitation', $capabilities); } + /** + * Check if the connected client supports sampling. + * + * Sampling lets a server borrow the client's model during tool execution. + * This method checks the client's advertised capabilities to determine if + * sampling/createMessage requests are supported. + * + * @return bool True if the client supports sampling, false otherwise + */ + public function supportsSampling(): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('sampling', $capabilities); + } + /** * Send a request to the client and wait for a response (blocking). * diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index 409669e..e6c2cee 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -43,6 +43,26 @@ final class ClientGatewayTest extends TestCase $this->assertFalse($gateway->supportsRoots()); } + public function testSupportsSamplingReturnsTrueWhenAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); + + $gateway = new ClientGateway($session); + + $this->assertTrue($gateway->supportsSampling()); + } + + public function testSupportsSamplingReturnsFalseWhenNotAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); + + $gateway = new ClientGateway($session); + + $this->assertFalse($gateway->supportsSampling()); + } + public function testListRootsReturnsRootsFromClient(): void { $session = $this->createMock(SessionInterface::class); From 7b12dc401039a5256f150156370afed8a21d935d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 02:24:48 +0200 Subject: [PATCH 077/143] [Tests] Introduce client-server integration tests (#419) * [Tests] Introduce client-server integration tests * Add an in-process loopback that connects a real client transport to a real server transport, draining both Fiber loops in one synchronous pass instead of polling. A stalled exchange fails where it happened rather than hanging. * Cover protocol version negotiation across both implementations, where the unit tests only ever drive one side against a canned counter-offer. * Cover the server-to-client round-trips end to end: elicitation, sampling, roots, plus progress and logging notifications. * Add the `integration` test suite and a `make integration-tests` target. * [Tests] Run the integration suite in CI The unit job pins `--testsuite=unit`, so the new suite would never have run on GitHub. It goes in that job rather than its own: the loopback leans on Fibers, and the matrix is what covers them down to the PHP 8.1 floor. * [Tests] Run integration tests against a real server process Replaces the in-process loopback with the setup from examples/client: the client spawns a fixture server over the real StdioTransport, so the tests exercise the shipped transports and no longer nest both sides' Fibers. Moves the suite into its own CI job, matrixed over PHP 8.1-8.5. --- .github/workflows/pipeline.yaml | 22 +++++ Makefile | 5 +- phpunit.xml.dist | 3 + tests/Integration/ElicitationTest.php | 107 +++++++++++++++++++++ tests/Integration/Fixture/elicitation.php | 49 ++++++++++ tests/Integration/Fixture/handshake.php | 32 ++++++ tests/Integration/Fixture/notification.php | 39 ++++++++ tests/Integration/Fixture/roots.php | 43 +++++++++ tests/Integration/Fixture/sampling.php | 42 ++++++++ tests/Integration/HandshakeTest.php | 85 ++++++++++++++++ tests/Integration/IntegrationTestCase.php | 83 ++++++++++++++++ tests/Integration/NotificationTest.php | 75 +++++++++++++++ tests/Integration/RootsTest.php | 98 +++++++++++++++++++ tests/Integration/SamplingTest.php | 98 +++++++++++++++++++ 14 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/ElicitationTest.php create mode 100644 tests/Integration/Fixture/elicitation.php create mode 100644 tests/Integration/Fixture/handshake.php create mode 100644 tests/Integration/Fixture/notification.php create mode 100644 tests/Integration/Fixture/roots.php create mode 100644 tests/Integration/Fixture/sampling.php create mode 100644 tests/Integration/HandshakeTest.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/NotificationTest.php create mode 100644 tests/Integration/RootsTest.php create mode 100644 tests/Integration/SamplingTest.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 9b76066..f7396e6 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -49,6 +49,28 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=unit + integration: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v4" + + - name: Tests + run: vendor/bin/phpunit --testsuite=integration + inspector: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 667aa04..c886bc5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs deps-stable: composer update --prefer-stable @@ -18,6 +18,9 @@ tests: unit-tests: vendor/bin/phpunit --testsuite=unit +integration-tests: + vendor/bin/phpunit --testsuite=integration + inspector-tests: vendor/bin/phpunit --testsuite=inspector diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 54c2a8e..eb07b79 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ tests/Unit + + tests/Integration + examples/server/oauth-microsoft/tests diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php new file mode 100644 index 0000000..dc16847 --- /dev/null +++ b/tests/Integration/ElicitationTest.php @@ -0,0 +1,107 @@ +connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']), + )); + + $result = $client->callTool('ask_name'); + + $this->assertFalse($result->isError); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('accept:Ada', $result->content[0]->text); + } + + #[TestDox('a declined elicitation reaches the tool as a decline, not an error')] + public function testDeclinedElicitation(): void + { + $client = $this->connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Decline), + )); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('decline:', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise elicitation is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + // The tool consults supportsElicitation(), which answers from the + // capabilities this client sent during the handshake. + $client = $this->connect('elicitation'); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('a client advertising elicitation without a handler fails the tool call')] + public function testAdvertisedCapabilityWithoutHandler(): void + { + // The client answers "method not found", which the gateway raises inside + // the tool as a ClientException rather than leaving it waiting. + $client = $this->connect( + 'elicitation', + $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), + ); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); + } + + private function clientAnswering(ElicitResult $answer): ClientBuilder + { + $callback = new class($answer) implements ElicitationCallbackInterface { + public function __construct(private readonly ElicitResult $answer) + { + } + + public function __invoke(ElicitRequest $request): ElicitResult + { + return $this->answer; + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler($callback)); + } +} diff --git a/tests/Integration/Fixture/elicitation.php b/tests/Integration/Fixture/elicitation.php new file mode 100644 index 0000000..ad588a9 --- /dev/null +++ b/tests/Integration/Fixture/elicitation.php @@ -0,0 +1,49 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsElicitation()) { + return 'unsupported'; + } + + try { + $result = $gateway->elicit('What is your name?', new ElicitationSchema([ + 'name' => new StringSchemaDefinition(title: 'Name'), + ])); + } catch (ClientException $e) { + return $e->getMessage(); + } + + return sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); + }, + name: 'ask_name', + description: 'Asks the client for a name.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php new file mode 100644 index 0000000..776f451 --- /dev/null +++ b/tests/Integration/Fixture/handshake.php @@ -0,0 +1,32 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.'); + +if (is_string($pinned = getenv('MCP_INTEGRATION_PROTOCOL_VERSION')) && '' !== $pinned) { + $builder->setProtocolVersion(ProtocolVersion::from($pinned)); +} + +$builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/notification.php b/tests/Integration/Fixture/notification.php new file mode 100644 index 0000000..166e84c --- /dev/null +++ b/tests/Integration/Fixture/notification.php @@ -0,0 +1,39 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + $gateway->log(LoggingLevel::Info, 'starting work'); + $gateway->progress(0.5, 1.0, 'halfway'); + $gateway->progress(1.0, 1.0, 'done'); + + return 'finished'; + }, + name: 'work', + description: 'Reports progress and logs while working.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/roots.php b/tests/Integration/Fixture/roots.php new file mode 100644 index 0000000..a4b6382 --- /dev/null +++ b/tests/Integration/Fixture/roots.php @@ -0,0 +1,43 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsRoots()) { + return 'unsupported'; + } + + $described = []; + foreach ($gateway->listRoots()->roots as $root) { + $described[] = sprintf('%s (%s)', $root->uri, $root->name ?? '-'); + } + + return implode(', ', $described); + }, + name: 'inspect_roots', + description: 'Reports the workspace roots the client exposes.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php new file mode 100644 index 0000000..bb37bbf --- /dev/null +++ b/tests/Integration/Fixture/sampling.php @@ -0,0 +1,42 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context, string $text): string { + try { + $result = $context->getClientGateway()->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize', + description: 'Summarizes text by asking the client to sample.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php new file mode 100644 index 0000000..5e0455f --- /dev/null +++ b/tests/Integration/HandshakeTest.php @@ -0,0 +1,85 @@ +clientBuilder(); + if (null !== $clientVersion) { + $client->setProtocolVersion($clientVersion); + } + + $connected = $this->connect( + 'handshake', + $client, + null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], + ); + + $this->assertSame($expected, $connected->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideNegotiations(): iterable + { + $latest = ProtocolVersion::latestHandshake(); + + yield 'both unconfigured' => [null, null, $latest]; + + // Whichever end of the supported range it sits at. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('client asks for %s', $version->value) => [$version, null, $version]; + } + + // A pinned server answers with its pin, and the client continues on it. + yield 'server pins an older revision' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26]; + yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; + yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; + + // Neither side reaches the modern era through `initialize`, so + // configuring it falls back to the handshake set on both ends. + yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; + yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; + yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; + } + + #[TestDox('the handshake carries the server identity to the client')] + public function testServerInfoIsExchanged(): void + { + $client = $this->connect('handshake'); + + $this->assertSame('integration-server', $client->getServerInfo()->name); + $this->assertSame('1.0.0', $client->getServerInfo()->version); + $this->assertSame('Be brief.', $client->getInstructions()); + $this->assertTrue($client->isConnected()); + } + + #[TestDox('the negotiated revision is unset before the handshake')] + public function testProtocolVersionIsNullBeforeConnecting(): void + { + $this->assertNull($this->clientBuilder()->build()->getProtocolVersion()); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..aa0dd9c --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,83 @@ + + */ +abstract class IntegrationTestCase extends TestCase +{ + /** + * Both sides answer immediately, so anything reaching this is a deadlock. + * Far below the SDK's two-minute default, to fail rather than hang. + */ + private const TIMEOUT = 5; + + private ?Client $client = null; + + protected function clientBuilder(): ClientBuilder + { + return Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT); + } + + /** + * Spawn a fixture server and connect a client to it. + * + * The returned client has completed the handshake. + * + * @param string $fixture basename of a script in {@see Fixture} + * @param array $env added to the server process environment + */ + protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client + { + $script = __DIR__.'/Fixture/'.$fixture.'.php'; + + $this->client = ($client ?? $this->clientBuilder())->build(); + + try { + $this->client->connect(new StdioTransport( + command: \PHP_BINARY, + args: [$script], + // proc_open() replaces the environment rather than adding to it. + env: [] === $env ? null : array_merge(getenv(), $env), + )); + } catch (ConnectionException $e) { + // The transport discards the child's stderr, so a fixture dying on + // startup arrives here as a bare timeout. + $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, $script)); + } + + return $this->client; + } + + protected function tearDown(): void + { + $this->client?->disconnect(); + $this->client = null; + } +} diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php new file mode 100644 index 0000000..0948718 --- /dev/null +++ b/tests/Integration/NotificationTest.php @@ -0,0 +1,75 @@ +connect('notification'); + + $updates = []; + $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { + $updates[] = [$progress, $total, $message]; + }); + + $this->assertSame([[0.5, 1.0, 'halfway'], [1.0, 1.0, 'done']], $updates); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('progress is skipped when the caller asked for none')] + public function testProgressIsSkippedWithoutAToken(): void + { + // Without an onProgress callback the request carries no progress token, + // so the gateway drops the notification instead of sending it. + $client = $this->connect('notification'); + + $result = $client->callTool('work'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('log notifications reach a registered logging handler')] + public function testLoggingReachesTheClient(): void + { + $logged = []; + $client = $this->connect( + 'notification', + $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) use (&$logged): void { + $logged[] = [$notification->level, $notification->data]; + }, + )), + ); + + $client->callTool('work'); + + $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); + } +} diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php new file mode 100644 index 0000000..6dfd065 --- /dev/null +++ b/tests/Integration/RootsTest.php @@ -0,0 +1,98 @@ +connect('roots', $this->clientExposing( + new Root('file:///workspace/app', 'App'), + new Root('file:///workspace/docs', 'Docs'), + )); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('file:///workspace/app (App), file:///workspace/docs (Docs)', $result->content[0]->text); + } + + #[TestDox('an empty root list is a valid answer, not a failure')] + public function testEmptyRootList(): void + { + $client = $this->connect('roots', $this->clientExposing()); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise roots is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + $client = $this->connect('roots'); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('the client can announce that its roots changed')] + public function testRootsListChangedNotification(): void + { + $client = $this->connect('roots', $this->clientExposing(new Root('file:///workspace'))); + + // A notification has no reply, so what this pins down is that sending + // one mid-session leaves the connection usable. + $client->sendRootsListChanged(); + + $this->assertTrue($client->isConnected()); + $this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]); + } + + private function clientExposing(Root ...$roots): ClientBuilder + { + $callback = new class(array_values($roots)) implements RootsCallbackInterface { + /** @param list $roots */ + public function __construct(private readonly array $roots) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult($this->roots); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler($callback)); + } +} diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php new file mode 100644 index 0000000..c581cf8 --- /dev/null +++ b/tests/Integration/SamplingTest.php @@ -0,0 +1,98 @@ +connect('sampling', $this->clientSampling()); + + $result = $client->callTool('summarize', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + + #[TestDox('the prompt the tool passed arrives at the client')] + public function testPromptReachesTheClient(): void + { + // The client stays in this process, so what it was asked can be + // collected by reference even though the tool asking runs in another. + /** @var \ArrayObject $seen */ + $seen = new \ArrayObject(); + $client = $this->connect('sampling', $this->clientSampling($seen)); + + $client->callTool('summarize', ['text' => 'inspect me']); + + $this->assertCount(1, $seen); + $this->assertInstanceOf(TextContent::class, $seen[0]->messages[0]->content); + $this->assertSame('inspect me', $seen[0]->messages[0]->content->text); + $this->assertSame(64, $seen[0]->maxTokens); + } + + #[TestDox('a client that cannot sample refuses instead of stalling the tool')] + public function testClientWithoutSamplingRefuses(): void + { + // The gateway has no supportsSampling() to consult, so the tool finds + // out by asking and the refusal surfaces as a ClientException. + $client = $this->connect('sampling'); + + $result = $client->callTool('summarize', ['text' => 'anything']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); + } + + /** + * @param \ArrayObject|null $seen collects what the server asked for + */ + private function clientSampling(?\ArrayObject $seen = null): ClientBuilder + { + $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { + /** @param \ArrayObject $seen */ + public function __construct(private readonly \ArrayObject $seen) + { + } + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->seen[] = $request; + + $prompt = $request->messages[0]->content; + \assert($prompt instanceof TextContent); + + return new CreateSamplingMessageResult(Role::Assistant, new TextContent($prompt->text), 'test-model'); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler($callback)); + } +} From d8cc21a345f9e87eb9fecd142f2e5a4a05888530 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 02:43:50 +0200 Subject: [PATCH 078/143] [Client] Implement setMaxRetries connection retries (#413) * [Client] Implement setMaxRetries connection retries * [Client] Reject a negative retry count in Configuration * [Client] Use a positive init timeout in the retry test * [Client] Drop redundant docblock note on setMaxRetries * [Client] Clear the initialized flag on a failed connection * [Client] Trim comments to the non-obvious ones * [Client] Cover retries and timeout cleanup with integration tests The retry fixture counts the processes it was started as, which is what separates a real retry from a second call into the same server. The timeout fixture outlives the client's patience and then goes idle, so the next request runs on a connection the client already gave up on once. --- docs/client.md | 24 +- src/Client.php | 36 ++- src/Client/Builder.php | 6 +- src/Client/Configuration.php | 4 + src/Client/Protocol.php | 33 +- tests/Integration/Fixture/request_timeout.php | 42 +++ tests/Integration/Fixture/retry.php | 46 +++ tests/Integration/IntegrationTestCase.php | 34 ++- tests/Integration/RequestTimeoutTest.php | 43 +++ tests/Integration/RetryTest.php | 93 ++++++ tests/Unit/ClientTest.php | 283 ++++++++++++++++++ 11 files changed, 617 insertions(+), 27 deletions(-) create mode 100644 tests/Integration/Fixture/request_timeout.php create mode 100644 tests/Integration/Fixture/retry.php create mode 100644 tests/Integration/RequestTimeoutTest.php create mode 100644 tests/Integration/RetryTest.php create mode 100644 tests/Unit/ClientTest.php diff --git a/docs/client.md b/docs/client.md index 9d585dd..78059df 100644 --- a/docs/client.md +++ b/docs/client.md @@ -57,10 +57,32 @@ $client = Client::builder() ->setClientInfo('My Application', '1.0.0', 'Description of my client') ->setInitTimeout(30) // Seconds to wait for initialization ->setRequestTimeout(120) // Seconds to wait for request responses - ->setMaxRetries(3) // Retry attempts for failed connections + ->setMaxRetries(3) // Retries for failed connections ->build(); ``` +### Connection Retries + +`setMaxRetries()` controls how often `connect()` retries a failed connection. It +counts retries rather than attempts, so the default of `3` means one initial +attempt plus up to three retries — four in total — before the `ConnectionException` +of the last attempt is rethrown: + +```php +$client = Client::builder() + ->setMaxRetries(0) // Fail on the first failed attempt + ->build(); +``` + +Between two attempts the transport is closed, so a retry never reuses a +half-established connection: a `StdioTransport` spawns a fresh server process and +an `HttpTransport` discards the session ID of the failed attempt. Each retry is +preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). + +Only the connection handshake is retried. Individual requests such as +`callTool()` are always sent once — retrying them is unsafe as tool calls are not +necessarily idempotent. + ### Client Information Set the client's identity reported to servers during initialization: diff --git a/src/Client.php b/src/Client.php index 18ec54f..ed5abc6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -58,6 +58,8 @@ use Psr\Log\NullLogger; */ class Client { + private const RETRY_BASE_DELAY_MS = 100; + private ?TransportInterface $transport = null; public function __construct( @@ -78,16 +80,44 @@ class Client /** * Connect to an MCP server using the provided transport. * - * @throws ConnectionException If connection or initialization fails + * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}. + * + * @throws ConnectionException If connection or initialization fails on every attempt */ public function connect(TransportInterface $transport): void { $this->transport = $transport; $this->protocol->connect($transport, $this->config); - $transport->connect(); + $maxAttempts = $this->config->maxRetries + 1; - $this->logger->info('Client connected and initialized'); + for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { + try { + $transport->connect(); + + $this->logger->info('Client connected and initialized', ['attempt' => $attempt]); + + return; + } catch (ConnectionException $e) { + // initialize() flags the session before sending the initialized + // notification, so a failure in between leaves the flag set. + $this->protocol->getState()->setInitialized(false); + + $transport->close(); + + if ($attempt === $maxAttempts) { + throw $e; + } + + $this->logger->warning('Connection attempt failed, retrying', [ + 'attempt' => $attempt, + 'max_attempts' => $maxAttempts, + 'exception' => $e, + ]); + + usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000); + } + } } /** diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 35f7f5d..9a69da1 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -96,7 +96,11 @@ final class Builder } /** - * Set maximum retry attempts for failed connections. + * Set the number of times a failed connection attempt is retried. + * + * Counts retries, not attempts: 3 means one initial attempt plus up to three + * retries. Pass 0 to fail on the first failure. Only applies to + * {@see Client::connect()}; individual requests are never retried. */ public function setMaxRetries(int $retries): self { diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index 98ad2d8..f0ed6f7 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -38,5 +38,9 @@ class Configuration if ($requestTimeout < 1) { throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout)); } + + if ($maxRetries < 0) { + throw new InvalidArgumentException(\sprintf('The maximum number of retries must be zero or greater, got %d.', $maxRetries)); + } } } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 50c51f1..e9eabde 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -179,22 +179,29 @@ class Protocol } $this->state->addPendingRequest($requestId, $timeout); - $this->sendRequest($request); - $immediate = $this->state->consumeResponse($requestId); - if (null !== $immediate) { - $this->logger->debug('Received immediate response', ['id' => $requestId]); + try { + $this->sendRequest($request); - return $immediate; + $immediate = $this->state->consumeResponse($requestId); + if (null !== $immediate) { + $this->logger->debug('Received immediate response', ['id' => $requestId]); + + return $immediate; + } + + $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + + return \Fiber::suspend([ + 'type' => 'await_response', + 'request_id' => $requestId, + 'timeout' => $timeout, + ]); + } finally { + // Only the response path clears it, so a request that timed out or + // whose send() threw would stay pending and fail every later one. + $this->state->removePendingRequest($requestId); } - - $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); - - return \Fiber::suspend([ - 'type' => 'await_response', - 'request_id' => $requestId, - 'timeout' => $timeout, - ]); } /** diff --git a/tests/Integration/Fixture/request_timeout.php b/tests/Integration/Fixture/request_timeout.php new file mode 100644 index 0000000..fb3697c --- /dev/null +++ b/tests/Integration/Fixture/request_timeout.php @@ -0,0 +1,42 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (): string { + sleep(2); + + return 'late'; + }, + name: 'slow', + description: 'Answers well after the client stopped waiting.', + ) + ->addTool( + static fn (): string => 'quick', + name: 'fast', + description: 'Returns at once.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/retry.php b/tests/Integration/Fixture/retry.php new file mode 100644 index 0000000..9bd25ee --- /dev/null +++ b/tests/Integration/Fixture/retry.php @@ -0,0 +1,46 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static fn (): string => 'quick', + name: 'fast', + description: 'Returns at once.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index aa0dd9c..4ad33d4 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -55,26 +55,42 @@ abstract class IntegrationTestCase extends TestCase */ protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client { - $script = __DIR__.'/Fixture/'.$fixture.'.php'; - $this->client = ($client ?? $this->clientBuilder())->build(); try { - $this->client->connect(new StdioTransport( - command: \PHP_BINARY, - args: [$script], - // proc_open() replaces the environment rather than adding to it. - env: [] === $env ? null : array_merge(getenv(), $env), - )); + $this->client->connect($this->transport($fixture, $env)); } catch (ConnectionException $e) { // The transport discards the child's stderr, so a fixture dying on // startup arrives here as a bare timeout. - $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, $script)); + $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, self::script($fixture))); } return $this->client; } + /** + * A transport that will spawn a fixture server, without connecting it. + * + * Tests that assert on a failing connection need the transport by itself, + * since {@see self::connect()} turns that failure into a test failure. + * + * @param array $env added to the server process environment + */ + protected function transport(string $fixture, array $env = []): StdioTransport + { + return new StdioTransport( + command: \PHP_BINARY, + args: [self::script($fixture)], + // proc_open() replaces the environment rather than adding to it. + env: [] === $env ? null : array_merge(getenv(), $env), + ); + } + + private static function script(string $fixture): string + { + return __DIR__.'/Fixture/'.$fixture.'.php'; + } + protected function tearDown(): void { $this->client?->disconnect(); diff --git a/tests/Integration/RequestTimeoutTest.php b/tests/Integration/RequestTimeoutTest.php new file mode 100644 index 0000000..fa3ef4c --- /dev/null +++ b/tests/Integration/RequestTimeoutTest.php @@ -0,0 +1,43 @@ +connect('request_timeout', $this->clientBuilder()->setRequestTimeout(1)); + + try { + $client->callTool('slow'); + $this->fail('The slow tool should have outlived the request timeout.'); + } catch (RequestException) { + } + + // The server is still executing the abandoned call and cannot answer + // anything until it returns, so the next request has to wait it out. + sleep(2); + + // Without the pending request being cleared this answers 'late': the + // response the client gave up on, handed to the request after it. + $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); + } +} diff --git a/tests/Integration/RetryTest.php b/tests/Integration/RetryTest.php new file mode 100644 index 0000000..7fc974c --- /dev/null +++ b/tests/Integration/RetryTest.php @@ -0,0 +1,93 @@ +fail('Could not create the spawn counter file.'); + } + + $this->counter = $counter; + file_put_contents($this->counter, '0'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @unlink($this->counter); + } + + #[TestDox('a failed attempt is retried against a newly spawned server')] + public function testRetrySpawnsAnotherServer(): void + { + $client = $this->connect( + 'retry', + $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(1), + $this->environment(failing: 1), + ); + + $this->assertSame(2, $this->spawns()); + $this->assertTrue($client->isConnected()); + $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); + } + + #[TestDox('no retries means the first failure is the last word')] + public function testRetriesCanBeDisabled(): void + { + $client = $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(0)->build(); + + try { + $client->connect($this->transport('retry', $this->environment(failing: 1))); + $this->fail('Connecting should not have succeeded.'); + } catch (ConnectionException) { + $this->assertSame(1, $this->spawns()); + } + } + + /** + * @return array + */ + private function environment(int $failing): array + { + return [ + 'MCP_SPAWN_COUNTER' => $this->counter, + 'MCP_FAIL_SPAWNS' => (string) $failing, + ]; + } + + private function spawns(): int + { + return (int) file_get_contents($this->counter); + } +} diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..558de95 --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,283 @@ +assertInstanceOf(Builder::class, Client::builder()); + } + + #[TestDox('connect() succeeds on the first attempt without retrying')] + public function testConnectSucceedsWithoutRetrying(): void + { + $transport = new FakeTransport([FakeTransport::ACCEPT]); + + $client = Client::builder()->build(); + $client->connect($transport); + + $this->assertSame(1, $transport->connectCalls); + $this->assertSame(0, $transport->closeCalls); + $this->assertTrue($client->isConnected()); + } + + #[TestDox('connect() retries a failed attempt and succeeds on a later one')] + public function testConnectRetriesUntilItSucceeds(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(3)->build(); + $client->connect($transport); + + $this->assertSame(3, $transport->connectCalls); + $this->assertTrue($client->isConnected()); + $this->assertSame('Test Server', $client->getServerInfo()?->name); + } + + #[TestDox('connect() closes the transport between two attempts')] + public function testConnectClosesTransportBetweenAttempts(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(1)->build(); + $client->connect($transport); + + $this->assertSame(2, $transport->connectCalls); + $this->assertSame(1, $transport->closeCalls, 'the failed attempt must be cleaned up before the retry'); + } + + #[TestDox('connect() rethrows once the retries are exhausted')] + public function testConnectThrowsWhenRetriesAreExhausted(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::REJECT]); + + $client = Client::builder()->setMaxRetries(2)->build(); + + try { + $client->connect($transport); + $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); + } catch (ConnectionException) { + // Expected. + } + + $this->assertSame(3, $transport->connectCalls, 'one initial attempt plus two retries'); + $this->assertSame(3, $transport->closeCalls, 'the last attempt must be cleaned up as well'); + $this->assertFalse($client->isConnected()); + } + + #[TestDox('setMaxRetries(0) disables retrying')] + public function testZeroRetriesAttemptsToConnectOnce(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + + try { + $client->connect($transport); + } finally { + $this->assertSame(1, $transport->connectCalls); + } + } + + #[TestDox('a negative retry count is rejected')] + public function testNegativeRetryCountIsRejected(): void + { + $builder = Client::builder()->setMaxRetries(-1); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The maximum number of retries must be zero or greater, got -1.'); + + $builder->build(); + } + + #[TestDox('a connection breaking after the handshake result does not leave the client connected')] + public function testFailureAfterHandshakeResultDoesNotLeaveClientConnected(): void + { + $transport = new FakeTransport([FakeTransport::BREAK_AFTER_ACCEPT]); + + $client = Client::builder()->setMaxRetries(0)->build(); + + try { + $client->connect($transport); + $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); + } catch (ConnectionException) { + // Expected. + } + + $this->assertFalse($client->isConnected()); + } + + #[TestDox('a timed out attempt does not leave state behind that fails the retry')] + public function testTimedOutAttemptDoesNotPoisonTheRetry(): void + { + // The shortest allowed init timeout, so this waits out about a second. + $transport = new FakeTransport([FakeTransport::IGNORE, FakeTransport::ACCEPT]); + + $client = Client::builder()->setInitTimeout(1)->setMaxRetries(1)->build(); + $client->connect($transport); + + $this->assertSame(2, $transport->connectCalls); + $this->assertTrue($client->isConnected()); + } +} + +/** + * Transport whose connection attempts succeed or fail on command. + * + * Requests are answered from the polling loop rather than from send(), the way + * a stdio transport does, with each connect() call answering according to the + * next configured outcome. + * + * @phpstan-import-type McpFiber from TransportInterface + */ +final class FakeTransport extends BaseTransport +{ + /** The initialize request is answered with a result. */ + public const ACCEPT = 'accept'; + + /** The initialize request is answered with a JSON-RPC error. */ + public const REJECT = 'reject'; + + /** The initialize request is not answered at all and has to time out. */ + public const IGNORE = 'ignore'; + + /** The initialize request is answered, but the connection breaks right after. */ + public const BREAK_AFTER_ACCEPT = 'break_after_accept'; + + public int $connectCalls = 0; + public int $closeCalls = 0; + + private string $outcome = self::ACCEPT; + + /** @var list Answers not yet delivered to the client */ + private array $outbox = []; + + /** + * @param list $attempts How each successive connect() call behaves + */ + public function __construct(private array $attempts = [self::ACCEPT]) + { + parent::__construct(); + } + + public function connect(): void + { + ++$this->connectCalls; + $this->outcome = array_shift($this->attempts) ?? self::ACCEPT; + + $result = $this->runRequest(new \Fiber(fn () => $this->handleInitialize())); + + if ($result instanceof Error) { + throw new ConnectionException('Initialization failed: '.$result->message); + } + } + + public function send(string $data): void + { + if (self::IGNORE === $this->outcome) { + return; + } + + $message = json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if (!isset($message['id'])) { + if (self::BREAK_AFTER_ACCEPT === $this->outcome) { + throw new ConnectionException('Connection lost'); + } + + return; // A notification, nothing to answer. + } + + $answer = self::REJECT === $this->outcome + ? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']] + : ['result' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => [], + 'serverInfo' => ['name' => 'Test Server', 'version' => '1.0.0'], + ]]; + + $this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id']] + $answer, \JSON_THROW_ON_ERROR); + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $fiber->start(); + + // Polls at the same 1ms interval as the real transports, so a request + // left unanswered runs into its timeout in real time. + for ($poll = 0; !$fiber->isTerminated(); ++$poll) { + if ($poll > 5000) { + throw new \LogicException('The fiber never terminated, no pending request became resolvable.'); + } + + foreach ($this->outbox as $answer) { + $this->handleMessage($answer); + } + $this->outbox = []; + + $this->resumeFiber($fiber); + + usleep(1000); + } + + return $fiber->getReturn(); + } + + public function close(): void + { + ++$this->closeCalls; + + $this->handleClose('Transport closed'); + } + + /** + * @param McpFiber $fiber + */ + private function resumeFiber(\Fiber $fiber): void + { + if (!$fiber->isSuspended() || null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $response = $this->state->consumeResponse($pending['request_id']); + + if (null !== $response) { + $fiber->resume($response); + + return; + } + + if (time() - $pending['timestamp'] >= $pending['timeout']) { + $fiber->resume(Error::forInternalError('Request timed out', $pending['request_id'])); + + return; + } + } + } +} From 82aac972c1dc0138285bfd1474491722570970aa Mon Sep 17 00:00:00 2001 From: wWzZb <806671633@qq.com> Date: Sat, 15 Aug 2026 09:28:11 +0800 Subject: [PATCH 079/143] [Schema][Server] feat: support sampling with tools (#409) * feat: support sampling with tools * address sampling tools review feedback * tighten sampling tools spec compliance * drop SamplingStopReason enum in favor of open string The spec leaves stopReason open for provider-specific values, so upcasting the four known ones to enum cases makes every future enum addition a silent BC break for string comparisons. --------- Co-authored-by: Christopher Hertel --- CHANGELOG.md | 1 + docs/client.md | 36 +++++++ docs/server-client-communication.md | 12 ++- src/Schema/ClientCapabilities.php | 23 ++++- src/Schema/Content/SamplingMessage.php | 80 ++++++++++++--- src/Schema/Content/ToolResultContent.php | 99 +++++++++++++++++++ src/Schema/Content/ToolUseContent.php | 75 ++++++++++++++ src/Schema/Enum/ToolChoiceMode.php | 19 ++++ .../Request/CreateSamplingMessageRequest.php | 55 ++++++++++- .../Result/CreateSamplingMessageResult.php | 53 ++++++++-- src/Schema/ToolChoice.php | 51 ++++++++++ src/Server/ClientGateway.php | 9 ++ .../Schema/ClientCapabilitiesSamplingTest.php | 45 +++++++++ .../Content/SamplingToolContentTest.php | 76 ++++++++++++++ .../CreateSamplingMessageRequestTest.php | 22 +++++ .../CreateSamplingMessageResultTest.php | 53 ++++++++++ 16 files changed, 682 insertions(+), 27 deletions(-) create mode 100644 src/Schema/Content/ToolResultContent.php create mode 100644 src/Schema/Content/ToolUseContent.php create mode 100644 src/Schema/Enum/ToolChoiceMode.php create mode 100644 src/Schema/ToolChoice.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesSamplingTest.php create mode 100644 tests/Unit/Schema/Content/SamplingToolContentTest.php create mode 100644 tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c180..e23af6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. +* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. 0.7.0 ----- diff --git a/docs/client.md b/docs/client.md index 78059df..b27b433 100644 --- a/docs/client.md +++ b/docs/client.md @@ -551,6 +551,42 @@ $client = Client::builder() ->build(); ``` +#### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: 'toolUse', +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + > [!IMPORTANT] > **Error Handling in Sampling Callbacks:** > diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index f54294b..e55f792 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -32,7 +32,7 @@ class MyService ## Sampling -With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to +With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: ```php @@ -41,12 +41,16 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper The `sample` method accepts four arguments: -1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SampleMessage` instances. +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` instances. 2. `maxTokens`, which defaults to `1000` 3. `timeout` in seconds, which defaults to `120` -4. `options` which might include `system_prompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` +4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, + `stopSequences`, `metadata`, `tools`, and `toolChoice` -[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) +Only send `includeContext` when the client advertises `sampling.context`, and only send `tools` or `toolChoice` when it +advertises `sampling.tools`. The context modes other than `none` are soft-deprecated by the current specification. + +[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages) ## Logging diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 3bb6081..4c7c451 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -30,6 +30,8 @@ class ClientCapabilities implements \JsonSerializable public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, public readonly ?array $extensions = null, + public readonly ?bool $samplingContext = null, + public readonly ?bool $samplingTools = null, ) { } @@ -38,7 +40,7 @@ class ClientCapabilities implements \JsonSerializable * roots?: array{ * listChanged?: bool, * }, - * sampling?: bool, + * sampling?: array{context?: mixed, tools?: mixed}|object, * elicitation?: bool, * experimental?: array, * extensions?: array, @@ -57,8 +59,17 @@ class ClientCapabilities implements \JsonSerializable } $sampling = null; + $samplingContext = null; + $samplingTools = null; if (isset($data['sampling'])) { $sampling = true; + if (\is_array($data['sampling'])) { + $samplingContext = isset($data['sampling']['context']); + $samplingTools = isset($data['sampling']['tools']); + } elseif (\is_object($data['sampling'])) { + $samplingContext = property_exists($data['sampling'], 'context'); + $samplingTools = property_exists($data['sampling'], 'tools'); + } } $elicitation = null; @@ -73,6 +84,8 @@ class ClientCapabilities implements \JsonSerializable $elicitation, \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, + $samplingContext, + $samplingTools, ); } @@ -95,8 +108,14 @@ class ClientCapabilities implements \JsonSerializable } } - if ($this->sampling) { + if ($this->sampling || $this->samplingContext || $this->samplingTools) { $data['sampling'] = new \stdClass(); + if ($this->samplingContext) { + $data['sampling']->context = new \stdClass(); + } + if ($this->samplingTools) { + $data['sampling']->tools = new \stdClass(); + } } if ($this->elicitation) { diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 18c8877..53973ae 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -17,19 +17,44 @@ use Mcp\Schema\Enum\Role; /** * Describes a message issued to or received from an LLM API during sampling. * + * @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent * @phpstan-type SamplingMessageData = array{ * role: 'user'|'assistant', - * content: TextContent|ImageContent|AudioContent + * content: array|array>, + * _meta?: array * } * * @author Kyrian Obikwelu */ class SamplingMessage extends Content { + /** + * @param SamplingContent|list $content + * @param ?array $meta + */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, + public readonly ?array $meta = null, ) { + $contents = \is_array($content) ? $content : [$content]; + foreach ($contents as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) { + throw new InvalidArgumentException('Sampling message content contains an unsupported content block.'); + } + if (Role::User === $role && $item instanceof ToolUseContent) { + throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); + } + if (Role::Assistant === $role && $item instanceof ToolResultContent) { + throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); + } + } + + if (array_filter($contents, static fn ($item): bool => $item instanceof ToolResultContent) + && array_filter($contents, static fn ($item): bool => !$item instanceof ToolResultContent)) { + throw new InvalidArgumentException('Tool result messages must not contain other content types.'); + } + parent::__construct('sampling'); } @@ -51,18 +76,26 @@ class SamplingMessage extends Content $contentData = $data['content']; $contentType = $contentData['type'] ?? null; - if (!\is_string($contentType)) { + if (null !== $contentType && !\is_string($contentType)) { throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.'); } - $contentInstance = match ($contentType) { - 'text' => TextContent::fromArray($contentData), - 'image' => ImageContent::fromArray($contentData), - 'audio' => AudioContent::fromArray($contentData), - default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), - }; + $isSingleContent = null !== $contentType; + $contentItems = $isSingleContent ? [$contentData] : $contentData; + $content = []; - return new self($role, $contentInstance); + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in SamplingMessage data.'); + } + $content[] = self::hydrateContent($item); + } + + return new self( + $role, + $isSingleContent ? $content[0] : $content, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** @@ -70,9 +103,34 @@ class SamplingMessage extends Content */ public function jsonSerialize(): array { - return [ + $data = [ 'role' => $this->role->value, 'content' => $this->content, ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } + + /** + * @param array $contentData + * + * @return SamplingContent + */ + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent + { + $contentType = $contentData['type'] ?? null; + + return match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), + 'tool_result' => ToolResultContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), + }; } } diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php new file mode 100644 index 0000000..a6a792d --- /dev/null +++ b/src/Schema/Content/ToolResultContent.php @@ -0,0 +1,99 @@ + $structuredContent + * @param ?array $meta + */ + public function __construct( + public readonly string $toolUseId, + public readonly array $content, + public readonly ?array $structuredContent = null, + public readonly bool $isError = false, + public readonly ?array $meta = null, + ) { + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof EmbeddedResource) { + throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); + } + } + + parent::__construct('tool_result'); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) { + throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.'); + } + + $content = []; + foreach ($data['content'] as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in ToolResultContent data.'); + } + + $content[] = match ($item['type'] ?? null) { + 'text' => TextContent::fromArray($item), + 'image' => ImageContent::fromArray($item), + 'audio' => AudioContent::fromArray($item), + 'resource' => EmbeddedResource::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)), + }; + } + + return new self( + $data['toolUseId'], + $content, + isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null, + isset($data['isError']) && true === $data['isError'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'toolUseId' => $this->toolUseId, + 'content' => $this->content, + 'isError' => $this->isError, + ]; + + if (null !== $this->structuredContent) { + $data['structuredContent'] = $this->structuredContent; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php new file mode 100644 index 0000000..6acdc43 --- /dev/null +++ b/src/Schema/Content/ToolUseContent.php @@ -0,0 +1,75 @@ + $input + * @param ?array $meta + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $input, + public readonly ?array $meta = null, + ) { + parent::__construct('tool_use'); + } + + /** + * @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['id']) || !\is_string($data['id'])) { + throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.'); + } + if (!isset($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.'); + } + if (!isset($data['input']) || !\is_array($data['input'])) { + throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.'); + } + + return new self( + $data['id'], + $data['name'], + $data['input'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array{type: 'tool_use', id: string, name: string, input: array|\stdClass, _meta?: array} + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'id' => $this->id, + 'name' => $this->name, + 'input' => $this->input ?: new \stdClass(), + ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Enum/ToolChoiceMode.php b/src/Schema/Enum/ToolChoiceMode.php new file mode 100644 index 0000000..76daec0 --- /dev/null +++ b/src/Schema/Enum/ToolChoiceMode.php @@ -0,0 +1,19 @@ + $metadata Optional metadata to pass through to the LLM provider. The format of * this metadata is provider-specific. + * @param ?Tool[] $tools tools that the model may use during generation + * @param ?ToolChoice $toolChoice controls how the model uses tools */ public function __construct( public readonly array $messages, @@ -51,12 +57,19 @@ final class CreateSamplingMessageRequest extends Request public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, + public readonly ?array $tools = null, + public readonly ?ToolChoice $toolChoice = null, ) { foreach ($this->messages as $message) { if (!$message instanceof SamplingMessage) { throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); } } + foreach ($this->tools ?? [] as $tool) { + if (!$tool instanceof Tool) { + throw new InvalidArgumentException('Tools must be instances of Tool.'); + } + } } public static function getMethod(): string @@ -122,6 +135,34 @@ final class CreateSamplingMessageRequest extends Request throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); } + $tools = null; + if (isset($params['tools'])) { + if (!\is_array($params['tools'])) { + throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.'); + } + $tools = []; + foreach ($params['tools'] as $toolData) { + if ($toolData instanceof Tool) { + $tools[] = $toolData; + } elseif (\is_array($toolData)) { + $tools[] = Tool::fromArray($toolData); + } else { + throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.'); + } + } + } + + $toolChoice = null; + if (isset($params['toolChoice'])) { + if ($params['toolChoice'] instanceof ToolChoice) { + $toolChoice = $params['toolChoice']; + } elseif (\is_array($params['toolChoice'])) { + $toolChoice = ToolChoice::fromArray($params['toolChoice']); + } else { + throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.'); + } + } + return new self( $messages, $params['maxTokens'], @@ -131,6 +172,8 @@ final class CreateSamplingMessageRequest extends Request isset($params['temperature']) ? (float) $params['temperature'] : null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, + $tools, + $toolChoice, ); } @@ -143,7 +186,9 @@ final class CreateSamplingMessageRequest extends Request * includeContext?: string, * temperature?: float, * stopSequences?: string[], - * metadata?: array + * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } */ protected function getParams(): array @@ -177,6 +222,14 @@ final class CreateSamplingMessageRequest extends Request $params['metadata'] = $this->metadata; } + if (null !== $this->tools) { + $params['tools'] = $this->tools; + } + + if (null !== $this->toolChoice) { + $params['toolChoice'] = $this->toolChoice; + } + return $params; } } diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 8eb4b13..fdb4461 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; use Mcp\Schema\JsonRpc\ResultInterface; @@ -28,17 +29,30 @@ use Mcp\Schema\JsonRpc\ResultInterface; class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent $content the content of the message - * @param string $model the name of the model that generated the message - * @param string|null $stopReason the reason why sampling stopped, if known + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent|ToolUseContent|list $content the content of the message + * @param string $model the name of the model that generated the message + * @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn", + * "stopSequence", "maxTokens" and "toolUse", but leaves the set open for + * provider-specific values, so this stays an unconstrained string. + * @param ?array $meta optional message metadata */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content, public readonly string $model, public readonly ?string $stopReason = null, + public readonly ?array $meta = null, ) { + if (Role::Assistant !== $role) { + throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".'); + } + + foreach (\is_array($content) ? $content : [$content] as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { + throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.'); + } + } } /** @@ -64,16 +78,31 @@ class CreateSamplingMessageResult implements ResultInterface $contentPayload = $data['content']; - $content = self::hydrateContent($contentPayload); + $isSingleContent = isset($contentPayload['type']); + $contentItems = $isSingleContent ? [$contentPayload] : $contentPayload; + $content = []; + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.'); + } + $content[] = self::hydrateContent($item); + } + $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; - return new self($role, $content, $data['model'], $stopReason); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + $data['model'], + $stopReason, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** * @param array $contentData */ - private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent { $type = $contentData['type'] ?? null; @@ -85,6 +114,7 @@ class CreateSamplingMessageResult implements ResultInterface 'text' => TextContent::fromArray($contentData), 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), }; } @@ -92,9 +122,10 @@ class CreateSamplingMessageResult implements ResultInterface /** * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent, + * content: TextContent|ImageContent|AudioContent|ToolUseContent|list, * model: string, * stopReason?: string, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -109,6 +140,10 @@ class CreateSamplingMessageResult implements ResultInterface $result['stopReason'] = $this->stopReason; } + if (null !== $this->meta) { + $result['_meta'] = $this->meta; + } + return $result; } } diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php new file mode 100644 index 0000000..4b55bac --- /dev/null +++ b/src/Schema/ToolChoice.php @@ -0,0 +1,51 @@ + $this->mode->value]; + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 5205a61..71fe678 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -36,6 +36,8 @@ use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use Mcp\Server\Session\SessionInterface; /** @@ -67,6 +69,8 @@ use Mcp\Server\Session\SessionInterface; * includeContext?: SamplingContext, * stopSequences?: string[], * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } * * @author Kyrian Obikwelu @@ -124,6 +128,9 @@ class ClientGateway * @param int $maxTokens Maximum tokens to generate * @param int $timeout The timeout in seconds * @param SampleOptions $options Additional sampling options (temperature, etc.) + * Context values other than `none` require the client's + * sampling.context capability; tools and toolChoice require + * the client's sampling.tools capability. * * @return CreateSamplingMessageResult The sampling response * @@ -152,6 +159,8 @@ class ClientGateway temperature: $options['temperature'] ?? null, stopSequences: $options['stopSequences'] ?? null, metadata: $options['metadata'] ?? null, + tools: $options['tools'] ?? null, + toolChoice: $options['toolChoice'] ?? null, ); $response = $this->request($request, $timeout); diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php new file mode 100644 index 0000000..4a33ec7 --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php @@ -0,0 +1,45 @@ +jsonSerialize(); + + $this->assertObjectHasProperty('context', $serialized['sampling']); + $this->assertObjectHasProperty('tools', $serialized['sampling']); + + $hydrated = ClientCapabilities::fromArray(json_decode(json_encode($serialized, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertTrue($hydrated->sampling); + $this->assertTrue($hydrated->samplingContext); + $this->assertTrue($hydrated->samplingTools); + } + + public function testSamplingSubCapabilitiesAreHydratedFromObject(): void + { + $sampling = new \stdClass(); + $sampling->context = new \stdClass(); + $sampling->tools = new \stdClass(); + + $capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]); + + $this->assertTrue($capabilities->sampling); + $this->assertTrue($capabilities->samplingContext); + $this->assertTrue($capabilities->samplingTools); + } +} diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php new file mode 100644 index 0000000..c10ad4c --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingToolContentTest.php @@ -0,0 +1,76 @@ + 'assistant', + '_meta' => ['provider' => 'test'], + 'content' => [ + ['type' => 'text', 'text' => 'I will check.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + ]); + $user = SamplingMessage::fromArray([ + 'role' => 'user', + 'content' => [[ + 'type' => 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + ]], + ]); + + $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); + $this->assertSame(['provider' => 'test'], $assistant->meta); + $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); + $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); + $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); + $textContent = $user->content[0]->content[0]; + $this->assertInstanceOf(TextContent::class, $textContent); + $this->assertSame('21 C', $textContent->text); + } + + public function testToolUseIsRejectedInUserMessage(): void + { + $this->expectException(InvalidArgumentException::class); + new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); + } + + public function testToolResultCannotBeMixedWithOtherContent(): void + { + $this->expectException(InvalidArgumentException::class); + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('done')]), + new TextContent('extra'), + ]); + } + + public function testToolResultRejectsNonStandardContentBlocks(): void + { + $this->expectException(InvalidArgumentException::class); + new ToolResultContent('call-1', [ + new SamplingMessage(Role::User, new TextContent('not a tool result content block')), + ]); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 57f790a..4254336 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -15,7 +15,10 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\ToolChoiceMode; use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use PHPUnit\Framework\TestCase; final class CreateSamplingMessageRequestTest extends TestCase @@ -48,4 +51,23 @@ final class CreateSamplingMessageRequestTest extends TestCase /* @phpstan-ignore argument.type */ new CreateSamplingMessageRequest($messages, 150); } + + public function testToolsAndToolChoiceRoundTrip(): void + { + $tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null); + $request = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))], + 150, + tools: [$tool], + toolChoice: new ToolChoice(ToolChoiceMode::Required), + ); + + $payload = $request->withId(1)->jsonSerialize(); + $this->assertSame('weather', $payload['params']['tools'][0]->name); + $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); + + $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertSame('weather', $hydrated->tools[0]->name); + $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); + } } diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php new file mode 100644 index 0000000..9366f75 --- /dev/null +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -0,0 +1,53 @@ + 'assistant', + 'content' => [ + ['type' => 'text', 'text' => 'Checking weather.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + 'model' => 'test-model', + 'stopReason' => 'toolUse', + '_meta' => ['traceId' => 'trace-1'], + ]); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); + $this->assertSame('toolUse', $result->stopReason); + $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); + $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); + } + + public function testProviderSpecificStopReasonIsPreserved(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'provider-specific', + ]); + + $this->assertSame('provider-specific', $result->stopReason); + $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); + } +} From becce29c41f1c8d5cbfe7f158001f3509c59c3b2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 04:03:20 +0200 Subject: [PATCH 080/143] [Capability] Harden `structuredContent` extraction (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Address review feedback on resource_link Three follow-ups from review of the resource_link work: * PromptResultFormatter: add a regression test proving the optional fields of a typed resource_link block (title, description, mimeType, size, annotations, _meta) survive formatting. The delegation to ResourceLink::fromArray() already landed while rebasing onto the PromptResultFormatter refactor, but the existing test only supplied uri/name and so could not catch a reintroduced field drop. * ResourceLink::fromArray(): validate optional fields consistently, the way the equivalent ResourceDefinition::fromArray() already does. description, mimeType and size now raise InvalidArgumentException instead of surfacing a TypeError (or silently coercing, for size's (int) cast); annotations goes through Annotations::tryFromArray(), which carries the is_array() guard that icons already had; icons uses Icon::listFromArray() so a non-array entry is reported in context. * ToolReference::extractStructuredContent(): never emit a list as structuredContent. The Content guard added earlier fixed only one instance of the real invariant - structuredContent must be a JSON object, and a PHP list can never be one. Tool::fromArray() already enforces the matching rule by rejecting an outputSchema whose type is not "object", so this aligns the runtime path with it. The test asserting the opposite for an array-typed outputSchema contradicted the phpstan type and the fromArray() check introduced alongside it in the same commit, so it is replaced by tests for the two list shapes. * Apply the structuredContent object rule to object results too `extractStructuredContent()` guards raw array results against being emitted as a JSON array, but the object branch handed back whatever `json_decode()` produced. A `JsonSerializable` returning a list or a scalar slipped straight through, producing exactly the `structuredContent` the array guard exists to prevent — and a return value that contradicts the method's own `array|null` signature. Check the decoded value before returning it, and cover the object branch, which had no tests at all. * Document structured tool output `outputSchema` and `structuredContent` were undocumented: the tool return value docs covered only the `content` side, and the schema generation section is about tool parameters. Add a "Structured Output" subsection covering how to declare the schema, which return values populate `structuredContent`, and why a list has to be wrapped in a key to get structured output at all. * Gate structuredContent on the negotiated protocol revision SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value; earlier revisions require an object. Resolve the revision per request and apply the matching rule, and warn when a declared outputSchema yields none. * Resolve the protocol revision on RequestContext Keeps the `_meta`-then-session lookup in one place instead of per handler, and exposes the negotiated revision to tool handlers. * Document reading the negotiated revision from RequestContext * Warn when a self-built CallToolResult carries an invalid structuredContent Returning a CallToolResult opts out of the extraction rules, so the value is still sent unchanged — but a JSON array is not valid before SEP-2106. --- CHANGELOG.md | 1 + docs/mcp-elements.md | 76 +++++++++ docs/server-client-communication.md | 11 ++ src/Capability/Registry/ToolReference.php | 48 +++++- src/Schema/Content/ResourceLink.php | 15 +- src/Schema/Enum/ProtocolVersion.php | 14 ++ .../Handler/Request/CallToolHandler.php | 27 +++- src/Server/RequestContext.php | 29 ++++ .../Formatter/PromptResultFormatterTest.php | 33 ++++ tests/Unit/Capability/RegistryTest.php | 144 +++++++++++++++--- .../Unit/Schema/Content/ResourceLinkTest.php | 20 +++ .../Unit/Schema/Enum/ProtocolVersionTest.php | 10 ++ .../Handler/Request/CallToolHandlerTest.php | 137 +++++++++++++++++ tests/Unit/Server/RequestContextTest.php | 89 +++++++++++ 14 files changed, 617 insertions(+), 37 deletions(-) create mode 100644 tests/Unit/Server/RequestContextTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e23af6f..95af36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. +* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. * Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 3617a2f..eb76ed0 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -170,6 +170,82 @@ public function getMultipleContent(): array } ``` +#### Structured Output + +Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its +shape with `outputSchema`, a JSON Schema of type `object`: + +```php +#[McpTool( + name: 'get_weather', + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: + +```php +// Structured content only from 2026-07-28 on: a bare list is not a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Client Communication](server-client-communication.md#client-gateway). + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + #### Error Handling Tool handlers can throw any exception, but the type determines how it's handled: diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index e55f792..5734452 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -30,6 +30,17 @@ class MyService $context->getClientGateway()->log(...); ``` +The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is +only available from a certain revision on: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { + // e.g. a bare list is only valid as `structuredContent` from this revision on +} +``` + ## Sampling With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index beec182..0431687 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Tool; /** @@ -59,22 +60,39 @@ class ToolReference extends ElementReference /** * Extracts structured content from a tool result using the output schema. * - * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * What may be sent as `structuredContent` depends on the protocol revision in + * use. Up to `2025-11-25` it has to be a JSON object, and `outputSchema` is + * restricted to `type: "object"` to match. From `2026-07-28` on (SEP-2106) + * `outputSchema` is any JSON Schema 2020-12 and `structuredContent` is any JSON + * value conforming to it — a list included. * - * @return array|null the structured content, or null if not extractable + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * @param ?ProtocolVersion $protocolVersion revision the result is produced for; defaults to the + * newest handshake revision, whose stricter rule is what + * every revision reachable through `initialize` requires + * + * @return array|null the structured content, or null if not extractable * * @throws \JsonException if JSON encoding fails for non-Content array/object results */ - public function extractStructuredContent(mixed $toolExecutionResult): ?array + public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array { + $objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent(); + if (\is_array($toolExecutionResult)) { + // A PHP list serializes to a JSON array, which the revisions predating + // SEP-2106 do not allow as `structuredContent` — strict clients reject + // the whole tool call when one is sent. + if ($objectOnly && array_is_list($toolExecutionResult)) { + return null; + } + foreach ($toolExecutionResult as $item) { if ($item instanceof Content) { // Content items are already reflected in the result's `content` - // array; a raw array holding one or more of them isn't - // structured data and, if it were serialized as-is, could - // produce a `structuredContent` value that isn't a JSON object - // (e.g. a list), which the spec doesn't allow. + // array; an array holding one or more of them isn't structured + // data. This holds in every revision — it is a duplication rule, + // not a shape rule. return null; } } @@ -88,9 +106,23 @@ class ToolReference extends ElementReference \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE ); - return json_decode( + $decoded = json_decode( $jsonResult, true, 512, \JSON_THROW_ON_ERROR ); + + // A plain object always encodes to a JSON object, but `JsonSerializable` + // can hand back anything. A scalar is dropped whatever the revision + // allows: `CallToolResult::$structuredContent` is typed `?array` and + // cannot carry one. + if (!\is_array($decoded)) { + return null; + } + + if ($objectOnly && array_is_list($decoded)) { + return null; + } + + return $decoded; } return null; diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php index 874cc85..946c9b8 100644 --- a/src/Schema/Content/ResourceLink.php +++ b/src/Schema/Content/ResourceLink.php @@ -84,6 +84,15 @@ class ResourceLink extends Content if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceLink data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceLink data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceLink data; expected an integer.'); + } return new self( uri: $data['uri'], @@ -91,9 +100,9 @@ class ResourceLink extends Content title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null, meta: $data['_meta'] ?? null, ); } diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index 9b127e1..b62396b 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -101,6 +101,20 @@ enum ProtocolVersion: string return $this->isAtLeast(self::FIRST_MODERN_VERSION); } + /** + * Whether this revision restricts `structuredContent` to a JSON object. + * + * SEP-2106, part of {@see self::V2026_07_28}, widened `outputSchema` to any + * JSON Schema 2020-12 and `structuredContent` to any JSON value conforming to + * it. Up to `2025-11-25` both are restricted to an object. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + */ + public function requiresObjectStructuredContent(): bool + { + return !$this->isAtLeast(self::V2026_07_28); + } + /** * Whether this revision is at least as new as $minimum. */ diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e78ce1b..254e838 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -22,6 +22,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -92,13 +93,37 @@ final class CallToolHandler implements RequestHandlerInterface $arguments['_session'] = $session; $arguments['_request'] = $request; + $context = new RequestContext($session, $request); + try { $result = $this->referenceHandler->handle($reference, $arguments); + $protocolVersion = $context->getProtocolVersion(); + $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result); + $structuredContent = $reference->extractStructuredContent($result, $protocolVersion); + + if (null === $structuredContent && null !== $reference->tool->outputSchema) { + $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ + 'name' => $toolName, + 'result_type' => get_debug_type($result), + ]); + } + $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); + } elseif ($protocolVersion->requiresObjectStructuredContent() + && \is_array($result->structuredContent) + && [] !== $result->structuredContent + && array_is_list($result->structuredContent) + ) { + // A tool building its own `CallToolResult` bypasses the extraction + // rules on purpose, so the value is sent as it was set — but a JSON + // array is not valid here before SEP-2106 and clients may reject it. + $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [ + 'name' => $toolName, + 'protocol_version' => $protocolVersion->value, + ]); } $this->logger->debug('Tool executed successfully', [ diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 158057c..1a4f837 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -12,6 +12,7 @@ namespace Mcp\Server; use Mcp\Capability\Logger\ClientLogger; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Server\Session\SessionInterface; @@ -25,6 +26,14 @@ use Mcp\Server\Session\SessionInterface; */ final class RequestContext { + /** + * `_meta` key carrying the protocol revision of a single request, introduced + * with the modern era that replaced the `initialize` handshake. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + */ + private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + private ?ClientGateway $clientGateway = null; private ?ClientLogger $clientLogger = null; @@ -44,6 +53,26 @@ final class RequestContext return $this->session; } + /** + * The protocol revision this request is served under. + * + * Modern revisions declare it per request in `_meta`, handshake ones negotiate + * it once and keep it on the session. Neither is guaranteed to be present — a + * transport may skip `initialize` entirely — so this falls back to the newest + * handshake revision, whose rules hold for every revision below it too. + */ + public function getProtocolVersion(): ProtocolVersion + { + $requested = $this->request->getMeta()[self::PROTOCOL_VERSION_META_KEY] + ?? $this->session->get('protocol_version'); + + if (!\is_string($requested)) { + return ProtocolVersion::latestHandshake(); + } + + return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); + } + public function getClientGateway(): ClientGateway { if (null == $this->clientGateway) { diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index eedba5b..52bb176 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -55,6 +55,39 @@ class PromptResultFormatterTest extends TestCase $this->assertSame('a.png', $result[0]->content->name); } + public function testFormatTypedResourceLinkContentPreservesOptionalFields(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + 'title' => 'A picture', + 'description' => 'The first picture', + 'mimeType' => 'image/png', + 'size' => 1024, + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + '_meta' => ['origin' => 'test'], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(ResourceLink::class, $content); + $this->assertSame('file:///a.png', $content->uri); + $this->assertSame('a.png', $content->name); + $this->assertSame('A picture', $content->title); + $this->assertSame('The first picture', $content->description); + $this->assertSame('image/png', $content->mimeType); + $this->assertSame(1024, $content->size); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + $this->assertSame(['origin' => 'test'], $content->meta); + } + public function testFormatUserAssistantShorthand(): void { $result = (new PromptResultFormatter())->format([ diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 3b2d7d7..9ea4b24 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -24,6 +24,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; @@ -496,50 +497,142 @@ class RegistryTest extends TestCase $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } - public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void + /** + * @dataProvider provideHandshakeVersions + */ + public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void { - // Arrange + // Up to 2025-11-25 a PHP list serializes to something `structuredContent` + // does not allow — a JSON array — and `Tool::fromArray()` enforces the + // matching rule by rejecting any outputSchema whose type is not "object". $outputSchema = [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'foo' => [ - 'type' => 'string', - 'description' => 'A static value', - ], - ], - 'required' => ['foo'], + 'type' => 'object', + 'properties' => [ + 'foo' => ['type' => 'string'], ], + 'required' => ['foo'], ]; $tool = $this->createValidTool('list_static_data', $outputSchema); $toolReturnValue = [ ['foo' => 'bar'], ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], ]; $this->registry->registerTool($tool, static fn () => $toolReturnValue); - // Act $toolRef = $this->registry->getTool('list_static_data'); - $structuredContent = $toolRef->extractStructuredContent($toolReturnValue); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version)); + } - // Assert - $this->assertNotNull($structuredContent); - $this->assertCount(4, $structuredContent); - $this->assertEquals([ + /** + * The revision is optional, and omitting it has to keep the strict rule: it is + * what every revision reachable through the `initialize` handshake requires. + * + * @return iterable + */ + public static function provideHandshakeVersions(): iterable + { + yield 'unspecified' => [null]; + + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield $version->value => [$version]; + } + } + + public function testExtractStructuredContentKeepsListResultsFromSep2106On(): void + { + // SEP-2106 widened `structuredContent` to any JSON value conforming to + // `outputSchema`, and `outputSchema` to any JSON Schema 2020-12 — the spec's + // own example of a legal result is a list of records like this one. + $outputSchema = [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['foo' => ['type' => 'string']], + ], + ]; + + $tool = $this->createValidTool('list_static_data', $outputSchema); + $toolReturnValue = [ ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ], $structuredContent); + ['foo' => 'baz'], + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_static_data'); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): void + { + $tool = $this->createValidTool('list_ids', null); + $toolReturnValue = ['101', '102', '103']; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_ids'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentEncodesObjectResults(): void + { + $tool = $this->createValidTool('describe_thing', null); + $toolReturnValue = new \stdClass(); + $toolReturnValue->id = 1; + $toolReturnValue->label = 'thing'; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('describe_thing'); + $this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue)); + } + + public function testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void + { + // `JsonSerializable` can hand back a list just as a raw array result can, + // and it is no more — and no less — valid for having come from an object. + $tool = $this->createValidTool('list_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return [['id' => 1], ['id' => 2]]; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame([['id' => 1], ['id' => 2]], $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void + { + // SEP-2106 allows a scalar `structuredContent`, but `CallToolResult` types + // the field as `?array` and cannot carry one — so it is dropped in every + // revision until that type widens. + $tool = $this->createValidTool('count_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): int + { + return 42; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('count_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void { + // Unlike the list rule, this one is revision-independent: the items are + // already carried in the result's `content`. $tool = $this->createValidTool('lookup_thing', null); $toolReturnValue = [ new TextContent('Found it.'), @@ -549,7 +642,8 @@ class RegistryTest extends TestCase $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('lookup_thing'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testConfiguredLoaderIsNotRunUntilFirstRead(): void diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php index 3e64377..ca06455 100644 --- a/tests/Unit/Schema/Content/ResourceLinkTest.php +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -227,5 +227,25 @@ final class ResourceLinkTest extends TestCase ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], 'Invalid "_meta" in ResourceLink data.', ]; + yield 'invalid description' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'description' => ['not-a-string']], + 'Invalid "description" in ResourceLink data.', + ]; + yield 'invalid mimeType' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'mimeType' => ['not-a-string']], + 'Invalid "mimeType" in ResourceLink data.', + ]; + yield 'invalid size' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'size' => 'not-an-int'], + 'Invalid "size" in ResourceLink data; expected an integer.', + ]; + yield 'invalid annotations' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'annotations' => 'not-an-array'], + 'Invalid "annotations" in ResourceLink data; expected an array.', + ]; + yield 'invalid icons entry' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'icons' => ['not-an-array']], + 'Each entry in "icons" of ResourceLink data must be an array.', + ]; } } diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php index 1870a79..00c50e9 100644 --- a/tests/Unit/Schema/Enum/ProtocolVersionTest.php +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -131,4 +131,14 @@ class ProtocolVersionTest extends TestCase { $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION); } + + #[TestDox('SEP-2106 lifts the object-only rule for structuredContent')] + public function testRequiresObjectStructuredContent(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertTrue($version->requiresObjectStructuredContent(), \sprintf('%s predates SEP-2106.', $version->value)); + } + + $this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent()); + } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b804f76..87a696b 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -477,6 +477,143 @@ class CallToolHandlerTest extends TestCase $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); } + /** + * @dataProvider provideStructuredContentRevisions + */ + public function testStructuredContentFollowsTheNegotiatedRevision(?string $negotiated, ?array $expected): void + { + $listResult = [['id' => 1], ['id' => 2]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1},{"id":2}]')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expected, $response->result->structuredContent); + } + + /** + * How a revision is resolved is {@see \Mcp\Server\RequestContext}'s business + * and covered there; this only pins that the handler applies it. + * + * @return iterable}> + */ + public static function provideStructuredContentRevisions(): iterable + { + // A list is only emittable from 2026-07-28 (SEP-2106) on. Without a + // negotiated revision the handler assumes the stricter handshake rule. + yield 'no negotiated revision' => [null, null]; + yield '2025-11-25' => ['2025-11-25', null]; + yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; + } + + /** + * @dataProvider provideSelfBuiltResults + */ + public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( + ?string $negotiated, + ?array $structuredContent, + int $expectedWarnings, + ): void { + $request = $this->createCallToolRequest('build_result', []); + $toolReference = $this->createToolReference('build_result', static fn () => null); + $callToolResult = new CallToolResult([new TextContent('Built by hand')], false, $structuredContent); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($callToolResult); + + $toolReference + ->expects($this->never()) + ->method('formatResult'); + + $this->logger + ->expects($this->exactly($expectedWarnings)) + ->method('warning'); + + $response = $this->handler->handle($request, $this->session); + + // Warned about, never rewritten: building the result is an explicit opt-out. + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($callToolResult, $response->result); + $this->assertSame($structuredContent, $response->result->structuredContent); + } + + /** + * @return iterable, int}> + */ + public static function provideSelfBuiltResults(): iterable + { + yield 'list before SEP-2106' => ['2025-11-25', [['id' => 1]], 1]; + yield 'list without a negotiated revision' => [null, [['id' => 1]], 1]; + yield 'list from SEP-2106 on' => ['2026-07-28', [['id' => 1]], 0]; + yield 'object before SEP-2106' => ['2025-11-25', ['items' => [['id' => 1]]], 0]; + yield 'none at all' => ['2025-11-25', null, 0]; + // Dropped by `CallToolResult::jsonSerialize()` anyway, so nothing to warn about. + yield 'empty' => ['2025-11-25', [], 0]; + } + + public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void + { + $listResult = [['id' => 1]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult, [ + 'type' => 'object', + 'properties' => ['items' => ['type' => 'array']], + ]); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1}]')]); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + $this->stringContains('outputSchema'), + $this->callback(static fn (array $context): bool => 'list_things' === $context['name'] && 'array' === $context['result_type']), + ); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNull($response->result->structuredContent); + } + public function testValidationError(): void { $schema = [ diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php new file mode 100644 index 0000000..fcbe65d --- /dev/null +++ b/tests/Unit/Server/RequestContextTest.php @@ -0,0 +1,89 @@ +createSession('2025-06-18'), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::V2025_06_18, $context->getProtocolVersion()); + } + + public function testPerRequestMetaTakesPrecedenceOverTheSession(): void + { + // Modern revisions have no `initialize`, so the revision travels with every + // single request instead of being negotiated once. + $context = new RequestContext( + $this->createSession('2025-11-25'), + $this->createRequest(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']), + ); + + $this->assertSame(ProtocolVersion::V2026_07_28, $context->getProtocolVersion()); + } + + /** + * @dataProvider provideUnusableVersions + */ + public function testUnusableVersionFallsBackToTheNewestHandshakeRevision(mixed $stored): void + { + $context = new RequestContext( + $this->createSession($stored), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::latestHandshake(), $context->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideUnusableVersions(): iterable + { + yield 'never negotiated' => [null]; + yield 'unknown revision' => ['1999-01-01']; + yield 'not a string' => [20260728]; + } + + private function createSession(mixed $protocolVersion): SessionInterface + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('protocol_version')->willReturn($protocolVersion); + + return $session; + } + + /** + * @param array|null $meta + */ + private function createRequest(?array $meta = null): CallToolRequest + { + $request = CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CallToolRequest::getMethod(), + 'id' => 'test-request', + 'params' => ['name' => 'test_tool', 'arguments' => []], + ]); + + return null === $meta ? $request : $request->withMeta($meta); + } +} From 43815e38a808d45dc9a4b098d9104b11a900c0b9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 04:13:53 +0200 Subject: [PATCH 081/143] [Schema][Server] Harden sampling tools against the spec (#420) * harden sampling tools against the spec Report tool-flow violations as -32602 instead of dropping the request, accept resource_link in tool results, gate tools on sampling.tools, and reject empty content. * normalize keyed content arrays to lists array_filter() and friends preserve keys, and a keyed array serializes as a JSON object instead of the content-block array the schema requires. --- CHANGELOG.md | 4 +- docs/client.md | 2 +- docs/server-client-communication.md | 29 +++- examples/client/http_client_communication.php | 2 +- .../client/stdio_client_communication.php | 2 +- .../Request/SamplingRequestHandler.php | 9 ++ src/Schema/ClientCapabilities.php | 7 +- src/Schema/Content/SamplingMessage.php | 65 ++++++--- src/Schema/Content/ToolResultContent.php | 31 +++- .../Request/CreateSamplingMessageRequest.php | 61 ++++++++ .../Result/CreateSamplingMessageResult.php | 49 +++++-- src/Server/ClientGateway.php | 44 ++++++ tests/Integration/Fixture/sampling_tools.php | 78 ++++++++++ tests/Integration/SamplingToolsTest.php | 135 ++++++++++++++++++ .../Request/SamplingRequestHandlerTest.php | 114 +++++++++++++++ .../Schema/ClientCapabilitiesSamplingTest.php | 45 ------ tests/Unit/Schema/ClientCapabilitiesTest.php | 43 ++++++ .../Schema/Content/SamplingMessageTest.php | 125 ++++++++++++++++ .../Content/SamplingToolContentTest.php | 76 ---------- .../Schema/Content/ToolResultContentTest.php | 119 +++++++++++++++ .../Schema/Content/ToolUseContentTest.php | 80 +++++++++++ .../CreateSamplingMessageRequestTest.php | 121 ++++++++++++++++ .../CreateSamplingMessageResultTest.php | 75 ++++++++++ tests/Unit/Schema/ToolChoiceTest.php | 63 ++++++++ 24 files changed, 1212 insertions(+), 167 deletions(-) create mode 100644 tests/Integration/Fixture/sampling_tools.php create mode 100644 tests/Integration/SamplingToolsTest.php create mode 100644 tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php delete mode 100644 tests/Unit/Schema/ClientCapabilitiesSamplingTest.php create mode 100644 tests/Unit/Schema/Content/SamplingMessageTest.php delete mode 100644 tests/Unit/Schema/Content/SamplingToolContentTest.php create mode 100644 tests/Unit/Schema/Content/ToolResultContentTest.php create mode 100644 tests/Unit/Schema/Content/ToolUseContentTest.php create mode 100644 tests/Unit/Schema/ToolChoiceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 95af36d..67f449b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. -* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. +* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one. +* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list. +* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires. 0.7.0 ----- diff --git a/docs/client.md b/docs/client.md index b27b433..962397c 100644 --- a/docs/client.md +++ b/docs/client.md @@ -792,7 +792,7 @@ $samplingCallback = new class implements SamplingCallbackInterface { role: Role::Assistant, content: new TextContent($response), model: 'mock-llm', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } catch (\Throwable $e) { throw new SamplingException( diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 5734452..151d531 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -58,8 +58,33 @@ The `sample` method accepts four arguments: 4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences`, `metadata`, `tools`, and `toolChoice` -Only send `includeContext` when the client advertises `sampling.context`, and only send `tools` or `toolChoice` when it -advertises `sampling.tools`. The context modes other than `none` are soft-deprecated by the current specification. +Both `tools`/`toolChoice` and `includeContext` are gated on what the client advertised, so check before sending: + +```php +if ($clientGateway->supportsSamplingTools()) { + $result = $clientGateway->sample($messages, options: ['tools' => $tools]); +} +``` + +A server **must not** send `tools` or `toolChoice` to a client that did not advertise `sampling.tools`. The +`includeContext` values other than `none` are soft-deprecated and should only be sent when the client advertises +`sampling.context` — `supportsSamplingContext()` reports that one. + +### Tool loops + +When the model wants to call a tool, the result comes back with `stopReason: 'toolUse'` and one or more +`ToolUseContent` blocks. Execute them, then send a follow-up request with the assistant's message and a user message +carrying a matching `ToolResultContent` for every `ToolUseContent`: + +```php +$messages[] = new SamplingMessage(Role::Assistant, $result->content); +$messages[] = new SamplingMessage(Role::User, [new ToolResultContent($toolUse->id, [new TextContent($output)])]); +``` + +The specification is strict about the shape of that exchange: tool results may not be mixed with other content in a +message, and every tool use must be answered before the conversation continues. `sample()` checks these rules before +sending and throws an `InvalidArgumentException` rather than letting the client reject the request with `-32602`. +Use `$result->getContentBlocks()` to iterate the response regardless of whether it holds one block or a list. [Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index f5a39ea..4b6e166 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -61,7 +61,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 8b71cf0..28e41b6 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -50,7 +50,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index a00fa47..d3da69f 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Handler\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\SamplingException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -50,6 +51,14 @@ class SamplingRequestHandler implements RequestHandlerInterface { \assert($request instanceof CreateSamplingMessageRequest); + try { + $request->validateToolFlow(); + } catch (InvalidArgumentException $e) { + $this->logger->warning('Rejecting sampling request violating the tool flow', ['exception' => $e]); + + return Error::forInvalidParams($e->getMessage(), $request->getId()); + } + try { $result = $this->callback->__invoke($request); diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 4c7c451..5eab9e6 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -21,7 +21,12 @@ class ClientCapabilities implements \JsonSerializable { /** * @param array $experimental - * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) + * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) + * @param ?bool $samplingContext the `sampling.context` sub-capability + * @param ?bool $samplingTools the `sampling.tools` sub-capability + * + * The two sampling sub-capabilities trail `extensions` rather than sitting next to + * `sampling` so that existing positional calls keep working. Pass them by name. */ public function __construct( public readonly ?bool $roots = false, diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 53973ae..fc6885b 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -17,11 +17,17 @@ use Mcp\Schema\Enum\Role; /** * Describes a message issued to or received from an LLM API during sampling. * + * Structural validity is enforced here, but the spec's tool-flow rules (which role may + * carry which block, tool results not being mixed with other content, every tool use + * being answered) are not: they span the whole message list and must be reportable as + * an "invalid params" error rather than as a parse failure. They live in + * {@see \Mcp\Schema\Request\CreateSamplingMessageRequest::validateToolFlow()}. + * * @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent - * @phpstan-type SamplingMessageData = array{ + * @phpstan-type SamplingMessageData array{ * role: 'user'|'assistant', - * content: array|array>, - * _meta?: array + * content: array|list>, + * _meta?: array, * } * * @author Kyrian Obikwelu @@ -29,35 +35,48 @@ use Mcp\Schema\Enum\Role; class SamplingMessage extends Content { /** - * @param SamplingContent|list $content - * @param ?array $meta + * @var SamplingContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content; + + /** + * @param SamplingContent|array $content keys are discarded, the property always holds a list + * @param ?array $meta */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, + TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, public readonly ?array $meta = null, ) { - $contents = \is_array($content) ? $content : [$content]; - foreach ($contents as $item) { - if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) { - throw new InvalidArgumentException('Sampling message content contains an unsupported content block.'); + if (\is_array($content)) { + if ([] === $content) { + throw new InvalidArgumentException('Sampling message content must not be empty.'); } - if (Role::User === $role && $item instanceof ToolUseContent) { - throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); - } - if (Role::Assistant === $role && $item instanceof ToolResultContent) { - throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); + + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) { + throw new InvalidArgumentException('Sampling message content contains an unsupported content block.'); + } } + + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $content = array_values($content); } - if (array_filter($contents, static fn ($item): bool => $item instanceof ToolResultContent) - && array_filter($contents, static fn ($item): bool => !$item instanceof ToolResultContent)) { - throw new InvalidArgumentException('Tool result messages must not contain other content types.'); - } + $this->content = $content; parent::__construct('sampling'); } + /** + * @return list + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; + } + /** * @param SamplingMessageData $data */ @@ -66,7 +85,7 @@ class SamplingMessage extends Content if (!isset($data['role']) || !\is_string($data['role'])) { throw new InvalidArgumentException('Missing or invalid "role" in SamplingMessage data.'); } - if (!isset($data['content']) || !\is_array($data['content'])) { + if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) { throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.'); } @@ -99,7 +118,11 @@ class SamplingMessage extends Content } /** - * @return SamplingMessageData + * @return array{ + * role: string, + * content: SamplingContent|list, + * _meta?: array, + * } */ public function jsonSerialize(): array { diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php index a6a792d..5e0e9b2 100644 --- a/src/Schema/Content/ToolResultContent.php +++ b/src/Schema/Content/ToolResultContent.php @@ -15,27 +15,41 @@ use Mcp\Exception\InvalidArgumentException; /** * The result of a tool use, provided by the user back to the assistant. + * + * Carries the same content blocks as a `tools/call` result, so it accepts everything + * {@see \Mcp\Schema\Result\CallToolResult} does. + * + * @phpstan-type ToolResultBlock TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource */ final class ToolResultContent extends Content { /** - * @param Content[] $content - * @param ?array $structuredContent - * @param ?array $meta + * @var list + */ + public readonly array $content; + + /** + * @param array $content keys are discarded, the property always holds a list + * @param ?array $structuredContent + * @param ?array $meta */ public function __construct( public readonly string $toolUseId, - public readonly array $content, + array $content, public readonly ?array $structuredContent = null, public readonly bool $isError = false, public readonly ?array $meta = null, ) { foreach ($content as $item) { - if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof EmbeddedResource) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ResourceLink && !$item instanceof EmbeddedResource) { throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); } } + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $this->content = array_values($content); + parent::__construct('tool_result'); } @@ -61,6 +75,7 @@ final class ToolResultContent extends Content 'text' => TextContent::fromArray($item), 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), + 'resource_link' => ResourceLink::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)), }; @@ -84,9 +99,13 @@ final class ToolResultContent extends Content 'type' => $this->type, 'toolUseId' => $this->toolUseId, 'content' => $this->content, - 'isError' => $this->isError, ]; + // Optional in the schema with a default of false, so only sent when it is true. + if ($this->isError) { + $data['isError'] = true; + } + if (null !== $this->structuredContent) { $data['structuredContent'] = $this->structuredContent; } diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 3f6a249..c23b3d8 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -13,6 +13,9 @@ namespace Mcp\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; +use Mcp\Schema\Content\ToolResultContent; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\ModelPreferences; @@ -177,6 +180,64 @@ final class CreateSamplingMessageRequest extends Request ); } + /** + * Assert the spec's tool-flow rules over the whole message list. + * + * These are deliberately kept out of the hydration path: a violation is an + * "invalid params" condition the peer must be told about, not a parse failure + * that would leave the request unanswered. Call it from whatever boundary can + * report it — the request handler when receiving, the gateway when sending. + * + * @throws InvalidArgumentException on the first violation found + */ + public function validateToolFlow(): void + { + $pendingToolUseIds = []; + + foreach ($this->messages as $message) { + $blocks = $message->getContentBlocks(); + + $toolResults = array_filter($blocks, static fn ($block): bool => $block instanceof ToolResultContent); + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + + if ($toolResults && \count($toolResults) !== \count($blocks)) { + throw new InvalidArgumentException('Tool results mixed with other content.'); + } + + if (Role::User === $message->role && $toolUses) { + throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); + } + + if (Role::Assistant === $message->role && $toolResults) { + throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); + } + + if ($pendingToolUseIds && !$toolResults) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + + foreach ($toolResults as $toolResult) { + $matched = array_search($toolResult->toolUseId, $pendingToolUseIds, true); + if (false === $matched) { + throw new InvalidArgumentException(\sprintf('Tool result "%s" does not answer a preceding tool use.', $toolResult->toolUseId)); + } + unset($pendingToolUseIds[$matched]); + } + + if ($pendingToolUseIds) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + + foreach ($toolUses as $toolUse) { + $pendingToolUseIds[] = $toolUse->id; + } + } + + if ($pendingToolUseIds) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + } + /** * @return array{ * messages: SamplingMessage[], diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index fdb4461..3a50a4a 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -29,17 +29,22 @@ use Mcp\Schema\JsonRpc\ResultInterface; class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent|ToolUseContent|list $content the content of the message - * @param string $model the name of the model that generated the message - * @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn", - * "stopSequence", "maxTokens" and "toolUse", but leaves the set open for - * provider-specific values, so this stays an unconstrained string. - * @param ?array $meta optional message metadata + * @var TextContent|ImageContent|AudioContent|ToolUseContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content; + + /** + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent|ToolUseContent|array $content The content of the message. Keys are discarded, the property always holds a list. + * @param string $model the name of the model that generated the message + * @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn", + * "stopSequence", "maxTokens" and "toolUse", but leaves the set open for + * provider-specific values, so this stays an unconstrained string. + * @param ?array $meta optional message metadata */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content, + TextContent|ImageContent|AudioContent|ToolUseContent|array $content, public readonly string $model, public readonly ?string $stopReason = null, public readonly ?array $meta = null, @@ -48,11 +53,31 @@ class CreateSamplingMessageResult implements ResultInterface throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".'); } - foreach (\is_array($content) ? $content : [$content] as $item) { - if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { - throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.'); + if (\is_array($content)) { + if ([] === $content) { + throw new InvalidArgumentException('CreateSamplingMessageResult content must not be empty.'); } + + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { + throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.'); + } + } + + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $content = array_values($content); } + + $this->content = $content; + } + + /** + * @return list + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; } /** @@ -64,7 +89,7 @@ class CreateSamplingMessageResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.'); } - if (!isset($data['content']) || !\is_array($data['content'])) { + if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) { throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.'); } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 71fe678..445c9e0 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -163,6 +163,9 @@ class ClientGateway toolChoice: $options['toolChoice'] ?? null, ); + // Fail here rather than letting the client reject the request with -32602. + $request->validateToolFlow(); + $response = $this->request($request, $timeout); if ($response instanceof Error) { @@ -276,6 +279,47 @@ class ClientGateway return \array_key_exists('sampling', $capabilities); } + /** + * Check if the connected client supports tools during sampling. + * + * Per the spec a server MUST NOT put `tools` or `toolChoice` on a + * `sampling/createMessage` request unless the client advertised + * `sampling.tools`, so check this before passing either option to + * {@see self::sample()}. + * + * @return bool True if the client supports tool-enabled sampling, false otherwise + */ + public function supportsSamplingTools(): bool + { + return $this->hasSamplingSubCapability('tools'); + } + + /** + * Check if the connected client supports context inclusion during sampling. + * + * The `includeContext` values other than `none` are soft-deprecated and should + * only be sent when the client advertised `sampling.context`. + * + * @return bool True if the client supports sampling context, false otherwise + */ + public function supportsSamplingContext(): bool + { + return $this->hasSamplingSubCapability('context'); + } + + private function hasSamplingSubCapability(string $name): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + $sampling = $capabilities['sampling'] ?? null; + + if (!\is_array($sampling) && !\is_object($sampling)) { + return false; + } + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists($name, (array) $sampling); + } + /** * Send a request to the client and wait for a response (blocking). * diff --git a/tests/Integration/Fixture/sampling_tools.php b/tests/Integration/Fixture/sampling_tools.php new file mode 100644 index 0000000..94cd561 --- /dev/null +++ b/tests/Integration/Fixture/sampling_tools.php @@ -0,0 +1,78 @@ + 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']], + 'Get current weather for a city', + null, +); + +Server::builder() + ->setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context, string $city) use ($weather): string { + $gateway = $context->getClientGateway(); + + // The spec forbids sending tools to a client that did not advertise + // sampling.tools, so the loop is only entered when it did. + if (!$gateway->supportsSamplingTools()) { + return 'client cannot use tools during sampling'; + } + + $messages = [new SamplingMessage(Role::User, new TextContent(sprintf('Weather in %s?', $city)))]; + + $answer = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); + $messages[] = new SamplingMessage(Role::Assistant, $answer->content); + + $toolResults = []; + foreach ($answer->getContentBlocks() as $block) { + if ($block instanceof ToolUseContent) { + $toolResults[] = new ToolResultContent( + $block->id, + [new TextContent(sprintf('%s: 18 C', $block->input['city'] ?? 'unknown'))], + ); + } + } + + if ([] === $toolResults) { + return 'the model asked for no tools'; + } + + $messages[] = new SamplingMessage(Role::User, $toolResults); + + $final = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); + assert($final->content instanceof TextContent); + + return sprintf('%s (%s)', $final->content->text, $final->stopReason); + }, + name: 'weather_report', + description: 'Reports weather by running a sampling tool loop.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php new file mode 100644 index 0000000..107f163 --- /dev/null +++ b/tests/Integration/SamplingToolsTest.php @@ -0,0 +1,135 @@ +connect('sampling_tools', $this->clientSamplingWithTools()); + + $result = $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Paris: 18 C is the answer. (endTurn)', $result->content[0]->text); + } + + #[TestDox('the tools the server offered arrive at the client')] + public function testToolsReachTheClient(): void + { + /** @var \ArrayObject $seen */ + $seen = new \ArrayObject(); + $client = $this->connect('sampling_tools', $this->clientSamplingWithTools($seen)); + + $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertCount(2, $seen); + $this->assertSame('get_weather', $seen[0]->tools[0]->name); + + // Second turn carries the assistant's tool use and the server's tool result. + $this->assertCount(3, $seen[1]->messages); + $this->assertInstanceOf(ToolUseContent::class, $seen[1]->messages[1]->getContentBlocks()[0]); + $toolResult = $seen[1]->messages[2]->getContentBlocks()[0]; + $this->assertInstanceOf(ToolResultContent::class, $toolResult); + $this->assertSame('call-1', $toolResult->toolUseId); + } + + #[TestDox('a client that did not advertise sampling.tools is never sent tools')] + public function testClientWithoutSamplingToolsIsNotOfferedTools(): void + { + $client = $this->connect('sampling_tools', $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler($this->neverCalled()))); + + $result = $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('client cannot use tools during sampling', $result->content[0]->text); + } + + /** + * @param \ArrayObject|null $seen collects what the server asked for + */ + private function clientSamplingWithTools(?\ArrayObject $seen = null): ClientBuilder + { + $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { + /** @param \ArrayObject $seen */ + public function __construct(private readonly \ArrayObject $seen) + { + } + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->seen[] = $request; + + $lastMessage = $request->messages[\count($request->messages) - 1]; + $answeredTools = array_filter( + $lastMessage->getContentBlocks(), + static fn ($block): bool => $block instanceof ToolResultContent, + ); + + // First turn: ask for the tool. Second: answer from its result. + if ([] === $answeredTools) { + return new CreateSamplingMessageResult( + Role::Assistant, + [new ToolUseContent('call-1', 'get_weather', ['city' => 'Paris'])], + 'test-model', + 'toolUse', + ); + } + + $toolResult = reset($answeredTools); + $text = $toolResult->content[0]; + \assert($text instanceof TextContent); + + return new CreateSamplingMessageResult( + Role::Assistant, + new TextContent(\sprintf('%s is the answer.', $text->text)), + 'test-model', + 'endTurn', + ); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true, samplingTools: true)) + ->addRequestHandler(new SamplingRequestHandler($callback)); + } + + private function neverCalled(): SamplingCallbackInterface + { + return new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + throw new \LogicException('The server must not sample a client that cannot use tools.'); + } + }; + } +} diff --git a/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php new file mode 100644 index 0000000..923d669 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php @@ -0,0 +1,114 @@ +callbackReturningText()); + + $request = $this->requestFor([ + new SamplingMessage(Role::User, new TextContent('Weather in Paris?')), + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', ['city' => 'Paris'])), + new SamplingMessage(Role::User, new ToolResultContent('call-1', [new TextContent('18 C')])), + ]); + + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($request->getId(), $response->id); + } + + /** + * The spec asks for -32602 on both of these, and the callback must not run. + * + * @return iterable + */ + public static function provideToolFlowViolations(): iterable + { + yield 'tool results mixed with other content' => [ + [ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new TextContent('and also...'), + ]), + ], + 'Tool results mixed with other content.', + ]; + + yield 'tool result missing' => [ + [new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', []))], + 'Tool result missing in request.', + ]; + } + + /** + * @param SamplingMessage[] $messages + */ + #[DataProvider('provideToolFlowViolations')] + public function testHandleRejectsToolFlowViolationsWithInvalidParams(array $messages, string $expectedMessage): void + { + $callback = new class implements SamplingCallbackInterface { + public bool $invoked = false; + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->invoked = true; + + throw new \LogicException('The callback must not run for an invalid request.'); + } + }; + + $request = $this->requestFor($messages); + $response = (new SamplingRequestHandler($callback))->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INVALID_PARAMS, $response->code); + $this->assertSame($expectedMessage, $response->message); + $this->assertSame($request->getId(), $response->id); + $this->assertFalse($callback->invoked); + } + + /** + * @param SamplingMessage[] $messages + */ + private function requestFor(array $messages): CreateSamplingMessageRequest + { + return (new CreateSamplingMessageRequest($messages, 150))->withId('sampling-1'); + } + + private function callbackReturningText(): SamplingCallbackInterface + { + return new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + return new CreateSamplingMessageResult(Role::Assistant, new TextContent('Paris is warm.'), 'test-model'); + } + }; + } +} diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php deleted file mode 100644 index 4a33ec7..0000000 --- a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php +++ /dev/null @@ -1,45 +0,0 @@ -jsonSerialize(); - - $this->assertObjectHasProperty('context', $serialized['sampling']); - $this->assertObjectHasProperty('tools', $serialized['sampling']); - - $hydrated = ClientCapabilities::fromArray(json_decode(json_encode($serialized, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); - $this->assertTrue($hydrated->sampling); - $this->assertTrue($hydrated->samplingContext); - $this->assertTrue($hydrated->samplingTools); - } - - public function testSamplingSubCapabilitiesAreHydratedFromObject(): void - { - $sampling = new \stdClass(); - $sampling->context = new \stdClass(); - $sampling->tools = new \stdClass(); - - $capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]); - - $this->assertTrue($capabilities->sampling); - $this->assertTrue($capabilities->samplingContext); - $this->assertTrue($capabilities->samplingTools); - } -} diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php index 3c62e00..bee2e58 100644 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -68,4 +68,47 @@ class ClientCapabilitiesTest extends TestCase $this->assertTrue($restored->roots); $this->assertTrue($restored->rootsListChanged); } + + public function testRoundTripPreservesSamplingSubCapabilities(): void + { + $capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true); + + $serialized = $capabilities->jsonSerialize(); + $this->assertObjectHasProperty('context', $serialized['sampling']); + $this->assertObjectHasProperty('tools', $serialized['sampling']); + + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + + $this->assertTrue($restored->sampling); + $this->assertTrue($restored->samplingContext); + $this->assertTrue($restored->samplingTools); + } + + public function testPlainSamplingLeavesSubCapabilitiesOff(): void + { + $capabilities = new ClientCapabilities(sampling: true); + + $serialized = $capabilities->jsonSerialize(); + $this->assertObjectNotHasProperty('context', $serialized['sampling']); + $this->assertObjectNotHasProperty('tools', $serialized['sampling']); + + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + + $this->assertTrue($restored->sampling); + $this->assertFalse($restored->samplingContext); + $this->assertFalse($restored->samplingTools); + } + + public function testSamplingSubCapabilitiesAreHydratedFromObject(): void + { + $sampling = new \stdClass(); + $sampling->context = new \stdClass(); + $sampling->tools = new \stdClass(); + + $capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]); + + $this->assertTrue($capabilities->sampling); + $this->assertTrue($capabilities->samplingContext); + $this->assertTrue($capabilities->samplingTools); + } } diff --git a/tests/Unit/Schema/Content/SamplingMessageTest.php b/tests/Unit/Schema/Content/SamplingMessageTest.php new file mode 100644 index 0000000..c464575 --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingMessageTest.php @@ -0,0 +1,125 @@ + 'assistant', + '_meta' => ['provider' => 'test'], + 'content' => [ + ['type' => 'text', 'text' => 'I will check.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + ]); + $user = SamplingMessage::fromArray([ + 'role' => 'user', + 'content' => [[ + 'type' => 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + ]], + ]); + + $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); + $this->assertSame(['provider' => 'test'], $assistant->meta); + $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); + $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); + $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); + + $this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant), true))); + $this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user), true))); + } + + public function testSingleContentBlockKeepsItsShape(): void + { + $message = SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'text', 'text' => 'hi']]); + + $this->assertInstanceOf(TextContent::class, $message->content); + $this->assertSame('{"role":"user","content":{"type":"text","text":"hi"}}', json_encode($message)); + $this->assertCount(1, $message->getContentBlocks()); + } + + public function testSingleElementListKeepsItsShape(): void + { + $message = SamplingMessage::fromArray(['role' => 'user', 'content' => [['type' => 'text', 'text' => 'hi']]]); + + $this->assertIsArray($message->content); + $this->assertSame('{"role":"user","content":[{"type":"text","text":"hi"}]}', json_encode($message)); + $this->assertCount(1, $message->getContentBlocks()); + } + + /** + * The tool-flow rules span the whole message list, so a single message is never + * rejected for carrying the "wrong" block — see CreateSamplingMessageRequestTest. + */ + public function testToolBlocksAreAcceptedRegardlessOfRole(): void + { + $message = new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); + + $this->assertInstanceOf(ToolUseContent::class, $message->content); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; + + // array_filter() preserves keys, so this list starts at index 1. + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + $message = new SamplingMessage(Role::Assistant, $toolUses); + + $this->assertSame( + '{"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]}', + json_encode($message), + ); + } + + public function testEmptyContentListIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new SamplingMessage(Role::User, []); + } + + public function testEmptyContentListIsRejectedWhenHydrating(): void + { + $this->expectException(InvalidArgumentException::class); + + SamplingMessage::fromArray(['role' => 'user', 'content' => []]); + } + + public function testUnknownContentTypeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'nope']]); + } + + public function testUnknownRoleIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + SamplingMessage::fromArray(['role' => 'system', 'content' => ['type' => 'text', 'text' => 'hi']]); + } +} diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php deleted file mode 100644 index c10ad4c..0000000 --- a/tests/Unit/Schema/Content/SamplingToolContentTest.php +++ /dev/null @@ -1,76 +0,0 @@ - 'assistant', - '_meta' => ['provider' => 'test'], - 'content' => [ - ['type' => 'text', 'text' => 'I will check.'], - ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], - ], - ]); - $user = SamplingMessage::fromArray([ - 'role' => 'user', - 'content' => [[ - 'type' => 'tool_result', - 'toolUseId' => 'call-1', - 'content' => [['type' => 'text', 'text' => '21 C']], - 'structuredContent' => ['temperature' => 21], - ]], - ]); - - $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); - $this->assertSame(['provider' => 'test'], $assistant->meta); - $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); - $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); - $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); - $textContent = $user->content[0]->content[0]; - $this->assertInstanceOf(TextContent::class, $textContent); - $this->assertSame('21 C', $textContent->text); - } - - public function testToolUseIsRejectedInUserMessage(): void - { - $this->expectException(InvalidArgumentException::class); - new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); - } - - public function testToolResultCannotBeMixedWithOtherContent(): void - { - $this->expectException(InvalidArgumentException::class); - new SamplingMessage(Role::User, [ - new ToolResultContent('call-1', [new TextContent('done')]), - new TextContent('extra'), - ]); - } - - public function testToolResultRejectsNonStandardContentBlocks(): void - { - $this->expectException(InvalidArgumentException::class); - new ToolResultContent('call-1', [ - new SamplingMessage(Role::User, new TextContent('not a tool result content block')), - ]); - } -} diff --git a/tests/Unit/Schema/Content/ToolResultContentTest.php b/tests/Unit/Schema/Content/ToolResultContentTest.php new file mode 100644 index 0000000..3c45f66 --- /dev/null +++ b/tests/Unit/Schema/Content/ToolResultContentTest.php @@ -0,0 +1,119 @@ + 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + 'isError' => true, + '_meta' => ['provider' => 'test'], + ]); + + $this->assertSame('tool_result', $content->type); + $this->assertSame('call-1', $content->toolUseId); + $this->assertSame(['temperature' => 21], $content->structuredContent); + $this->assertTrue($content->isError); + $this->assertSame(['provider' => 'test'], $content->meta); + + $textContent = $content->content[0]; + $this->assertInstanceOf(TextContent::class, $textContent); + $this->assertSame('21 C', $textContent->text); + + $restored = ToolResultContent::fromArray(json_decode(json_encode($content), true)); + $this->assertEquals($content, $restored); + } + + public function testIsErrorIsOmittedWhenFalse(): void + { + $content = new ToolResultContent('call-1', [new TextContent('ok')]); + + $serialized = $content->jsonSerialize(); + + $this->assertArrayNotHasKey('isError', $serialized); + $this->assertArrayNotHasKey('structuredContent', $serialized); + $this->assertArrayNotHasKey('_meta', $serialized); + $this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content), true))->isError); + } + + public function testAcceptsEveryCallToolResultContentBlock(): void + { + $content = ToolResultContent::fromArray([ + 'toolUseId' => 'call-1', + 'content' => [ + ['type' => 'text', 'text' => 'a'], + ['type' => 'image', 'data' => base64_encode('img'), 'mimeType' => 'image/png'], + ['type' => 'audio', 'data' => base64_encode('snd'), 'mimeType' => 'audio/wav'], + ['type' => 'resource_link', 'uri' => 'file:///report.txt', 'name' => 'report'], + ['type' => 'resource', 'resource' => ['uri' => 'file:///a.txt', 'mimeType' => 'text/plain', 'text' => 'a']], + ], + ]); + + $this->assertInstanceOf(ResourceLink::class, $content->content[3]); + $this->assertInstanceOf(EmbeddedResource::class, $content->content[4]); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('drop me'), new TextContent('keep me')]; + + // array_filter() preserves keys, so this list starts at index 1. + $kept = array_filter($blocks, static fn (TextContent $block): bool => 'keep me' === $block->text); + $content = new ToolResultContent('call-1', $kept); + + $this->assertSame( + '{"type":"tool_result","toolUseId":"call-1","content":[{"type":"text","text":"keep me"}]}', + json_encode($content), + ); + } + + public function testRejectsNonStandardContentBlocks(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + new ToolResultContent('call-1', [ + new SamplingMessage(Role::User, new TextContent('not a tool result content block')), + ]); + } + + public function testRejectsUnsupportedContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolResultContent::fromArray([ + 'toolUseId' => 'call-1', + 'content' => [['type' => 'tool_use', 'id' => 'x', 'name' => 'y', 'input' => []]], + ]); + } + + public function testRejectsMissingToolUseId(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolResultContent::fromArray(['content' => []]); + } +} diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php new file mode 100644 index 0000000..bf7d07b --- /dev/null +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -0,0 +1,80 @@ + 'tool_use', + 'id' => 'call-1', + 'name' => 'weather', + 'input' => ['city' => 'Paris'], + '_meta' => ['provider' => 'test'], + ]); + + $this->assertSame('tool_use', $content->type); + $this->assertSame('call-1', $content->id); + $this->assertSame('weather', $content->name); + $this->assertSame(['city' => 'Paris'], $content->input); + $this->assertSame(['provider' => 'test'], $content->meta); + + $this->assertSame( + '{"type":"tool_use","id":"call-1","name":"weather","input":{"city":"Paris"},"_meta":{"provider":"test"}}', + json_encode($content), + ); + } + + public function testEmptyInputSerializesAsObject(): void + { + $content = new ToolUseContent('call-1', 'ping', []); + + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } + + public function testEmptyInputSurvivesRoundTrip(): void + { + $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', [])), true); + + $this->assertSame([], ToolUseContent::fromArray($decoded)->input); + } + + /** + * @return iterable}> + */ + public static function provideInvalidData(): iterable + { + yield 'missing id' => [['name' => 'weather', 'input' => []]]; + yield 'non-string id' => [['id' => 1, 'name' => 'weather', 'input' => []]]; + yield 'missing name' => [['id' => 'call-1', 'input' => []]]; + yield 'non-string name' => [['id' => 'call-1', 'name' => 1, 'input' => []]]; + yield 'missing input' => [['id' => 'call-1', 'name' => 'weather']]; + yield 'non-array input' => [['id' => 'call-1', 'name' => 'weather', 'input' => 'nope']]; + } + + /** + * @param array $data + */ + #[DataProvider('provideInvalidData')] + public function testInvalidDataIsRejected(array $data): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray($data); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 4254336..fb065e6 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -14,6 +14,8 @@ namespace Mcp\Tests\Unit\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolResultContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\ToolChoiceMode; use Mcp\Schema\Request\CreateSamplingMessageRequest; @@ -70,4 +72,123 @@ final class CreateSamplingMessageRequestTest extends TestCase $this->assertSame('weather', $hydrated->tools[0]->name); $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); } + + public function testValidToolFlowPasses(): void + { + $this->expectNotToPerformAssertions(); + + $this->requestFor([ + new SamplingMessage(Role::User, new TextContent('Weather in Paris and London?')), + new SamplingMessage(Role::Assistant, [ + new ToolUseContent('call-1', 'weather', ['city' => 'Paris']), + new ToolUseContent('call-2', 'weather', ['city' => 'London']), + ]), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new ToolResultContent('call-2', [new TextContent('15 C')]), + ]), + new SamplingMessage(Role::Assistant, new TextContent('Paris is warmer.')), + ])->validateToolFlow(); + } + + public function testToolResultsMixedWithOtherContentAreRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new TextContent('and also...'), + ]), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool results mixed with other content.'); + + $request->validateToolFlow(); + } + + public function testToolUseInUserMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolUseContent is only valid in assistant sampling messages.'); + + $request->validateToolFlow(); + } + + public function testToolResultInAssistantMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolResultContent('call-1', [new TextContent('18 C')])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolResultContent is only valid in user sampling messages.'); + + $request->validateToolFlow(); + } + + public function testUnansweredToolUseIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, [ + new ToolUseContent('call-1', 'weather', []), + new ToolUseContent('call-2', 'weather', []), + ]), + new SamplingMessage(Role::User, [new ToolResultContent('call-1', [new TextContent('18 C')])]), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testTrailingToolUseIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testToolUseFollowedByPlainMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, new TextContent('never mind')), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testUnsolicitedToolResultIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::User, new ToolResultContent('call-9', [new TextContent('18 C')])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result "call-9" does not answer a preceding tool use.'); + + $request->validateToolFlow(); + } + + /** + * @param SamplingMessage[] $messages + */ + private function requestFor(array $messages): CreateSamplingMessageRequest + { + return new CreateSamplingMessageRequest($messages, 150); + } } diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php index 9366f75..4196105 100644 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -11,8 +11,10 @@ namespace Mcp\Tests\Unit\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Result\CreateSamplingMessageResult; use PHPUnit\Framework\TestCase; @@ -50,4 +52,77 @@ final class CreateSamplingMessageResultTest extends TestCase $this->assertSame('provider-specific', $result->stopReason); $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); } + + public function testKnownStopReasonStaysAString(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'endTurn', + ]); + + $this->assertSame('endTurn', $result->stopReason); + } + + public function testSingleContentBlockKeepsItsShape(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + + $this->assertInstanceOf(TextContent::class, $result->content); + $this->assertCount(1, $result->getContentBlocks()); + $this->assertSame('{"type":"text","text":"Done"}', json_encode($result->jsonSerialize()['content'])); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; + + // array_filter() preserves keys, so this list starts at index 1. + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + $result = new CreateSamplingMessageResult(Role::Assistant, $toolUses, 'test-model'); + + $this->assertSame( + '[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]', + json_encode($result->jsonSerialize()['content']), + ); + } + + public function testNonAssistantRoleIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CreateSamplingMessageResult role must be "assistant".'); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + } + + public function testEmptyContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => [], + 'model' => 'test-model', + ]); + } + + public function testToolResultContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'tool_result', 'toolUseId' => 'call-1', 'content' => []], + 'model' => 'test-model', + ]); + } } diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php new file mode 100644 index 0000000..66ba54d --- /dev/null +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -0,0 +1,63 @@ + + */ + public static function provideModes(): iterable + { + yield 'auto' => [ToolChoiceMode::Auto]; + yield 'required' => [ToolChoiceMode::Required]; + yield 'none' => [ToolChoiceMode::None]; + } + + #[DataProvider('provideModes')] + public function testRoundTrip(ToolChoiceMode $mode): void + { + $choice = new ToolChoice($mode); + + $this->assertSame(\sprintf('{"mode":"%s"}', $mode->value), json_encode($choice)); + $this->assertSame($mode, ToolChoice::fromArray(json_decode(json_encode($choice), true))->mode); + } + + public function testModeDefaultsToAuto(): void + { + $this->assertSame(ToolChoiceMode::Auto, (new ToolChoice())->mode); + $this->assertSame(ToolChoiceMode::Auto, ToolChoice::fromArray([])->mode); + } + + public function testUnknownModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid tool choice mode "any".'); + + ToolChoice::fromArray(['mode' => 'any']); + } + + public function testNonStringModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type */ + ToolChoice::fromArray(['mode' => 1]); + } +} From 76778a4d9e036194fcaf6bca8395209ab7544a5f Mon Sep 17 00:00:00 2001 From: wWzZb Date: Sun, 16 Aug 2026 15:31:28 +0800 Subject: [PATCH 082/143] [Schema] Support PHPStan/Psalm integer ranges (#422) --- src/Capability/Discovery/SchemaGenerator.php | 9 +++++++-- .../Discovery/SchemaGeneratorFixture.php | 15 +++++++++++++++ .../Capability/Discovery/SchemaGeneratorTest.php | 10 ++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index a4efdd6..80779c7 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -677,7 +677,12 @@ final class SchemaGenerator implements SchemaGeneratorInterface return ['array']; } - // PRIORITY 3: Handle unions (recursive) + // PRIORITY 3: Treat PHPStan/Psalm integer ranges as their base type + if (preg_match('/^int\s*<\s*[^<>]+\s*>$/i', $normalizedType)) { + return ['integer']; + } + + // PRIORITY 4: Handle unions (recursive) if (str_contains($normalizedType, '|')) { $types = explode('|', $normalizedType); $jsonTypes = []; @@ -689,7 +694,7 @@ final class SchemaGenerator implements SchemaGeneratorInterface return array_values(array_unique($jsonTypes)); } - // PRIORITY 4: Handle simple built-in types + // PRIORITY 5: Handle simple built-in types return match ($normalizedType) { 'string', 'scalar' => ['string'], '?string' => ['null', 'string'], diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index d695917..548c5d9 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -226,6 +226,21 @@ class SchemaGeneratorFixture ): void { } + /** + * PHPStan/Psalm integer ranges should keep their base integer type. + * + * @param int<0, max> $offset Positive offset + * @param int $negative Negative value + * @param int<-5, 10> $bounded Bounded value + */ + public function integerRangeTypes( + #[Schema(minimum: 0)] + int $offset, + int $negative, + int $bounded, + ): void { + } + // ===== ENUM SCENARIOS ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 0c223cb..b5dbb09 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -190,6 +190,16 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); } + public function testMapsPhpStanAndPsalmIntegerRangesToBaseIntegerType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'integerRangeTypes'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals(['type' => 'integer', 'description' => 'Positive offset', 'minimum' => 0], $schema['properties']['offset']); + $this->assertEquals(['type' => 'integer', 'description' => 'Negative value'], $schema['properties']['negative']); + $this->assertEquals(['type' => 'integer', 'description' => 'Bounded value'], $schema['properties']['bounded']); + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); From f84d8f5aa947d30ee30348f25797fe494115a4fa Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 17 Aug 2026 23:09:39 +0200 Subject: [PATCH 083/143] [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface Ports the type definitions this SDK still misses outside of sampling tool use, which #409 and #420 already cover. Every addition is optional and defaults to current behaviour, so a connection negotiated on an older revision is unaffected. From 2025-11-25, elicitation gains modes. ElicitationMode splits `form` — build a form from the requested schema — from `url`, which sends the user out of band and returns only the accept/decline/cancel outcome. That is why requestedSchema becomes optional and `url` appears beside it. ClientCapabilities learns the matching sub-capabilities, where an `elicitation` naming no mode declares form, the only shape that existed before. Icon gains `theme` from the same revision, and Implementation gains the `title` BaseMetadata has carried since 2025-06-18. From 2026-07-28, schemas loosen where the revision loosens them: SEP-2106 drops the object-only restriction, so Tool::outputSchema may describe any JSON value and CallToolResult::structuredContent follows. The same revision defines three error codes (-32020 header mismatch, -32021 missing required client capability, -32022 unsupported protocol version). ProtocolVersionMiddleware switches to the last of them, so a rejected version carries the supported set as structured data the client can retry from rather than only as prose. * [Schema] Reject what the 2026-07-28 types cannot represent `Implementation::title` reached the typed constructor unchecked, so malformed wire data raised a TypeError instead of InvalidArgumentException. `ToolUseContent::input` accepted a list and serialized it as a JSON array, where the protocol requires an object. The empty array stays exempt: it is also an empty map and still emits `{}`. `ToolChoice` and `ElicitRequest` read their mode with isset(), which is false for an explicit null, so `{"mode": null}` silently became the default instead of being rejected. Both use array_key_exists() now, letting the existing type check refuse null. * [Client][Server] Advertise the implementation title through both builders `Implementation::title` could be parsed but never sent: neither `Client\Builder::setClientInfo()` nor `Server\Builder::setServerInfo()` accepted one, so every SDK user emitted null. Both gain a trailing optional `$title`. On the server it sits where the Implementation constructor already puts it, so existing positional calls keep their meaning; the client builder forwards it by name, leaving the icons and websiteUrl slots defaulted. * [Schema] Serialize every non-null structuredContent The object-only hydration guard was never that: `!is_array()` admitted `[1, 2, 3]` and `[]`, which serialize to JSON arrays, while rejecting the scalars 2026-07-28 permits. The truthiness emission gate was backwards in the same way — it dropped `[]`, `0`, `false` and `""`, yet emitted lists, strings and an empty stdClass. Hydration now accepts any JSON value, and `null` alone means absent, matching `ToolResultContent` which already carries this field that way. Which values a given revision permits is a question for version-aware serialization, which results cannot answer yet. * [Schema][Server] Make url elicitation reachable `ElicitRequest::forUrl()` built a request no SDK user could send: the only public gateway method always constructed form mode from an ElicitationSchema, and `request()` is private. `elicitUrl()` joins `elicit()`, and both funnel through one send path that hydrates the result with the request's own mode. Without that, a url-mode accept — contentless by design — threw, because ElicitResult requires content whenever the action is accept. The result carries no discriminator of its own, so the mode has to come from the request it answers. `supportsElicitationUrl()` reports whether the client named the mode, reusing the sub-capability reader the sampling checks already use. * [Schema] Add CHANGELOG entry for the 2026-07-28 surface * [Capability][Schema][Server] Carry the widened structuredContent end to end Review follow-up on the SEP-2106 widening, which stopped at the type. `ToolReference::extractStructuredContent()` returned `?array`, so a tool declaring a scalar `outputSchema` had its result dropped and logged as unsendable. It returns `mixed` now and keeps a scalar — but only from 2026-07-28 on, and only when the tool declared an outputSchema: without one the value is already carried in `content`, and advertising it twice is not an improvement. `CallToolHandler` only warned about list-shaped `structuredContent` on a self-built `CallToolResult`, so a scalar reached revisions that require an object unremarked. The check is on the shape now, not on the list case alone. `ElicitResult` retained a `content` the spec says is absent from url-mode results, leaving a malformed response indistinguishable from a valid one. `Tool::jsonSerialize()` emitted the empty root schema as `[]`, which is not a schema at all. Sub-schemas already had this treatment. --- CHANGELOG.md | 1 + src/Capability/Registry/ToolReference.php | 26 ++- src/Client/Builder.php | 7 +- src/Schema/ClientCapabilities.php | 45 ++++- src/Schema/Content/ToolUseContent.php | 9 +- src/Schema/Enum/ElicitationMode.php | 31 ++++ src/Schema/Enum/IconTheme.php | 25 +++ src/Schema/Icon.php | 28 ++- src/Schema/Implementation.php | 12 ++ src/Schema/JsonRpc/Error.php | 56 ++++++ src/Schema/Request/ElicitRequest.php | 74 ++++++-- src/Schema/Result/CallToolResult.php | 15 +- src/Schema/Result/ElicitResult.php | 18 +- src/Schema/Tool.php | 13 +- src/Schema/ToolChoice.php | 4 +- src/Server/Builder.php | 4 +- src/Server/ClientGateway.php | 79 +++++++-- .../Handler/Request/CallToolHandler.php | 21 ++- .../Middleware/ProtocolVersionMiddleware.php | 25 ++- tests/Unit/Capability/RegistryTest.php | 40 +++++ tests/Unit/Client/BuilderTest.php | 53 ++++++ tests/Unit/Schema/ClientCapabilitiesTest.php | 66 +++++++ .../Schema/Content/ToolUseContentTest.php | 24 +++ tests/Unit/Schema/ElicitationModeTest.php | 146 +++++++++++++++ tests/Unit/Schema/IconTest.php | 16 ++ tests/Unit/Schema/ImplementationTest.php | 31 ++++ tests/Unit/Schema/JsonRpc/ErrorCodesTest.php | 78 ++++++++ .../Unit/Schema/NonObjectOutputSchemaTest.php | 166 ++++++++++++++++++ tests/Unit/Schema/Result/ElicitResultTest.php | 29 +++ tests/Unit/Schema/ToolChoiceTest.php | 9 + tests/Unit/Server/BuilderTest.php | 40 +++++ tests/Unit/Server/ClientGatewayTest.php | 105 ++++++++++- 32 files changed, 1205 insertions(+), 91 deletions(-) create mode 100644 src/Schema/Enum/ElicitationMode.php create mode 100644 src/Schema/Enum/IconTheme.php create mode 100644 tests/Unit/Client/BuilderTest.php create mode 100644 tests/Unit/Schema/ElicitationModeTest.php create mode 100644 tests/Unit/Schema/JsonRpc/ErrorCodesTest.php create mode 100644 tests/Unit/Schema/NonObjectOutputSchemaTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f449b..2d9aede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one. * [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list. * [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires. +* Close the schema gaps left in `2025-06-18` and `2025-11-25` and add the non-sampling part of `2026-07-28`, all of it optional and defaulting to current behaviour. From `2025-11-25`: url-mode elicitation (`ElicitationMode`, `ElicitRequest::forUrl()`, `ClientGateway::elicitUrl()` and `supportsElicitationUrl()`), whose result carries the user's action alone — `ElicitResult::fromArray()` takes the request's mode, requires content only in form mode and rejects it in url mode; the `elicitation.form` / `elicitation.url` sub-capabilities, where a capability naming no mode declares form; and `Icon::theme`. From `2025-06-18`: `Implementation::title`, now settable through `Client\Builder::setClientInfo()` and `Server\Builder::setServerInfo()`. From `2026-07-28` (SEP-2106): `Tool::outputSchema` and `CallToolResult::structuredContent` accept any JSON value — `ToolReference::extractStructuredContent()` keeps a scalar when the tool declared an outputSchema and the negotiated revision allows it, and `CallToolHandler` warns when a self-built result carries a value the revision does not permit — plus the revision's three error codes (`-32020` header mismatch, `-32021` missing required client capability, `-32022` unsupported protocol version), the last of which `ProtocolVersionMiddleware` returns with the supported set as structured data the client can retry from. 0.7.0 ----- diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 0431687..97ace43 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -71,11 +71,11 @@ class ToolReference extends ElementReference * newest handshake revision, whose stricter rule is what * every revision reachable through `initialize` requires * - * @return array|null the structured content, or null if not extractable + * @return mixed the structured content, or null if not extractable * * @throws \JsonException if JSON encoding fails for non-Content array/object results */ - public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array + public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): mixed { $objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent(); @@ -111,11 +111,9 @@ class ToolReference extends ElementReference ); // A plain object always encodes to a JSON object, but `JsonSerializable` - // can hand back anything. A scalar is dropped whatever the revision - // allows: `CallToolResult::$structuredContent` is typed `?array` and - // cannot carry one. + // can hand back anything, scalars included. if (!\is_array($decoded)) { - return null; + return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null; } if ($objectOnly && array_is_list($decoded)) { @@ -125,6 +123,20 @@ class ToolReference extends ElementReference return $decoded; } - return null; + // A scalar is structured content only from SEP-2106 on, and only when the + // tool declared an outputSchema: without one, every string-returning tool + // would start advertising a duplicate of its own `content`. + return $this->acceptsScalarStructuredContent($objectOnly) && \is_scalar($toolExecutionResult) + ? $toolExecutionResult + : null; + } + + /** + * Whether the negotiated revision and the tool's own declaration together allow + * a non-object `structuredContent`. + */ + private function acceptsScalarStructuredContent(bool $objectOnly): bool + { + return !$objectOnly && null !== $this->tool->outputSchema; } } diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 9a69da1..7c39617 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -30,6 +30,7 @@ final class Builder private string $name = 'mcp-php-client'; private string $version = '1.0.0'; private ?string $description = null; + private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; private ?ClientCapabilities $capabilities = null; private int $initTimeout = 30; @@ -45,12 +46,15 @@ final class Builder /** * Set the client name and version. + * + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ - public function setClientInfo(string $name, string $version, ?string $description = null): self + public function setClientInfo(string $name, string $version, ?string $description = null, ?string $title = null): self { $this->name = $name; $this->version = $version; $this->description = $description; + $this->title = $title; return $this; } @@ -152,6 +156,7 @@ final class Builder $this->name, $this->version, $this->description, + title: $this->title, ); $config = new Configuration( diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 5eab9e6..b2a953e 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -24,9 +24,12 @@ class ClientCapabilities implements \JsonSerializable * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) * @param ?bool $samplingContext the `sampling.context` sub-capability * @param ?bool $samplingTools the `sampling.tools` sub-capability + * @param ?bool $elicitationForm The `elicitation.form` sub-capability. Implied by declaring + * `elicitation` without naming any mode. + * @param ?bool $elicitationUrl the `elicitation.url` sub-capability * - * The two sampling sub-capabilities trail `extensions` rather than sitting next to - * `sampling` so that existing positional calls keep working. Pass them by name. + * The sub-capabilities trail `extensions` rather than sitting next to `sampling` and + * `elicitation` so that existing positional calls keep working. Pass them by name. */ public function __construct( public readonly ?bool $roots = false, @@ -37,6 +40,8 @@ class ClientCapabilities implements \JsonSerializable public readonly ?array $extensions = null, public readonly ?bool $samplingContext = null, public readonly ?bool $samplingTools = null, + public readonly ?bool $elicitationForm = null, + public readonly ?bool $elicitationUrl = null, ) { } @@ -46,7 +51,7 @@ class ClientCapabilities implements \JsonSerializable * listChanged?: bool, * }, * sampling?: array{context?: mixed, tools?: mixed}|object, - * elicitation?: bool, + * elicitation?: array{form?: mixed, url?: mixed}|object|bool, * experimental?: array, * extensions?: array, * } $data @@ -78,8 +83,15 @@ class ClientCapabilities implements \JsonSerializable } $elicitation = null; + $elicitationForm = null; + $elicitationUrl = null; if (isset($data['elicitation'])) { $elicitation = true; + $elicitationUrl = self::namesMode($data['elicitation'], 'url'); + // Form mode is the backwards-compatible default: an `elicitation` capability + // naming no mode at all means form, the only shape that existed before `url`. + // Naming any mode is an explicit statement, so `{"url": {}}` is not form. + $elicitationForm = self::namesMode($data['elicitation'], 'form') || !$elicitationUrl; } return new self( @@ -91,9 +103,28 @@ class ClientCapabilities implements \JsonSerializable \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, $samplingContext, $samplingTools, + $elicitationForm, + $elicitationUrl, ); } + /** + * A mode is declared by the presence of a (possibly empty) object, so only the + * key matters — not whatever it holds. A boolean `elicitation` names none. + */ + private static function namesMode(mixed $capability, string $name): bool + { + if (\is_array($capability)) { + return \array_key_exists($name, $capability); + } + + if (\is_object($capability)) { + return property_exists($capability, $name); + } + + return false; + } + /** * @return array{ * roots?: object, @@ -123,8 +154,14 @@ class ClientCapabilities implements \JsonSerializable } } - if ($this->elicitation) { + if ($this->elicitation || $this->elicitationForm || $this->elicitationUrl) { $data['elicitation'] = new \stdClass(); + if ($this->elicitationForm) { + $data['elicitation']->form = new \stdClass(); + } + if ($this->elicitationUrl) { + $data['elicitation']->url = new \stdClass(); + } } if ($this->experimental) { diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php index 6acdc43..2117667 100644 --- a/src/Schema/Content/ToolUseContent.php +++ b/src/Schema/Content/ToolUseContent.php @@ -28,6 +28,10 @@ final class ToolUseContent extends Content public readonly array $input, public readonly ?array $meta = null, ) { + if ([] !== $input && array_is_list($input)) { + throw new InvalidArgumentException('ToolUseContent "input" must be a map of argument names, not a list.'); + } + parent::__construct('tool_use'); } @@ -45,12 +49,15 @@ final class ToolUseContent extends Content if (!isset($data['input']) || !\is_array($data['input'])) { throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.'); } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ToolUseContent data.'); + } return new self( $data['id'], $data['name'], $data['input'], - isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + $data['_meta'] ?? null, ); } diff --git a/src/Schema/Enum/ElicitationMode.php b/src/Schema/Enum/ElicitationMode.php new file mode 100644 index 0000000..adb0493 --- /dev/null +++ b/src/Schema/Enum/ElicitationMode.php @@ -0,0 +1,31 @@ + + */ +enum ElicitationMode: string +{ + /** + * Present a form built from the requested schema, and return the filled values. + */ + case Form = 'form'; + + /** + * Send the user to a URL to complete the interaction out of band. The result + * carries no content — only whether the user accepted, declined, or cancelled. + */ + case Url = 'url'; +} diff --git a/src/Schema/Enum/IconTheme.php b/src/Schema/Enum/IconTheme.php new file mode 100644 index 0000000..abfac9e --- /dev/null +++ b/src/Schema/Enum/IconTheme.php @@ -0,0 +1,25 @@ + + */ +enum IconTheme: string +{ + case Light = 'light'; + case Dark = 'dark'; +} diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index 13929e0..2751343 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -12,6 +12,7 @@ namespace Mcp\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\IconTheme; /** * A url pointing to an icon URL or a base64-encoded data URI. @@ -20,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException; * src: string, * mimeType?: string, * sizes?: string[], + * theme?: string, * } * * @author Christopher Hertel @@ -27,16 +29,19 @@ use Mcp\Exception\InvalidArgumentException; class Icon implements \JsonSerializable { /** - * @param string $src a standard URI pointing to an icon resource - * @param ?string $mimeType optional override if the server's MIME type is missing or generic - * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. - * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for - * scalable formats like SVG. + * @param string $src a standard URI pointing to an icon resource + * @param ?string $mimeType optional override if the server's MIME type is missing or generic + * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for + * scalable formats like SVG. + * @param ?IconTheme $theme Optional background this icon is designed for. When omitted, the icon is + * assumed to work against any background. */ public function __construct( public readonly string $src, public readonly ?string $mimeType = null, public readonly ?array $sizes = null, + public readonly ?IconTheme $theme = null, ) { if (empty($src)) { throw new InvalidArgumentException('Icon "src" must be a non-empty string.'); @@ -72,7 +77,14 @@ class Icon implements \JsonSerializable throw new InvalidArgumentException('Invalid "sizes" in Icon data.'); } - return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null); + $theme = null; + if (isset($data['theme'])) { + if (!\is_string($data['theme']) || null === $theme = IconTheme::tryFrom($data['theme'])) { + throw new InvalidArgumentException('Invalid "theme" in Icon data.'); + } + } + + return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null, $theme); } /** @@ -114,6 +126,10 @@ class Icon implements \JsonSerializable $data['sizes'] = $this->sizes; } + if (null !== $this->theme) { + $data['theme'] = $this->theme->value; + } + return $data; } } diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 214a38f..b48be54 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -24,6 +24,7 @@ class Implementation implements \JsonSerializable { /** * @param ?Icon[] $icons + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ public function __construct( public readonly string $name = 'app', @@ -31,6 +32,7 @@ class Implementation implements \JsonSerializable public readonly ?string $description = null, public readonly ?array $icons = null, public readonly ?string $websiteUrl = null, + public readonly ?string $title = null, ) { } @@ -41,6 +43,7 @@ class Implementation implements \JsonSerializable * description?: string, * icons?: IconData[], * websiteUrl?: string, + * title?: string, * } $data */ public static function fromArray(array $data): self @@ -66,6 +69,9 @@ class Implementation implements \JsonSerializable if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) { throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.'); } + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in Implementation data.'); + } return new self( $data['name'], @@ -73,6 +79,7 @@ class Implementation implements \JsonSerializable $data['description'] ?? null, $data['icons'] ?? null, $data['websiteUrl'] ?? null, + $data['title'] ?? null, ); } @@ -83,6 +90,7 @@ class Implementation implements \JsonSerializable * description?: string, * icons?: Icon[], * websiteUrl?: string, + * title?: string, * } */ public function jsonSerialize(): array @@ -104,6 +112,10 @@ class Implementation implements \JsonSerializable $data['websiteUrl'] = $this->websiteUrl; } + if (null !== $this->title) { + $data['title'] = $this->title; + } + return $data; } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 532e406..683ed0d 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -12,6 +12,8 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; /** * A response to a request that indicates an error occurred. @@ -36,6 +38,24 @@ class Error implements MessageInterface public const SERVER_ERROR = -32000; public const RESOURCE_NOT_FOUND = -32002; + /** + * Values in the HTTP headers contradict the request body, or a required + * header is missing or malformed. Answered with `400 Bad Request`. + */ + public const HEADER_MISMATCH = -32020; + + /** + * Handling the request needs a client capability the client never declared. + * Answered with `400 Bad Request`. + */ + public const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; + + /** + * The request's protocol version is unknown to this server, or is a version + * it has chosen not to implement. Answered with `400 Bad Request`. + */ + public const UNSUPPORTED_PROTOCOL_VERSION = -32022; + /** * @param int $code the error type that occurred * @param string $message a short description of the error @@ -111,6 +131,42 @@ class Error implements MessageInterface return new self($id, self::RESOURCE_NOT_FOUND, $message); } + final public static function forHeaderMismatch(string $message, string|int $id = ''): self + { + return new self($id, self::HEADER_MISMATCH, $message); + } + + /** + * @param ClientCapabilities $requiredCapabilities the capabilities the server needs to process the request + */ + final public static function forMissingRequiredClientCapability( + string $message, + ClientCapabilities $requiredCapabilities, + string|int $id = '', + ): self { + return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [ + 'requiredCapabilities' => $requiredCapabilities, + ]); + } + + /** + * The client is expected to pick a mutually supported version out of + * $supported and retry, so the list travels with the error rather than + * only in the message. + * + * @param list $supported versions this server does support + */ + final public static function forUnsupportedProtocolVersion( + string $requested, + array $supported, + string|int $id = '', + ): self { + return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [ + 'requested' => $requested, + 'supported' => array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $supported)), + ]); + } + public function getId(): string|int { return $this->id; diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index ab56e10..15a74ee 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -13,26 +13,56 @@ namespace Mcp\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\Request; /** * A request from the server to elicit additional information from the user. * - * The client will present the message and requested schema to the user, allowing them - * to provide the requested information, decline, or cancel the operation. + * Comes in two modes. In `form` mode the client presents the message alongside + * the requested schema and returns the values the user filled in. In `url` mode + * it sends the user to a URL to complete the interaction out of band, and the + * result carries only the user's action — no content. * * @author Johannes Wachter */ final class ElicitRequest extends Request { /** - * @param string $message A human-readable message describing what information is needed - * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user + * @param string $message A human-readable message describing what information is needed + * @param ?ElicitationSchema $requestedSchema The schema defining the fields to elicit; required in form mode + * @param ElicitationMode $mode how the client should collect the information + * @param ?string $url the URL to send the user to; required in url mode */ public function __construct( public readonly string $message, - public readonly ElicitationSchema $requestedSchema, + public readonly ?ElicitationSchema $requestedSchema = null, + public readonly ElicitationMode $mode = ElicitationMode::Form, + public readonly ?string $url = null, ) { + if (ElicitationMode::Form === $mode && null === $requestedSchema) { + throw new InvalidArgumentException('Form elicitation requires a "requestedSchema".'); + } + + if (ElicitationMode::Url === $mode && (null === $url || '' === $url)) { + throw new InvalidArgumentException('URL elicitation requires a non-empty "url".'); + } + } + + /** + * Elicit values from the user through a form built from $requestedSchema. + */ + public static function forForm(string $message, ElicitationSchema $requestedSchema): self + { + return new self($message, $requestedSchema); + } + + /** + * Send the user to $url to complete the interaction out of band. + */ + public static function forUrl(string $message, string $url): self + { + return new self($message, null, ElicitationMode::Url, $url); } public static function getMethod(): string @@ -46,24 +76,48 @@ final class ElicitRequest extends Request throw new InvalidArgumentException('Missing or invalid "message" parameter for elicitation/create.'); } + // Default `mode` is form - url elicitation is opt-in. + $mode = ElicitationMode::Form; + if (\array_key_exists('mode', $params)) { + if (!\is_string($params['mode']) || null === $mode = ElicitationMode::tryFrom($params['mode'])) { + throw new InvalidArgumentException('Invalid "mode" parameter for elicitation/create.'); + } + } + + if (ElicitationMode::Url === $mode) { + if (!isset($params['url']) || !\is_string($params['url'])) { + throw new InvalidArgumentException('Missing or invalid "url" parameter for url-mode elicitation/create.'); + } + + return new self($params['message'], null, $mode, $params['url']); + } + if (!isset($params['requestedSchema']) || !\is_array($params['requestedSchema'])) { throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.'); } - return new self( - $params['message'], - ElicitationSchema::fromArray($params['requestedSchema']), - ); + return new self($params['message'], ElicitationSchema::fromArray($params['requestedSchema']), $mode); } /** * @return array{ * message: string, - * requestedSchema: ElicitationSchema, + * mode?: string, + * requestedSchema?: ElicitationSchema, + * url?: string, * } */ protected function getParams(): array { + if (ElicitationMode::Url === $this->mode) { + return [ + 'message' => $this->message, + 'mode' => $this->mode->value, + 'url' => $this->url, + ]; + } + + // We don't need to send the mode if it's the default (form). return [ 'message' => $this->message, 'requestedSchema' => $this->requestedSchema, diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index c661839..b986bec 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -41,13 +41,15 @@ class CallToolResult implements ResultInterface * * @param Content[] $content The content of the tool result * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). - * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param mixed $structuredContent Structured result of the call. Any JSON value — object, array, + * string, number, boolean or null — conforming to the tool's + * outputSchema when one is declared. * @param array|null $meta Optional metadata */ public function __construct( public readonly array $content, public readonly bool $isError = false, - public readonly ?array $structuredContent = null, + public readonly mixed $structuredContent = null, public readonly ?array $meta = null, ) { foreach ($this->content as $item) { @@ -84,7 +86,7 @@ class CallToolResult implements ResultInterface * content: array, * isError?: bool, * _meta?: array, - * structuredContent?: array + * structuredContent?: mixed * } $data */ public static function fromArray(array $data): self @@ -114,9 +116,6 @@ class CallToolResult implements ResultInterface if (isset($data['isError']) && !\is_bool($data['isError'])) { throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); } - if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { - throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); - } if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); } @@ -133,7 +132,7 @@ class CallToolResult implements ResultInterface * @return array{ * content: array, * isError: bool, - * structuredContent?: array, + * structuredContent?: mixed, * _meta?: array, * } */ @@ -144,7 +143,7 @@ class CallToolResult implements ResultInterface 'isError' => $this->isError, ]; - if ($this->structuredContent) { + if (null !== $this->structuredContent) { $result['structuredContent'] = $this->structuredContent; } diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index 7041595..533d28f 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -13,13 +13,15 @@ namespace Mcp\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\ResultInterface; /** * The client's response to an elicitation/create request from the server. * * Contains the user's action (accept, decline, or cancel) and the content - * they provided when accepting. + * they provided when accepting. Only form elicitation collects content: a url-mode + * interaction happens out of band, so an accepted one carries the action alone. * * @author Johannes Wachter */ @@ -36,9 +38,13 @@ final class ElicitResult implements ResultInterface } /** + * The result carries no discriminator of its own, so the mode of the request it + * answers has to be passed in: it decides whether an accepted response is + * expected to carry content. + * * @param array{action: string, content?: array} $data */ - public static function fromArray(array $data): self + public static function fromArray(array $data, ElicitationMode $mode = ElicitationMode::Form): self { if (!isset($data['action']) || !\is_string($data['action'])) { throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); @@ -50,8 +56,12 @@ final class ElicitResult implements ResultInterface $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; - if (ElicitAction::Accept === $action && null === $content) { - throw new InvalidArgumentException('Content must be provided when action is "accept".'); + if (ElicitationMode::Form === $mode) { + if (ElicitAction::Accept === $action && null === $content) { + throw new InvalidArgumentException('Content must be provided when action is "accept".'); + } + } elseif (isset($data['content'])) { + throw new InvalidArgumentException('Content must not be provided for a url-mode elicitation result.'); } return new self($action, $content); diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index c4062cf..b5aee7e 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -25,7 +25,7 @@ use Mcp\Exception\InvalidArgumentException; * required: string[]|null * } * @phpstan-type ToolOutputSchema array{ - * type: 'object', + * type?: string, * properties?: array|\stdClass, * required?: string[]|null, * additionalProperties?: bool|array|\stdClass, @@ -103,7 +103,9 @@ class Tool implements \JsonSerializable * @param ?ToolAnnotations $annotations optional additional tool information * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata - * @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure + * @param ToolOutputSchema|null $outputSchema Optional JSON Schema (as a PHP array) describing the tool's + * structuredContent. Unlike $inputSchema its root is unconstrained — + * it may describe an array, a primitive, or a composition. */ public function __construct( public readonly string $name, @@ -142,9 +144,6 @@ class Tool implements \JsonSerializable $outputSchema = null; if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { - if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { - throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); - } $outputSchema = $data['outputSchema']; } @@ -169,7 +168,7 @@ class Tool implements \JsonSerializable * annotations?: ToolAnnotations, * icons?: Icon[], * _meta?: array, - * outputSchema?: ToolOutputSchema + * outputSchema?: ToolOutputSchema|\stdClass * } */ public function jsonSerialize(): array @@ -192,7 +191,7 @@ class Tool implements \JsonSerializable $data['_meta'] = $this->meta; } if (null !== $this->outputSchema) { - $data['outputSchema'] = $this->outputSchema; + $data['outputSchema'] = [] === $this->outputSchema ? new \stdClass() : $this->outputSchema; } return $data; diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php index 4b55bac..38488b8 100644 --- a/src/Schema/ToolChoice.php +++ b/src/Schema/ToolChoice.php @@ -29,11 +29,11 @@ final class ToolChoice implements \JsonSerializable */ public static function fromArray(array $data): self { - if (isset($data['mode']) && !\is_string($data['mode'])) { + if (\array_key_exists('mode', $data) && !\is_string($data['mode'])) { throw new InvalidArgumentException('Invalid "mode" in ToolChoice data.'); } - $mode = isset($data['mode']) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto; + $mode = \array_key_exists('mode', $data) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto; if (null === $mode) { throw new InvalidArgumentException(\sprintf('Invalid tool choice mode "%s".', $data['mode'])); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index fbce3d9..dca38ef 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -227,6 +227,7 @@ final class Builder * Sets the server's identity. Required. * * @param ?Icon[] $icons + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ public function setServerInfo( string $name, @@ -234,8 +235,9 @@ final class Builder ?string $description = null, ?array $icons = null, ?string $websiteUrl = null, + ?string $title = null, ): self { - $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); + $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl, $title); return $this; } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 445c9e0..e508bc3 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -56,9 +56,9 @@ use Mcp\Server\Session\SessionInterface; * $client->notify(new ProgressNotification("Starting analysis...")); * * // Request LLM sampling from client - * $response = $client->request(new SamplingRequest($text)); + * $result = $client->sample($text); * - * return $response->content->text; + * return $result->content->text; * } * ``` * @@ -176,7 +176,7 @@ class ClientGateway } /** - * Convenience method for elicitation requests. + * Convenience method for form-mode elicitation requests. * * Requests additional information from the user via the client. The user can * accept (providing the requested data), decline, or cancel the request. @@ -191,15 +191,28 @@ class ClientGateway */ public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult { - $request = new ElicitRequest($message, $requestedSchema); + return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout); + } - $response = $this->request($request, $timeout); - - if ($response instanceof Error) { - throw new ClientException($response); + /** + * Convenience method for url-mode elicitation requests. + * + * Sends the user to $url to complete the interaction out of band — an OAuth + * consent screen, a checkout, a form hosted elsewhere. The result carries only + * the user's action; unlike form mode there is no content to read back, so + * whatever the user did there has to be picked up through the URL's own channel. + * + * @throws ClientException if the client request results in an error message + * @throws InvalidArgumentException if the client did not declare url-mode elicitation + */ + public function elicitUrl(string $message, string $url, int $timeout = 120): ElicitResult + { + // URL mode only exists from 2025-11-25 on, and only for clients declaring it + if (!$this->supportsElicitationUrl()) { + throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); } - return ElicitResult::fromArray($response->result); + return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout); } /** @@ -291,7 +304,7 @@ class ClientGateway */ public function supportsSamplingTools(): bool { - return $this->hasSamplingSubCapability('tools'); + return $this->hasSubCapability('sampling', 'tools'); } /** @@ -304,20 +317,52 @@ class ClientGateway */ public function supportsSamplingContext(): bool { - return $this->hasSamplingSubCapability('context'); + return $this->hasSubCapability('sampling', 'context'); } - private function hasSamplingSubCapability(string $name): bool + /** + * Check if the connected client supports url-mode elicitation. + * + * An `elicitation` capability naming no mode declares form mode — the only shape + * that existed before URL elicitation — so url mode has to be named explicitly. + * + * @return bool True if the client supports url-mode elicitation, false otherwise + */ + public function supportsElicitationUrl(): bool + { + return $this->hasSubCapability('elicitation', 'url'); + } + + /** + * Sub-capabilities are declared by the presence of a (possibly empty) object, so + * only the key matters — not whatever it holds. The value arrives as an object on + * a live session and as an array once the session has round-tripped through JSON, + * hence both shapes. + */ + private function hasSubCapability(string $capability, string $name): bool { $capabilities = (array) $this->session->get('client_capabilities', []); - $sampling = $capabilities['sampling'] ?? null; + $declared = $capabilities[$capability] ?? null; - if (!\is_array($sampling) && !\is_object($sampling)) { - return false; + if (\is_array($declared)) { + return \array_key_exists($name, $declared); } - // MCP spec: capability presence indicates support (value is typically {} or []) - return \array_key_exists($name, (array) $sampling); + return \is_object($declared) && property_exists($declared, $name); + } + + /** + * @throws ClientException if the client request results in an error message + */ + private function sendElicitation(ElicitRequest $request, int $timeout): ElicitResult + { + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return ElicitResult::fromArray($response->result, $request->mode); } /** diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 254e838..3d43d0a 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -113,16 +113,18 @@ final class CallToolHandler implements RequestHandlerInterface $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); } elseif ($protocolVersion->requiresObjectStructuredContent() - && \is_array($result->structuredContent) + && null !== $result->structuredContent && [] !== $result->structuredContent - && array_is_list($result->structuredContent) + && !self::isJsonObject($result->structuredContent) ) { // A tool building its own `CallToolResult` bypasses the extraction - // rules on purpose, so the value is sent as it was set — but a JSON - // array is not valid here before SEP-2106 and clients may reject it. - $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [ + // rules on purpose, so the value is sent as it was set — but before + // SEP-2106 only a JSON object is valid here, whether the value is a + // list or a scalar, and clients may reject it. + $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is not a JSON object, which the negotiated protocol revision requires; sending it unchanged.', [ 'name' => $toolName, 'protocol_version' => $protocolVersion->value, + 'structured_content_type' => get_debug_type($result->structuredContent), ]); } @@ -152,4 +154,13 @@ final class CallToolHandler implements RequestHandlerInterface return Error::forInternalError('Error while executing tool', $request->getId()); } } + + /** + * Whether a `structuredContent` value encodes as a JSON object — the only shape + * revisions predating SEP-2106 accept. + */ + private static function isJsonObject(mixed $value): bool + { + return \is_array($value) && !array_is_list($value); + } } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index a4fb76b..e00a849 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -50,6 +50,9 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface /** @var list */ private readonly array $supportedVersions; + /** @var list */ + private readonly array $supported; + /** * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) @@ -61,7 +64,8 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface ?StreamFactoryInterface $streamFactory = null, ) { $versions = $supportedVersions ?? ProtocolVersion::handshakeVersions(); - $this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions)); + $this->supported = array_values($versions); + $this->supportedVersions = array_map(static fn (ProtocolVersion $v): string => $v->value, $this->supported); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } @@ -82,20 +86,11 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface return $handler->handle($request); } - $message = '' === $headerValue - ? \sprintf( - 'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $version, - implode(', ', $this->supportedVersions), - ) - : \sprintf( - 'Unsupported %s header value: %s. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $headerValue, - implode(', ', $this->supportedVersions), - ); + // The client is expected to pick a mutually supported version from the + // error payload and retry, so the supported set travels as structured + // data rather than only inside the message. + $error = Error::forUnsupportedProtocolVersion($version, $this->supported); - return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message)); + return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, $error); } } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 9ea4b24..0b796db 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -753,6 +753,46 @@ class RegistryTest extends TestCase }; } + public function testExtractStructuredContentKeepsAScalarFromSep2106On(): void + { + $tool = $this->createValidTool('test_tool', ['type' => 'string']); + $this->registry->registerTool($tool, static fn () => 'sunny'); + + $toolRef = $this->registry->getTool('test_tool'); + + $this->assertSame('sunny', $toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28)); + $this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2025_11_25)); + } + + public function testExtractStructuredContentDropsAScalarWithoutAnOutputSchema(): void + { + $tool = $this->createValidTool('test_tool', null); + $this->registry->registerTool($tool, static fn () => 'sunny'); + + $toolRef = $this->registry->getTool('test_tool'); + + // Without a declared schema the value is already carried in `content`; + // advertising it as structured too would duplicate it. + $this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentKeepsAJsonSerializableScalarFromSep2106On(): void + { + $tool = $this->createValidTool('test_tool', ['type' => 'number']); + $result = new class implements \JsonSerializable { + public function jsonSerialize(): float + { + return 22.5; + } + }; + $this->registry->registerTool($tool, static fn () => $result); + + $toolRef = $this->registry->getTool('test_tool'); + + $this->assertSame(22.5, $toolRef->extractStructuredContent($result, ProtocolVersion::V2026_07_28)); + $this->assertNull($toolRef->extractStructuredContent($result, ProtocolVersion::V2025_11_25)); + } + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( diff --git a/tests/Unit/Client/BuilderTest.php b/tests/Unit/Client/BuilderTest.php new file mode 100644 index 0000000..cd0bdf7 --- /dev/null +++ b/tests/Unit/Client/BuilderTest.php @@ -0,0 +1,53 @@ +setClientInfo('my-client', '1.0.0', 'A test client', 'My Client') + ->build(); + + $clientInfo = $this->extractConfiguration($client)->clientInfo; + + $this->assertSame('my-client', $clientInfo->name); + $this->assertSame('1.0.0', $clientInfo->version); + $this->assertSame('A test client', $clientInfo->description); + $this->assertSame('My Client', $clientInfo->title); + } + + #[TestDox('setClientInfo() leaves the title absent when it is not given')] + public function testSetClientInfoTitleIsOptional(): void + { + $client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->build(); + + $this->assertNull($this->extractConfiguration($client)->clientInfo->title); + } + + private function extractConfiguration(Client $client): Configuration + { + $config = (new \ReflectionClass($client))->getProperty('config')->getValue($client); + $this->assertInstanceOf(Configuration::class, $config); + + return $config; + } +} diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php index bee2e58..c2176e9 100644 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Schema\ClientCapabilities; +use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; class ClientCapabilitiesTest extends TestCase @@ -111,4 +112,69 @@ class ClientCapabilitiesTest extends TestCase $this->assertTrue($capabilities->samplingContext); $this->assertTrue($capabilities->samplingTools); } + + #[TestDox('reads the elicitation sub-capabilities')] + public function testReadsElicitationSubCapabilities(): void + { + $capabilities = ClientCapabilities::fromArray([ + 'elicitation' => ['form' => [], 'url' => []], + ]); + + $this->assertTrue($capabilities->elicitation); + $this->assertTrue($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an elicitation capability naming no mode declares form')] + public function testElicitationWithoutModeImpliesForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => []]); + + $this->assertTrue($capabilities->elicitationForm); + $this->assertFalse($capabilities->elicitationUrl); + } + + #[TestDox('naming url alone does not declare form')] + public function testElicitationUrlAloneIsNotForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => ['url' => []]]); + + $this->assertFalse($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an absent elicitation capability declares no mode either way')] + public function testAbsentElicitationLeavesModesNull(): void + { + $capabilities = ClientCapabilities::fromArray([]); + + $this->assertNull($capabilities->elicitation); + $this->assertNull($capabilities->elicitationForm); + $this->assertNull($capabilities->elicitationUrl); + } + + #[TestDox('round-trips the elicitation sub-capabilities')] + public function testRoundTripPreservesElicitationSubCapabilities(): void + { + $capabilities = new ClientCapabilities(elicitation: true, elicitationForm: true, elicitationUrl: true); + + $encoded = json_encode($capabilities) ?: ''; + $this->assertStringContainsString('"elicitation":{"form":{},"url":{}}', $encoded); + + $restored = ClientCapabilities::fromArray(json_decode($encoded, true)); + + $this->assertTrue($restored->elicitationForm); + $this->assertTrue($restored->elicitationUrl); + } + + #[TestDox('declaring only a mode still advertises elicitation')] + public function testElicitationModeImpliesParent(): void + { + $capabilities = new ClientCapabilities(elicitationUrl: true); + + $encoded = json_decode(json_encode($capabilities) ?: '', true); + + $this->assertArrayHasKey('elicitation', $encoded); + $this->assertArrayHasKey('url', $encoded['elicitation']); + } } diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php index bf7d07b..686481b 100644 --- a/tests/Unit/Schema/Content/ToolUseContentTest.php +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -77,4 +77,28 @@ final class ToolUseContentTest extends TestCase ToolUseContent::fromArray($data); } + + public function testRejectsListInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolUseContent "input" must be a map of argument names, not a list.'); + + /* @phpstan-ignore argument.type (deliberately list-shaped) */ + new ToolUseContent('call-1', 'get_weather', ['Berlin']); + } + + public function testRejectsListInputFromArray(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'x', 'input' => [1]]); + } + + public function testEmptyInputIsAccepted(): void + { + $content = ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'ping', 'input' => []]); + + $this->assertSame([], $content->input); + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } } diff --git a/tests/Unit/Schema/ElicitationModeTest.php b/tests/Unit/Schema/ElicitationModeTest.php new file mode 100644 index 0000000..1502e1e --- /dev/null +++ b/tests/Unit/Schema/ElicitationModeTest.php @@ -0,0 +1,146 @@ +createSchema()); + + $params = $this->paramsOf($request); + + $this->assertArrayNotHasKey('mode', $params); + $this->assertArrayHasKey('requestedSchema', $params); + $this->assertSame(ElicitationMode::Form, $request->mode); + } + + #[TestDox('url elicitation carries mode and url, and no schema')] + public function testUrlCarriesModeAndUrl(): void + { + $request = ElicitRequest::forUrl('Finish setup', 'https://example.com/setup'); + + $params = $this->paramsOf($request); + + $this->assertSame('url', $params['mode']); + $this->assertSame('https://example.com/setup', $params['url']); + $this->assertArrayNotHasKey('requestedSchema', $params); + $this->assertNull($request->requestedSchema); + } + + #[TestDox('a request without mode is still read as form elicitation')] + public function testAbsentModeDefaultsToForm(): void + { + $request = $this->requestFromParams([ + 'message' => 'Your name?', + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertNotNull($request->requestedSchema); + } + + #[TestDox('parses an explicit url-mode request')] + public function testParsesUrlMode(): void + { + $request = $this->requestFromParams([ + 'message' => 'Finish setup', + 'mode' => 'url', + 'url' => 'https://example.com/setup', + ]); + + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/setup', $request->url); + } + + #[TestDox('url mode without a url is rejected')] + public function testUrlModeRequiresUrl(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Finish setup', 'mode' => 'url']); + } + + #[TestDox('form mode without a schema is rejected')] + public function testFormModeRequiresSchema(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Your name?']); + } + + #[TestDox('an unknown mode is rejected rather than silently treated as a form')] + public function testUnknownModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'hi', 'mode' => 'telepathy']); + } + + #[TestDox('an explicit null mode is rejected rather than defaulting to form')] + public function testNullModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" parameter'); + + $this->requestFromParams([ + 'message' => 'Your name?', + 'mode' => null, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + } + + private function createSchema(): ElicitationSchema + { + return new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']); + } + + /** + * @param array $params + */ + private function requestFromParams(array $params): ElicitRequest + { + return ElicitRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => ElicitRequest::getMethod(), + 'params' => $params, + ]); + } + + /** + * @return array + */ + private function paramsOf(ElicitRequest $request): array + { + /** @var array{params?: array} $encoded */ + $encoded = json_decode(json_encode($request->withId('request-1')) ?: '', true); + + return $encoded['params'] ?? []; + } +} diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php index 500a81b..65e00e2 100644 --- a/tests/Unit/Schema/IconTest.php +++ b/tests/Unit/Schema/IconTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\IconTheme; use Mcp\Schema\Icon; use PHPUnit\Framework\TestCase; @@ -85,4 +86,19 @@ class IconTest extends TestCase $this->assertSame($dataUri, $icon->src); } + + public function testFromArrayReadsTheme(): void + { + $icon = Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'dark']); + + $this->assertSame(IconTheme::Dark, $icon->theme); + $this->assertSame('dark', $icon->jsonSerialize()['theme']); + } + + public function testFromArrayRejectsUnknownTheme(): void + { + $this->expectException(InvalidArgumentException::class); + + Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'sepia']); + } } diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php index 1e19dea..3268b6b 100644 --- a/tests/Unit/Schema/ImplementationTest.php +++ b/tests/Unit/Schema/ImplementationTest.php @@ -26,6 +26,7 @@ final class ImplementationTest extends TestCase $this->assertNull($implementation->description); $this->assertNull($implementation->icons); $this->assertNull($implementation->websiteUrl); + $this->assertNull($implementation->title); } public function testFromArrayWithMinimalData(): void @@ -148,6 +149,36 @@ final class ImplementationTest extends TestCase Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); } + public function testFromArrayThrowsOnNonStringDescription(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "description" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'description' => 42]); + } + + public function testFromArrayThrowsOnNonStringTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "title" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'title' => ['nope']]); + } + + public function testFromArrayReadsTitle(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '1.0.0', + 'title' => 'My Client', + ]); + + $this->assertSame('My Client', $implementation->title); + $this->assertArrayHasKey('title', $implementation->jsonSerialize()); + } + public function testJsonSerializeRoundTrip(): void { $implementation = Implementation::fromArray([ diff --git a/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php new file mode 100644 index 0000000..464f347 --- /dev/null +++ b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php @@ -0,0 +1,78 @@ +assertSame(Error::HEADER_MISMATCH, $error->code); + $this->assertSame(-32020, $error->code); + } + + #[TestDox('missing client capability carries -32021 and the required capabilities')] + public function testMissingClientCapability(): void + { + $error = Error::forMissingRequiredClientCapability( + 'Sampling is required', + new ClientCapabilities(sampling: true), + 'req-1', + ); + + $this->assertSame(-32021, $error->code); + + /** @var array{requiredCapabilities: ClientCapabilities} $data */ + $data = $error->data; + $this->assertInstanceOf(ClientCapabilities::class, $data['requiredCapabilities']); + + $encoded = json_decode(json_encode($error) ?: '', true); + $this->assertArrayHasKey('sampling', $encoded['error']['data']['requiredCapabilities']); + } + + #[TestDox('unsupported version carries -32022 plus the requested and supported versions')] + public function testUnsupportedProtocolVersion(): void + { + $error = Error::forUnsupportedProtocolVersion( + '1900-01-01', + [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18], + 'req-1', + ); + + $this->assertSame(-32022, $error->code); + $this->assertSame([ + 'requested' => '1900-01-01', + 'supported' => ['2025-11-25', '2025-06-18'], + ], $error->data); + } + + #[TestDox('the supported list survives JSON encoding as a plain array')] + public function testUnsupportedVersionEncoding(): void + { + $error = Error::forUnsupportedProtocolVersion('1900-01-01', ProtocolVersion::handshakeVersions(), 'req-1'); + + $encoded = json_decode(json_encode($error) ?: '', true); + + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $encoded['error']['data']['supported'], + ); + } +} diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php new file mode 100644 index 0000000..8e0fb0b --- /dev/null +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -0,0 +1,166 @@ +, required: string[]|null} + */ + private static function validInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + 'required' => null, + ]; + } + + /** + * @param array $outputSchema + */ + #[TestDox('outputSchema accepts a non-object root')] + #[DataProvider('provideNonObjectSchemas')] + public function testAcceptsNonObjectOutputSchema(array $outputSchema): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => $outputSchema, + ]); + + $this->assertSame($outputSchema, $tool->outputSchema); + } + + /** + * @return iterable}> + */ + public static function provideNonObjectSchemas(): iterable + { + yield 'array' => [['type' => 'array', 'items' => ['type' => 'string']]]; + yield 'string' => [['type' => 'string']]; + yield 'number' => [['type' => 'number']]; + yield 'boolean' => [['type' => 'boolean']]; + yield 'composition without a root type' => [['oneOf' => [['type' => 'string'], ['type' => 'number']]]]; + } + + #[TestDox('inputSchema still requires an object root, since tool arguments are always an object')] + public function testInputSchemaStillRequiresObjectRoot(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore-next-line argument.type (deliberately invalid: an array root must be rejected) */ + Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => ['type' => 'array', 'properties' => [], 'required' => null], + ]); + } + + #[TestDox('structuredContent holds any JSON value')] + #[DataProvider('provideStructuredValues')] + public function testStructuredContentHoldsAnyJsonValue(mixed $value): void + { + $result = new CallToolResult([new TextContent('ok')], false, $value); + + $this->assertSame($value, $result->structuredContent); + } + + /** + * @return iterable + */ + public static function provideStructuredValues(): iterable + { + yield 'list' => [[1, 2, 3]]; + yield 'string' => ['hello']; + yield 'int' => [42]; + yield 'float' => [1.5]; + yield 'true' => [true]; + yield 'zero' => [0]; + yield 'false' => [false]; + yield 'empty string' => ['']; + yield 'empty array' => [[]]; + } + + /** + * `null` is the only value that means absent, so every other JSON value — + * falsy ones included — reaches the wire. This matches the sibling field on + * {@see \Mcp\Schema\Content\ToolResultContent}, which carries the same tool + * result inside a sampling turn. + */ + #[TestDox('every non-null value reaches the wire, falsy ones included')] + #[DataProvider('provideStructuredValues')] + public function testNonNullValueIsSerialized(mixed $value): void + { + $result = new CallToolResult([new TextContent('ok')], false, $value); + + $serialized = $result->jsonSerialize(); + + $this->assertArrayHasKey('structuredContent', $serialized); + $this->assertSame($value, $serialized['structuredContent']); + } + + #[TestDox('a null structuredContent is omitted entirely')] + public function testNullStructuredContentOmitted(): void + { + $result = new CallToolResult([new TextContent('ok')]); + + $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); + } + + #[TestDox('round-trips any JSON structuredContent through fromArray')] + #[DataProvider('provideStructuredValues')] + public function testRoundTrip(mixed $value): void + { + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => $value, + ]); + + $this->assertSame($value, $result->structuredContent); + } + + #[TestDox('an object structuredContent still round-trips unchanged')] + public function testObjectRoundTrip(): void + { + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => ['temperature' => 22.5], + ]); + + $this->assertSame(['temperature' => 22.5], $result->structuredContent); + $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent']); + } + + #[TestDox('the empty root schema serializes as {} rather than []')] + public function testEmptyOutputSchemaSerializesAsObject(): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => [], + ]); + + $this->assertSame('{}', json_encode($tool->jsonSerialize()['outputSchema'])); + } +} diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 62091a5..b946c77 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -13,6 +13,7 @@ namespace Mcp\Tests\Unit\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\Result\ElicitResult; use PHPUnit\Framework\TestCase; @@ -99,6 +100,23 @@ final class ElicitResultTest extends TestCase ElicitResult::fromArray(['action' => 'accept']); } + public function testFromArrayWithUrlModeAllowsContentlessAccept(): void + { + $result = ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Url); + + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + $this->assertTrue($result->isAccepted()); + } + + public function testFromArrayWithExplicitFormModeRequiresContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must be provided when action is "accept"'); + + ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Form); + } + public function testIsAccepted(): void { $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); @@ -159,4 +177,15 @@ final class ElicitResultTest extends TestCase 'action' => 'cancel', ], $result->jsonSerialize()); } + + public function testUrlModeRejectsContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must not be provided for a url-mode elicitation result.'); + + ElicitResult::fromArray( + ['action' => 'accept', 'content' => ['name' => 'Ada']], + ElicitationMode::Url, + ); + } } diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php index 66ba54d..2b5e7cd 100644 --- a/tests/Unit/Schema/ToolChoiceTest.php +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -60,4 +60,13 @@ final class ToolChoiceTest extends TestCase /* @phpstan-ignore argument.type */ ToolChoice::fromArray(['mode' => 1]); } + + public function testExplicitNullModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type (deliberately null, as malformed wire data would be) */ + ToolChoice::fromArray(['mode' => null]); + } } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 255a142..a29c9f4 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -18,6 +18,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; @@ -206,6 +207,45 @@ final class BuilderTest extends TestCase $this->assertFalse($capabilities->tools); } + #[TestDox('setServerInfo() forwards the title to the advertised serverInfo')] + public function testSetServerInfoForwardsTitle(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0', 'A test server', null, 'https://example.com', 'Test Server') + ->build(); + + $serverInfo = $this->extractServerInfo($server); + + $this->assertSame('test', $serverInfo->name); + $this->assertSame('A test server', $serverInfo->description); + $this->assertSame('https://example.com', $serverInfo->websiteUrl); + $this->assertSame('Test Server', $serverInfo->title); + } + + #[TestDox('setServerInfo() leaves the title absent when it is not given')] + public function testSetServerInfoTitleIsOptional(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->build(); + + $this->assertNull($this->extractServerInfo($server)->title); + } + + private function extractServerInfo(Server $server): Implementation + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof InitializeHandler) { + return $handler->configuration->serverInfo; + } + } + + $this->fail('InitializeHandler not found in request handlers'); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index e6c2cee..61f5eb0 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -12,9 +12,18 @@ namespace Mcp\Tests\Unit\Server; use Mcp\Exception\ClientException; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ListRootsResult; use Mcp\Server\ClientGateway; use Mcp\Server\Session\SessionInterface; @@ -63,6 +72,76 @@ final class ClientGatewayTest extends TestCase $this->assertFalse($gateway->supportsSampling()); } + public function testSupportsSamplingToolsReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['sampling' => ['tools' => []]])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor(['sampling' => []])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor([])->supportsSamplingTools()); + } + + public function testSupportsSamplingToolsReadsTheObjectShapeToo(): void + { + $capabilities = (new ClientCapabilities(samplingTools: true))->jsonSerialize(); + + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools()); + } + + public function testSupportsElicitationUrlReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl()); + // An `elicitation` naming no mode declares form mode alone. + $this->assertFalse($this->gatewayFor(['elicitation' => []])->supportsElicitationUrl()); + $this->assertFalse($this->gatewayFor(['elicitation' => ['form' => []]])->supportsElicitationUrl()); + } + + public function testElicitUrlSendsAUrlModeRequest(): void + { + $gateway = $this->gatewayFor(['elicitation' => ['url' => []]]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'), + // A url-mode accept carries no content — the interaction happened out of band. + $this->response(['action' => 'accept']), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/consent', $request->url); + $this->assertInstanceOf(ElicitResult::class, $result); + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + } + + public function testElicitUrlRejectsAClientThatOnlySupportsForms(): void + { + $gateway = $this->gatewayFor(['elicitation' => []]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/elicitation\.url/'); + + $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'); + } + + public function testElicitStillSendsFormMode(): void + { + $gateway = $this->gatewayFor([]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicit('Your name?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])), + $this->response(['action' => 'accept', 'content' => ['name' => 'Ada']]), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertSame(['name' => 'Ada'], $result->content); + } + public function testListRootsReturnsRootsFromClient(): void { $session = $this->createMock(SessionInterface::class); @@ -98,19 +177,39 @@ final class ClientGatewayTest extends TestCase } /** - * Runs the gateway call inside a Fiber, asserts it suspends with a roots/list + * @param array $capabilities the client capabilities the session reports + */ + private function gatewayFor(array $capabilities): ClientGateway + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => 'client_capabilities' === $key ? $capabilities : $default, + ); + + return new ClientGateway($session); + } + + /** + * Runs the gateway call inside a Fiber, asserts it suspends with the expected * request, then resumes it with the given client response. * * @param Response>|Error $response + * @param class-string $expectedRequest + * @param ?Request $request receives the request the gateway suspended with + * + * @param-out Request $request */ - private function runInFiber(\Closure $call, Response|Error $response): mixed + private function runInFiber(\Closure $call, Response|Error $response, string $expectedRequest = ListRootsRequest::class, mixed &$request = null): mixed { $fiber = new \Fiber($call); $suspend = $fiber->start(); $this->assertIsArray($suspend); $this->assertSame('request', $suspend['type']); - $this->assertInstanceOf(ListRootsRequest::class, $suspend['request']); + $this->assertInstanceOf($expectedRequest, $suspend['request']); + + $request = $suspend['request']; $fiber->resume($response); From 1ff9a745212fbf08a9ee78124d8a9b408d55b9da Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 17 Aug 2026 23:26:05 +0200 Subject: [PATCH 084/143] [Tests] Bump MCP Inspector to 2.2.0 (#426) --- tests/Inspector/InspectorSnapshotTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 35d08e1..ddfbacb 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ use Symfony\Component\Process\Process; abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.22.0'; + private const INSPECTOR_VERSION = '2.2.0'; /** @param array $options */ #[DataProvider('provideMethods')] From 590afb575b30d9375f0007e19d43199471823b2d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 01:23:32 +0200 Subject: [PATCH 085/143] [Schema][Client][Server] Deprecate Roots, Sampling and Logging (SEP-2577) (#427) * [Schema][Client][Server] Deprecate Roots, Sampling and Logging (SEP-2577) * Emit runtime deprecation notices and document the migration (SEP-2596) --- CHANGELOG.md | 1 + composer.json | 1 + docs/client.md | 6 ++++++ docs/mcp-elements.md | 2 ++ docs/server-client-communication.md | 4 ++++ src/Capability/Logger/ClientLogger.php | 3 +++ .../Notification/LoggingNotificationHandler.php | 4 ++++ .../Handler/Request/ListRootsRequestHandler.php | 5 +++++ .../Handler/Request/RootsCallbackInterface.php | 4 ++++ .../Request/SamplingCallbackInterface.php | 3 +++ .../Handler/Request/SamplingRequestHandler.php | 4 ++++ src/Schema/Content/SamplingMessage.php | 3 +++ src/Schema/Content/ToolResultContent.php | 3 +++ src/Schema/Content/ToolUseContent.php | 3 +++ src/Schema/Enum/LoggingLevel.php | 3 +++ src/Schema/Enum/SamplingContext.php | 9 +++++++++ src/Schema/Enum/ToolChoiceMode.php | 6 ++++++ src/Schema/ModelHint.php | 3 +++ src/Schema/ModelPreferences.php | 3 +++ .../Notification/LoggingMessageNotification.php | 3 +++ .../RootsListChangedNotification.php | 4 ++++ .../Request/CreateSamplingMessageRequest.php | 3 +++ src/Schema/Request/ListRootsRequest.php | 4 ++++ src/Schema/Request/SetLogLevelRequest.php | 3 +++ .../Result/CreateSamplingMessageResult.php | 3 +++ src/Schema/Result/ListRootsResult.php | 4 ++++ src/Schema/Root.php | 4 ++++ src/Schema/ToolChoice.php | 3 +++ src/Server/ClientGateway.php | 16 ++++++++++++++++ .../Handler/Request/SetLogLevelHandler.php | 3 +++ 30 files changed, 120 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9aede..9bbabda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. diff --git a/composer.json b/composer.json index b883603..5c572b0 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "suggest": { diff --git a/docs/client.md b/docs/client.md index 962397c..f17b855 100644 --- a/docs/client.md +++ b/docs/client.md @@ -471,6 +471,8 @@ The client can receive requests and notifications from the server when configure ### Logging Notifications +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + Receive structured log messages from the server: ```php @@ -504,6 +506,8 @@ $client->setLoggingLevel(LoggingLevel::Info); ### Sampling (LLM Requests) +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + Handle server requests for LLM completions: ```php @@ -673,6 +677,8 @@ elicitation demo server. ### Roots +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. + Roots let the client expose a list of `file://` "workspace folders" that the server is allowed to operate on. Advertise the `roots` capability and register a handler that answers server `roots/list` requests: diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index eb76ed0..3480906 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -601,6 +601,8 @@ public function generatePrompt(string $topic, string $style): array ## Logging +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + The SDK provides support to send structured log messages to clients. All standard PSR-3 log levels are supported. Level **warning** as the default level. diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 151d531..10195eb 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -43,6 +43,8 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { ## Sampling +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: @@ -90,6 +92,8 @@ Use `$result->getContentBlocks()` to iterate the response regardless of whether ## Logging +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers to send structured log messages as notification to clients: diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index 1e0a959..ae0dd77 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -22,6 +22,9 @@ use Psr\Log\AbstractLogger; * * @author Adam Jamiu * @author Tobias Nyholm + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class ClientLogger extends AbstractLogger { diff --git a/src/Client/Handler/Notification/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php index c160ccd..a6853d4 100644 --- a/src/Client/Handler/Notification/LoggingNotificationHandler.php +++ b/src/Client/Handler/Notification/LoggingNotificationHandler.php @@ -18,6 +18,9 @@ use Mcp\Schema\Notification\LoggingMessageNotification; * Handler for logging message notifications from the server. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ class LoggingNotificationHandler implements NotificationHandlerInterface { @@ -27,6 +30,7 @@ class LoggingNotificationHandler implements NotificationHandlerInterface public function __construct( private readonly mixed $callback, ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.'); } public function supports(Notification $notification): bool diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php index eb18961..50806c1 100644 --- a/src/Client/Handler/Request/ListRootsRequestHandler.php +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -30,6 +30,10 @@ use Psr\Log\NullLogger; * @implements RequestHandlerInterface * * @author Johannes Wachter + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class ListRootsRequestHandler implements RequestHandlerInterface { @@ -37,6 +41,7 @@ class ListRootsRequestHandler implements RequestHandlerInterface private readonly RootsCallbackInterface $callback, private readonly LoggerInterface $logger = new NullLogger(), ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP roots are deprecated since protocol revision 2026-07-28 (SEP-2577); pass directories through tool arguments or resource URIs instead.'); } public function supports(Request $request): bool diff --git a/src/Client/Handler/Request/RootsCallbackInterface.php b/src/Client/Handler/Request/RootsCallbackInterface.php index 80677f1..9dcccb2 100644 --- a/src/Client/Handler/Request/RootsCallbackInterface.php +++ b/src/Client/Handler/Request/RootsCallbackInterface.php @@ -21,6 +21,10 @@ use Mcp\Schema\Result\ListRootsResult; * requested by the server. * * @author Johannes Wachter + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ interface RootsCallbackInterface { diff --git a/src/Client/Handler/Request/SamplingCallbackInterface.php b/src/Client/Handler/Request/SamplingCallbackInterface.php index bc0d554..0761de3 100644 --- a/src/Client/Handler/Request/SamplingCallbackInterface.php +++ b/src/Client/Handler/Request/SamplingCallbackInterface.php @@ -18,6 +18,9 @@ use Mcp\Schema\Result\CreateSamplingMessageResult; * Contract for callbacks used by SamplingRequestHandler. * * Implementations perform the actual LLM sampling when requested by the server. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ interface SamplingCallbackInterface { diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index d3da69f..3a7f862 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -30,6 +30,9 @@ use Psr\Log\NullLogger; * @implements RequestHandlerInterface * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class SamplingRequestHandler implements RequestHandlerInterface { @@ -37,6 +40,7 @@ class SamplingRequestHandler implements RequestHandlerInterface private readonly SamplingCallbackInterface $callback, private readonly LoggerInterface $logger = new NullLogger(), ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP sampling is deprecated since protocol revision 2026-07-28 (SEP-2577); integrate with an LLM provider\'s API directly instead.'); } public function supports(Request $request): bool diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index fc6885b..17343d0 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -31,6 +31,9 @@ use Mcp\Schema\Enum\Role; * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class SamplingMessage extends Content { diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php index 5e0e9b2..7f2b11c 100644 --- a/src/Schema/Content/ToolResultContent.php +++ b/src/Schema/Content/ToolResultContent.php @@ -20,6 +20,9 @@ use Mcp\Exception\InvalidArgumentException; * {@see \Mcp\Schema\Result\CallToolResult} does. * * @phpstan-type ToolResultBlock TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ final class ToolResultContent extends Content { diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php index 2117667..8952e8c 100644 --- a/src/Schema/Content/ToolUseContent.php +++ b/src/Schema/Content/ToolUseContent.php @@ -15,6 +15,9 @@ use Mcp\Exception\InvalidArgumentException; /** * A request from the assistant to call a tool. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ final class ToolUseContent extends Content { diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index e9aecef..7b03d13 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -18,6 +18,9 @@ namespace Mcp\Schema\Enum; * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ enum LoggingLevel: string { diff --git a/src/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php index 4c1f185..8ffe6ec 100644 --- a/src/Schema/Enum/SamplingContext.php +++ b/src/Schema/Enum/SamplingContext.php @@ -11,6 +11,15 @@ namespace Mcp\Schema\Enum; +/** + * Which servers' context a sampling request asks the client to attach. + * + * `thisServer` and `allServers` are themselves deprecated since 2025-11-25 + * (SEP-2596): omit the field or use `none`. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. + */ enum SamplingContext: string { case NONE = 'none'; diff --git a/src/Schema/Enum/ToolChoiceMode.php b/src/Schema/Enum/ToolChoiceMode.php index 76daec0..46a3332 100644 --- a/src/Schema/Enum/ToolChoiceMode.php +++ b/src/Schema/Enum/ToolChoiceMode.php @@ -11,6 +11,12 @@ namespace Mcp\Schema\Enum; +/** + * How the model may use the tools offered on a sampling request. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. + */ enum ToolChoiceMode: string { case Auto = 'auto'; diff --git a/src/Schema/ModelHint.php b/src/Schema/ModelHint.php index 0ad04cc..5bdd88b 100644 --- a/src/Schema/ModelHint.php +++ b/src/Schema/ModelHint.php @@ -17,6 +17,9 @@ namespace Mcp\Schema; * Keys not declared here are currently left unspecified by the spec and are up to the client to interpret. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class ModelHint implements \JsonSerializable { diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php index 88cf558..e668429 100644 --- a/src/Schema/ModelPreferences.php +++ b/src/Schema/ModelPreferences.php @@ -34,6 +34,9 @@ use Mcp\Exception\InvalidArgumentException; * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class ModelPreferences implements \JsonSerializable { diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php index 3088f49..17cbd70 100644 --- a/src/Schema/Notification/LoggingMessageNotification.php +++ b/src/Schema/Notification/LoggingMessageNotification.php @@ -17,6 +17,9 @@ use Mcp\Schema\JsonRpc\Notification; /** * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ class LoggingMessageNotification extends Notification { diff --git a/src/Schema/Notification/RootsListChangedNotification.php b/src/Schema/Notification/RootsListChangedNotification.php index 3c95829..89a0e47 100644 --- a/src/Schema/Notification/RootsListChangedNotification.php +++ b/src/Schema/Notification/RootsListChangedNotification.php @@ -19,6 +19,10 @@ use Mcp\Schema\JsonRpc\Notification; * The server should then request an updated list of roots using the ListRootsRequest. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class RootsListChangedNotification extends Notification { diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index c23b3d8..12e0309 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -28,6 +28,9 @@ use Mcp\Schema\ToolChoice; * loop) and decide whether to approve it. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ final class CreateSamplingMessageRequest extends Request { diff --git a/src/Schema/Request/ListRootsRequest.php b/src/Schema/Request/ListRootsRequest.php index 35c8a00..ea5987f 100644 --- a/src/Schema/Request/ListRootsRequest.php +++ b/src/Schema/Request/ListRootsRequest.php @@ -23,6 +23,10 @@ use Mcp\Schema\JsonRpc\Request; * structure or access specific locations that the client has permission to read from. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ final class ListRootsRequest extends Request { diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index 1a441cc..f781ca5 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -19,6 +19,9 @@ use Mcp\Schema\JsonRpc\Request; * A request from the client to the server, to enable or adjust logging. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class SetLogLevelRequest extends Request { diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 3a50a4a..a325ed9 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -25,6 +25,9 @@ use Mcp\Schema\JsonRpc\ResultInterface; * the server to see it. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class CreateSamplingMessageResult implements ResultInterface { diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index b2088dd..613b53f 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -21,6 +21,10 @@ use Mcp\Schema\Root; * or file that the server can operate on. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class ListRootsResult implements ResultInterface { diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 3c20e9e..97a2e3a 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -22,6 +22,10 @@ use Mcp\Exception\InvalidArgumentException; * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class Root implements \JsonSerializable { diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php index 38488b8..ad00323 100644 --- a/src/Schema/ToolChoice.php +++ b/src/Schema/ToolChoice.php @@ -16,6 +16,9 @@ use Mcp\Schema\Enum\ToolChoiceMode; /** * Controls how the model uses tools during sampling. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ final class ToolChoice implements \JsonSerializable { diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index e508bc3..b696ff7 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -99,9 +99,14 @@ class ClientGateway /** * Convenience method to send a logging notification to the client. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void { + trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.'); + $this->notify(new LoggingMessageNotification($level, $data, $logger)); } @@ -135,9 +140,14 @@ class ClientGateway * @return CreateSamplingMessageResult The sampling response * * @throws ClientException if the client request results in an error message + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult { + trigger_deprecation('mcp/sdk', '0.8', 'MCP sampling is deprecated since protocol revision 2026-07-28 (SEP-2577); integrate with an LLM provider\'s API directly instead.'); + $preferences = $options['preferences'] ?? null; if (null !== $preferences && !$preferences instanceof ModelPreferences) { throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); @@ -227,9 +237,15 @@ class ClientGateway * @return ListRootsResult The roots exposed by the client * * @throws ClientException if the client request results in an error message + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource URIs or server + * configuration instead. */ public function listRoots(int $timeout = 120): ListRootsResult { + trigger_deprecation('mcp/sdk', '0.8', 'MCP roots are deprecated since protocol revision 2026-07-28 (SEP-2577); pass directories through tool arguments or resource URIs instead.'); + $request = new ListRootsRequest(); $response = $this->request($request, $timeout); diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index 5563898..a04d22b 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -27,6 +27,9 @@ use Mcp\Server\Session\SessionInterface; * @implements RequestHandlerInterface * * @author Adam Jamiu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class SetLogLevelHandler implements RequestHandlerInterface { From 6f58fe596a20ca6f0aac628b3e9562c2bff3423f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 01:46:53 +0200 Subject: [PATCH 086/143] [Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks (#429) * [Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks Add ClientGateway::supportsExtension(), Client\Builder::enableExtension() and ClientCapabilities::withExtensions(); introduce side-agnostic ExtensionInterface and deprecate ServerExtensionInterface; document attribute-based UI linkage. * Drop ServerExtensionInterface in favour of ExtensionInterface --- CHANGELOG.md | 1 + docs/extensions.md | 55 +++++++++++++++++-- examples/server/mcp-apps/WeatherApp.php | 13 ++++- src/Client/Builder.php | 36 +++++++++++- src/Schema/ClientCapabilities.php | 19 +++++++ src/Schema/Extension/Apps/McpApps.php | 7 ++- ...onInterface.php => ExtensionInterface.php} | 13 +++-- src/Server/Builder.php | 4 +- src/Server/ClientGateway.php | 15 +++++ .../Capability/Discovery/DiscoveryTest.php | 13 ++++- .../Fixtures/DiscoverableUiHandler.php | 53 ++++++++++++++++++ tests/Unit/Client/BuilderTest.php | 38 +++++++++++++ .../Schema/Extension/Apps/McpAppsTest.php | 4 +- .../Extension/CapabilitiesExtensionsTest.php | 11 ++++ tests/Unit/Server/ClientGatewayTest.php | 12 ++++ 15 files changed, 273 insertions(+), 21 deletions(-) rename src/Schema/Extension/{ServerExtensionInterface.php => ExtensionInterface.php} (68%) create mode 100644 tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbabda..59c5324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. diff --git a/docs/extensions.md b/docs/extensions.md index f181702..aff06ab 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -13,14 +13,37 @@ $server = Server::builder() ->build(); ``` -Pass one or more `ServerExtensionInterface` instances; multiple extensions can +Pass one or more `ExtensionInterface` instances; multiple extensions can be enabled in a single call. Enabling the same extension twice throws a `LogicException`. +Clients (hosts) advertise the extensions they support the same way, via +`Client\Builder::enableExtension()`; the payload lands under +`capabilities.extensions` in the initialize request. + > Note: extensions enabled via `enableExtension()` are merged into the -> `extensions` capability even when you supply your own `ServerCapabilities` via -> `setCapabilities()`. An enabled extension overrides any entry under the same -> id already present in those capabilities. +> `extensions` capability even when you supply your own `ServerCapabilities` / +> `ClientCapabilities` via `setCapabilities()`. An enabled extension overrides +> any entry under the same id already present in those capabilities. + +## Checking what the client negotiated + +An extension is only in effect when both sides advertise it. On the server, a +handler can ask the `ClientGateway` from the injected `RequestContext` whether the +connected client did: + +```php +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Server\RequestContext; + +public function getWeather(string $city, RequestContext $context): string +{ + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + // text-only fallback for hosts without MCP Apps support + } + // ... +} +``` ## MCP Apps (`io.modelcontextprotocol/ui`) @@ -78,6 +101,30 @@ flagging it as an MCP App, while the resource *content* returned by `resources/r carries the structured `UiResourceContentMeta` with the actual CSP and permission configuration. +### Attribute-based discovery + +The same linkage works with `#[McpResource]` / `#[McpTool]`, since both accept a +`meta` array and PHP allows `new` in attribute arguments. The one difference is the +descriptor marker: `McpApps::resourceMarker()` is a method call and cannot appear +in an attribute, so spell it as `new \stdClass()` there. + +```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Extension\Apps\ToolVisibility; +use Mcp\Schema\Extension\Apps\UiToolMeta; + +final class WeatherApp +{ + #[McpResource(uri: 'ui://my-app', mimeType: McpApps::MIME_TYPE, meta: ['ui' => new \stdClass()])] + public function view(): TextResourceContents { /* as above */ } + + #[McpTool(name: 'my_tool', meta: ['ui' => new UiToolMeta(resourceUri: 'ui://my-app', visibility: [ToolVisibility::Model, ToolVisibility::App])])] + public function myTool(string $city): string { /* ... */ } +} +``` + ### Server-side DTOs | Class | Purpose | diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php index 5facead..159e8c4 100644 --- a/examples/server/mcp-apps/WeatherApp.php +++ b/examples/server/mcp-apps/WeatherApp.php @@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Extension\Apps\UiResourceContentMeta; use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; +use Mcp\Server\RequestContext; final class WeatherApp { @@ -39,7 +40,7 @@ final class WeatherApp ); } - public function getWeather(string $city): string + public function getWeather(string $city, RequestContext $context): string { $weather = [ 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'], @@ -56,12 +57,20 @@ final class WeatherApp $key = strtolower($city); $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%']; - return \sprintf( + $text = \sprintf( 'Weather in %s: %s, %s, Humidity: %s', $city, $data['temp'], $data['condition'], $data['humidity'], ); + + // Hosts without MCP Apps support only ever see this text; the ones that + // negotiated the extension render the ui://weather-app view on top of it. + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + return $text.' (text-only, host does not support MCP Apps)'; + } + + return $text; } } diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 7c39617..bfed70f 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -14,8 +14,10 @@ namespace Mcp\Client; use Mcp\Client; use Mcp\Client\Handler\Notification\NotificationHandlerInterface; use Mcp\Client\Handler\Request\RequestHandlerInterface; +use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Implementation; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -33,6 +35,9 @@ final class Builder private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; private ?ClientCapabilities $capabilities = null; + + /** @var array> */ + private array $extensions = []; private int $initTimeout = 30; private int $requestTimeout = 120; private int $maxRetries = 3; @@ -79,6 +84,27 @@ final class Builder return $this; } + /** + * Enable one or more MCP protocol extensions, announced to the server under + * `capabilities.extensions` in the initialize request. + * + * @throws LogicException if the same extension is enabled more than once + */ + public function enableExtension(ExtensionInterface ...$extensions): self + { + foreach ($extensions as $extension) { + $id = $extension->getId(); + + if (isset($this->extensions[$id])) { + throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); + } + + $this->extensions[$id] = $extension->getCapabilities(); + } + + return $this; + } + /** * Set initialization timeout in seconds. */ @@ -159,9 +185,17 @@ final class Builder title: $this->title, ); + $capabilities = $this->capabilities ?? new ClientCapabilities(); + + // Extensions enabled via enableExtension() are folded into caller-supplied + // capabilities too, so setCapabilities() does not silently drop them. + if ([] !== $this->extensions) { + $capabilities = $capabilities->withExtensions($this->extensions); + } + $config = new Configuration( clientInfo: $clientInfo, - capabilities: $this->capabilities ?? new ClientCapabilities(), + capabilities: $capabilities, protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index b2a953e..bc71cfc 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -125,6 +125,25 @@ class ClientCapabilities implements \JsonSerializable return false; } + /** + * @param array $extensions + */ + public function withExtensions(array $extensions): self + { + return new self( + $this->roots, + $this->rootsListChanged, + $this->sampling, + $this->elicitation, + $this->experimental, + array_replace($this->extensions ?? [], $extensions), + $this->samplingContext, + $this->samplingTools, + $this->elicitationForm, + $this->elicitationUrl, + ); + } + /** * @return array{ * roots?: object, diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php index bd702c8..a9124a4 100644 --- a/src/Schema/Extension/Apps/McpApps.php +++ b/src/Schema/Extension/Apps/McpApps.php @@ -11,7 +11,7 @@ namespace Mcp\Schema\Extension\Apps; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\ExtensionInterface; /** * The MCP Apps extension (io.modelcontextprotocol/ui). @@ -19,13 +19,14 @@ use Mcp\Schema\Extension\ServerExtensionInterface; * MCP Apps allows servers to expose interactive HTML UI applications as resources. * Clients that support the extension render these in sandboxed iframes. * - * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}. + * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()} and on the + * client (host) via {@see \Mcp\Client\Builder::enableExtension()}. * * @see https://github.com/modelcontextprotocol/ext-apps * * @author Christopher Hertel */ -final class McpApps implements ServerExtensionInterface +final class McpApps implements ExtensionInterface { public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; public const MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ExtensionInterface.php similarity index 68% rename from src/Schema/Extension/ServerExtensionInterface.php rename to src/Schema/Extension/ExtensionInterface.php index 8793a96..f8a4038 100644 --- a/src/Schema/Extension/ServerExtensionInterface.php +++ b/src/Schema/Extension/ExtensionInterface.php @@ -12,15 +12,16 @@ namespace Mcp\Schema\Extension; /** - * A server-side MCP protocol extension advertised during capability negotiation. + * An MCP protocol extension advertised during capability negotiation. * * Implementations are typically zero-config — they expose a stable identifier and the - * capability payload announced under `capabilities.extensions[]` in the initialize - * response. + * capability payload announced under `capabilities.extensions[]`. The same + * extension object can be enabled on a server (initialize response) and on a client + * (initialize request); the side that enables it decides which. * * @author Christopher Hertel */ -interface ServerExtensionInterface +interface ExtensionInterface { /** * The reverse-DNS identifier used as the key under `capabilities.extensions`. @@ -31,8 +32,8 @@ interface ServerExtensionInterface * The capability payload announced for this extension. * * The returned array is cast to an object and embedded under - * `capabilities.extensions[]` in the initialize response, so every value - * must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects). + * `capabilities.extensions[]`, so every value must be JSON-serializable + * (scalars, arrays, or `JsonSerializable` objects). * * @return array */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index dca38ef..c01dd73 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,7 +32,7 @@ use Mcp\Exception\LogicException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -282,7 +282,7 @@ final class Builder * * @throws LogicException if the same extension is enabled more than once */ - public function enableExtension(ServerExtensionInterface ...$extensions): self + public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { $id = $extension->getId(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index b696ff7..2df6fa3 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -349,6 +349,21 @@ class ClientGateway return $this->hasSubCapability('elicitation', 'url'); } + /** + * Check if the connected client negotiated the given protocol extension. + * + * Extensions are advertised under `capabilities.extensions` keyed by their + * reverse-DNS id, e.g. `McpApps::EXTENSION_ID` — a server offering MCP Apps + * should check this before pointing a tool at a `ui://` resource, and fall back + * to a text-only result otherwise. + * + * @return bool True if the client advertised the extension, false otherwise + */ + public function supportsExtension(string $id): bool + { + return $this->hasSubCapability('extensions', $id); + } + /** * Sub-capabilities are declared by the presence of a (possibly empty) object, so * only the key matters — not whatever it holds. The value arrives as an object on diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index b419726..5c716d7 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Registry\ToolReference; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; @@ -38,7 +39,7 @@ class DiscoveryTest extends TestCase $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); $tools = $discovery->getTools(); - $this->assertCount(5, $tools); + $this->assertCount(6, $tools); $this->assertArrayHasKey('greet_user', $tools); $this->assertEquals('greet_user', $tools['greet_user']->tool->name); @@ -63,7 +64,7 @@ class DiscoveryTest extends TestCase $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); $resources = $discovery->getResources(); - $this->assertCount(3, $resources); + $this->assertCount(4, $resources); $this->assertArrayHasKey('app://info/version', $resources); $this->assertEquals('app_version', $resources['app://info/version']->resource->name); @@ -72,6 +73,14 @@ class DiscoveryTest extends TestCase $this->assertArrayHasKey('invokable://config/status', $resources); $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); + $this->assertArrayHasKey('ui://widget/clock', $resources); + $this->assertEquals(McpApps::MIME_TYPE, $resources['ui://widget/clock']->resource->mimeType); + $this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta)); + $this->assertJsonStringEqualsJsonString( + '{"ui":{"resourceUri":"ui://widget/clock","visibility":["app"]}}', + json_encode($tools['show_clock']->tool->meta), + ); + $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php new file mode 100644 index 0000000..f5d03ed --- /dev/null +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php @@ -0,0 +1,53 @@ + new \stdClass()], + )] + public function widget(): TextResourceContents + { + return new TextResourceContents( + uri: 'ui://widget/clock', + mimeType: McpApps::MIME_TYPE, + text: '', + meta: ['ui' => new UiResourceContentMeta(csp: new UiResourceCsp(connectDomains: ['https://time.example.com']))], + ); + } + + #[McpTool( + name: 'show_clock', + meta: ['ui' => new UiToolMeta(resourceUri: 'ui://widget/clock', visibility: [ToolVisibility::App])], + )] + public function showClock(string $timezone): string + { + return $timezone; + } +} diff --git a/tests/Unit/Client/BuilderTest.php b/tests/Unit/Client/BuilderTest.php index cd0bdf7..b462b9b 100644 --- a/tests/Unit/Client/BuilderTest.php +++ b/tests/Unit/Client/BuilderTest.php @@ -13,6 +13,9 @@ namespace Mcp\Tests\Unit\Client; use Mcp\Client; use Mcp\Client\Configuration; +use Mcp\Exception\LogicException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Extension\Apps\McpApps; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -43,6 +46,41 @@ final class BuilderTest extends TestCase $this->assertNull($this->extractConfiguration($client)->clientInfo->title); } + #[TestDox('enableExtension() announces the extension under capabilities.extensions')] + public function testEnableExtensionAdvertisesExtension(): void + { + $client = Client::builder() + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertSame([McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]], $capabilities->extensions); + } + + #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] + public function testEnableExtensionMergesIntoCustomCapabilities(): void + { + $client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true, extensions: ['io.example/other' => []])) + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertTrue($capabilities->elicitation); + $this->assertSame(['io.example/other', McpApps::EXTENSION_ID], array_keys($capabilities->extensions ?? [])); + } + + #[TestDox('enableExtension() throws when the same extension is enabled twice')] + public function testEnableExtensionRejectsDuplicate(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(McpApps::EXTENSION_ID); + + Client::builder()->enableExtension(new McpApps(), new McpApps()); + } + private function extractConfiguration(Client $client): Configuration { $config = (new \ReflectionClass($client))->getProperty('config')->getValue($client); diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index ec0fd2b..cdb8237 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -17,14 +17,16 @@ use Mcp\Schema\Extension\Apps\UiResourceContentMeta; use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; use Mcp\Schema\Extension\Apps\UiToolMeta; +use Mcp\Schema\Extension\ExtensionInterface; use PHPUnit\Framework\TestCase; class McpAppsTest extends TestCase { - public function testServerExtensionInterface(): void + public function testExtensionInterface(): void { $extension = new McpApps(); + $this->assertInstanceOf(ExtensionInterface::class, $extension); $this->assertSame('io.modelcontextprotocol/ui', $extension->getId()); $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); } diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php index d966111..7ef916e 100644 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -113,6 +113,17 @@ class CapabilitiesExtensionsTest extends TestCase $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); } + public function testClientCapabilitiesWithExtensionsMerges(): void + { + $caps = new ClientCapabilities(elicitation: true, extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]]); + + $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); + + $this->assertTrue($merged->elicitation); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 99], 'c' => ['z' => 3]], $merged->extensions); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); + } + public function testClientCapabilitiesWithExtensions(): void { $extensions = [ diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index 61f5eb0..2b70fd6 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -18,6 +18,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\ElicitAction; use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -86,6 +87,17 @@ final class ClientGatewayTest extends TestCase $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools()); } + public function testSupportsExtensionReflectsTheNegotiatedExtensions(): void + { + $this->assertTrue($this->gatewayFor(['extensions' => [McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor(['extensions' => ['io.example/other' => []]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor([])->supportsExtension(McpApps::EXTENSION_ID)); + + // Live sessions hold the serialized object shape. + $capabilities = (new ClientCapabilities(extensions: [McpApps::EXTENSION_ID => (new McpApps())->getCapabilities()]))->jsonSerialize(); + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsExtension(McpApps::EXTENSION_ID)); + } + public function testSupportsElicitationUrlReflectsTheSubCapability(): void { $this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl()); From d5351759d7cd071aa04ff25a405f69116227f888 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 02:09:07 +0200 Subject: [PATCH 087/143] [Schema] Drop SDK-only name pattern on resources and resource templates (#430) * [Schema] Drop SDK-only name pattern on resources and resource templates * Update src/Schema/ResourceDefinition.php * Update src/Schema/ResourceTemplate.php --- CHANGELOG.md | 1 + src/Schema/ResourceDefinition.php | 8 ------- src/Schema/ResourceTemplate.php | 8 ------- tests/Unit/Schema/ResourceDefinitionTest.php | 24 ++++++++++++++++++++ tests/Unit/Schema/ResourceTemplateTest.php | 24 ++++++++++++++++++++ 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c5324..c980e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* [BC Break] Drop the SDK-only `[a-zA-Z0-9_-]+` restriction on `ResourceDefinition::$name` and `ResourceTemplate::$name`: the specification puts no pattern on `name` (its own examples use `main.rs` and `Project Files`), and the classes also parse what a peer sent, so the rule rejected conformant servers. Any string is now accepted; the URI / URI template validation is unchanged. * Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index ca9d0e6..69c44d5 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -35,11 +35,6 @@ use Mcp\Exception\InvalidArgumentException; */ class ResourceDefinition implements \JsonSerializable { - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - /** * URI pattern regex - requires a valid scheme followed by colon and optional path (RFC 3986). * Example patterns: file://path, db://table, urn:isbn:123, config:key, etc. @@ -68,9 +63,6 @@ class ResourceDefinition implements \JsonSerializable public readonly ?array $icons = null, public readonly ?array $meta = null, ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } if (!preg_match(self::URI_PATTERN, $uri)) { throw new InvalidArgumentException(\sprintf('Invalid resource URI: "%s" must be a valid URI with a scheme and optional path.', $uri)); } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index a26e8b9..4603397 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -32,11 +32,6 @@ use Mcp\Exception\InvalidArgumentException; */ class ResourceTemplate implements \JsonSerializable { - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - /** * URI Template pattern regex - requires a valid scheme followed by colon and path with at least one placeholder (RFC 3986). * Example patterns: file://{path}/contents.txt, db://{table}/{id}, config:{key}, etc. @@ -61,9 +56,6 @@ class ResourceTemplate implements \JsonSerializable public readonly ?Annotations $annotations = null, public readonly ?array $meta = null, ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } if (!preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate)) { throw new InvalidArgumentException(\sprintf('Invalid URI template : "%s" must be a valid URI template with at least one placeholder.', $uriTemplate)); } diff --git a/tests/Unit/Schema/ResourceDefinitionTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php index ab39f41..e6bbe56 100644 --- a/tests/Unit/Schema/ResourceDefinitionTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -116,6 +116,30 @@ class ResourceDefinitionTest extends TestCase $this->assertArrayNotHasKey('title', $data); } + /** + * The specification puts no pattern on `name`; its own examples use + * names such as "main.rs" and "Project Files". + */ + #[DataProvider('provideSpecExampleNames')] + public function testConstructorAcceptsAnyName(string $name): void + { + $resource = new ResourceDefinition( + uri: self::VALID_URI, + name: $name, + ); + + $this->assertSame($name, $resource->name); + } + + public static function provideSpecExampleNames(): iterable + { + yield 'file name with dot' => ['main.rs']; + yield 'display name with space' => ['Project Files']; + yield 'uppercase file name' => ['README.md']; + yield 'namespaced' => ['acme/reports:quarterly']; + yield 'unicode' => ['Ünïcödé 名前']; + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index 3e2908d..b99c9d0 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -114,6 +114,30 @@ class ResourceTemplateTest extends TestCase $this->assertArrayNotHasKey('title', $data); } + /** + * The specification puts no pattern on `name`; its own examples use + * names such as "main.rs" and "Project Files". + */ + #[DataProvider('provideSpecExampleNames')] + public function testConstructorAcceptsAnyName(string $name): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: $name, + ); + + $this->assertSame($name, $resource->name); + } + + public static function provideSpecExampleNames(): iterable + { + yield 'file name with dot' => ['main.rs']; + yield 'display name with space' => ['Project Files']; + yield 'uppercase file name' => ['README.md']; + yield 'namespaced' => ['acme/reports:quarterly']; + yield 'unicode' => ['Ünïcödé 名前']; + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { From 740766126029abc532f44af3f87ad1775a41f34a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 02:48:16 +0200 Subject: [PATCH 088/143] Tighten the 0.8.0 CHANGELOG section (#431) * Add missing security fixes and bug fixes to 0.8.0 CHANGELOG * Drop security-fixes bullet from CHANGELOG * Drop bugfix bullets from CHANGELOG --- CHANGELOG.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c980e05..c02faf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,21 +5,21 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- -* [BC Break] Drop the SDK-only `[a-zA-Z0-9_-]+` restriction on `ResourceDefinition::$name` and `ResourceTemplate::$name`: the specification puts no pattern on `name` (its own examples use `main.rs` and `Project Files`), and the classes also parse what a peer sent, so the rule rejected conformant servers. Any string is now accepted; the URI / URI template validation is unchanged. -* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. -* Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. -* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. -* Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. -* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. -* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. -* Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. -* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. -* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. -* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. -* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one. -* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list. -* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires. -* Close the schema gaps left in `2025-06-18` and `2025-11-25` and add the non-sampling part of `2026-07-28`, all of it optional and defaulting to current behaviour. From `2025-11-25`: url-mode elicitation (`ElicitationMode`, `ElicitRequest::forUrl()`, `ClientGateway::elicitUrl()` and `supportsElicitationUrl()`), whose result carries the user's action alone — `ElicitResult::fromArray()` takes the request's mode, requires content only in form mode and rejects it in url mode; the `elicitation.form` / `elicitation.url` sub-capabilities, where a capability naming no mode declares form; and `Icon::theme`. From `2025-06-18`: `Implementation::title`, now settable through `Client\Builder::setClientInfo()` and `Server\Builder::setServerInfo()`. From `2026-07-28` (SEP-2106): `Tool::outputSchema` and `CallToolResult::structuredContent` accept any JSON value — `ToolReference::extractStructuredContent()` keeps a scalar when the tool declared an outputSchema and the negotiated revision allows it, and `CallToolHandler` warns when a self-built result carries a value the revision does not permit — plus the revision's three error codes (`-32020` header mismatch, `-32021` missing required client capability, `-32022` unsupported protocol version), the last of which `ProtocolVersionMiddleware` returns with the supported set as structured data the client can retry from. +* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. +* Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. +* Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. +* [BC Break] Gate `structuredContent` on the negotiated protocol revision: a tool result that's a PHP list (or an object serializing to a JSON array) is now only sent as `structuredContent` on protocol revisions `2026-07-28`+; older revisions omit it and keep the JSON-encoded value in `content`. +* Add protocol revision negotiation during `initialize`: the server counter-offers a revision it supports, and the client now fails the handshake instead of continuing on an unagreed revision. Adds `Client::getProtocolVersion()` and the `2026-07-28` revision. +* Add sampling-with-tools support: sampling requests can include tools and tool-choice preferences, messages support tool-use/tool-result content blocks, and clients advertise `sampling.context`/`sampling.tools` via `ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`. A request that violates the spec's tool-flow rules is now rejected with a proper JSON-RPC error instead of being left unanswered. +* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now be a list of content blocks instead of just one — use the new `getContentBlocks()` to always get a list. +* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, per spec. +* Close the remaining schema gaps for `2025-06-18`/`2025-11-25` and add the `2026-07-28` surface (SEP-2106): url-mode elicitation (`ClientGateway::elicitUrl()`/`supportsElicitationUrl()`), `Implementation::title`, and `outputSchema`/`structuredContent` accepting any JSON value rather than only objects. +* Add `Mcp\Schema\Content\ResourceLink` — reference a resource by URI/name in tool results and prompt messages without embedding its contents. +* Add client-side Roots support: `RootsCallbackInterface`, `Client::sendRootsListChanged()`, and server-side `ClientGateway::listRoots()`/`supportsRoots()`. +* Add `ClientGateway::supportsSampling()` to check the client's advertised capabilities before sending a sampling request, matching `supportsRoots()`/`supportsElicitation()`. +* Fix empty tool/resource schemas serializing as `[]` instead of `{}` in `inputSchema`/`outputSchema`. +* Fix `PromptResultFormatter` dropping `annotations`, `_meta`, and `mimeType` when a prompt generator returns content as a plain array. +* Add `annotations` support to `ImageContent`, matching `TextContent`/`AudioContent`. 0.7.0 ----- From 0751a836587a7b1f6e18aaaa8027a01fdc61c40e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 14:51:34 +0200 Subject: [PATCH 089/143] [Examples][Tests] Make the MCP Apps example work over HTTP, and pin it (#439) * [Examples][Tests] Make the MCP Apps example work over HTTP, and pin it It was the only example without a session store, so every request under `php -S` or php-fpm - each its own process - was answered "Session not found or has expired". It now uses a FileSessionStore like the others. Inspector snapshots pin what the extension actually puts on the wire: the ui:// resource's `text/html;profile=mcp-app` MIME type and `ui` marker, and the `_meta.ui` on the tool that links the two. * Apply suggestion from @chr-hertel * Apply suggestion from @chr-hertel --- examples/server/mcp-apps/server.php | 2 + tests/Inspector/Http/HttpMcpAppsTest.php | 50 +++++++++++++++++++ .../HttpMcpAppsTest-prompts_list.json | 3 ++ .../HttpMcpAppsTest-resources_list.json | 13 +++++ ...cpAppsTest-resources_read-weather_app.json | 22 ++++++++ ...pMcpAppsTest-resources_templates_list.json | 3 ++ ...ttpMcpAppsTest-tools_call-get_weather.json | 9 ++++ .../snapshots/HttpMcpAppsTest-tools_list.json | 28 +++++++++++ 8 files changed, 130 insertions(+) create mode 100644 tests/Inspector/Http/HttpMcpAppsTest.php create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json create mode 100644 tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php index d8ca259..d61c15a 100644 --- a/examples/server/mcp-apps/server.php +++ b/examples/server/mcp-apps/server.php @@ -18,12 +18,14 @@ use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Extension\Apps\ToolVisibility; use Mcp\Schema\Extension\Apps\UiToolMeta; use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; logger()->info('Starting MCP Apps Example Server...'); $server = Server::builder() ->setServerInfo('MCP Apps Weather Example', '1.0.0') ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->enableExtension(new McpApps()) ->addResource( [WeatherApp::class, 'getWeatherApp'], diff --git a/tests/Inspector/Http/HttpMcpAppsTest.php b/tests/Inspector/Http/HttpMcpAppsTest.php new file mode 100644 index 0000000..76866f6 --- /dev/null +++ b/tests/Inspector/Http/HttpMcpAppsTest.php @@ -0,0 +1,50 @@ + [ + 'method' => 'resources/read', + 'options' => ['uri' => 'ui://weather-app'], + 'testName' => 'weather_app', + ], + 'Call the linked tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'get_weather', + 'toolArgs' => ['city' => 'london'], + ], + 'testName' => 'get_weather', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php'; + } +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json new file mode 100644 index 0000000..7292222 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json new file mode 100644 index 0000000..2ff24ba --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json @@ -0,0 +1,13 @@ +{ + "resources": [ + { + "name": "weather-app", + "uri": "ui://weather-app", + "description": "Interactive weather dashboard", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": {} + } + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json new file mode 100644 index 0000000..84e1751 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json @@ -0,0 +1,22 @@ +{ + "contents": [ + { + "uri": "ui://weather-app", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": { + "csp": { + "connectDomains": [ + "https://api.weather.example.com" + ] + }, + "permissions": { + "geolocation": {} + }, + "prefersBorder": true + } + }, + "text": "\n\n\n \n \n Weather Dashboard\n \n\n\n
\n \n \n
\n
\n
\n
\n
—
\n
—
\n
\n
🌤️
\n
\n
—
\n
\n Humidity —\n
\n
\n
\n \n\n\n" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json new file mode 100644 index 0000000..e867d9d --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json new file mode 100644 index 0000000..8d42069 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Weather in london: 15°C, Cloudy, Humidity: 78%" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json new file mode 100644 index 0000000..dcb064a --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json @@ -0,0 +1,28 @@ +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + }, + "_meta": { + "ui": { + "resourceUri": "ui://weather-app", + "visibility": [ + "model", + "app" + ] + } + } + } + ] +} From ced762614b8319634535452c18797fcbd2ca7fb5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 14:54:52 +0200 Subject: [PATCH 090/143] [Capability] Stop a union type losing a branch (#434) * [Capability] Stop a union type losing a branch mapPhpTypeToJsonSchemaType() tested for array syntax before splitting the union, so string[]|int mapped to array alone; applyArrayConstraints() then overwrote the type with array whenever the union contained one, dropping the scalar branch a second time. A tool declaring such a parameter rejected a valid integer argument. The split now happens first and only at the top level, so array and array{a: int|string} stay one type rather than two that do not exist, and the array branch keeps its items alongside the others - which is what 2020-12 means by items: a constraint that applies only when the instance is an array. * Apply suggestion from @chr-hertel * Isolate the array branch before inferring its element type * Don't apply items to arrays whose union branches disagree on element type --- src/Capability/Discovery/SchemaGenerator.php | 110 ++++++++++++--- .../Discovery/SchemaGeneratorUnionTest.php | 130 ++++++++++++++++++ .../Capability/Discovery/UnionFixture.php | 27 ++++ 3 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php create mode 100644 tests/Unit/Capability/Discovery/UnionFixture.php diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 80779c7..3d9f8aa 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -443,7 +443,23 @@ final class SchemaGenerator implements SchemaGeneratorInterface } // Handle regular arrays elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { - $itemsType = $this->inferArrayItemsType($typeString); + // `inferArrayItemsType()` expects a single array type, not a union — for + // `string[]|int` it would see the whole string, match nothing, and lose + // the element-type constraint. Isolate the branch(es) that are arrays. + $arrayBranches = array_values(array_filter( + self::splitUnion($typeString), + fn (string $branch): bool => \in_array('array', $this->mapPhpTypeToJsonSchemaType($branch), true), + )); + if ([] === $arrayBranches) { + $arrayBranches = [$typeString]; + } + + // Several array branches (`string[]|int[]`) can disagree on their + // element type. Applying just one would reject values valid under + // the other, so `items` only applies when every array branch agrees. + $itemsTypes = array_map($this->inferArrayItemsType(...), $arrayBranches); + $itemsType = 1 === \count(array_unique(array_map(json_encode(...), $itemsTypes))) ? $itemsTypes[0] : 'any'; + if ('any' !== $itemsType) { if (\is_string($itemsType)) { $paramSchema['items'] = ['type' => $itemsType]; @@ -455,11 +471,25 @@ final class SchemaGenerator implements SchemaGeneratorInterface } } - if ($allowsNull) { - $paramSchema['type'] = ['array', 'null']; + // Only when the parameter is an array and nothing else. A union + // like `string[]|int` keeps both branches: `items` constrains the + // instance only when it *is* an array, so the two coexist and + // overwriting the type here would drop the scalar branch — + // rejecting a value the handler accepts. + $otherTypes = array_values(array_diff( + (array) $paramSchema['type'], + ['array', 'null'], + )); + + if ([] === $otherTypes) { + $paramSchema['type'] = $allowsNull ? ['array', 'null'] : 'array'; + + if (\is_array($paramSchema['type'])) { + sort($paramSchema['type']); + } + } elseif ($allowsNull && !\in_array('null', (array) $paramSchema['type'], true)) { + $paramSchema['type'] = [...(array) $paramSchema['type'], 'null']; sort($paramSchema['type']); - } else { - $paramSchema['type'] = 'array'; } } @@ -655,6 +685,42 @@ final class SchemaGenerator implements SchemaGeneratorInterface return $typeString ?: 'mixed'; } + /** + * Splits a type string on its top-level `|`, leaving generics and shapes + * intact. + * + * `int|string` splits; `array` and `array{a: int|string}` do + * not, because the `|` there is a parameter of the outer type rather than + * an alternative to it. + * + * @return list + */ + public static function splitUnion(string $type): array + { + $branches = []; + $depth = 0; + $current = ''; + + foreach (str_split($type) as $character) { + if (\in_array($character, ['<', '{', '('], true)) { + ++$depth; + } elseif (\in_array($character, ['>', '}', ')'], true)) { + $depth = max(0, $depth - 1); + } elseif ('|' === $character && 0 === $depth) { + $branches[] = trim($current); + $current = ''; + + continue; + } + + $current .= $character; + } + + $branches[] = trim($current); + + return array_values(array_filter($branches, static fn (string $branch): bool => '' !== $branch)); + } + /** * Maps a PHP type string (potentially a union) to an array of JSON Schema type names. * @@ -664,12 +730,28 @@ final class SchemaGenerator implements SchemaGeneratorInterface { $normalizedType = strtolower(trim($phpTypeString)); - // PRIORITY 1: Check for array{} syntax which should be treated as object + // PRIORITY 1: Handle unions before anything else. A `|` at the top + // level joins alternatives; one inside `<>` or `{}` belongs to a + // generic (`array`) and is not a union of the whole type. + // Checked first because a branch may itself be an array shape — and + // `string[]|int` read as "an array" silently loses the `int`. + $branches = self::splitUnion($normalizedType); + + if (\count($branches) > 1) { + $jsonTypes = []; + foreach ($branches as $branch) { + $jsonTypes = array_merge($jsonTypes, $this->mapPhpTypeToJsonSchemaType($branch)); + } + + return array_values(array_unique($jsonTypes)); + } + + // PRIORITY 2: Check for array{} syntax which should be treated as object if (preg_match('/^array\s*{/i', $normalizedType)) { return ['object']; } - // PRIORITY 2: Check for array syntax first (T[] or generics) + // PRIORITY 3: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') || preg_match('/^(array|list|iterable|collection)]+\s*>$/i', $normalizedType)) { return ['integer']; } - // PRIORITY 4: Handle unions (recursive) - if (str_contains($normalizedType, '|')) { - $types = explode('|', $normalizedType); - $jsonTypes = []; - foreach ($types as $type) { - $mapped = $this->mapPhpTypeToJsonSchemaType(trim($type)); - $jsonTypes = array_merge($jsonTypes, $mapped); - } - - return array_values(array_unique($jsonTypes)); - } - // PRIORITY 5: Handle simple built-in types return match ($normalizedType) { 'string', 'scalar' => ['string'], diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php new file mode 100644 index 0000000..9a8a6cf --- /dev/null +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php @@ -0,0 +1,130 @@ +}> + */ + public static function unions(): iterable + { + yield 'not a union' => ['string', ['string']]; + yield 'simple union' => ['int|string', ['int', 'string']]; + yield 'three branches' => ['int|string|bool', ['int', 'string', 'bool']]; + yield 'array branch alongside a scalar' => ['string[]|int', ['string[]', 'int']]; + yield 'generic branch alongside a scalar' => ['array|int', ['array', 'int']]; + + // The `|` here parameterises the outer type; splitting it would turn + // one type into two that do not exist. + yield 'union inside a generic' => ['array', ['array']]; + yield 'union inside a shape' => ['array{a: int|string}', ['array{a: int|string}']]; + yield 'nested generics' => ['array>', ['array>']]; + yield 'shape beside a scalar' => ['array{a: int|string}|null', ['array{a: int|string}', 'null']]; + + yield 'whitespace is trimmed' => ['int | string', ['int', 'string']]; + yield 'empty branches are dropped' => ['int||string', ['int', 'string']]; + } + + /** + * @param list $expected + */ + #[DataProvider('unions')] + #[TestDox('a type string splits on its top-level union only')] + public function testSplitUnion(string $type, array $expected): void + { + $this->assertSame($expected, SchemaGenerator::splitUnion($type)); + } + + /** + * @return iterable}> + */ + public static function mappedTypes(): iterable + { + yield 'scalar union' => ['int|string', ['integer', 'string']]; + + // The regression: the `[]` check ran before the union split, so this + // came back as ['array'] and the `int` branch vanished. + yield 'array beside a scalar keeps both' => ['string[]|int', ['array', 'integer']]; + yield 'scalar before an array keeps both' => ['int|string[]', ['integer', 'array']]; + yield 'generic beside a scalar keeps both' => ['array|bool', ['array', 'boolean']]; + + yield 'a generic union is still one array' => ['array', ['array']]; + yield 'a shape is still one object' => ['array{a: int|string}', ['object']]; + } + + /** + * @param list $expected + */ + #[DataProvider('mappedTypes')] + #[TestDox('a union maps to every JSON Schema type its branches need')] + public function testMappedTypes(string $type, array $expected): void + { + $generator = new SchemaGenerator(new DocBlockParser()); + + $map = new \ReflectionMethod($generator, 'mapPhpTypeToJsonSchemaType'); + + $this->assertSame($expected, $map->invoke($generator, $type)); + } + + #[TestDox('a generated schema keeps both branches of an array-or-scalar union')] + public function testGeneratedUnionSchemaKeepsBothBranches(): void + { + $schema = (new SchemaGenerator(new DocBlockParser())) + ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); + + // `items` constrains the instance only when it *is* an array, so it + // coexists with the scalar branch instead of excluding it. + $this->assertSame(['array', 'integer'], $schema['properties']['mixedish']['type']); + // The array branch keeps its own element-type constraint rather than + // losing it to the union as a whole (`items` would otherwise be `{}`). + $this->assertSame(['type' => 'string'], $schema['properties']['mixedish']['items']); + + $this->assertSame(['integer', 'string'], $schema['properties']['scalars']['type']); + $this->assertSame('array', $schema['properties']['arrayOnly']['type']); + $this->assertSame(['array', 'null'], $schema['properties']['nullableArray']['type']); + + // Two array branches with different element types can't both be + // enforced without rejecting a value that is valid under one of + // them, so neither constraint is applied (falls back to the "matches + // anything" empty schema `ensureArrayItems()` defaults every array to). + $this->assertSame('array', $schema['properties']['disagreeingArrays']['type']); + $this->assertEquals(new \stdClass(), $schema['properties']['disagreeingArrays']['items']); + } + + #[TestDox('a value from either branch validates against the generated schema')] + public function testBothBranchesValidate(): void + { + $schema = (new SchemaGenerator(new DocBlockParser())) + ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); + + $validator = new SchemaValidator(); + $base = ['scalars' => 1, 'arrayOnly' => ['a'], 'nullableArray' => null, 'disagreeingArrays' => []]; + + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => ['a', 'b']], $schema)); + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42], $schema)); + + // A float is in neither branch, so it is still refused. + $this->assertNotSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 1.5], $schema)); + + // Neither element type is enforced, so both branches' values pass. + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => ['a', 'b']], $schema)); + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => [1, 2]], $schema)); + } +} diff --git a/tests/Unit/Capability/Discovery/UnionFixture.php b/tests/Unit/Capability/Discovery/UnionFixture.php new file mode 100644 index 0000000..b6d400d --- /dev/null +++ b/tests/Unit/Capability/Discovery/UnionFixture.php @@ -0,0 +1,27 @@ + Date: Tue, 18 Aug 2026 14:59:12 +0200 Subject: [PATCH 091/143] [Schema] Serialize a settingless extension as an object (#438) * [Schema] Serialize a settingless extension as an object An extension declaring support with no settings was advertised as `[]`, an empty JSON array, which the wire schema rejects - `extensions` maps an id to a settings *object*. Both ServerCapabilities and ClientCapabilities now cast each entry, so it goes out as `{}`. * Apply suggestion from @chr-hertel --- src/Schema/ClientCapabilities.php | 5 ++++- src/Schema/ServerCapabilities.php | 7 ++++++- .../Extension/CapabilitiesExtensionsTest.php | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index bc71cfc..75d50ff 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -188,7 +188,10 @@ class ClientCapabilities implements \JsonSerializable } if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; + $data['extensions'] = (object) array_map( + static fn (mixed $settings): mixed => \is_array($settings) ? (object) $settings : $settings, + $this->extensions, + ); } return $data ?: new \stdClass(); diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 0e47c61..33cc1ee 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -189,7 +189,12 @@ class ServerCapabilities implements \JsonSerializable } if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; + // Each entry is a settings *object*; an extension with no settings + // declares `{}`, and an empty PHP array would serialize as `[]`. + $data['extensions'] = (object) array_map( + static fn (mixed $settings): mixed => \is_array($settings) ? (object) $settings : $settings, + $this->extensions, + ); } return $data; diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php index 7ef916e..dbb7e7e 100644 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -57,6 +57,24 @@ class CapabilitiesExtensionsTest extends TestCase $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); } + public function testServerCapabilitiesSerializeSettinglessExtensionAsObject(): void + { + $caps = new ServerCapabilities(tools: true, extensions: ['acme/thing' => []]); + + $json = json_encode($caps->jsonSerialize(), \JSON_UNESCAPED_SLASHES); + + $this->assertStringContainsString('"acme/thing":{}', (string) $json); + } + + public function testClientCapabilitiesSerializeSettinglessExtensionAsObject(): void + { + $caps = new ClientCapabilities(elicitation: true, extensions: ['acme/thing' => []]); + + $json = json_encode($caps->jsonSerialize(), \JSON_UNESCAPED_SLASHES); + + $this->assertStringContainsString('"acme/thing":{}', (string) $json); + } + public function testServerCapabilitiesJsonSerializeWithoutExtensions(): void { $caps = new ServerCapabilities(tools: true); From cfe5e8f570af67a4147fcfb4bdcc5835842193da Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 15:09:58 +0200 Subject: [PATCH 092/143] [Capability] Apply a parameter's complete schema definition (#435) * [Capability] Apply a parameter's complete schema definition Schema(definition:) is documented as taking precedence over the individual keywords, and generate() unwraps it that way at method level. At parameter level it was merged as an ordinary key instead, in both the plain and the variadic path, so the property reached clients carrying a literal "definition" member - not JSON Schema, read by nothing, and everything inside it lost. Both paths now replace the schema with the definition. The variadic one still forces type: array afterwards, since a variadic arrives as an array however the definition describes it, and the signature default survives a definition that states none - it is what the handler receives when the argument is omitted, and default is an annotation rather than a constraint. * Fix docblock description overwriting definition on variadic params * Apply suggestion from @chr-hertel --- src/Capability/Discovery/SchemaGenerator.php | 33 +++++++++--- .../Discovery/SchemaGeneratorFixture.php | 45 ++++++++++++++++ .../Discovery/SchemaGeneratorTest.php | 52 +++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 3d9f8aa..673477d 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -237,7 +237,17 @@ final class SchemaGenerator implements SchemaGeneratorInterface // Parameter-level takes highest precedence $parameterLevelSchema = $paramInfo['parameter_schema']; if (!empty($parameterLevelSchema)) { - $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); + // A complete definition replaces the schema, as it does at method level. + if (isset($parameterLevelSchema['definition']) && \is_array($parameterLevelSchema['definition'])) { + $mergedSchema = $parameterLevelSchema['definition']; + + // The default comes from the signature, not from the schema. + if (!\array_key_exists('default', $mergedSchema) && \array_key_exists('default', $inferredSchema)) { + $mergedSchema['default'] = $inferredSchema['default']; + } + } else { + $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); + } } // Run after all merges so that when a Schema attribute reshapes the parameter @@ -324,17 +334,24 @@ final class SchemaGenerator implements SchemaGeneratorInterface { $paramSchema = ['type' => 'array']; - // Apply parameter-level Schema attributes first - if (!empty($paramInfo['parameter_schema'])) { - $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']); - // Ensure type is always array for variadic - $paramSchema['type'] = 'array'; - } - + // Add description from docblock before parameter-level schema is merged in, + // so a definition's own description takes precedence (matches buildParameterSchema). if ($paramInfo['description']) { $paramSchema['description'] = $paramInfo['description']; } + // Apply parameter-level Schema attributes first + if (!empty($paramInfo['parameter_schema'])) { + $parameterLevelSchema = $paramInfo['parameter_schema']; + + $paramSchema = isset($parameterLevelSchema['definition']) && \is_array($parameterLevelSchema['definition']) + ? $parameterLevelSchema['definition'] + : array_merge($paramSchema, $parameterLevelSchema); + + // Ensure type is always array for variadic + $paramSchema['type'] = 'array'; + } + // If no items specified by Schema attribute, infer from type if (!isset($paramSchema['items'])) { $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 548c5d9..14f2d8f 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -130,6 +130,30 @@ class SchemaGeneratorFixture // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== + /** + * Parameter-level Schema with complete definition. + */ + public function parameterLevelCompleteDefinition( + #[Schema(definition: [ + 'type' => 'string', + 'description' => 'The region to query.', + 'x-mcp-header' => 'Region', + ])] + string $region = 'eu', + #[Schema(definition: ['type' => 'integer', 'minimum' => 1, 'maximum' => 10])] + int $limit = 3, + ): void { + } + + /** + * Parameter-level definition that reshapes the parameter's type. + */ + public function definitionReshapingTheParameter( + #[Schema(definition: ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']]])] + string $payload = '{}', + ): void { + } + /** * Parameter-level Schema attributes only. */ @@ -326,6 +350,27 @@ class SchemaGeneratorFixture // ===== VARIADIC SCENARIOS ===== + /** + * Variadic parameter with a complete definition. + */ + public function variadicCompleteDefinition( + #[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])] + string ...$tags, + ): void { + } + + /** + * Variadic parameter with both a docblock description and a complete definition; + * the definition's own description must win. + * + * @param string ...$tags Docblock description that must lose to the definition's own + */ + public function variadicCompleteDefinitionWithDocblockDescription( + #[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])] + string ...$tags, + ): void { + } + /** * Variadic parameter scenarios. * diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index b5dbb09..51d73d2 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -92,6 +92,58 @@ final class SchemaGeneratorTest extends TestCase ], $schema); } + public function testUsesCompleteSchemaDefinitionFromParameterLevelSchemaAttribute(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals([ + 'type' => 'string', + 'description' => 'The region to query.', + 'x-mcp-header' => 'Region', + 'default' => 'eu', + ], $schema['properties']['region']); + + $this->assertEquals([ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'default' => 3, + ], $schema['properties']['limit']); + + $this->assertArrayNotHasKey('required', $schema); + } + + public function testUsesCompleteSchemaDefinitionFromVariadicParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + // The array type is forced: a variadic always arrives as one. + $this->assertEquals([ + 'type' => 'array', + 'description' => 'Tags to apply.', + 'items' => ['type' => 'string'], + ], $schema['properties']['tags']); + } + + public function testDefinitionDescriptionWinsOverDocblockOnVariadicParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicCompleteDefinitionWithDocblockDescription'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertSame('Tags to apply.', $schema['properties']['tags']['description']); + } + + public function testKeepsTheSignatureDefaultWhenADefinitionReshapesTheParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'definitionReshapingTheParameter'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertSame('{}', $schema['properties']['payload']['default']); + $this->assertSame('object', $schema['properties']['payload']['type']); + } + public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); From 5041caf77a3f8c01434cfabafec60c5ade0b7788 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 15:24:19 +0200 Subject: [PATCH 093/143] [Schema] Omit the id of an error response that never had one (#442) * [Schema] Omit the id of an error response that never had one An error response whose id could not be read sent `"id": ""`, which claims the peer issued a request with an empty-string id - a different statement from "the id could not be read". JSON-RPC spells the latter as a null id, and a receiver that cannot read the id is exactly the case the code covers. [BC Break] Error::$id accepts null and getId() may return it. All the for*() factories default to null, fromArray() accepts a missing or explicitly-null id, and the member is omitted from the serialized form when absent. * Guard null response id in Client/Server Protocol handlers Also fix Error::jsonSerialize()'s phpdoc: data belongs inside error, not beside it. --- CHANGELOG.md | 1 + src/Client/Protocol.php | 6 +++ src/Schema/JsonRpc/Error.php | 63 +++++++++++++---------- src/Server/Protocol.php | 6 +++ tests/Unit/Client/ProtocolTest.php | 13 +++++ tests/Unit/JsonRpc/MessageFactoryTest.php | 20 +++++-- tests/Unit/Server/ProtocolTest.php | 24 +++++++++ 7 files changed, 103 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c02faf3..8c2b058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index e9eabde..47acde7 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -281,6 +281,12 @@ class Protocol { $requestId = $response->getId(); + if (null === $requestId) { + $this->logger->warning('Received an id-less error response; cannot correlate it to a request.', ['response' => $response->jsonSerialize()]); + + return; + } + $this->logger->debug('Handling response', ['id' => $requestId]); $this->state->storeResponse($requestId, $response->jsonSerialize()); diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 683ed0d..4f95f72 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -57,12 +57,15 @@ class Error implements MessageInterface public const UNSUPPORTED_PROTOCOL_VERSION = -32022; /** - * @param int $code the error type that occurred - * @param string $message a short description of the error - * @param mixed|null $data additional information about the error + * @param string|int|null $id The id of the request this answers. `null` only when it could not be + * read — a malformed body, or a notification that was refused — in which + * case the member is omitted rather than sent as an id nobody issued. + * @param int $code the error type that occurred + * @param string $message a short description of the error + * @param mixed|null $data additional information about the error */ public function __construct( - public readonly string|int $id, + public readonly string|int|null $id, public readonly int $code, public readonly string $message, public readonly mixed $data = null, @@ -77,10 +80,9 @@ class Error implements MessageInterface if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) { throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); } - if (!isset($data['id'])) { - throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); - } - if (!\is_string($data['id']) && !\is_int($data['id'])) { + // An error response carrying no id is well-formed: it is what a + // receiver sends when the id could not be read off the request. + if (isset($data['id']) && !\is_string($data['id']) && !\is_int($data['id'])) { throw new InvalidArgumentException('Invalid "id" type in Error data.'); } if (!isset($data['error']) || !\is_array($data['error'])) { @@ -93,45 +95,45 @@ class Error implements MessageInterface throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); } - return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); + return new self($data['id'] ?? null, $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); } - final public static function forParseError(string $message, string|int $id = ''): self + final public static function forParseError(string $message, string|int|null $id = null): self { return new self($id, self::PARSE_ERROR, $message); } - final public static function forInvalidRequest(string $message, string|int $id = ''): self + final public static function forInvalidRequest(string $message, string|int|null $id = null): self { return new self($id, self::INVALID_REQUEST, $message); } - final public static function forMethodNotFound(string $message, string|int $id = ''): self + final public static function forMethodNotFound(string $message, string|int|null $id = null): self { return new self($id, self::METHOD_NOT_FOUND, $message); } - final public static function forInvalidParams(string $message, string|int $id = '', mixed $data = null): self + final public static function forInvalidParams(string $message, string|int|null $id = null, mixed $data = null): self { return new self($id, self::INVALID_PARAMS, $message, $data); } - final public static function forInternalError(string $message, string|int $id = ''): self + final public static function forInternalError(string $message, string|int|null $id = null): self { return new self($id, self::INTERNAL_ERROR, $message); } - final public static function forServerError(string $message, string|int $id = ''): self + final public static function forServerError(string $message, string|int|null $id = null): self { return new self($id, self::SERVER_ERROR, $message); } - final public static function forResourceNotFound(string $message, string|int $id = ''): self + final public static function forResourceNotFound(string $message, string|int|null $id = null): self { return new self($id, self::RESOURCE_NOT_FOUND, $message); } - final public static function forHeaderMismatch(string $message, string|int $id = ''): self + final public static function forHeaderMismatch(string $message, string|int|null $id = null): self { return new self($id, self::HEADER_MISMATCH, $message); } @@ -142,7 +144,7 @@ class Error implements MessageInterface final public static function forMissingRequiredClientCapability( string $message, ClientCapabilities $requiredCapabilities, - string|int $id = '', + string|int|null $id = null, ): self { return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [ 'requiredCapabilities' => $requiredCapabilities, @@ -159,7 +161,7 @@ class Error implements MessageInterface final public static function forUnsupportedProtocolVersion( string $requested, array $supported, - string|int $id = '', + string|int|null $id = null, ): self { return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [ 'requested' => $requested, @@ -167,7 +169,7 @@ class Error implements MessageInterface ]); } - public function getId(): string|int + public function getId(): string|int|null { return $this->id; } @@ -175,12 +177,12 @@ class Error implements MessageInterface /** * @return array{ * jsonrpc: string, - * id: string|int, + * id?: string|int, * error: array{ * code: int, * message: string, + * data?: mixed, * }, - * data?: mixed, * } */ public function jsonSerialize(): array @@ -194,10 +196,17 @@ class Error implements MessageInterface $error['data'] = $this->data; } - return [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $this->id, - 'error' => $error, - ]; + $data = ['jsonrpc' => MessageInterface::JSONRPC_VERSION]; + + // Omitted, not empty: `"id": ""` claims the sender issued a request + // with an empty-string id, which is a different statement from "the + // id could not be read". + if (null !== $this->id) { + $data['id'] = $this->id; + } + + $data['error'] = $error; + + return $data; } } diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 5a4e358..f9f79c3 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -341,6 +341,12 @@ class Protocol $messageId = $response->getId(); + if (null === $messageId) { + $this->logger->warning('Received an id-less error response from client; cannot correlate it to a pending request.', ['response' => $response->jsonSerialize()]); + + return; + } + $session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize()); $session->forget(self::SESSION_ACTIVE_REQUEST_META); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 7545f73..722df9f 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -99,6 +99,19 @@ final class ProtocolTest extends TestCase yield 'modern revision' => [ProtocolVersion::V2026_07_28->value]; } + #[TestDox('logs and ignores an id-less error response instead of crashing on it')] + public function testIgnoresIdLessErrorResponse(): void + { + $protocol = new Protocol(logger: $logger = new CollectingLogger()); + + $protocol->processMessage(json_encode([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'error' => ['code' => -32700, 'message' => 'Parse error'], + ], \JSON_THROW_ON_ERROR)); + + $this->assertCount(1, $logger->warnings); + } + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration { return new Configuration( diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 441a500..fe2c0d2 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -278,13 +278,15 @@ final class MessageFactoryTest extends TestCase public function testErrorMissingId(): void { + // Well-formed: an error response leaves the member out when the id + // could not be read off the request it answers. $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; $results = $this->factory->create($json); $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('id', $results[0]->getMessage()); + $this->assertInstanceOf(Error::class, $results[0]); + $this->assertNull($results[0]->getId()); } public function testErrorMissingCode(): void @@ -365,9 +367,21 @@ final class MessageFactoryTest extends TestCase $this->assertStringContainsString('id', $results[0]->getMessage()); } + public function testErrorWithNullId(): void + { + // JSON-RPC 2.0 spells the same thing as an explicit null. + $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(Error::class, $results[0]); + $this->assertNull($results[0]->getId()); + } + public function testErrorWithInvalidIdType(): void { - $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + $json = '{"jsonrpc": "2.0", "id": {"not": "an id"}, "error": {"code": -32600, "message": "Invalid"}}'; $results = $this->factory->create($json); diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index b5f836e..4b64b4a 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -82,6 +82,30 @@ final class ProtocolTest extends TestCase ); } + #[TestDox('An id-less error response from the client is logged and ignored, not stored under a collapsed session key')] + public function testIdLessErrorResponseIsIgnored(): void + { + $session = $this->createMock(SessionInterface::class); + $session->expects($this->never())->method('set'); + + $this->sessionManager->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}}', + $sessionId + ); + } + #[TestDox('A single request is handled only by the first matching handler')] public function testRequestHandledByFirstMatchingHandler(): void { From 19614144320ef44ada50b64b5d0b3ac295d9eee9 Mon Sep 17 00:00:00 2001 From: Valeriu Gutu <60061959+valeriudev@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:47:27 +0300 Subject: [PATCH 094/143] [Schema][Server] Preserve request id in JSON-RPC error responses instead of fabricating id:"" (#379) * Preserve request id in JSON-RPC error responses instead of fabricating id:"" When the server received input it could not turn into a valid message, the error response carried a fabricated empty-string id that was never in the request, breaking JSON-RPC client correlation. The id default for the error factories was '' and that default reached the wire unchanged: unrecoverable parse errors emitted id:"", and for invalid- but-parseable messages MessageFactory discarded the decoded id so it also fell back to "". Now unrecoverable parse errors (-32700) return id: null per JSON-RPC 2.0, and invalid-but-parseable requests (-32600) preserve the original request id. Error::$id / getId() and the $id parameter of forParseError() / forInvalidRequest() are widened to string|int|null, and the recoverable id is threaded through InvalidInputMessageException. Fixes #333 * Preserve nullable request id consistently across Error factories and fromArray - Widen the remaining for*() factories (forMethodNotFound, forInvalidParams, forInternalError, forServerError, forResourceNotFound) to string|int|null defaulting to null, so no error path can fabricate id:"" again. The only defaulting call site (ProtocolVersionMiddleware) now emits id:null instead of id:"" for a pre-parse rejection, which is the spec-correct value. - Accept a null id in Error::fromArray(): require the id key to be present (JSON-RPC responses must carry it) but allow its value to be null, so a spec-compliant id:null error response round-trips and incoming error responses with id:null are accepted instead of rejected. - Widen the @phpstan-type ErrorData id to string|int|null to match the ctor, getId(), and jsonSerialize() shape. * Document intentional id recovery guard in MessageFactory * Cover id: 0 and string ids in recoverable-id preservation test * Fixed client handling of null-id error responses --- CHANGELOG.md | 1 + .../InvalidInputMessageException.php | 13 +++ src/JsonRpc/MessageFactory.php | 5 + src/Schema/JsonRpc/Error.php | 2 +- src/Server/Protocol.php | 2 +- tests/Unit/Client/ProtocolTest.php | 14 +++ tests/Unit/Server/ProtocolTest.php | 95 +++++++++++++++++++ 7 files changed, 130 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2b058..fb7f8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. +* Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/Exception/InvalidInputMessageException.php b/src/Exception/InvalidInputMessageException.php index 4ab485a..0f96122 100644 --- a/src/Exception/InvalidInputMessageException.php +++ b/src/Exception/InvalidInputMessageException.php @@ -16,4 +16,17 @@ namespace Mcp\Exception; */ class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface { + private string|int|null $requestId = null; + + public function getRequestId(): string|int|null + { + return $this->requestId; + } + + public function setRequestId(string|int|null $requestId): self + { + $this->requestId = $requestId; + + return $this; + } } diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index d9a895e..806eea5 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -148,6 +148,11 @@ final class MessageFactory $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { + // Recover the id only when it's a valid JSON-RPC scalar; + // a null or malformed id is left at the exception's null default. + if (\is_array($message) && isset($message['id']) && (\is_string($message['id']) || \is_int($message['id']))) { + $e->setRequestId($message['id']); + } $messages[] = $e; } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 4f95f72..7749fa8 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -20,7 +20,7 @@ use Mcp\Schema\Enum\ProtocolVersion; * * @phpstan-type ErrorData array{ * jsonrpc: string, - * id: string|int, + * id: string|int|null, * code: int, * message: string, * data?: mixed, diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index f9f79c3..a6e9f1a 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -228,7 +228,7 @@ class Protocol { $this->logger->warning('Failed to create message.', ['exception' => $exception]); - $error = Error::forInvalidRequest($exception->getMessage()); + $error = Error::forInvalidRequest($exception->getMessage(), $exception->getRequestId()); $this->sendResponse($transport, $error, $session); } diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 722df9f..4ac40fd 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -112,6 +112,20 @@ final class ProtocolTest extends TestCase $this->assertCount(1, $logger->warnings); } + #[TestDox('stores an error response under its id so the pending request can be correlated')] + public function testErrorResponseWithIdIsStoredForItsPendingRequest(): void + { + $protocol = new Protocol(); + + $protocol->processMessage('{"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found"}}'); + + $response = $protocol->getState()->consumeResponse(7); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(7, $response->getId()); + $this->assertSame(Error::METHOD_NOT_FOUND, $response->code); + } + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration { return new Configuration( diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 4b64b4a..997c39d 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -29,6 +29,7 @@ use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use Mcp\Tests\Unit\Fixtures\ThrowingRequest; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -323,6 +324,40 @@ final class ProtocolTest extends TestCase ); } + #[TestDox('Unrecoverable parse error does not fabricate an empty-string id')] + public function testParseErrorDoesNotFabricateEmptyStringId(): void + { + $sentPayload = null; + $this->transport->expects($this->once()) + ->method('send') + ->willReturnCallback(static function ($data) use (&$sentPayload) { + $sentPayload = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + // Well-formed JSON nested past PHP's json_decode() depth limit (512), mirroring + // issue #333: json_decode() throws "Maximum stack depth exceeded" so the request + // carries a real numeric id (900512) that cannot be recovered once decoding fails. + $deeplyNested = str_repeat('[', 600).str_repeat(']', 600); + $input = '{"jsonrpc":"2.0","id":900512,"method":"initialize","params":'.$deeplyNested.'}'; + + $protocol->processInput($this->transport, $input, null); + + $this->assertNotNull($sentPayload); + $decoded = json_decode($sentPayload, true); + $this->assertSame(Error::PARSE_ERROR, $decoded['error']['code']); + // The original id is genuinely unrecoverable after a parse failure: it must never be + // fabricated as an empty string, and — per the MCP `RequestId` schema, which never + // allows `null` — the key must be omitted rather than sent as `id: null`. + $this->assertArrayNotHasKey('id', $decoded, 'Unrecoverable parse error must omit id, not fabricate one'); + } + #[TestDox('Invalid message structure returns error')] public function testInvalidMessageStructureReturnsError(): void { @@ -530,6 +565,66 @@ final class ProtocolTest extends TestCase $this->assertSame([], $protocol->consumeOutgoingMessages($sessionId)); } + /** + * @return iterable + */ + public static function recoverableIdProvider(): iterable + { + yield 'positive int' => ['{"jsonrpc": "2.0", "id": 42, "params": {}}', 42]; + yield 'zero int (truthiness trap)' => ['{"jsonrpc": "2.0", "id": 0, "params": {}}', 0]; + yield 'string id' => ['{"jsonrpc": "2.0", "id": "req-1", "params": {}}', 'req-1']; + } + + #[DataProvider('recoverableIdProvider')] + #[TestDox('Invalid but parseable message preserves its recoverable id')] + public function testInvalidMessagePreservesRecoverableId(string $input, string|int $expectedId): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + // Configure session mock for queue operations (mirrors testInvalidMessageStructureReturnsError). + $queue = []; + $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } + + return $default; + }); + + $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $sessionId = Uuid::v4(); + // Valid JSON carrying a real id but missing method/result/error: the message is + // structurally invalid, yet its id IS recoverable from the decoded payload. + $protocol->processInput( + $this->transport, + $input, + $sessionId + ); + + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); + $this->assertSame($expectedId, $message['id'], 'Invalid-but-parseable message must preserve its recoverable id, not return ""'); + } + #[TestDox('Request without handler returns method not found error')] public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void { From cf2c4d4ead21e60d6ddaed6439f51af32a59516a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 19:49:15 +0200 Subject: [PATCH 095/143] [Schema] Stop rejecting elicitation schemas the specification allows (#437) * [Schema] Stop rejecting elicitation schemas the specification allows The specification makes `title` optional on every elicitation schema definition. AbstractSchemaDefinition required it, so a client reading a conformant server's elicitation/create refused the request - and a server building one could not omit it either. `title` is now nullable on all seven definitions, omitted from the serialized form when absent, and rejected only when present and not a string. * Fix stale title docblocks; allow omitting title where safe * Give Number's title param a default too * Apply suggestions from code review Co-authored-by: Christopher Hertel --- .../Elicitation/AbstractSchemaDefinition.php | 19 ++++++++-------- .../Elicitation/BooleanSchemaDefinition.php | 10 ++++----- .../Elicitation/EnumSchemaDefinition.php | 22 ++++++++++--------- .../MultiSelectEnumSchemaDefinition.php | 8 +++---- .../Elicitation/NumberSchemaDefinition.php | 10 ++++----- .../Elicitation/StringSchemaDefinition.php | 10 ++++----- .../TitledEnumSchemaDefinition.php | 8 +++---- .../TitledMultiSelectEnumSchemaDefinition.php | 8 +++---- .../BooleanSchemaDefinitionTest.php | 16 +++++++++++--- .../Elicitation/EnumSchemaDefinitionTest.php | 16 +++++++++++--- .../MultiSelectEnumSchemaDefinitionTest.php | 17 ++++++++++++-- .../NumberSchemaDefinitionTest.php | 16 +++++++++++--- .../StringSchemaDefinitionTest.php | 16 +++++++++++--- .../TitledEnumSchemaDefinitionTest.php | 16 +++++++++++--- ...ledMultiSelectEnumSchemaDefinitionTest.php | 17 ++++++++++++-- 15 files changed, 144 insertions(+), 65 deletions(-) diff --git a/src/Schema/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php index 63fdc21..a05135f 100644 --- a/src/Schema/Elicitation/AbstractSchemaDefinition.php +++ b/src/Schema/Elicitation/AbstractSchemaDefinition.php @@ -21,13 +21,13 @@ use Mcp\Exception\InvalidArgumentException; abstract class AbstractSchemaDefinition implements \JsonSerializable { public function __construct( - public readonly string $title, + public readonly ?string $title = null, public readonly ?string $description = null, ) { } /** - * Validate that title exists and is a string in the data array. + * Reject a title that is present but not a string. * * @param array $data * @@ -35,22 +35,23 @@ abstract class AbstractSchemaDefinition implements \JsonSerializable */ protected static function validateTitle(array $data, string $schemaType): void { - if (!isset($data['title']) || !\is_string($data['title'])) { - throw new InvalidArgumentException(\sprintf('Missing or invalid "title" for %s schema definition.', $schemaType)); + if (\array_key_exists('title', $data) && null !== $data['title'] && !\is_string($data['title'])) { + throw new InvalidArgumentException(\sprintf('Invalid "title" for %s schema definition.', $schemaType)); } } /** - * Build the base JSON structure with type, title, and optional description. + * Build the base JSON structure with type, optional title and description. * * @return array */ protected function buildBaseJson(string $type): array { - $data = [ - 'type' => $type, - 'title' => $this->title, - ]; + $data = ['type' => $type]; + + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php index 9cfaa8a..3976685 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -19,12 +19,12 @@ namespace Mcp\Schema\Elicitation; final class BooleanSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param bool|null $default Optional default value */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?bool $default = null, ) { @@ -33,7 +33,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: bool, * } $data @@ -43,7 +43,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition self::validateTitle($data, 'boolean'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: isset($data['default']) ? (bool) $data['default'] : null, ); @@ -52,7 +52,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: bool, * } diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 003bf35..1eaef8b 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -23,14 +23,14 @@ use Mcp\Exception\InvalidArgumentException; final class EnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must be in enum) * @param string[]|null $enumNames Optional human-readable labels for each enum value */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -75,7 +75,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -86,7 +86,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -95,11 +95,13 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition */ public function jsonSerialize(): array { - $data = [ - 'type' => 'string', - 'title' => $this->title, - 'enum' => $this->enum, - ]; + $data = ['type' => 'string']; + + if (null !== $this->title) { + $data['title'] = $this->title; + } + + $data['enum'] = $this->enum; if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php index 28046bc..ce2ab08 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of enum) @@ -31,7 +31,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?array $default = null, @@ -73,7 +73,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * items: array{type: string, enum: string[]}, * description?: string, * default?: string[], @@ -90,7 +90,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['items']['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php index 0ad3277..d2bf540 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class NumberSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param bool $integerOnly Whether to restrict to integer values only * @param string|null $description Optional description/help text * @param int|float|null $default Optional default value @@ -31,7 +31,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition * @param int|float|null $maximum Optional maximum value (inclusive) */ public function __construct( - string $title, + ?string $title = null, public readonly bool $integerOnly = false, ?string $description = null, public readonly int|float|null $default = null, @@ -60,7 +60,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, @@ -75,7 +75,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition $integerOnly = 'integer' === $type; return new self( - title: $data['title'], + title: $data['title'] ?? null, integerOnly: $integerOnly, description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -87,7 +87,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php index 7631926..36c5e44 100644 --- a/src/Schema/Elicitation/StringSchemaDefinition.php +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -25,7 +25,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition private const VALID_FORMATS = ['date', 'date-time', 'email', 'uri']; /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param string|null $default Optional default value * @param string|null $format Optional format constraint (date, date-time, email, uri) @@ -33,7 +33,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxLength Optional maximum string length */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?string $default = null, public readonly ?string $format = null, @@ -61,7 +61,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, @@ -74,7 +74,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition self::validateTitle($data, 'string'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: $data['default'] ?? null, format: $data['format'] ?? null, @@ -86,7 +86,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php index 54eb568..f6c47d4 100644 --- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -24,13 +24,13 @@ use Mcp\Exception\InvalidArgumentException; final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $oneOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must match a const) */ public function __construct( - string $title, + ?string $title, public readonly array $oneOf, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * oneOf: list, * description?: string, * default?: string, @@ -74,7 +74,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, oneOf: $data['oneOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php index baab9b0..ee49a05 100644 --- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $anyOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) @@ -31,7 +31,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $anyOf, ?string $description = null, public readonly ?array $default = null, @@ -78,7 +78,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti /** * @param array{ - * title: string, + * title?: string, * items: array{anyOf: list}, * description?: string, * default?: string[], @@ -95,7 +95,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti } return new self( - title: $data['title'], + title: $data['title'] ?? null, anyOf: $data['items']['anyOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php index fefea47..a9e1173 100644 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -71,13 +71,23 @@ final class BooleanSchemaDefinitionTest extends TestCase $this->assertTrue($schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = BooleanSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for boolean schema definition.'); /* @phpstan-ignore argument.type */ - BooleanSchemaDefinition::fromArray([]); + BooleanSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php index 1bd55cc..ea200a8 100644 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -114,13 +114,23 @@ final class EnumSchemaDefinitionTest extends TestCase $this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for enum schema definition.'); /* @phpstan-ignore argument.type */ - EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + EnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingEnum(): void diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php index 167ad08..208f5d2 100644 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -137,13 +137,26 @@ final class MultiSelectEnumSchemaDefinitionTest extends TestCase $this->assertSame(3, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['type' => 'string', 'enum' => ['a']], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['type' => 'string', 'enum' => ['a']], ]); } diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php index 8ebdee7..ea04038 100644 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -133,13 +133,23 @@ final class NumberSchemaDefinitionTest extends TestCase $this->assertSame(10, $schema->maximum); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = NumberSchemaDefinition::fromArray(['type' => 'integer']); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for number schema definition.'); /* @phpstan-ignore argument.type */ - NumberSchemaDefinition::fromArray(['type' => 'integer']); + NumberSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeAsInteger(): void diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php index dbb3277..02998cc 100644 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -114,13 +114,23 @@ final class StringSchemaDefinitionTest extends TestCase $this->assertSame(100, $schema->maxLength); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = StringSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for string schema definition.'); /* @phpstan-ignore argument.type */ - StringSchemaDefinition::fromArray([]); + StringSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php index f8c706f..2650283 100644 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -124,13 +124,23 @@ final class TitledEnumSchemaDefinitionTest extends TestCase $this->assertSame('b', $schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled enum schema definition.'); /* @phpstan-ignore argument.type */ - TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + TitledEnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingOneOf(): void diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php index 6c1b776..170893f 100644 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -166,13 +166,26 @@ final class TitledMultiSelectEnumSchemaDefinitionTest extends TestCase $this->assertSame(2, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], ]); } From 605df8771fb49d0b17e6ca107a82fb150aa33846 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 20:16:35 +0200 Subject: [PATCH 096/143] [Schema] Refuse an invalid x-mcp-header annotation at definition time (#441) * [Schema] Refuse an invalid x-mcp-header annotation at definition time SEP-2243 calls a tool definition invalid when its x-mcp-header annotation is empty, is not an HTTP field name, collides case-insensitively with another, or sits on a number, array or object property - none of which can be mirrored onto a header. Nothing said so. The earliest place to is where the tool is defined, rather than on the request that trips over it. * Clarify x-mcp-header validation only reaches properties * Make checkHeaderAnnotations() a private instance method * Make headerAnnotations() a private instance method * Apply suggestion from @chr-hertel --- src/Schema/Tool.php | 91 ++++++++++ .../Unit/Schema/ToolHeaderAnnotationTest.php | 160 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 tests/Unit/Schema/ToolHeaderAnnotationTest.php diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index b5aee7e..4fd87f4 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -125,6 +125,97 @@ class Tool implements \JsonSerializable // sub-schemas — not only SchemaGenerator / fromArray. $this->inputSchema = self::normalizeSchema($inputSchema); $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; + + // An out-of-bounds `x-mcp-header` reachable through `properties` makes + // the whole tool definition invalid, so it is refused where the tool + // is defined rather than discovered when a header comparison + // mysteriously fails. + if (null !== $reason = $this->checkHeaderAnnotations()) { + throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); + } + } + + /** + * Validates every `x-mcp-header` annotation reachable through `properties` + * in an input schema (SEP-2243). + * + * The value becomes an HTTP field name, so it has to be one; it has to be + * unique case-insensitively, or two arguments would fight over one header; + * and it may only sit on a primitive that is not `number`, because a float + * has no single decimal spelling for a receiver to compare against. + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + private function checkHeaderAnnotations(): ?string + { + $seen = []; + + foreach ($this->headerAnnotations($this->inputSchema) as [$name, $type, $path]) { + if ('' === $name) { + return \sprintf('the annotation at "%s" is empty', $path); + } + + // RFC 9110 tchar; excludes CR, LF and every other control character. + if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + return \sprintf('"%s" is not a valid HTTP field name', $name); + } + + $folded = strtolower($name); + if (isset($seen[$folded])) { + return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); + } + $seen[$folded] = $path; + + if ('number' === $type) { + return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); + } + + if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { + return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); + } + } + + return null; + } + + /** + * Every annotation reachable through `properties` alone, as name, declared + * type and dotted path. + * + * @param array $schema + * + * @return list + */ + private function headerAnnotations(array $schema, string $prefix = ''): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; + $annotation = $definition['x-mcp-header'] ?? null; + + if (null !== $annotation) { + if (!\is_string($annotation)) { + $found[] = ['', null, $path]; + } else { + $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; + } + } + + $found = [...$found, ...$this->headerAnnotations($definition, $path)]; + } + + return $found; } /** diff --git a/tests/Unit/Schema/ToolHeaderAnnotationTest.php b/tests/Unit/Schema/ToolHeaderAnnotationTest.php new file mode 100644 index 0000000..8fab282 --- /dev/null +++ b/tests/Unit/Schema/ToolHeaderAnnotationTest.php @@ -0,0 +1,160 @@ + $properties + */ + private static function tool(array $properties): Tool + { + return new Tool( + name: 'a_tool', + title: null, + inputSchema: ['type' => 'object', 'properties' => $properties, 'required' => null], + description: 'x', + annotations: null, + ); + } + + #[TestDox('a well-formed annotation is accepted')] + public function testValidAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'dry_run' => ['type' => 'boolean', 'x-mcp-header' => 'Dry-Run'], + 'query' => ['type' => 'string'], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + #[TestDox('an annotation on a nested property is accepted: the chain is all properties')] + public function testNestedAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + /** + * @return iterable, string}> + */ + public static function invalidAnnotations(): iterable + { + yield 'empty name' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => '']], + 'is empty', + ]; + + yield 'name with a space' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'My Header']], + 'not a valid HTTP field name', + ]; + + yield 'name with a newline' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\nInjected: yes"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a carriage return' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\rY"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a colon' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'X:Y']], + 'not a valid HTTP field name', + ]; + + yield 'duplicate, differing only in case' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'b' => ['type' => 'string', 'x-mcp-header' => 'region'], + ], + 'declared twice', + ]; + + yield 'on a number' => [ + ['a' => ['type' => 'number', 'x-mcp-header' => 'Amount']], + 'cannot be mirrored', + ]; + + yield 'on an array' => [ + ['a' => ['type' => 'array', 'items' => ['type' => 'string'], 'x-mcp-header' => 'Tags']], + 'only string, integer and boolean', + ]; + + yield 'on an object' => [ + ['a' => ['type' => 'object', 'x-mcp-header' => 'Blob']], + 'only string, integer and boolean', + ]; + + yield 'non-string annotation value' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 42]], + 'is empty', + ]; + + yield 'duplicate across nesting levels' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'nested' => [ + 'type' => 'object', + 'properties' => ['b' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'declared twice', + ]; + } + + /** + * @param array $properties + */ + #[DataProvider('invalidAnnotations')] + #[TestDox('an out-of-bounds annotation makes the tool definition invalid')] + public function testInvalidAnnotationIsRefused(array $properties, string $reason): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/'.preg_quote($reason, '/').'/'); + + self::tool($properties); + } + + #[TestDox('an annotation the walk cannot reach statically is simply not seen')] + public function testUnreachableAnnotationIsIgnored(): void + { + // Under `items`, so it is not reachable through `properties` alone. + // The spec calls such a definition invalid; this SDK does not mirror + // what it cannot reach, and does not pretend the annotation exists. + $tool = self::tool([ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'x-mcp-header' => 'Bad Name With Spaces'], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } +} From 5e3e69acc41a78b8f6ad75863f2eb8e1ad4a36b7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 20:20:55 +0200 Subject: [PATCH 097/143] [Tests] Pin that protected resource metadata advertises no scope of its own (#440) SEP-2207 says a resource server must not advertise `offline_access` as a required scope. Nothing in src/ writes it - `scopesSupported` is entirely operator-supplied - so there was nothing to fix, but also nothing stopping a future default from quietly starting to advertise one. --- .../OAuth/ProtectedResourceMetadataTest.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php index fbb51a9..5016c73 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -83,4 +83,33 @@ class ProtectedResourceMetadataTest extends TestCase new ProtectedResourceMetadata([]); } + + #[TestDox('the SDK advertises exactly the scopes it was given, and never adds offline_access')] + public function testScopesAreOperatorSuppliedOnly(): void + { + // SEP-2207: `offline_access` is a refresh-token scope, not something a + // resource requires. Nothing in the SDK injects it — this pins that, so + // a future default cannot quietly start advertising one. + $metadata = new ProtectedResourceMetadata( + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + ); + + $data = $metadata->jsonSerialize(); + + $this->assertSame(['mcp:read', 'mcp:write'], $data['scopes_supported']); + $this->assertNotContains('offline_access', $data['scopes_supported']); + } + + #[TestDox('no scopes given means no scopes_supported member at all')] + public function testNoScopesMeansNoMember(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + ); + + $this->assertArrayNotHasKey('scopes_supported', $metadata->jsonSerialize()); + } } From 60711951d45aa3f2094c997ae9f1e3792a64e8af Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 20:47:27 +0200 Subject: [PATCH 098/143] [Schema][Server] Add the extensions framework SEP-2133 defines (#443) * [Schema][Server] Add the extensions framework SEP-2133 defines enableExtension() took any string and advertised it, with no way for an extension to add a method: MessageFactory could not decode one, so nothing downstream ever saw it. Identifiers are now checked against the _meta key naming rules through ExtensionIdentifier - a prefix is mandatory, since an unprefixed name has no owner, and the modelcontextprotocol/mcp second labels the specification reserves are recognised. An extension implementing MethodProvidingExtensionInterface contributes both its message classes and the handlers serving them. MessageFactory::make() takes an $additional list of message classes, and RequestHandlerInterface's result template is covariant so a handler declaring a concrete result satisfies a collection typed by the interface. * Fix stale @see reference in MethodProvidingExtensionInterface docblock * [Schema][Server][Client] Make ExtensionIdentifier a value object ExtensionInterface::getId() now returns ExtensionIdentifier, which validates the SEP-2133 naming rules at construction instead of the callers checking it. getMessages()/getRequestHandlers() move into ExtensionInterface itself; AbstractExtension gives them empty defaults for extensions that only announce a capability. --- CHANGELOG.md | 1 + src/Client/Builder.php | 6 +- src/JsonRpc/MessageFactory.php | 6 +- src/Schema/Extension/AbstractExtension.php | 32 ++++++ src/Schema/Extension/Apps/McpApps.php | 9 +- src/Schema/Extension/ExtensionIdentifier.php | 98 +++++++++++++++++++ src/Schema/Extension/ExtensionInterface.php | 31 +++++- src/Schema/JsonRpc/Response.php | 6 +- src/Server/Builder.php | 25 ++++- src/Server/ClientGateway.php | 5 +- .../Request/RequestHandlerInterface.php | 6 +- .../Extension/AbstractExtensionTest.php | 39 ++++++++ .../Schema/Extension/Apps/McpAppsTest.php | 2 +- .../Extension/ExtensionIdentifierTest.php | 88 +++++++++++++++++ tests/Unit/Server/BuilderTest.php | 43 ++++++++ .../Unit/Server/Extension/ThingExtension.php | 47 +++++++++ .../Server/Extension/ThingListHandler.php | 33 +++++++ .../Server/Extension/ThingListRequest.php | 32 ++++++ .../Unit/Server/Extension/ThingListResult.php | 33 +++++++ 19 files changed, 525 insertions(+), 17 deletions(-) create mode 100644 src/Schema/Extension/AbstractExtension.php create mode 100644 src/Schema/Extension/ExtensionIdentifier.php create mode 100644 tests/Unit/Schema/Extension/AbstractExtensionTest.php create mode 100644 tests/Unit/Schema/Extension/ExtensionIdentifierTest.php create mode 100644 tests/Unit/Server/Extension/ThingExtension.php create mode 100644 tests/Unit/Server/Extension/ThingListHandler.php create mode 100644 tests/Unit/Server/Extension/ThingListRequest.php create mode 100644 tests/Unit/Server/Extension/ThingListResult.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7f8bd..f6c4558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. +* [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/Client/Builder.php b/src/Client/Builder.php index bfed70f..098b6e6 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -14,6 +14,7 @@ namespace Mcp\Client; use Mcp\Client; use Mcp\Client\Handler\Notification\NotificationHandlerInterface; use Mcp\Client\Handler\Request\RequestHandlerInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; @@ -88,12 +89,13 @@ final class Builder * Enable one or more MCP protocol extensions, announced to the server under * `capabilities.extensions` in the initialize request. * - * @throws LogicException if the same extension is enabled more than once + * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix + * @throws LogicException if the same extension is enabled more than once */ public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { - $id = $extension->getId(); + $id = (string) $extension->getId(); if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 806eea5..27860a2 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -95,10 +95,12 @@ final class MessageFactory /** * Creates a new Factory instance with all the protocol's default messages. + * + * @param list|class-string> $additional message classes an extension defines */ - public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self + public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, array $additional = []): self { - return new self(self::REGISTERED_MESSAGES, $maxBatchSize); + return new self([...self::REGISTERED_MESSAGES, ...$additional], $maxBatchSize); } /** diff --git a/src/Schema/Extension/AbstractExtension.php b/src/Schema/Extension/AbstractExtension.php new file mode 100644 index 0000000..a449c8a --- /dev/null +++ b/src/Schema/Extension/AbstractExtension.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractExtension implements ExtensionInterface +{ + public function getMessages(): array + { + return []; + } + + public function getRequestHandlers(): iterable + { + return []; + } +} diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php index a9124a4..dcf3c9e 100644 --- a/src/Schema/Extension/Apps/McpApps.php +++ b/src/Schema/Extension/Apps/McpApps.php @@ -11,7 +11,8 @@ namespace Mcp\Schema\Extension\Apps; -use Mcp\Schema\Extension\ExtensionInterface; +use Mcp\Schema\Extension\AbstractExtension; +use Mcp\Schema\Extension\ExtensionIdentifier; /** * The MCP Apps extension (io.modelcontextprotocol/ui). @@ -26,15 +27,15 @@ use Mcp\Schema\Extension\ExtensionInterface; * * @author Christopher Hertel */ -final class McpApps implements ExtensionInterface +final class McpApps extends AbstractExtension { public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; public const MIME_TYPE = 'text/html;profile=mcp-app'; public const URI_SCHEME = 'ui'; - public function getId(): string + public function getId(): ExtensionIdentifier { - return self::EXTENSION_ID; + return new ExtensionIdentifier(self::EXTENSION_ID); } /** diff --git a/src/Schema/Extension/ExtensionIdentifier.php b/src/Schema/Extension/ExtensionIdentifier.php new file mode 100644 index 0000000..2d714d6 --- /dev/null +++ b/src/Schema/Extension/ExtensionIdentifier.php @@ -0,0 +1,98 @@ + + */ +final class ExtensionIdentifier implements \Stringable +{ + /** Second labels only the specification may use. */ + public const RESERVED_LABELS = ['modelcontextprotocol', 'mcp']; + + /** Prefixes the specification itself allocates. */ + public const OFFICIAL_PREFIX = 'io.modelcontextprotocol/'; + + /** A label: starts with a letter, ends alphanumeric, hyphens inside. */ + private const LABEL = '[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?'; + + /** A name: alphanumeric at both ends, `-`, `_` and `.` inside. */ + private const NAME = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?'; + + /** + * @throws InvalidArgumentException if $identifier is not a valid `_meta` prefix + */ + public function __construct( + private readonly string $identifier, + ) { + if (null !== $reason = self::check($identifier)) { + throw new InvalidArgumentException($reason); + } + } + + /** + * Whether this identifier claims a prefix the specification reserves. + * + * Not an error on its own — the official extensions legitimately use it — + * but a third party doing so is misrepresenting itself, so callers that are + * not the SDK should refuse. + */ + public function isReserved(): bool + { + $labels = explode('.', strstr($this->identifier, '/', true) ?: ''); + + return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true); + } + + public function __toString(): string + { + return $this->identifier; + } + + /** + * @return string|null the reason $identifier is invalid, or null when it is well-formed + */ + private static function check(string $identifier): ?string + { + $slash = strpos($identifier, '/'); + + if (false === $slash) { + return \sprintf('"%s" has no prefix; an extension identifier must be prefixed, e.g. "com.example/my-extension".', $identifier); + } + + $prefix = substr($identifier, 0, $slash); + $name = substr($identifier, $slash + 1); + + if (1 !== preg_match('/^'.self::LABEL.'(?:\.'.self::LABEL.')*$/', $prefix)) { + return \sprintf('"%s" is not a valid prefix: labels must start with a letter, end alphanumeric, and be separated by dots.', $prefix); + } + + if ('' === $name || 1 !== preg_match('/^'.self::NAME.'$/', $name)) { + return \sprintf('"%s" is not a valid extension name: it must start and end alphanumeric.', $name); + } + + return null; + } +} diff --git a/src/Schema/Extension/ExtensionInterface.php b/src/Schema/Extension/ExtensionInterface.php index f8a4038..a663525 100644 --- a/src/Schema/Extension/ExtensionInterface.php +++ b/src/Schema/Extension/ExtensionInterface.php @@ -11,6 +11,11 @@ namespace Mcp\Schema\Extension; +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\ResultInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; + /** * An MCP protocol extension advertised during capability negotiation. * @@ -19,6 +24,10 @@ namespace Mcp\Schema\Extension; * extension object can be enabled on a server (initialize response) and on a client * (initialize request); the side that enables it decides which. * + * An extension that only announces a capability, without adding RPC methods of its + * own, can extend {@see AbstractExtension} and skip {@see self::getMessages()} and + * {@see self::getRequestHandlers()} entirely. + * * @author Christopher Hertel */ interface ExtensionInterface @@ -26,7 +35,7 @@ interface ExtensionInterface /** * The reverse-DNS identifier used as the key under `capabilities.extensions`. */ - public function getId(): string; + public function getId(): ExtensionIdentifier; /** * The capability payload announced for this extension. @@ -38,4 +47,24 @@ interface ExtensionInterface * @return array */ public function getCapabilities(): array; + + /** + * Every message class this extension defines. + * + * These are registered with the {@see \Mcp\JsonRpc\MessageFactory}, without + * which an extension's method cannot be decoded off the wire at all, and + * their method names are what let a server distinguish an extension it does + * not serve from a method that does not exist. An extension with no methods + * of its own returns an empty array. + * + * @return list|class-string> + */ + public function getMessages(): array; + + /** + * The handlers serving those methods. + * + * @return iterable> + */ + public function getRequestHandlers(): iterable; } diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 7f2d82b..4c64881 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -14,7 +14,11 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; /** - * @template TResult + * Covariant because a Response only hands its result out, never consumes it, + * so a handler can declare the union of results it may answer with while each + * return path constructs one concrete type. + * + * @template-covariant TResult * * @phpstan-type ResponseData array{ * jsonrpc: string, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index c01dd73..431fe54 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,6 +32,7 @@ use Mcp\Exception\LogicException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\AbstractExtension; use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; @@ -214,6 +215,9 @@ final class Builder */ private array $extensions = []; + /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ + private array $extensionMessages = []; + /** * @var LoaderInterface[] */ @@ -280,18 +284,33 @@ final class Builder * Enable one or more MCP protocol extensions, announced to clients under * `capabilities.extensions` during the initialize handshake. * - * @throws LogicException if the same extension is enabled more than once + * An extension also contributes the message classes its methods decode + * into and the handlers serving them, if any — see {@see AbstractExtension} + * for extensions that only announce a capability. + * + * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix + * @throws LogicException if the same extension is enabled more than once */ public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { - $id = $extension->getId(); + $id = (string) $extension->getId(); if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); } $this->extensions[$id] = $extension->getCapabilities(); + + // Without this the method cannot be decoded at all, so nothing + // downstream ever sees it. + foreach ($extension->getMessages() as $message) { + $this->extensionMessages[] = $message; + } + + foreach ($extension->getRequestHandlers() as $handler) { + $this->requestHandlers[] = $handler; + } } return $this; @@ -709,7 +728,7 @@ final class Builder $eagerlyLoaded = !$this->lazyLoading; } - $messageFactory = MessageFactory::make(); + $messageFactory = MessageFactory::make(additional: $this->extensionMessages); $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 2df6fa3..16a80f0 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -23,6 +23,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingContext; +use Mcp\Schema\Extension\ExtensionIdentifier; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -359,9 +360,9 @@ class ClientGateway * * @return bool True if the client advertised the extension, false otherwise */ - public function supportsExtension(string $id): bool + public function supportsExtension(ExtensionIdentifier|string $id): bool { - return $this->hasSubCapability('extensions', $id); + return $this->hasSubCapability('extensions', (string) $id); } /** diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php index d81c079..9a9c013 100644 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -17,7 +17,11 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Server\Session\SessionInterface; /** - * @template TResult + * Covariant in TResult: a handler only ever produces its result, so one + * declaring a concrete result type satisfies a collection of handlers typed by + * the interface every result implements. + * + * @template-covariant TResult * * @author Kyrian Obikwelu */ diff --git a/tests/Unit/Schema/Extension/AbstractExtensionTest.php b/tests/Unit/Schema/Extension/AbstractExtensionTest.php new file mode 100644 index 0000000..9ae06ab --- /dev/null +++ b/tests/Unit/Schema/Extension/AbstractExtensionTest.php @@ -0,0 +1,39 @@ +assertSame([], $extension->getMessages()); + $this->assertSame([], $extension->getRequestHandlers()); + } +} diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index cdb8237..6e2f60a 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -27,7 +27,7 @@ class McpAppsTest extends TestCase $extension = new McpApps(); $this->assertInstanceOf(ExtensionInterface::class, $extension); - $this->assertSame('io.modelcontextprotocol/ui', $extension->getId()); + $this->assertSame('io.modelcontextprotocol/ui', (string) $extension->getId()); $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); } diff --git a/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php new file mode 100644 index 0000000..96ff22d --- /dev/null +++ b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php @@ -0,0 +1,88 @@ + + */ + public static function validIdentifiers(): iterable + { + yield 'official tasks' => ['io.modelcontextprotocol/tasks']; + yield 'official ui' => ['io.modelcontextprotocol/ui']; + yield 'vendor' => ['com.example/my-extension']; + yield 'deep prefix' => ['org.example.api.v2/thing']; + yield 'name with dots' => ['com.example/a.b.c']; + yield 'name with underscores' => ['com.example/a_b']; + yield 'digits inside labels' => ['com.example2/x1']; + } + + #[DataProvider('validIdentifiers')] + #[TestDox('a well-formed identifier is accepted and stringifies back to itself')] + public function testValidIdentifiers(string $identifier): void + { + $this->assertSame($identifier, (string) new ExtensionIdentifier($identifier)); + } + + /** + * @return iterable + */ + public static function invalidIdentifiers(): iterable + { + yield 'no prefix' => ['tasks', 'has no prefix']; + yield 'empty name' => ['com.example/', 'not a valid extension name']; + yield 'prefix label starting with a digit' => ['1com.example/x', 'not a valid prefix']; + yield 'prefix label ending with a hyphen' => ['com.example-/x', 'not a valid prefix']; + yield 'empty prefix label' => ['com..example/x', 'not a valid prefix']; + yield 'name starting with a dot' => ['com.example/.x', 'not a valid extension name']; + yield 'name ending with a hyphen' => ['com.example/x-', 'not a valid extension name']; + yield 'space in the name' => ['com.example/my extension', 'not a valid extension name']; + } + + #[DataProvider('invalidIdentifiers')] + #[TestDox('a malformed identifier is refused at construction, with the reason')] + public function testInvalidIdentifiers(string $identifier, string $reason): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($reason); + + new ExtensionIdentifier($identifier); + } + + /** + * @return iterable + */ + public static function reservations(): iterable + { + yield 'io.modelcontextprotocol is reserved' => ['io.modelcontextprotocol/tasks', true]; + yield 'dev.mcp is reserved' => ['dev.mcp/thing', true]; + yield 'org.modelcontextprotocol.api is reserved' => ['org.modelcontextprotocol.api/thing', true]; + yield 'com.mcp.tools is reserved' => ['com.mcp.tools/thing', true]; + yield 'com.example.mcp is not: the second label is example' => ['com.example.mcp/thing', false]; + yield 'com.example is not' => ['com.example/thing', false]; + yield 'a single-label prefix has no second label' => ['example/thing', false]; + } + + #[DataProvider('reservations')] + #[TestDox('the reserved second labels are recognised, and only those')] + public function testReservedPrefixes(string $identifier, bool $reserved): void + { + $this->assertSame($reserved, (new ExtensionIdentifier($identifier))->isReserved()); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index a29c9f4..63bef73 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Extension\Apps\McpApps; @@ -24,9 +25,14 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; +use Mcp\Server\Builder; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; +use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Tests\Unit\Server\Extension\ThingExtension; +use Mcp\Tests\Unit\Server\Extension\ThingListHandler; +use Mcp\Tests\Unit\Server\Extension\ThingListRequest; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -172,6 +178,43 @@ final class BuilderTest extends TestCase $this->assertSame($builder, $builder->setLazyLoading(false)); } + #[TestDox('An extension identifier must be a valid _meta prefix')] + public function testEnableExtensionRejectsUnprefixedIdentifier(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('has no prefix'); + + Server::builder()->enableExtension(new ThingExtension('things')); + } + + #[TestDox('A method-providing extension contributes the message classes its methods decode into')] + public function testEnableExtensionRegistersItsMessages(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()) + ->build(); + + $factory = (new \ReflectionProperty(Protocol::class, 'messageFactory')) + ->getValue((new \ReflectionProperty(Server::class, 'protocol'))->getValue($server)); + + $decoded = $factory->create('{"jsonrpc":"2.0","id":1,"method":"com.example/things.list"}'); + + $this->assertInstanceOf(ThingListRequest::class, $decoded[0]); + } + + #[TestDox('A method-providing extension contributes the handlers serving its methods')] + public function testEnableExtensionRegistersItsHandlers(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()); + + $handlers = (new \ReflectionProperty(Builder::class, 'requestHandlers'))->getValue($builder); + + $this->assertContainsOnlyInstancesOf(ThingListHandler::class, $handlers); + } + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void { diff --git a/tests/Unit/Server/Extension/ThingExtension.php b/tests/Unit/Server/Extension/ThingExtension.php new file mode 100644 index 0000000..88d862d --- /dev/null +++ b/tests/Unit/Server/Extension/ThingExtension.php @@ -0,0 +1,47 @@ +id); + } + + public function getCapabilities(): array + { + return ['flavour' => 'vanilla']; + } + + public function getMessages(): array + { + return [ThingListRequest::class]; + } + + public function getRequestHandlers(): iterable + { + yield new ThingListHandler(); + } +} diff --git a/tests/Unit/Server/Extension/ThingListHandler.php b/tests/Unit/Server/Extension/ThingListHandler.php new file mode 100644 index 0000000..8c34b88 --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListHandler.php @@ -0,0 +1,33 @@ + + */ +final class ThingListHandler implements RequestHandlerInterface +{ + public function supports(Request $request): bool + { + return $request instanceof ThingListRequest; + } + + public function handle(Request $request, SessionInterface $session): Response + { + return new Response($request->getId(), new ThingListResult(['a', 'b'])); + } +} diff --git a/tests/Unit/Server/Extension/ThingListRequest.php b/tests/Unit/Server/Extension/ThingListRequest.php new file mode 100644 index 0000000..53d5396 --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListRequest.php @@ -0,0 +1,32 @@ + $things + */ + public function __construct( + public readonly array $things, + ) { + } + + /** + * @return array{things: list} + */ + public function jsonSerialize(): array + { + return ['things' => $this->things]; + } +} From 3d596ca75b7874af70c806992dfa3e0e180887cd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 22:08:54 +0200 Subject: [PATCH 099/143] [Server][Tests] Serve the modern (SEP-2575) lifecycle (#444) * [Server][Tests] Serve the modern (SEP-2575) lifecycle The revision removed the initialize handshake: every request carries its own _meta - protocol version, client capabilities, client info - and answers server/discover for what initialize used to return. There is no session to keep, so there is nothing for the handshake-era dispatcher to do. Builder::buildStateless() produces StatelessProtocol, and StatelessHttpTransport serves it over one POST-only endpoint - GET and DELETE are 405, and no Mcp-Session-Id exists to hand out. Results go through a per-era wire codec rather than the handlers, so resultType and the caching hints are stamped for 2026-07-28 and left off the revisions that do not define them. Covered by the modern conformance suite (make conformance-draft-server) against its own baseline; unit coverage arrives with the fixes stacked on this. * Apply suggestions from code review Co-authored-by: Christopher Hertel * Fix critical stateless-lifecycle bugs from review - Don't leak internal exception messages to clients on uncaught \Throwable; use a generic message like the handshake-era protocol and every built-in handler already do. - Default a missing/invalid request id to null instead of "", and stop answering notifications and malformed messages as if they were "method not found" 404s. - Read the POST body in a loop instead of a single read() call, which PSR-7 allows to return short. --- Makefile | 38 +- src/Exception/MissingRequestMetaException.php | 24 ++ ...ssingRequiredClientCapabilityException.php | 38 ++ src/Schema/Enum/CacheScope.php | 34 ++ src/Schema/Enum/ResultType.php | 34 ++ src/Schema/Result/DiscoverResult.php | 75 ++++ src/Schema/Result/InputRequiredResult.php | 100 +++++ src/Server/Builder.php | 71 +++- .../Handler/Request/CallToolHandler.php | 16 +- src/Server/Stateless/NotificationFilter.php | 100 +++++ src/Server/Stateless/RequestMeta.php | 95 +++++ src/Server/Stateless/StatelessProtocol.php | 395 ++++++++++++++++++ src/Server/Stateless/StatelessResult.php | 121 ++++++ .../Transport/StatelessHttpTransport.php | 180 ++++++++ src/Server/Wire/Rev2025Codec.php | 32 ++ src/Server/Wire/Rev2026Codec.php | 159 +++++++ src/Server/Wire/WireCodecInterface.php | 49 +++ tests/Conformance/Fixtures/docker-compose.yml | 4 +- tests/Conformance/Fixtures/nginx.conf | 8 + .../conformance-baseline-2026-07-28.yml | 71 ++++ tests/Conformance/server-stateless.php | 107 +++++ 21 files changed, 1735 insertions(+), 16 deletions(-) create mode 100644 src/Exception/MissingRequestMetaException.php create mode 100644 src/Exception/MissingRequiredClientCapabilityException.php create mode 100644 src/Schema/Enum/CacheScope.php create mode 100644 src/Schema/Enum/ResultType.php create mode 100644 src/Schema/Result/DiscoverResult.php create mode 100644 src/Schema/Result/InputRequiredResult.php create mode 100644 src/Server/Stateless/NotificationFilter.php create mode 100644 src/Server/Stateless/RequestMeta.php create mode 100644 src/Server/Stateless/StatelessProtocol.php create mode 100644 src/Server/Stateless/StatelessResult.php create mode 100644 src/Server/Transport/StatelessHttpTransport.php create mode 100644 src/Server/Wire/Rev2025Codec.php create mode 100644 src/Server/Wire/Rev2026Codec.php create mode 100644 src/Server/Wire/WireCodecInterface.php create mode 100644 tests/Conformance/conformance-baseline-2026-07-28.yml create mode 100644 tests/Conformance/server-stateless.php diff --git a/Makefile b/Makefile index c886bc5..1ad8675 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,10 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client check-conformance-repo docs + +# The published @modelcontextprotocol/conformance release carries no 2026-07-28 +# scenarios, so the draft targets below run a local checkout of the conformance +# repo instead. Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`. +CONFORMANCE_REPO ?= $(CURDIR)/../conformance +CONFORMANCE_DRAFT = node $(CONFORMANCE_REPO)/dist/index.js deps-stable: composer update --prefer-stable @@ -31,15 +37,41 @@ conformance-server: @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --output-dir results || true + cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --spec-version 2025-11-25 --output-dir results || true php tests/Conformance/score.php server docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results || true + cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline.yml --output-dir results || true php tests/Conformance/score.php client +# --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ +# Local-checkout only for now; not wired into CI until upstream publishes a +# release carrying the draft scenarios. + +conformance-draft: conformance-draft-server conformance-draft-client + +check-conformance-repo: + @test -f $(CONFORMANCE_REPO)/dist/index.js || { \ + echo "No conformance build at $(CONFORMANCE_REPO)/dist/index.js."; \ + echo "Clone modelcontextprotocol/conformance and run 'npm install && npm run build' there,"; \ + echo "or point CONFORMANCE_REPO at an existing checkout."; \ + exit 1; \ + } + +conformance-draft-server: check-conformance-repo + docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d + @echo "Waiting for server to start..." + @sleep 5 + rm -rf tests/Conformance/results-2026-07-28 + cd tests/Conformance && $(CONFORMANCE_DRAFT) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + docker compose -f tests/Conformance/Fixtures/docker-compose.yml down + +conformance-draft-client: check-conformance-repo + rm -rf tests/Conformance/results-2026-07-28 + cd tests/Conformance && $(CONFORMANCE_DRAFT) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/src/Exception/MissingRequestMetaException.php b/src/Exception/MissingRequestMetaException.php new file mode 100644 index 0000000..ed7d9d6 --- /dev/null +++ b/src/Exception/MissingRequestMetaException.php @@ -0,0 +1,24 @@ + + */ +class MissingRequestMetaException extends InvalidArgumentException +{ +} diff --git a/src/Exception/MissingRequiredClientCapabilityException.php b/src/Exception/MissingRequiredClientCapabilityException.php new file mode 100644 index 0000000..66f6d06 --- /dev/null +++ b/src/Exception/MissingRequiredClientCapabilityException.php @@ -0,0 +1,38 @@ + + */ +class MissingRequiredClientCapabilityException extends \RuntimeException implements ExceptionInterface +{ + public function __construct( + public readonly ClientCapabilities $requiredCapabilities, + string $message = 'Request requires a client capability that was not declared.', + ) { + parent::__construct($message); + } +} diff --git a/src/Schema/Enum/CacheScope.php b/src/Schema/Enum/CacheScope.php new file mode 100644 index 0000000..f06c057 --- /dev/null +++ b/src/Schema/Enum/CacheScope.php @@ -0,0 +1,34 @@ + + */ +enum CacheScope: string +{ + /** Contains no caller-specific data; any cache may share it. */ + case Public = 'public'; + + /** Reusable only within the same authorization context. */ + case Private = 'private'; +} diff --git a/src/Schema/Enum/ResultType.php b/src/Schema/Enum/ResultType.php new file mode 100644 index 0000000..6ba0f58 --- /dev/null +++ b/src/Schema/Enum/ResultType.php @@ -0,0 +1,34 @@ + + */ +enum ResultType: string +{ + /** The request finished; the result holds the final content. */ + case Complete = 'complete'; + + /** The request needs more input before it can finish (MRTR). */ + case InputRequired = 'input_required'; +} diff --git a/src/Schema/Result/DiscoverResult.php b/src/Schema/Result/DiscoverResult.php new file mode 100644 index 0000000..d29a468 --- /dev/null +++ b/src/Schema/Result/DiscoverResult.php @@ -0,0 +1,75 @@ + + */ +class DiscoverResult implements ResultInterface +{ + /** + * @param list $supportedVersions every revision this server can answer + */ + public function __construct( + public readonly array $supportedVersions, + public readonly ServerCapabilities $capabilities, + public readonly ?string $instructions = null, + ) { + if ([] === $this->supportedVersions) { + throw new InvalidArgumentException('A DiscoverResult must advertise at least one supported version.'); + } + } + + /** + * Carries no `resultType`, caching hints or serverInfo identity: those are + * wire vocabulary, stamped by {@see \Mcp\Server\Wire\Rev2026Codec} on the + * way out. What is modelled here is only what discovery actually answers. + * + * @return array{ + * supportedVersions: list, + * capabilities: ServerCapabilities, + * instructions?: string, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'supportedVersions' => array_values(array_map( + static fn (ProtocolVersion $version): string => $version->value, + $this->supportedVersions, + )), + 'capabilities' => $this->capabilities, + ]; + + if (null !== $this->instructions) { + $data['instructions'] = $this->instructions; + } + + return $data; + } +} diff --git a/src/Schema/Result/InputRequiredResult.php b/src/Schema/Result/InputRequiredResult.php new file mode 100644 index 0000000..a33d8fe --- /dev/null +++ b/src/Schema/Result/InputRequiredResult.php @@ -0,0 +1,100 @@ + + */ +class InputRequiredResult implements ResultInterface +{ + public const RESULT_TYPE = 'input_required'; + + /** + * @param array $inputRequests server-assigned keys, unique within this request + * @param string|null $requestState opaque server context the client echoes back + */ + public function __construct( + public readonly array $inputRequests = [], + public readonly ?string $requestState = null, + ) { + // "Servers MUST include at least one of inputRequests or requestState + // in every InputRequiredResult response." A result carrying neither + // tells the client to retry with nothing new, which loops forever. + if ([] === $this->inputRequests && null === $this->requestState) { + throw new InvalidArgumentException('An InputRequiredResult must carry at least one of "inputRequests" or "requestState".'); + } + } + + /** + * @return array{ + * resultType: string, + * inputRequests?: array, + * requestState?: string, + * } + */ + public function jsonSerialize(): array + { + $data = ['resultType' => self::RESULT_TYPE]; + + if ([] !== $this->inputRequests) { + $requests = []; + foreach ($this->inputRequests as $key => $request) { + // The map values are bare method/params pairs rather than whole + // JSON-RPC requests: they are not messages in their own right, + // and giving them ids would imply a correlation the retry does + // not use — the client keys its answers by the map key instead. + // + // Params are reachable only through the JSON-RPC envelope + // (getParams() is protected), and rendering that envelope needs + // an id these requests never have. Hence the throwaway id: it + // exists for the length of this expression and is discarded + // with the envelope it was needed to build. + $envelope = $request->withId(0)->jsonSerialize(); + + $requests[$key] = [ + 'method' => $request::getMethod(), + 'params' => $envelope['params'] ?? new \stdClass(), + ]; + } + $data['inputRequests'] = $requests; + } + + if (null !== $this->requestState) { + $data['requestState'] = $this->requestState; + } + + return $data; + } +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 431fe54..55bf184 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Stateless\StatelessProtocol; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -680,6 +681,58 @@ final class Builder * Builds the fully configured Server instance. */ public function build(): Server + { + $parts = $this->assemble(); + + $protocol = new Protocol( + requestHandlers: $parts['requestHandlers'], + notificationHandlers: $parts['notificationHandlers'], + messageFactory: $parts['messageFactory'], + sessionManager: $parts['sessionManager'], + logger: $parts['logger'], + eventDispatcher: $this->eventDispatcher, + ); + + return new Server($protocol, $parts['logger']); + } + + /** + * Builds a dispatcher for the modern (SEP-2575) lifecycle. + * + * Everything the server exposes — tools, prompts, resources, and the + * handlers that serve them — is era-independent and shared with + * {@see self::build()}; only the dispatch model differs. So one builder + * configuration can drive either lifecycle, and a server can offer both at + * once by mounting each on its own endpoint. + * + * @param list $supportedVersions revisions this dispatcher will answer for + */ + public function buildStateless(array $supportedVersions = [ProtocolVersion::V2026_07_28]): StatelessProtocol + { + $parts = $this->assemble(); + + return new StatelessProtocol( + requestHandlers: $parts['requestHandlers'], + messageFactory: $parts['messageFactory'], + configuration: $parts['configuration'], + supportedVersions: $supportedVersions, + logger: $parts['logger'], + ); + } + + /** + * Resolves the builder's configuration into the parts both lifecycles need. + * + * @return array{ + * logger: LoggerInterface, + * configuration: Configuration, + * messageFactory: MessageFactory, + * sessionManager: SessionManagerInterface, + * requestHandlers: list>, + * notificationHandlers: list, + * } + */ + private function assemble(): array { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); @@ -769,16 +822,14 @@ final class Builder new Handler\Notification\InitializedHandler(), ]); - $protocol = new Protocol( - requestHandlers: $requestHandlers, - notificationHandlers: $notificationHandlers, - messageFactory: $messageFactory, - sessionManager: $sessionManager, - logger: $logger, - eventDispatcher: $this->eventDispatcher, - ); - - return new Server($protocol, $logger); + return [ + 'logger' => $logger, + 'configuration' => $configuration, + 'messageFactory' => $messageFactory, + 'sessionManager' => $sessionManager, + 'requestHandlers' => $requestHandlers, + 'notificationHandlers' => $notificationHandlers, + ]; } /** diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 3d43d0a..749d1ca 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -14,6 +14,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -22,13 +23,17 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * A tools/call can answer with the tool's output or, under MRTR, with a request + * for the input the tool still needs — hence the two result types. + * + * @implements RequestHandlerInterface * * @author Christopher Hertel * @author Tobias Nyholm @@ -52,7 +57,7 @@ final class CallToolHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -98,6 +103,11 @@ final class CallToolHandler implements RequestHandlerInterface try { $result = $this->referenceHandler->handle($reference, $arguments); + // Tool asking for more input as response + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + $protocolVersion = $context->getProtocolVersion(); $structuredContent = null; @@ -135,6 +145,8 @@ final class CallToolHandler implements RequestHandlerInterface ]); return new Response($request->getId(), $result); + } catch (MissingRequiredClientCapabilityException $e) { + throw $e; } catch (ToolCallException $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, diff --git a/src/Server/Stateless/NotificationFilter.php b/src/Server/Stateless/NotificationFilter.php new file mode 100644 index 0000000..d44724e --- /dev/null +++ b/src/Server/Stateless/NotificationFilter.php @@ -0,0 +1,100 @@ + + */ +final class NotificationFilter +{ + /** + * @param list $resourceSubscriptions resource URIs to report updates for + */ + public function __construct( + public readonly bool $toolsListChanged = false, + public readonly bool $promptsListChanged = false, + public readonly bool $resourcesListChanged = false, + public readonly array $resourceSubscriptions = [], + ) { + } + + /** + * @param array|null $notifications the request's `params.notifications` member + */ + public static function fromParams(?array $notifications): self + { + $notifications ??= []; + + $uris = $notifications['resourceSubscriptions'] ?? []; + + return new self( + true === ($notifications['toolsListChanged'] ?? false), + true === ($notifications['promptsListChanged'] ?? false), + true === ($notifications['resourcesListChanged'] ?? false), + \is_array($uris) ? array_values(array_filter($uris, \is_string(...))) : [], + ); + } + + /** + * Narrows the filter to what this server can actually deliver. + * + * The acknowledgment must reflect "the subset the server agreed to honor", + * so a type the server does not support is dropped here rather than + * acknowledged and then silently never sent — a client comparing the + * acknowledgment against its request would otherwise wait forever for + * notifications that were never coming. + */ + public function intersect(ServerCapabilities $capabilities): self + { + return new self( + $this->toolsListChanged && true === $capabilities->toolsListChanged, + $this->promptsListChanged && true === $capabilities->promptsListChanged, + $this->resourcesListChanged && true === $capabilities->resourcesListChanged, + true === $capabilities->resourcesSubscribe ? $this->resourceSubscriptions : [], + ); + } + + /** + * The acknowledgment's `notifications` member: agreed types only, with + * declined ones omitted rather than reported as `false`. + * + * @return array + */ + public function toAcknowledgedArray(): array + { + $data = []; + + if ($this->toolsListChanged) { + $data['toolsListChanged'] = true; + } + if ($this->promptsListChanged) { + $data['promptsListChanged'] = true; + } + if ($this->resourcesListChanged) { + $data['resourcesListChanged'] = true; + } + if ([] !== $this->resourceSubscriptions) { + $data['resourceSubscriptions'] = $this->resourceSubscriptions; + } + + return $data; + } +} diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php new file mode 100644 index 0000000..2304673 --- /dev/null +++ b/src/Server/Stateless/RequestMeta.php @@ -0,0 +1,95 @@ +|null $params the request's `params` member, if any + * + * @throws MissingRequestMetaException when a structurally required member is absent or malformed + */ + public static function fromParams(?array $params): self + { + $meta = $params['_meta'] ?? null; + + if (!\is_array($meta)) { + throw new MissingRequestMetaException('Request is missing the required "params._meta" member.'); + } + + $version = $meta[self::PROTOCOL_VERSION] ?? null; + if (!\is_string($version) || '' === $version) { + throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::PROTOCOL_VERSION)); + } + + // Required, but an empty object is a perfectly valid value: it declares + // a client that supports none of the optional capabilities. Only its + // absence — or a non-object — is a protocol error. + $capabilities = $meta[self::CLIENT_CAPABILITIES] ?? null; + if (!\is_array($capabilities) && !$capabilities instanceof \stdClass) { + throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::CLIENT_CAPABILITIES)); + } + + $clientInfo = $meta[self::CLIENT_INFO] ?? null; + + return new self( + $version, + ClientCapabilities::fromArray((array) $capabilities), + \is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null, + self::parseLogLevel($meta[self::LOG_LEVEL] ?? null), + ); + } + + /** + * An unparseable level is treated as "no level requested" rather than as a + * protocol error: log verbosity is a diagnostic preference, and rejecting + * the whole request over it would fail the caller's actual work. + */ + private static function parseLogLevel(mixed $level): ?LoggingLevel + { + return \is_string($level) ? LoggingLevel::tryFrom($level) : null; + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php new file mode 100644 index 0000000..a5bb31d --- /dev/null +++ b/src/Server/Stateless/StatelessProtocol.php @@ -0,0 +1,395 @@ + + */ +final class StatelessProtocol +{ + private readonly WireCodecInterface $codec; + + /** + * Methods the modern era deleted outright. They are answered as unknown + * methods rather than as errors specific to each, because that is exactly + * what they are to a modern server — listing them separately only serves + * to document the intent. + */ + public const REMOVED_METHODS = [ + 'initialize', + 'notifications/initialized', + 'ping', + 'logging/setLevel', + ]; + + public const DISCOVER_METHOD = 'server/discover'; + public const LISTEN_METHOD = 'subscriptions/listen'; + public const ACKNOWLEDGED_NOTIFICATION = 'notifications/subscriptions/acknowledged'; + + /** + * What a client is told when a handler fails in a way nothing anticipated. + * + * Deliberately generic rather than {@see \Throwable::getMessage()}: the + * real message is logged, not returned, so an internal detail (a + * connection string, a file path, another library's error text) never + * reaches the client that triggered it. + */ + private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; + + /** + * @param iterable> $requestHandlers + * @param list $supportedVersions + */ + public function __construct( + private readonly iterable $requestHandlers, + private readonly MessageFactory $messageFactory, + private readonly Configuration $configuration, + private readonly array $supportedVersions = [ProtocolVersion::V2026_07_28], + private readonly LoggerInterface $logger = new NullLogger(), + private readonly float $subscriptionLifetime = 30.0, + ?WireCodecInterface $codec = null, + ) { + $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo); + } + + /** + * Answers one JSON-RPC request read from an HTTP request body. + * + * @param array $headers request headers, case-insensitively matched + */ + public function handle(string $body, array $headers = []): StatelessResult + { + try { + /** @var array|null $decoded */ + $decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return StatelessResult::error(Error::forParseError($e->getMessage()), 400); + } + + if (!\is_array($decoded)) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must be a JSON object.'), 400); + } + + // The id is read before anything is validated so that every error below + // can echo it when there is one to echo. A missing or malformed id is + // left as null rather than coerced to "" — Error::jsonSerialize() + // already knows to omit a null id instead of fabricating one, and + // "id": "" would falsely claim the sender issued a request with an + // empty-string id. + $id = $decoded['id'] ?? null; + if (!\is_string($id) && !\is_int($id)) { + $id = null; + } + + $method = $decoded['method'] ?? null; + if (!\is_string($method) || '' === $method) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must carry a "method".', $id), 400); + } + + $params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null; + + try { + $meta = RequestMeta::fromParams($params); + } catch (MissingRequestMetaException $e) { + return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); + } + + if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) { + return $versionError; + } + + if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) { + // Both answer with a single response tied to this request's id, + // unlike a genuine notification — so unlike the general dispatch + // path below, a missing id here is this request being invalid + // rather than this request needing no answer at all. + if (null === $id) { + return StatelessResult::error(Error::forInvalidRequest(\sprintf('Method "%s" requires an "id".', $method)), 400); + } + + if (self::DISCOVER_METHOD === $method) { + return $this->encode($method, $id, $this->discover()); + } + + return $this->listen($params, $id); + } + + // Both the deleted methods and never-known ones are "not found". The + // 404 pairs the HTTP layer with the JSON-RPC code so a client that + // routes on status alone reaches the same conclusion. + if (\in_array($method, self::REMOVED_METHODS, true)) { + return StatelessResult::error( + Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id), + 404, + ); + } + + return $this->dispatch($method, $decoded, $meta, $id); + } + + /** + * The header and `_meta` must agree on the version before that version can + * be judged supported or not. + * + * The order matters: when the two disagree, the server has been told two + * different things and cannot know which the client meant, so "these + * contradict" is the honest answer even if one of the two values happens to + * be unsupported. Reporting it as an unsupported version instead would send + * the client off to renegotiate a version that was never the problem. + * + * @param array $headers + */ + private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult + { + $headerVersion = $this->header($headers, 'MCP-Protocol-Version'); + + if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) { + return StatelessResult::error( + Error::forHeaderMismatch( + \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $meta->protocolVersion), + $id, + ), + 400, + ); + } + + $version = ProtocolVersion::tryFrom($meta->protocolVersion); + + if (null === $version || !\in_array($version, $this->supportedVersions, true)) { + return StatelessResult::error( + Error::forUnsupportedProtocolVersion($meta->protocolVersion, $this->supportedVersions, $id), + 400, + ); + } + + return null; + } + + /** + * Opens a `subscriptions/listen` stream. + * + * The subscription needs no identifier of its own: the spec defines it as + * the JSON-RPC id of this very request, which both sides already have. So + * there is no id to mint, store, or hand back — every frame is tagged with + * something the client chose. + * + * @param array|null $params + */ + private function listen(?array $params, string|int $id): StatelessResult + { + $notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null; + $agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities); + + $lifetime = $this->subscriptionLifetime; + + return StatelessResult::stream(static function () use ($agreed, $id, $lifetime): \Generator { + yield [ + 'jsonrpc' => '2.0', + 'method' => self::ACKNOWLEDGED_NOTIFICATION, + 'params' => [ + '_meta' => [RequestMeta::SUBSCRIPTION_ID => $id], + 'notifications' => (object) $agreed->toAcknowledgedArray(), + ], + ]; + + // Nothing else is emitted yet: delivering list-changed and + // resource-updated notifications means observing mutations made by + // *other* requests, which under a share-nothing runtime needs a + // cross-process channel this SDK does not yet define. Holding the + // stream open for a bounded window keeps the subscription honest — + // the client sees an open, acknowledged stream — without pinning a + // worker indefinitely for events that cannot arrive. + // + // The tick is what makes that bound real. PHP only learns the peer + // hung up by trying to write to it, so a loop that merely sleeps + // holds its worker for the full lifetime after the client has gone. + // On a fixed-size FPM pool a handful of abandoned subscriptions is + // enough to starve every other request, which is exactly what makes + // it worth the extra yield. + $deadline = microtime(true) + $lifetime; + while (microtime(true) < $deadline) { + yield null; + + if (connection_aborted()) { + return; + } + + usleep(250_000); + } + + // Graceful closure (SHOULD): tell the client the subscription ended + // deliberately, so it can distinguish this from a dropped transport. + yield [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'resultType' => 'complete', + '_meta' => [RequestMeta::SUBSCRIPTION_ID => $id], + ], + ]; + }); + } + + private function discover(): DiscoverResult + { + return new DiscoverResult( + $this->supportedVersions, + $this->configuration->capabilities, + $this->configuration->instructions, + ); + } + + /** + * @param array $decoded + */ + private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id): StatelessResult + { + try { + $messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR)); + } catch (\Throwable $e) { + $this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404); + } + + $request = $messages[0] ?? null; + + // A notification (no id) is never answered, successful or not — this is + // one of the SDK's own registered message classes, just not a Request. + if ($request instanceof Notification) { + return StatelessResult::accepted(); + } + + // The factory hands back an exception object rather than throwing one, + // distinguishing a genuinely unknown method (-32601, the client should + // stop asking) from a known method the message could not otherwise be + // parsed into (-32600, the request itself is malformed). + if ($request instanceof InvalidInputMessageException) { + $unknownMethod = \sprintf('Unknown method "%s".', $method) === $request->getMessage(); + + return StatelessResult::error( + $unknownMethod + ? Error::forMethodNotFound($request->getMessage(), $id) + : Error::forInvalidRequest($request->getMessage(), $id), + $unknownMethod ? 404 : 400, + ); + } + + if (!$request instanceof Request) { + // Reachable only for a well-formed Response/Error object posted to + // this endpoint — decodable by the factory, but not a request this + // server can answer. + return StatelessResult::error(Error::forInvalidRequest(\sprintf('"%s" is not a request this server can answer.', $method), $id), 400); + } + + // Request::fromArray() already rejected a missing/invalid id (as an + // InvalidInputMessageException, handled above), so this request's id + // is always present here — reusing it narrows $id for the rest of this + // method instead of trusting the raw, still-nullable value from above. + $id = $request->getId(); + + $session = new Session(new InMemorySessionStore()); + $session->set(RequestMeta::class, $meta); + + foreach ($this->requestHandlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + try { + $result = $handler->handle($request, $session); + } catch (MissingRequiredClientCapabilityException $e) { + return StatelessResult::error( + Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id), + 400, + ); + } catch (\InvalidArgumentException $e) { + return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); + } catch (\Throwable $e) { + $this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500); + } + + if ($result instanceof Error) { + return StatelessResult::error($result, 400); + } + + return $this->encode($method, $id, $result->result); + } + + return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404); + } + + /** + * Runs a result through the wire codec on its way out. + * + * The round-trip through JSON is what flattens the result — and everything + * nested inside it — into the plain arrays the codec stamps. Handing the + * codec objects instead would make it responsible for knowing how each one + * serializes, which is exactly the coupling this layer exists to avoid. + */ + private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult + { + /** @var array $body */ + $body = json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); + + return StatelessResult::ok($id, $this->codec->encodeResult($method, $body)); + } + + /** + * @param array $headers + */ + private function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return $value; + } + } + + return null; + } +} diff --git a/src/Server/Stateless/StatelessResult.php b/src/Server/Stateless/StatelessResult.php new file mode 100644 index 0000000..6870a21 --- /dev/null +++ b/src/Server/Stateless/StatelessResult.php @@ -0,0 +1,121 @@ + + */ +final class StatelessResult +{ + /** + * @param (\Closure(): \Generator)|null $frames set instead of $message when the answer is a stream + * @param array|null $body a result body already through the wire codec + */ + private function __construct( + public readonly ?MessageInterface $message, + public readonly int $httpStatus, + public readonly ?\Closure $frames = null, + private readonly ?array $body = null, + private readonly string|int|null $id = null, + ) { + } + + /** + * A successful answer whose result body the wire codec has already stamped. + * + * The body arrives as a plain array rather than a result object because the + * codec's job is to add fields the result classes deliberately do not model + * — once it has run, there is no object left that describes the payload. + * + * @param array $body + */ + public static function ok(string|int $id, array $body): self + { + return new self(null, 200, null, $body, $id); + } + + public static function error(Error $error, int $httpStatus): self + { + return new self($error, $httpStatus); + } + + /** + * A notification never gets an answer, successful or not — per JSON-RPC, a + * message with no id must never receive a response. This is the "I read + * it, there is nothing to say back" answer: HTTP 202 with no body. + */ + public static function accepted(): self + { + return new self(null, 202); + } + + /** + * A long-lived answer delivered as a sequence of frames rather than one + * message — `subscriptions/listen` is the only such method today. + * + * The generator is deferred rather than eager because the frames are + * produced over the life of the connection: building them up front would + * defeat the point of streaming and hold the whole subscription in memory. + * + * @param \Closure(): \Generator $frames + */ + public static function stream(\Closure $frames): self + { + return new self(null, 200, $frames); + } + + public function isStream(): bool + { + return null !== $this->frames; + } + + /** + * An accepted notification: no message, no body, no frames — just the + * status code. + */ + public function isEmpty(): bool + { + return null === $this->message && null === $this->body && null === $this->frames; + } + + public function isError(): bool + { + return $this->message instanceof Error; + } + + public function toJson(): string + { + if (null !== $this->body) { + return json_encode([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $this->id, + 'result' => $this->body, + ], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } + + if (null === $this->message) { + throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.'); + } + + return json_encode($this->message, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php new file mode 100644 index 0000000..96210ca --- /dev/null +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -0,0 +1,180 @@ + + */ +final class StatelessHttpTransport +{ + /** + * Upper bound on the request body read for a POST, guarding against memory + * exhaustion from an oversized (or unbounded chunked) payload. + */ + public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; + + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly StatelessProtocol $protocol, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + private readonly LoggerInterface $logger = new NullLogger(), + private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + if ('OPTIONS' === $request->getMethod()) { + return $this->responseFactory->createResponse(204); + } + + // No GET-opened notification stream and no DELETE teardown: without a + // session there is nothing for either to address. + if ('POST' !== $request->getMethod()) { + return $this->json( + json_encode(Error::forInvalidRequest(\sprintf('The modern lifecycle accepts POST only, got %s.', $request->getMethod())), \JSON_THROW_ON_ERROR), + 405, + ); + } + + $body = $request->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + $payload = $this->readBody($body); + + if (null === $payload) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->json( + json_encode(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), \JSON_THROW_ON_ERROR), + 413, + ); + } + + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + $result = $this->protocol->handle($payload, $headers); + + if ($result->isStream()) { + return $this->sse($result->frames); + } + + if ($result->isEmpty()) { + return $this->responseFactory->createResponse($result->httpStatus); + } + + return $this->json($result->toJson(), $result->httpStatus); + } + + /** + * @param \Closure(): \Generator $frames + */ + private function sse(\Closure $frames): ResponseInterface + { + $logger = $this->logger; + + $callback = static function () use ($frames, $logger): void { + try { + foreach ($frames() as $frame) { + // A null frame is a keep-alive tick, not a message: it goes + // out as an SSE comment, which the client ignores and which + // gives PHP the write it needs to notice a dropped peer. + echo null === $frame + ? ": keep-alive\n\n" + : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; + flush(); + } + } catch (\Throwable $e) { + // The status line and headers are long gone by the time a frame + // fails, so there is no way to turn this into an error response. + // Log it and let the stream end; the client sees a close without + // the graceful-closure frame, which is exactly what it means. + $logger->error('Subscription stream ended with an error.', ['exception' => $e]); + } + }; + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody(new CallbackStream($callback, $this->logger)); + } + + /** + * Reads the request body, bounded by {@see self::$maxBodyBytes}. + * + * Returns the body contents, or `null` when the payload exceeds the cap. + * A single `read()` call is not enough: PSR-7 allows it to return fewer + * bytes than requested, so a body arriving across more than one physical + * read would otherwise be silently truncated. Reading incrementally to + * EOF is what {@see StreamableHttpTransport::readBody()} already does for + * the same reason. + */ + private function readBody(StreamInterface $body): ?string + { + $size = $body->getSize(); + if (null !== $size && $size > $this->maxBodyBytes) { + return null; + } + + $contents = ''; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ('' === $chunk) { + break; + } + + $contents .= $chunk; + if (\strlen($contents) > $this->maxBodyBytes) { + return null; + } + } + + return $contents; + } + + private function json(string $payload, int $status): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } +} diff --git a/src/Server/Wire/Rev2025Codec.php b/src/Server/Wire/Rev2025Codec.php new file mode 100644 index 0000000..016a78b --- /dev/null +++ b/src/Server/Wire/Rev2025Codec.php @@ -0,0 +1,32 @@ + + */ +final class Rev2025Codec implements WireCodecInterface +{ + public function encodeResult(string $method, array $result): array + { + return $result; + } +} diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php new file mode 100644 index 0000000..d08ea06 --- /dev/null +++ b/src/Server/Wire/Rev2026Codec.php @@ -0,0 +1,159 @@ + + */ +final class Rev2026Codec implements WireCodecInterface +{ + /** + * Methods whose result vocabulary goes beyond `complete`, because they can + * come back asking for input (MRTR). + * + * `subscriptions/listen` is deliberately absent: it never sends an ordinary + * result, so there is nothing for the stamp to reach. + */ + public const EXTENDED_RESULT_TYPE_METHODS = [ + 'tools/call', + 'prompts/get', + 'resources/read', + ]; + + /** Methods whose results a client may cache, and which therefore MUST carry hints. */ + public const CACHEABLE_METHODS = [ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read', + ]; + + public function __construct( + private readonly ?Implementation $serverInfo = null, + private readonly CacheScope $defaultCacheScope = CacheScope::Private, + private readonly int $defaultTtlMs = 0, + ) { + } + + public function encodeResult(string $method, array $result): array + { + return $this->stampServerInfo( + $this->fillCacheHints($method, $this->stampResultType($method, $result)), + ); + } + + /** + * A result that already names its own type keeps it, but only where the + * method's vocabulary allows more than `complete`. Elsewhere the value + * would be meaningless to the client, and quietly rewriting it would bury + * a server bug that is better surfaced by the handler. + * + * @param array $result + * + * @return array + */ + private function stampResultType(string $method, array $result): array + { + $provided = $result['resultType'] ?? null; + + if (null === $provided) { + return [...$result, 'resultType' => ResultType::Complete->value]; + } + + if (ResultType::Complete->value === $provided || \in_array($method, self::EXTENDED_RESULT_TYPE_METHODS, true)) { + return $result; + } + + return [...$result, 'resultType' => ResultType::Complete->value]; + } + + /** + * Fills `ttlMs`/`cacheScope`, most-specific author first: a valid value the + * result already carries wins over the server's configured policy, which + * wins over the conservative default of "private, do not cache". + * + * @param array $result + * + * @return array + */ + private function fillCacheHints(string $method, array $result): array + { + if (ResultType::Complete->value !== ($result['resultType'] ?? null)) { + return $result; + } + + if (!\in_array($method, self::CACHEABLE_METHODS, true)) { + return $result; + } + + $ttl = $result['ttlMs'] ?? null; + $scope = $result['cacheScope'] ?? null; + + // An invalid authored value is dropped rather than repaired: it cannot + // go on the wire, and the next author down already has a usable answer. + if (!\is_int($ttl) || $ttl < 0) { + $ttl = $this->defaultTtlMs; + } + + if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) { + $scope = $this->defaultCacheScope->value; + } + + return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope]; + } + + /** + * Servers SHOULD identify themselves on every response. A result that + * already carries an identity keeps it; without a configured one this is + * the identity function. + * + * @param array $result + * + * @return array + */ + private function stampServerInfo(array $result): array + { + if (null === $this->serverInfo) { + return $result; + } + + $meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : []; + + if (isset($meta[RequestMeta::SERVER_INFO])) { + return $result; + } + + $meta[RequestMeta::SERVER_INFO] = $this->serverInfo; + + return [...$result, '_meta' => $meta]; + } +} diff --git a/src/Server/Wire/WireCodecInterface.php b/src/Server/Wire/WireCodecInterface.php new file mode 100644 index 0000000..c34870a --- /dev/null +++ b/src/Server/Wire/WireCodecInterface.php @@ -0,0 +1,49 @@ + + */ +interface WireCodecInterface +{ + /** + * Stamps an already-serialized result with whatever this era requires. + * + * @param string $method the request method the result answers + * @param array $result the neutral result body + * + * @return array + */ + public function encodeResult(string $method, array $result): array; +} diff --git a/tests/Conformance/Fixtures/docker-compose.yml b/tests/Conformance/Fixtures/docker-compose.yml index 62e2e8b..11a84a6 100644 --- a/tests/Conformance/Fixtures/docker-compose.yml +++ b/tests/Conformance/Fixtures/docker-compose.yml @@ -2,7 +2,9 @@ services: nginx: image: nginx:1.26-alpine ports: - - "8000:80" + # Overridable so the fixture can coexist with whatever else a developer + # already has on 8000; CI and the Makefile default keep using 8000. + - "${CONFORMANCE_PORT:-8000}:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ../../..:/app:ro diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf index 9159c46..4f2f414 100644 --- a/tests/Conformance/Fixtures/nginx.conf +++ b/tests/Conformance/Fixtures/nginx.conf @@ -3,6 +3,14 @@ server { server_name localhost; root /app; + # The two lifecycles are served side by side so one conformance run can + # target either without restarting the fixture: / speaks the handshake era + # (2025-11-25 and earlier), /stateless speaks the modern era (SEP-2575). + # Declared before `location /` so the prefix match wins. + location /stateless { + try_files $uri /tests/Conformance/server-stateless.php$is_args$args; + } + location / { try_files $uri /tests/Conformance/server.php$is_args$args; } diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml new file mode 100644 index 0000000..52bf84d --- /dev/null +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -0,0 +1,71 @@ +# Expected conformance failures for spec revision 2026-07-28. +# +# Separate from conformance-baseline.yml because the two revisions exercise +# different lifecycles against different endpoints: the dated baseline covers +# the handshake era served at /, this one covers the modern (SEP-2575) era +# served at /stateless. +# +# Entries are per-check (scenario:check-id) rather than whole scenarios, so a +# scenario that is mostly conformant keeps reporting the parts that work and a +# new regression inside it still fails the run. +# +# Run with: +# make conformance-draft-server +# +# Absent by design, because they pass in full: server-stateless (the SEP-2575 +# lifecycle), caching (SEP-2549 hints), and every wire-schema-valid check bar +# one noted below. + +server: + # --- SEP-2243: standard HTTP headers --------------------------------------- + # Mcp-Method / Mcp-Name validation and the X-MCP-* parameter rules are not + # implemented. Self-contained and the largest remaining block; a good next + # chunk after MRTR. + - http-header-validation:sep-2243-server-reject-invalid-headers + - http-custom-header-server-validation:sep-2243-server-no-xmcp-tool + - http-custom-header-server-validation:sep-2243-server-decode-base64 + - http-custom-header-server-validation:sep-2243-server-validate-param-match + - http-custom-header-server-validation:sep-2243-server-reject-invalid-param-chars + - http-custom-header-server-validation:sep-2243-server-reject-param-mismatch + + # --- SEP-2322: the MRTR retry half ----------------------------------------- + # InputRequiredResult exists and tools/call can answer with one — that is what + # makes server-stateless green. What is missing is the second round trip: the + # SDK does not read inputResponses, verify requestState integrity, or resume a + # call from it. Until it does, every scenario that retries fails at the retry. + - input-required-result-basic-elicitation:sep-2322-elicitation-incomplete + - input-required-result-basic-list-roots:sep-2322-list-roots-incomplete + - input-required-result-basic-sampling:sep-2322-sampling-incomplete + - input-required-result-capability-check:sep-2322-respect-client-capabilities + - input-required-result-multi-round:sep-2322-multi-round-r1 + - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete + - input-required-result-non-tool-request:sep-2322-non-tool-incomplete + - input-required-result-request-state:sep-2322-request-state-incomplete + - input-required-result-result-type:sep-2322-result-type-included + - input-required-result-tampered-state:sep-2322-reject-tampered-state + + # --- SHOULD-level warnings ------------------------------------------------- + # These report WARNING rather than FAILURE (the severity follows the spec + # keyword), so the run summary counts them as passed — but the baseline + # evaluator still wants them declared, and declaring them is right: each is a + # SHOULD the SDK does not yet honour. + - http-header-validation:sep-2243-server-reject-error-code + - input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params + - input-required-result-missing-input-response:sep-2322-missing-response-rerequests + - sep-2164-resource-not-found:sep-2164-data-uri + - sep-2164-resource-not-found:sep-2164-error-code + + # --- Pre-existing, not lifecycle-specific ---------------------------------- + # Each of these fails identically on the handshake endpoint at /, so none is + # caused by (or fixable in) the modern lifecycle. Listed so the draft run is + # honest, not to excuse them — resources-templates-read in particular is the + # single wire-schema failure left, a resource-template URI the fixture emits + # in a shape the schema rejects on both revisions. + - dns-rebinding-protection:localhost-host-rebinding-rejected + - json-schema-2020-12:json-schema-2020-12-tool-found + - prompts-get-embedded-resource:prompts-get-embedded-resource + - prompts-get-with-image:prompts-get-with-image + - resources-templates-read:wire-schema-valid + - tools-call-with-progress:tools-call-with-progress + +client: [] diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php new file mode 100644 index 0000000..5bb2bb1 --- /dev/null +++ b/tests/Conformance/server-stateless.php @@ -0,0 +1,107 @@ +createServerRequestFromGlobals(); + +$protocol = Server::builder() + ->setServerInfo('mcp-conformance-test-server', '1.0.0') + ->setLogger($logger) + // Tools + ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') + ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') + ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') + ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') + ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') + ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + // Exercises the -32021 path: the tool needs to call back into the client, + // which a client that never declared `sampling` cannot service. + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); + }, + name: 'test_missing_capability', + description: 'Always reports a missing client capability, for testing -32021 handling', + ) + // Asks for input the MRTR way: the ask travels back inside the result, so + // the response stream never carries a server-initiated request. + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult( + [ + 'conformance_probe' => new ElicitRequest( + 'Please provide a value for the conformance probe.', + new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), + ), + ], + requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), + ), + name: 'test_streaming_elicitation', + description: 'Returns an InputRequiredResult asking for elicitation input', + ) + // The logger writes to the fixture's log file, never to the wire: a modern + // server emits notifications/message only when the request asked for a log + // level, and this one deliberately does not. + ->addTool( + static function () use ($logger): string { + $logger->info('test_logging_tool executed'); + + return 'Logged.'; + }, + name: 'test_logging_tool', + description: 'Emits a server-side log message while returning normally', + ) + // Resources + ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') + ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') + ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') + // Prompts + ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') + ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +$transport = new StatelessHttpTransport($protocol, logger: $logger); + +(new SapiEmitter())->emit($transport->handle($request)); From de35ca40bf4a07a2a0732e79c14bbccd2f9049d2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 22:27:05 +0200 Subject: [PATCH 100/143] [Server][Tests] Validate the standard MCP request headers (SEP-2243) (#445) The revision puts the method, the subject name and the mirrored arguments in headers so an intermediary can route and cache without parsing the body. A header that contradicts the body it travels with therefore has to be refused, not ignored: StandardHeaderValidator compares each against the request and answers -32020 on a mismatch. Set with Builder::setHeaderValidator(); a transport with no header layer is unaffected. --- CHANGELOG.md | 1 + src/Server/Builder.php | 21 ++ .../Stateless/StandardHeaderValidator.php | 265 ++++++++++++++++++ src/Server/Stateless/StatelessProtocol.php | 9 + .../conformance-baseline-2026-07-28.yml | 16 +- tests/Conformance/server-stateless.php | 15 + .../Stateless/StandardHeaderValidatorTest.php | 165 +++++++++++ 7 files changed, 478 insertions(+), 14 deletions(-) create mode 100644 src/Server/Stateless/StandardHeaderValidator.php create mode 100644 tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c4558..1f816ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. * [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 55bf184..ac2d223 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -228,6 +229,8 @@ final class Builder private bool $lazyLoading = true; + private bool $headerValidation = true; + /** * Sets the server's identity. Required. * @@ -389,6 +392,21 @@ final class Builder return $this; } + /** + * Controls whether {@see self::buildStateless()} checks the standard MCP + * request headers (SEP-2243) against the JSON-RPC body. On by default. + * + * Disable for a `StatelessProtocol` served by a transport with no header + * layer — the validator would otherwise reject every request for + * carrying none of the standard headers. + */ + public function setHeaderValidator(bool $headerValidation = true): self + { + $this->headerValidation = $headerValidation; + + return $this; + } + /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ @@ -717,6 +735,7 @@ final class Builder configuration: $parts['configuration'], supportedVersions: $supportedVersions, logger: $parts['logger'], + headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, ); } @@ -728,6 +747,7 @@ final class Builder * configuration: Configuration, * messageFactory: MessageFactory, * sessionManager: SessionManagerInterface, + * registry: RegistryInterface, * requestHandlers: list>, * notificationHandlers: list, * } @@ -824,6 +844,7 @@ final class Builder return [ 'logger' => $logger, + 'registry' => $registry, 'configuration' => $configuration, 'messageFactory' => $messageFactory, 'sessionManager' => $sessionManager, diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php new file mode 100644 index 0000000..3d77a3c --- /dev/null +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -0,0 +1,265 @@ + + */ +final class StandardHeaderValidator +{ + public const METHOD_HEADER = 'Mcp-Method'; + public const NAME_HEADER = 'Mcp-Name'; + public const PARAM_HEADER_PREFIX = 'Mcp-Param-'; + + /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ + private const BASE64_PREFIX = '=?base64?'; + private const BASE64_SUFFIX = '?='; + + public function __construct( + private readonly ?RegistryInterface $registry = null, + ) { + } + + /** + * @param array|null $params + * @param array $headers + * + * @return string|null the reason to reject, or null when the request is consistent + */ + public function validate(string $method, ?array $params, array $headers): ?string + { + return $this->checkMethod($method, $headers) + ?? $this->checkName($method, $params, $headers) + ?? $this->checkParams($method, $params, $headers); + } + + /** + * @param array $headers + */ + private function checkMethod(string $method, array $headers): ?string + { + $declared = $this->header($headers, self::METHOD_HEADER); + + if (null === $declared) { + return \sprintf('Missing required %s header (body method is "%s").', self::METHOD_HEADER, $method); + } + + if ($declared !== $method) { + return \sprintf('%s header "%s" does not match the body method "%s".', self::METHOD_HEADER, $declared, $method); + } + + return null; + } + + /** + * `Mcp-Name` names whatever the request acts on, which is a different + * member per method. When the body carries one, the header must repeat it; + * when it does not, the header has nothing to agree with and the server + * must not demand one. + * + * @param array|null $params + * @param array $headers + */ + private function checkName(string $method, ?array $params, array $headers): ?string + { + $expected = self::nameFor($method, $params); + $declared = $this->header($headers, self::NAME_HEADER); + + if (null === $expected) { + return null; + } + + if (null === $declared) { + return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected); + } + + if ($declared !== $expected) { + return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $declared, $expected); + } + + return null; + } + + /** + * The subject of a request, per method. Anything not listed carries no + * name, and so is exempt from the header entirely. + * + * @param array|null $params + */ + public static function nameFor(string $method, ?array $params): ?string + { + $value = match ($method) { + 'tools/call', 'prompts/get' => $params['name'] ?? null, + 'resources/read' => $params['uri'] ?? null, + 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, + default => null, + }; + + return \is_string($value) ? $value : null; + } + + /** + * Tool arguments a tool marked with `x-mcp-header` are mirrored into + * `Mcp-Param-*` headers, and must survive the round trip unchanged. + * + * Only headers the *tool* declares are checked. An unrecognized + * `Mcp-Param-*` belongs to somebody else in the chain — the spec has + * intermediaries forward what they do not understand — so rejecting it + * would break traffic this server has no stake in. + * + * @param array|null $params + * @param array $headers + */ + private function checkParams(string $method, ?array $params, array $headers): ?string + { + if ('tools/call' !== $method || null === $this->registry) { + return null; + } + + $toolName = $params['name'] ?? null; + if (!\is_string($toolName)) { + return null; + } + + try { + $tool = $this->registry->getTool($toolName)->tool; + } catch (ToolNotFoundException) { + // Not this validator's error to report: the handler will answer + // with a proper tool-not-found rather than a header complaint. + return null; + } + + $properties = $tool->inputSchema['properties'] ?? []; + if (!\is_array($properties)) { + return null; + } + + $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition) || !\is_string($definition['x-mcp-header'] ?? null)) { + continue; + } + + $error = $this->checkParam( + self::PARAM_HEADER_PREFIX.$definition['x-mcp-header'], + $headers, + \array_key_exists($property, $arguments) ? $arguments[$property] : null, + ); + + if (null !== $error) { + return $error; + } + } + + return null; + } + + /** + * @param array $headers + */ + private function checkParam(string $headerName, array $headers, mixed $argument): ?string + { + $declared = $this->header($headers, $headerName); + + // A null or omitted argument means the client omits the header, and the + // server must not expect one. + if (null === $argument) { + return null; + } + + if (null === $declared) { + return \sprintf('Missing required %s header (body carries the mirrored argument).', $headerName); + } + + $decoded = self::decode($declared); + + if (null === $decoded) { + return \sprintf('%s header is not a well-formed Base64 wrapper.', $headerName); + } + + // Numbers travel as their decimal string, booleans as "true"/"false" — + // so the comparison is between the decoded header and the argument + // rendered the same way, not between PHP values. + $expected = match (true) { + \is_bool($argument) => $argument ? 'true' : 'false', + \is_scalar($argument) => (string) $argument, + default => null, + }; + + if (null === $expected) { + return null; + } + + if ($decoded !== $expected) { + return \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); + } + + return null; + } + + /** + * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. + * + * Decoding is strict. PHP's decoder is happy to accept mispadded or + * out-of-alphabet input and hand back plausible-looking bytes, which would + * turn a corrupted header into a silent mismatch — or worse, a silent + * match. Returns null when the wrapper is present but its contents are not + * valid Base64. + */ + public static function decode(string $value): ?string + { + if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { + return $value; + } + + $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); + + $decoded = base64_decode($encoded, true); + + if (false === $decoded || base64_encode($decoded) !== $encoded) { + return null; + } + + return $decoded; + } + + /** + * Header names compare case-insensitively, and values carry no meaning in + * the optional whitespace around them (RFC 9110 §5.5). + * + * @param array $headers + */ + private function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return trim($value); + } + } + + return null; + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index a5bb31d..27a231d 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -90,6 +90,7 @@ final class StatelessProtocol private readonly LoggerInterface $logger = new NullLogger(), private readonly float $subscriptionLifetime = 30.0, ?WireCodecInterface $codec = null, + private readonly ?StandardHeaderValidator $headerValidator = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo); } @@ -140,6 +141,14 @@ final class StatelessProtocol return $versionError; } + // After the version is settled, because a peer on the wrong revision + // has a more fundamental problem than a header that disagrees with its + // body — and telling it about the headers first would send it fixing + // the wrong thing. + if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) { + return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400); + } + if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) { // Both answer with a single response tied to this request's id, // unlike a genuine notification — so unlike the general dispatch diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 52bf84d..a5af23f 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -13,21 +13,10 @@ # make conformance-draft-server # # Absent by design, because they pass in full: server-stateless (the SEP-2575 -# lifecycle), caching (SEP-2549 hints), and every wire-schema-valid check bar -# one noted below. +# lifecycle), caching (SEP-2549 hints), both SEP-2243 header-validation +# scenarios, and every wire-schema-valid check bar one noted below. server: - # --- SEP-2243: standard HTTP headers --------------------------------------- - # Mcp-Method / Mcp-Name validation and the X-MCP-* parameter rules are not - # implemented. Self-contained and the largest remaining block; a good next - # chunk after MRTR. - - http-header-validation:sep-2243-server-reject-invalid-headers - - http-custom-header-server-validation:sep-2243-server-no-xmcp-tool - - http-custom-header-server-validation:sep-2243-server-decode-base64 - - http-custom-header-server-validation:sep-2243-server-validate-param-match - - http-custom-header-server-validation:sep-2243-server-reject-invalid-param-chars - - http-custom-header-server-validation:sep-2243-server-reject-param-mismatch - # --- SEP-2322: the MRTR retry half ----------------------------------------- # InputRequiredResult exists and tools/call can answer with one — that is what # makes server-stateless green. What is missing is the second round trip: the @@ -49,7 +38,6 @@ server: # keyword), so the run summary counts them as passed — but the baseline # evaluator still wants them declared, and declaring them is right: each is a # SHOULD the SDK does not yet honour. - - http-header-validation:sep-2243-server-reject-error-code - input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params - input-required-result-missing-input-response:sep-2322-missing-response-rerequests - sep-2164-resource-not-found:sep-2164-data-uri diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php index 5bb2bb1..38a1f48 100644 --- a/tests/Conformance/server-stateless.php +++ b/tests/Conformance/server-stateless.php @@ -93,6 +93,21 @@ $protocol = Server::builder() name: 'test_logging_tool', description: 'Emits a server-side log message while returning normally', ) + // Mirrors its arguments into Mcp-Param-* headers (SEP-2243), so the header/ + // body agreement rules have something to be checked against. + ->addTool( + static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), + name: 'test_custom_headers', + description: 'Tests custom header mirroring via x-mcp-header', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + ], + 'required' => ['region'], + ], + ) // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php new file mode 100644 index 0000000..2e3e28a --- /dev/null +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -0,0 +1,165 @@ +validator = new StandardHeaderValidator(); + } + + #[TestDox('a request whose headers agree with its body passes')] + public function testConsistentRequestPasses(): void + { + $this->assertNull($this->validator->validate( + 'tools/call', + ['name' => 'do_thing', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'do_thing'], + )); + } + + #[TestDox('a missing Mcp-Method is rejected')] + public function testMissingMethodHeaderRejected(): void + { + $this->assertStringContainsString( + 'Mcp-Method', + (string) $this->validator->validate('tools/list', null, []), + ); + } + + #[TestDox('an Mcp-Method that disagrees with the body is rejected')] + public function testMismatchedMethodHeaderRejected(): void + { + $this->assertStringContainsString( + 'does not match', + (string) $this->validator->validate('tools/list', null, ['Mcp-Method' => 'prompts/list']), + ); + } + + #[TestDox('header names compare case-insensitively')] + #[DataProvider('headerCasings')] + public function testHeaderNameCasingIsIgnored(string $name): void + { + $this->assertNull($this->validator->validate('tools/list', null, [$name => 'tools/list'])); + } + + /** + * @return iterable + */ + public static function headerCasings(): iterable + { + yield 'canonical' => ['Mcp-Method']; + yield 'lowercase' => ['mcp-method']; + yield 'uppercase' => ['MCP-METHOD']; + yield 'mixed' => ['mCp-MeThOd']; + } + + #[TestDox('surrounding whitespace is not part of a header value (RFC 9110 §5.5)')] + public function testOptionalWhitespaceIsTrimmed(): void + { + $this->assertNull($this->validator->validate( + 'tools/call', + ['name' => 'do_thing'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => ' do_thing '], + )); + } + + #[TestDox('Mcp-Name is required when the body carries a name, and rejected when it disagrees')] + public function testNameHeaderMustMatchBody(): void + { + $body = ['name' => 'do_thing']; + + $this->assertStringContainsString('Missing required Mcp-Name', (string) $this->validator->validate( + 'tools/call', + $body, + ['Mcp-Method' => 'tools/call'], + )); + + $this->assertStringContainsString('does not match', (string) $this->validator->validate( + 'tools/call', + $body, + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'something_else'], + )); + } + + #[TestDox('a method that carries no name does not require the header')] + public function testNamelessMethodNeedsNoNameHeader(): void + { + $this->assertNull($this->validator->validate('tools/list', null, ['Mcp-Method' => 'tools/list'])); + } + + #[TestDox('each method names its subject through its own params member')] + #[DataProvider('nameSources')] + public function testNameIsReadFromTheRightMember(string $method, array $params, ?string $expected): void + { + $this->assertSame($expected, StandardHeaderValidator::nameFor($method, $params)); + } + + /** + * @return iterable, ?string}> + */ + public static function nameSources(): iterable + { + yield 'tools/call uses name' => ['tools/call', ['name' => 'a'], 'a']; + yield 'prompts/get uses name' => ['prompts/get', ['name' => 'b'], 'b']; + yield 'resources/read uses uri' => ['resources/read', ['uri' => 'test://c'], 'test://c']; + yield 'tasks/get uses taskId' => ['tasks/get', ['taskId' => 'd'], 'd']; + yield 'tools/list has no subject' => ['tools/list', [], null]; + // A non-string value is not a name: treating it as one would compare a + // header against something that was never a header value. + yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null]; + } + + #[TestDox('a plain header value decodes to itself')] + public function testPlainValuePassesThroughDecode(): void + { + $this->assertSame('Hello', StandardHeaderValidator::decode('Hello')); + } + + #[TestDox('a well-formed Base64 wrapper decodes to its contents')] + public function testValidBase64Decodes(): void + { + $this->assertSame('Hello', StandardHeaderValidator::decode('=?base64?'.base64_encode('Hello').'?=')); + } + + #[TestDox('malformed Base64 is refused rather than salvaged')] + #[DataProvider('malformedBase64')] + public function testMalformedBase64IsRefused(string $encoded): void + { + // PHP's decoder will happily return bytes for most of these. Accepting + // them would let a corrupted header silently compare equal — or, worse, + // silently unequal — instead of being reported as the transport-level + // fault it is. + $this->assertNull(StandardHeaderValidator::decode('=?base64?'.$encoded.'?=')); + } + + /** + * @return iterable + */ + public static function malformedBase64(): iterable + { + yield 'missing padding' => ['SGVsbG8']; + yield 'out-of-alphabet character' => ['SGVsbG8!']; + yield 'stray whitespace' => ['SGVs bG8=']; + } +} From ef0c1c2808e066d37258fa89dc69b054197f6f69 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 22:45:35 +0200 Subject: [PATCH 101/143] [Server][Tests] Complete the multi round-trip request flow (SEP-2322) (#446) The revision has no way for a server to put a request on a response stream, so a handler that needs input from the user says so in its result instead: a handler returning InputRequiredResult comes back as resultType: "input_required" with the asks and an opaque requestState, and the client retries the same request carrying inputResponses. RequestContext::getInputContext() is what the handler reads them through. RequestStateCodec signs and time-bounds the state, so a tampered or expired one is refused rather than trusted - the state travels through the client, which is not a place to keep something the server later believes. --- CHANGELOG.md | 1 + Makefile | 9 +- src/Exception/MissingRequestMetaException.php | 6 +- ...ssingRequiredClientCapabilityException.php | 11 +- src/Exception/RequestStateException.php | 24 ++ src/Schema/Enum/CacheScope.php | 10 +- src/Schema/Enum/ResultType.php | 10 +- src/Schema/Result/DiscoverResult.php | 15 +- src/Schema/Result/InputRequiredResult.php | 38 +--- src/Server/Builder.php | 35 ++- .../Handler/Request/CallToolHandler.php | 8 +- .../Handler/Request/GetPromptHandler.php | 10 +- src/Server/RequestContext.php | 47 ++++ src/Server/Stateless/InputContext.php | 63 ++++++ src/Server/Stateless/NotificationFilter.php | 18 +- src/Server/Stateless/RequestMeta.php | 21 +- src/Server/Stateless/RequestStateCodec.php | 125 +++++++++++ .../Stateless/StandardHeaderValidator.php | 50 ++--- src/Server/Stateless/StatelessProtocol.php | 140 +++++++----- src/Server/Stateless/StatelessResult.php | 22 +- .../Transport/StatelessHttpTransport.php | 61 +++-- src/Server/Wire/Rev2025Codec.php | 8 +- src/Server/Wire/Rev2026Codec.php | 42 +--- src/Server/Wire/WireCodecInterface.php | 25 +-- tests/Conformance/Fixtures/docker-compose.yml | 3 +- tests/Conformance/Fixtures/nginx.conf | 6 +- tests/Conformance/MrtrElements.php | 212 ++++++++++++++++++ .../conformance-baseline-2026-07-28.yml | 50 ++--- tests/Conformance/server-stateless.php | 38 ++-- .../Stateless/RequestStateCodecTest.php | 128 +++++++++++ .../Stateless/StandardHeaderValidatorTest.php | 11 +- 31 files changed, 906 insertions(+), 341 deletions(-) create mode 100644 src/Exception/RequestStateException.php create mode 100644 src/Server/Stateless/InputContext.php create mode 100644 src/Server/Stateless/RequestStateCodec.php create mode 100644 tests/Conformance/MrtrElements.php create mode 100644 tests/Unit/Server/Stateless/RequestStateCodecTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f816ed..e21bb06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. diff --git a/Makefile b/Makefile index 1ad8675..b019429 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ .PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client check-conformance-repo docs -# The published @modelcontextprotocol/conformance release carries no 2026-07-28 -# scenarios, so the draft targets below run a local checkout of the conformance -# repo instead. Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`. +# The published conformance release carries no 2026-07-28 scenarios, so the +# draft targets run a local checkout instead. +# Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`. CONFORMANCE_REPO ?= $(CURDIR)/../conformance CONFORMANCE_DRAFT = node $(CONFORMANCE_REPO)/dist/index.js @@ -47,8 +47,7 @@ conformance-client: php tests/Conformance/score.php client # --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ -# Local-checkout only for now; not wired into CI until upstream publishes a -# release carrying the draft scenarios. +# Local-checkout only until upstream publishes the draft scenarios. conformance-draft: conformance-draft-server conformance-draft-client diff --git a/src/Exception/MissingRequestMetaException.php b/src/Exception/MissingRequestMetaException.php index ed7d9d6..1499205 100644 --- a/src/Exception/MissingRequestMetaException.php +++ b/src/Exception/MissingRequestMetaException.php @@ -12,10 +12,8 @@ namespace Mcp\Exception; /** - * A modern-era request omitted metadata the server structurally needs to - * answer it — the protocol version or the client's capabilities. - * - * Answered with a JSON-RPC `-32602 Invalid params` error and HTTP 400. + * A modern-era request omitted the protocol version or client capabilities. + * Answered with `-32602 Invalid params` and HTTP 400. * * @author Christopher Hertel */ diff --git a/src/Exception/MissingRequiredClientCapabilityException.php b/src/Exception/MissingRequiredClientCapabilityException.php index 66f6d06..aa8b031 100644 --- a/src/Exception/MissingRequiredClientCapabilityException.php +++ b/src/Exception/MissingRequiredClientCapabilityException.php @@ -14,16 +14,11 @@ namespace Mcp\Exception; use Mcp\Schema\ClientCapabilities; /** - * Answering the request needs a client capability the request never declared. - * - * In the handshake era a server learned the client's capabilities once, at - * `initialize`, and could refuse to advertise features the client could not - * use. A modern request restates its capabilities every time, so the check - * moves to call time — a handler that needs to call back into the client - * throws this, and the protocol answers `-32021` with HTTP 400. + * Answering the request needs a client capability it never declared; the + * protocol answers `-32021` with HTTP 400. * * The capabilities travel as a {@see ClientCapabilities} object rather than a - * list of names so the client can compare them against what it would send. + * list of names, so the client can compare them against what it would send. * * @author Christopher Hertel */ diff --git a/src/Exception/RequestStateException.php b/src/Exception/RequestStateException.php new file mode 100644 index 0000000..2dde880 --- /dev/null +++ b/src/Exception/RequestStateException.php @@ -0,0 +1,24 @@ + + */ +class RequestStateException extends InvalidArgumentException +{ +} diff --git a/src/Schema/Enum/CacheScope.php b/src/Schema/Enum/CacheScope.php index f06c057..2c71cf8 100644 --- a/src/Schema/Enum/CacheScope.php +++ b/src/Schema/Enum/CacheScope.php @@ -14,13 +14,9 @@ namespace Mcp\Schema\Enum; /** * How widely a cacheable result may be reused (SEP-2549). * - * The analogue of HTTP's `Cache-Control: public` vs `private`. The distinction - * is a security boundary, not a performance knob: marking a result public - * allows shared gateways and proxies to serve it to *other* authorization - * contexts, so anything shaped by who asked must stay private. - * - * {@see self::Private} is therefore the default everywhere in this SDK — an - * over-cautious hint costs a cache hit, an over-broad one leaks a response. + * A security boundary, not a performance knob: `public` lets shared caches + * serve the result to other authorization contexts, so anything shaped by who + * asked must stay private — which is why private is the default throughout. * * @author Christopher Hertel */ diff --git a/src/Schema/Enum/ResultType.php b/src/Schema/Enum/ResultType.php index 6ba0f58..8319bc4 100644 --- a/src/Schema/Enum/ResultType.php +++ b/src/Schema/Enum/ResultType.php @@ -14,13 +14,9 @@ namespace Mcp\Schema\Enum; /** * Tells the client how to read a result before it looks at the body. * - * Introduced in 2026-07-28, where a request can come back either finished or - * asking for more input; without a discriminator the client would have to - * infer which by probing for fields. Servers on this revision MUST send it. - * - * Earlier revisions have no such field, and a client receiving a result - * without one MUST read it as {@see self::Complete} — which is what makes it - * safe to emit unconditionally rather than only on the newer revision. + * Required from 2026-07-28, where a request may come back finished or asking + * for input. A client seeing no `resultType` MUST read it as + * {@see self::Complete}. * * @author Christopher Hertel */ diff --git a/src/Schema/Result/DiscoverResult.php b/src/Schema/Result/DiscoverResult.php index d29a468..22222e9 100644 --- a/src/Schema/Result/DiscoverResult.php +++ b/src/Schema/Result/DiscoverResult.php @@ -20,13 +20,9 @@ use Mcp\Schema\ServerCapabilities; /** * The server's response to a `server/discover` request. * - * This is the modern era's replacement for `InitializeResult`. The difference - * is not just the method name: `initialize` *negotiates* a single version and - * remembers it for the connection, whereas `server/discover` merely *reports* - * every version this server speaks and lets each subsequent request pick one. - * - * Server identity travels in the result `_meta` rather than in a body - * `serverInfo` member (spec PR #3002 removed the latter). + * The modern era's replacement for `InitializeResult`: it reports every + * version this server speaks rather than negotiating one, and each request + * picks from that list. * * @author Christopher Hertel */ @@ -46,9 +42,8 @@ class DiscoverResult implements ResultInterface } /** - * Carries no `resultType`, caching hints or serverInfo identity: those are - * wire vocabulary, stamped by {@see \Mcp\Server\Wire\Rev2026Codec} on the - * way out. What is modelled here is only what discovery actually answers. + * `resultType`, caching hints and serverInfo are wire vocabulary, stamped + * by {@see \Mcp\Server\Wire\Rev2026Codec} rather than modelled here. * * @return array{ * supportedVersions: list, diff --git a/src/Schema/Result/InputRequiredResult.php b/src/Schema/Result/InputRequiredResult.php index a33d8fe..a0fca5e 100644 --- a/src/Schema/Result/InputRequiredResult.php +++ b/src/Schema/Result/InputRequiredResult.php @@ -20,18 +20,10 @@ use Mcp\Schema\Request\ListRootsRequest; /** * Tells the client the server needs more input before it can finish (MRTR). * - * This is how a modern-era server asks the client for anything — elicitation, - * sampling, roots. The handshake era let a server open its own request to the - * client mid-call; 2026-07-28 removed that outright, so the ask travels back as - * part of the *result* and the client re-sends the original call with the - * answers attached. The exchange is two independent requests, which is what - * lets any server instance serve the retry. - * - * `requestState` is the server's own context, opaque to the client and echoed - * back verbatim. Because it round-trips through the client it is - * attacker-controlled on return: anything that influences authorization or - * business logic MUST be integrity-protected and verified, and rejected when - * verification fails. + * The modern era removed server-initiated requests, so the ask travels back in + * the result and the client re-sends the original call with the answers. The + * `requestState` round-trips through the client and is therefore + * attacker-controlled on return — see {@see \Mcp\Server\Stateless\RequestStateCodec}. * * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr * @@ -49,9 +41,7 @@ class InputRequiredResult implements ResultInterface public readonly array $inputRequests = [], public readonly ?string $requestState = null, ) { - // "Servers MUST include at least one of inputRequests or requestState - // in every InputRequiredResult response." A result carrying neither - // tells the client to retry with nothing new, which loops forever. + // Neither member would tell the client to retry with nothing new. if ([] === $this->inputRequests && null === $this->requestState) { throw new InvalidArgumentException('An InputRequiredResult must carry at least one of "inputRequests" or "requestState".'); } @@ -71,21 +61,17 @@ class InputRequiredResult implements ResultInterface if ([] !== $this->inputRequests) { $requests = []; foreach ($this->inputRequests as $key => $request) { - // The map values are bare method/params pairs rather than whole - // JSON-RPC requests: they are not messages in their own right, - // and giving them ids would imply a correlation the retry does - // not use — the client keys its answers by the map key instead. - // - // Params are reachable only through the JSON-RPC envelope - // (getParams() is protected), and rendering that envelope needs - // an id these requests never have. Hence the throwaway id: it - // exists for the length of this expression and is discarded - // with the envelope it was needed to build. + // Values are bare method/params pairs, not messages: the client + // keys answers by the map key. getParams() is protected, so the + // envelope is built with a throwaway id and then discarded. $envelope = $request->withId(0)->jsonSerialize(); + $params = $envelope['params'] ?? null; + $requests[$key] = [ 'method' => $request::getMethod(), - 'params' => $envelope['params'] ?? new \stdClass(), + // An empty PHP array would encode as `[]`, not `{}`. + 'params' => [] === $params || null === $params ? new \stdClass() : $params, ]; } $data['inputRequests'] = $requests; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ac2d223..fe7fd33 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; use Psr\Container\ContainerInterface; @@ -106,6 +107,10 @@ final class Builder private ?ProtocolVersion $protocolVersion = null; + private ?string $requestStateKey = null; + + private int $requestStateTtl = 600; + /** * @var array> */ @@ -250,6 +255,25 @@ final class Builder return $this; } + /** + * Sets the key signing the `requestState` carried across the rounds of a + * multi round-trip request (SEP-2322). Without one, every echoed state is + * refused. + * + * The same key must reach every instance that might serve the retry, so a + * per-process random value only works for a single-process deployment. + * + * @param string $key at least 32 bytes + * @param int $ttl how long a minted state stays valid, in seconds + */ + public function setRequestState(string $key, int $ttl = 600): self + { + $this->requestStateKey = $key; + $this->requestStateTtl = $ttl; + + return $this; + } + /** * Configures the server's pagination limit. */ @@ -717,11 +741,9 @@ final class Builder /** * Builds a dispatcher for the modern (SEP-2575) lifecycle. * - * Everything the server exposes — tools, prompts, resources, and the - * handlers that serve them — is era-independent and shared with - * {@see self::build()}; only the dispatch model differs. So one builder - * configuration can drive either lifecycle, and a server can offer both at - * once by mounting each on its own endpoint. + * Tools, prompts, resources and their handlers are era-independent, so one + * builder configuration drives either lifecycle and a server can offer both + * by mounting each on its own endpoint. * * @param list $supportedVersions revisions this dispatcher will answer for */ @@ -736,6 +758,9 @@ final class Builder supportedVersions: $supportedVersions, logger: $parts['logger'], headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, + requestStateCodec: null !== $this->requestStateKey + ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) + : null, ); } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 749d1ca..8fa2ed7 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -30,8 +30,8 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * A tools/call can answer with the tool's output or, under MRTR, with a request - * for the input the tool still needs — hence the two result types. + * A tools/call answers with the tool's output or, under MRTR, with a request + * for the input it still needs. * * @implements RequestHandlerInterface * @@ -103,7 +103,7 @@ final class CallToolHandler implements RequestHandlerInterface try { $result = $this->referenceHandler->handle($reference, $arguments); - // Tool asking for more input as response + // An ask is a result in its own right, not tool output. if ($result instanceof InputRequiredResult) { return new Response($request->getId(), $result); } @@ -146,6 +146,8 @@ final class CallToolHandler implements RequestHandlerInterface return new Response($request->getId(), $result); } catch (MissingRequiredClientCapabilityException $e) { + // Not a tool failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. throw $e; } catch (ToolCallException $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 745b9b8..6b9a708 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -20,12 +20,13 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -44,7 +45,7 @@ final class GetPromptHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -61,6 +62,11 @@ final class GetPromptHandler implements RequestHandlerInterface $result = $this->referenceHandler->handle($reference, $arguments); + // An ask is a result in its own right, not prompt content. + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result); return new Response($request->getId(), new GetPromptResult($formatted)); diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 1a4f837..10df2b1 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -12,9 +12,14 @@ namespace Mcp\Server; use Mcp\Capability\Logger\ClientLogger; +use Mcp\Exception\LogicException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\InputContext; +use Mcp\Server\Stateless\RequestMeta; +use Mcp\Server\Stateless\RequestStateCodec; /** * Context related to a single request. This includes information about the session and @@ -82,6 +87,48 @@ final class RequestContext return $this->clientGateway; } + /** + * What a multi round-trip retry carried back, or null on a first call — + * which is the signal to return an + * {@see \Mcp\Schema\Result\InputRequiredResult} instead of an answer. + */ + public function getInputContext(): ?InputContext + { + $context = $this->session->get(InputContext::class); + + return $context instanceof InputContext ? $context : null; + } + + /** + * What the client declared on this request. A server MUST NOT ask for + * input the client cannot supply. Null in the handshake era, where + * capabilities are connection state. + */ + public function getClientCapabilities(): ?ClientCapabilities + { + $meta = $this->session->get(RequestMeta::class); + + return $meta instanceof RequestMeta ? $meta->clientCapabilities : null; + } + + /** + * Seals handler context into the string an + * {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client. + * Signed, not encrypted: nothing secret belongs in the payload. + * + * @param array $payload + */ + public function mintRequestState(array $payload): string + { + $codec = $this->session->get(RequestStateCodec::class); + + if (!$codec instanceof RequestStateCodec) { + throw new LogicException('No requestState signing key is configured; call Builder::setRequestState() before returning state from a handler.'); + } + + return $codec->mint($payload); + } + public function getClientLogger(): ClientLogger { if (null === $this->clientLogger) { diff --git a/src/Server/Stateless/InputContext.php b/src/Server/Stateless/InputContext.php new file mode 100644 index 0000000..9075109 --- /dev/null +++ b/src/Server/Stateless/InputContext.php @@ -0,0 +1,63 @@ + + */ +final class InputContext +{ + /** + * @param array $responses client answers, keyed as the inputRequests were + * @param array $requestState the verified payload the server sealed last round + */ + public function __construct( + private readonly array $responses = [], + private readonly array $requestState = [], + ) { + } + + /** + * The client's answer for `$key`, or null when it did not provide one. + */ + public function response(string $key): mixed + { + return $this->responses[$key] ?? null; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->responses); + } + + /** + * @return array + */ + public function all(): array + { + return $this->responses; + } + + /** + * @return array + */ + public function requestState(): array + { + return $this->requestState; + } +} diff --git a/src/Server/Stateless/NotificationFilter.php b/src/Server/Stateless/NotificationFilter.php index d44724e..ae80628 100644 --- a/src/Server/Stateless/NotificationFilter.php +++ b/src/Server/Stateless/NotificationFilter.php @@ -16,10 +16,8 @@ use Mcp\Schema\ServerCapabilities; /** * Which notification types a `subscriptions/listen` stream will carry. * - * The filter is an allow-list, not a hint: "The server MUST NOT send - * notification types the client has not explicitly requested." Omitting a - * field is the same as declining that type, so absence and `false` mean the - * same thing here and the type never needs to distinguish them. + * An allow-list, not a hint: a server MUST NOT send types the client did not + * request, and an omitted field declines just as `false` does. * * @author Christopher Hertel */ @@ -54,13 +52,8 @@ final class NotificationFilter } /** - * Narrows the filter to what this server can actually deliver. - * - * The acknowledgment must reflect "the subset the server agreed to honor", - * so a type the server does not support is dropped here rather than - * acknowledged and then silently never sent — a client comparing the - * acknowledgment against its request would otherwise wait forever for - * notifications that were never coming. + * Narrows the filter to what this server can deliver, so the acknowledgment + * reflects the subset it agreed to honour rather than promising silence. */ public function intersect(ServerCapabilities $capabilities): self { @@ -73,8 +66,7 @@ final class NotificationFilter } /** - * The acknowledgment's `notifications` member: agreed types only, with - * declined ones omitted rather than reported as `false`. + * Agreed types only; declined ones are omitted rather than sent as `false`. * * @return array */ diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php index 2304673..23b691c 100644 --- a/src/Server/Stateless/RequestMeta.php +++ b/src/Server/Stateless/RequestMeta.php @@ -20,15 +20,11 @@ use Mcp\Schema\Implementation; * The per-request metadata that replaces the `initialize` handshake in the * modern era (SEP-2575). * - * A handshake-era connection learns the protocol version, the client's - * identity and its capabilities once, then keeps them in session state. With - * no session to keep them in, every modern request restates them in - * `params._meta` — so this object is the stateless equivalent of the session - * a {@see \Mcp\Server\Protocol} run would have resolved. + * `protocolVersion` and `clientCapabilities` are structurally required; a + * server cannot decide how to answer without them. `clientInfo` is a SHOULD, + * and a request omitting it MUST NOT be refused. * - * `protocolVersion` and `clientCapabilities` are structurally required: a - * server cannot decide how to answer without them. `clientInfo` is not — it is - * a SHOULD, and servers MUST NOT refuse a request that omits it. + * @author Christopher Hertel */ final class RequestMeta { @@ -65,9 +61,7 @@ final class RequestMeta throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::PROTOCOL_VERSION)); } - // Required, but an empty object is a perfectly valid value: it declares - // a client that supports none of the optional capabilities. Only its - // absence — or a non-object — is a protocol error. + // An empty object is valid — a client with no optional capabilities. $capabilities = $meta[self::CLIENT_CAPABILITIES] ?? null; if (!\is_array($capabilities) && !$capabilities instanceof \stdClass) { throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::CLIENT_CAPABILITIES)); @@ -84,9 +78,8 @@ final class RequestMeta } /** - * An unparseable level is treated as "no level requested" rather than as a - * protocol error: log verbosity is a diagnostic preference, and rejecting - * the whole request over it would fail the caller's actual work. + * An unparseable level means "none requested": failing the caller's actual + * work over a diagnostic preference would be worse. */ private static function parseLogLevel(mixed $level): ?LoggingLevel { diff --git a/src/Server/Stateless/RequestStateCodec.php b/src/Server/Stateless/RequestStateCodec.php new file mode 100644 index 0000000..9dcf477 --- /dev/null +++ b/src/Server/Stateless/RequestStateCodec.php @@ -0,0 +1,125 @@ + + */ +final class RequestStateCodec +{ + /** Below this the MAC — the only thing making the blob trustworthy — is forgeable. */ + public const MINIMUM_KEY_BYTES = 32; + + private const ALGORITHM = 'sha256'; + + public function __construct( + private readonly string $key, + private readonly int $ttlSeconds = 600, + ) { + if (\strlen($this->key) < self::MINIMUM_KEY_BYTES) { + throw new InvalidArgumentException(\sprintf('The requestState signing key must be at least %d bytes, got %d.', self::MINIMUM_KEY_BYTES, \strlen($this->key))); + } + + if ($this->ttlSeconds < 1) { + throw new InvalidArgumentException(\sprintf('The requestState TTL must be at least one second, got %d.', $this->ttlSeconds)); + } + } + + /** + * @param array $payload server context to carry to the retry — never secrets + */ + public function mint(array $payload, ?int $now = null): string + { + $body = self::encode(json_encode([ + 'exp' => ($now ?? time()) + $this->ttlSeconds, + 'data' => $payload, + ], \JSON_THROW_ON_ERROR)); + + return $body.'.'.self::encode($this->sign($body)); + } + + /** + * @return array the payload that was sealed + * + * @throws RequestStateException when the value is malformed, unsigned by this key, or expired + */ + public function verify(string $state, ?int $now = null): array + { + $parts = explode('.', $state); + + if (2 !== \count($parts)) { + throw new RequestStateException('malformed'); + } + + [$body, $mac] = $parts; + + $expected = $this->sign($body); + $actual = self::decode($mac); + + // Constant-time: a short-circuiting compare is a byte-at-a-time oracle. + if (null === $actual || !hash_equals($expected, $actual)) { + throw new RequestStateException('mac'); + } + + $decoded = self::decode($body); + + if (null === $decoded) { + throw new RequestStateException('malformed'); + } + + try { + /** @var array $claims */ + $claims = json_decode($decoded, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new RequestStateException('malformed'); + } + + if (!\is_array($claims) || !\is_int($claims['exp'] ?? null) || !\is_array($claims['data'] ?? null)) { + throw new RequestStateException('malformed'); + } + + // Bounds replay; does not make the state single-use. + if ($claims['exp'] < ($now ?? time())) { + throw new RequestStateException('expired'); + } + + return $claims['data']; + } + + private function sign(string $body): string + { + return hash_hmac(self::ALGORITHM, $body, $this->key, true); + } + + private static function encode(string $raw): string + { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + private static function decode(string $encoded): ?string + { + $decoded = base64_decode(strtr($encoded, '-_', '+/'), true); + + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index 3d77a3c..85e7809 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -17,13 +17,9 @@ use Mcp\Exception\ToolNotFoundException; /** * Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243). * - * The headers exist so an intermediary — a gateway, a proxy, a rate limiter — - * can route and police MCP traffic without parsing the body. That only works - * if the two cannot disagree, so a server that *does* read the body is - * required to confirm they match and to reject the request when they do not. - * Otherwise a caller could show a gateway one request and the server another. - * - * Failures are all one kind: HTTP 400 with JSON-RPC `-32020`. + * The headers let an intermediary route MCP traffic without parsing the body, + * which only holds if the two cannot disagree — otherwise a caller could show + * a gateway one request and the server another. * * @author Christopher Hertel */ @@ -74,10 +70,8 @@ final class StandardHeaderValidator } /** - * `Mcp-Name` names whatever the request acts on, which is a different - * member per method. When the body carries one, the header must repeat it; - * when it does not, the header has nothing to agree with and the server - * must not demand one. + * When the body carries a name the header must repeat it; when it does not, + * the server must not demand one. * * @param array|null $params * @param array $headers @@ -103,8 +97,7 @@ final class StandardHeaderValidator } /** - * The subject of a request, per method. Anything not listed carries no - * name, and so is exempt from the header entirely. + * The subject of a request, per method. Anything unlisted is exempt. * * @param array|null $params */ @@ -121,13 +114,9 @@ final class StandardHeaderValidator } /** - * Tool arguments a tool marked with `x-mcp-header` are mirrored into - * `Mcp-Param-*` headers, and must survive the round trip unchanged. - * - * Only headers the *tool* declares are checked. An unrecognized - * `Mcp-Param-*` belongs to somebody else in the chain — the spec has - * intermediaries forward what they do not understand — so rejecting it - * would break traffic this server has no stake in. + * Only headers the tool itself declares are checked: an unrecognized + * `Mcp-Param-*` belongs to somebody else in the chain, and intermediaries + * are meant to forward what they do not understand. * * @param array|null $params * @param array $headers @@ -146,8 +135,7 @@ final class StandardHeaderValidator try { $tool = $this->registry->getTool($toolName)->tool; } catch (ToolNotFoundException) { - // Not this validator's error to report: the handler will answer - // with a proper tool-not-found rather than a header complaint. + // The handler reports this properly; a header complaint would not. return null; } @@ -184,8 +172,7 @@ final class StandardHeaderValidator { $declared = $this->header($headers, $headerName); - // A null or omitted argument means the client omits the header, and the - // server must not expect one. + // An omitted argument means an omitted header. if (null === $argument) { return null; } @@ -200,9 +187,7 @@ final class StandardHeaderValidator return \sprintf('%s header is not a well-formed Base64 wrapper.', $headerName); } - // Numbers travel as their decimal string, booleans as "true"/"false" — - // so the comparison is between the decoded header and the argument - // rendered the same way, not between PHP values. + // Numbers travel as decimal strings, booleans as "true"/"false". $expected = match (true) { \is_bool($argument) => $argument ? 'true' : 'false', \is_scalar($argument) => (string) $argument, @@ -223,11 +208,9 @@ final class StandardHeaderValidator /** * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. * - * Decoding is strict. PHP's decoder is happy to accept mispadded or - * out-of-alphabet input and hand back plausible-looking bytes, which would - * turn a corrupted header into a silent mismatch — or worse, a silent - * match. Returns null when the wrapper is present but its contents are not - * valid Base64. + * Strict: PHP's decoder accepts mispadded input and returns plausible + * bytes, which would turn a corrupted header into a silent mismatch. + * Null when the wrapper is present but its contents are not valid Base64. */ public static function decode(string $value): ?string { @@ -247,8 +230,7 @@ final class StandardHeaderValidator } /** - * Header names compare case-insensitively, and values carry no meaning in - * the optional whitespace around them (RFC 9110 §5.5). + * Case-insensitive name, whitespace-trimmed value (RFC 9110 §5.5). * * @param array $headers */ diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 27a231d..c9e7dcb 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -14,6 +14,7 @@ namespace Mcp\Server\Stateless; use Mcp\Exception\InvalidInputMessageException; use Mcp\Exception\MissingRequestMetaException; use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\Exception\RequestStateException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; @@ -33,17 +34,9 @@ use Psr\Log\NullLogger; /** * Dispatches a single modern-era (SEP-2575) request. * - * The handshake-era {@see \Mcp\Server\Protocol} is built around a session that - * outlives the request: it resolves one, replays `initialize` state into it, - * and keeps a fiber attached to it across HTTP round-trips. None of that - * survives into the modern era, where each request is self-describing and - * independent, so this is a separate dispatcher rather than a mode flag on the - * old one — the two eras share handlers, not control flow. - * - * Request handlers are reused verbatim. They expect a session, so each request - * gets an ephemeral one that is discarded when the request ends; handlers that - * merely stash per-request scratch state keep working, and handlers that tried - * to persist across requests would have had nothing to persist into anyway. + * Separate from {@see \Mcp\Server\Protocol} because the modern era has no + * session to resolve, replay or keep a fiber against; the two eras share + * request handlers, not control flow. * * @author Christopher Hertel */ @@ -52,10 +45,8 @@ final class StatelessProtocol private readonly WireCodecInterface $codec; /** - * Methods the modern era deleted outright. They are answered as unknown - * methods rather than as errors specific to each, because that is exactly - * what they are to a modern server — listing them separately only serves - * to document the intent. + * Methods the modern era deleted. Answered as unknown methods, which is + * what they are to a modern server. */ public const REMOVED_METHODS = [ 'initialize', @@ -91,6 +82,7 @@ final class StatelessProtocol private readonly float $subscriptionLifetime = 30.0, ?WireCodecInterface $codec = null, private readonly ?StandardHeaderValidator $headerValidator = null, + private readonly ?RequestStateCodec $requestStateCodec = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo); } @@ -141,10 +133,8 @@ final class StatelessProtocol return $versionError; } - // After the version is settled, because a peer on the wrong revision - // has a more fundamental problem than a header that disagrees with its - // body — and telling it about the headers first would send it fixing - // the wrong thing. + // After the version check: a peer on the wrong revision has a more + // fundamental problem than headers that disagree with its body. if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) { return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400); } @@ -165,9 +155,6 @@ final class StatelessProtocol return $this->listen($params, $id); } - // Both the deleted methods and never-known ones are "not found". The - // 404 pairs the HTTP layer with the JSON-RPC code so a client that - // routes on status alone reaches the same conclusion. if (\in_array($method, self::REMOVED_METHODS, true)) { return StatelessResult::error( Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id), @@ -179,14 +166,9 @@ final class StatelessProtocol } /** - * The header and `_meta` must agree on the version before that version can - * be judged supported or not. - * - * The order matters: when the two disagree, the server has been told two - * different things and cannot know which the client meant, so "these - * contradict" is the honest answer even if one of the two values happens to - * be unsupported. Reporting it as an unsupported version instead would send - * the client off to renegotiate a version that was never the problem. + * Header and `_meta` must agree before the version can be judged supported: + * when they disagree the server cannot know which the client meant, so a + * mismatch outranks an unsupported version. * * @param array $headers */ @@ -217,12 +199,8 @@ final class StatelessProtocol } /** - * Opens a `subscriptions/listen` stream. - * - * The subscription needs no identifier of its own: the spec defines it as - * the JSON-RPC id of this very request, which both sides already have. So - * there is no id to mint, store, or hand back — every frame is tagged with - * something the client chose. + * Opens a `subscriptions/listen` stream. The subscription id is the + * JSON-RPC id of this request, so there is none to mint. * * @param array|null $params */ @@ -243,20 +221,10 @@ final class StatelessProtocol ], ]; - // Nothing else is emitted yet: delivering list-changed and - // resource-updated notifications means observing mutations made by - // *other* requests, which under a share-nothing runtime needs a - // cross-process channel this SDK does not yet define. Holding the - // stream open for a bounded window keeps the subscription honest — - // the client sees an open, acknowledged stream — without pinning a - // worker indefinitely for events that cannot arrive. - // - // The tick is what makes that bound real. PHP only learns the peer - // hung up by trying to write to it, so a loop that merely sleeps - // holds its worker for the full lifetime after the client has gone. - // On a fixed-size FPM pool a handful of abandoned subscriptions is - // enough to starve every other request, which is exactly what makes - // it worth the extra yield. + // Cross-request notifications need a channel this SDK does not + // define yet, so the stream only waits. The tick is not optional: + // PHP spots a dropped peer by writing, and a sleeping loop would + // pin an FPM worker for the full lifetime. $deadline = microtime(true) + $lifetime; while (microtime(true) < $deadline) { yield null; @@ -268,8 +236,8 @@ final class StatelessProtocol usleep(250_000); } - // Graceful closure (SHOULD): tell the client the subscription ended - // deliberately, so it can distinguish this from a dropped transport. + // Graceful closure (SHOULD), so the client can tell this from a + // dropped transport. yield [ 'jsonrpc' => '2.0', 'id' => $id, @@ -342,6 +310,24 @@ final class StatelessProtocol $session = new Session(new InMemorySessionStore()); $session->set(RequestMeta::class, $meta); + try { + $input = $this->liftInputContext($decoded['params'] ?? null); + } catch (RequestStateException $e) { + // Invalid params, not an authorization failure: the client only + // echoes what it was given, and the reason stays out of the answer. + $this->logger->warning('Rejected a requestState that failed verification.', ['method' => $method, 'reason' => $e->getMessage()]); + + return StatelessResult::error(Error::forInvalidParams('The supplied requestState failed verification.', $id), 400); + } + + if (null !== $input) { + $session->set(InputContext::class, $input); + } + + if (null !== $this->requestStateCodec) { + $session->set(RequestStateCodec::class, $this->requestStateCodec); + } + foreach ($this->requestHandlers as $handler) { if (!$handler->supports($request)) { continue; @@ -373,19 +359,51 @@ final class StatelessProtocol } /** - * Runs a result through the wire codec on its way out. + * Reads the multi round-trip material off a retry, verifying the state + * before any of it reaches a handler. Neither member means a first call, + * which is what a handler tests to decide whether it still needs to ask. * - * The round-trip through JSON is what flattens the result — and everything - * nested inside it — into the plain arrays the codec stamps. Handing the - * codec objects instead would make it responsible for knowing how each one - * serializes, which is exactly the coupling this layer exists to avoid. + * @param array|null $params + * + * @throws RequestStateException when a state is present but does not verify + */ + private function liftInputContext(?array $params): ?InputContext + { + $responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null; + $state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null; + + if (null === $responses && null === $state) { + return null; + } + + // Answers are result objects; dropping non-objects leaves the handler + // to ask again rather than read a malformed retry as satisfied. + if (null !== $responses) { + $responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response)); + } + + $payload = []; + + if (null !== $state) { + // A server with no codec never minted a state, so this one cannot + // have come from here. + if (null === $this->requestStateCodec) { + throw new RequestStateException('mac'); + } + + $payload = $this->requestStateCodec->verify($state); + } + + return new InputContext($responses ?? [], $payload); + } + + /** + * Runs a result through the wire codec. Passed as-is rather than via a + * json round trip, which would turn a nested `{}` into `[]`. */ private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult { - /** @var array $body */ - $body = json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); - - return StatelessResult::ok($id, $this->codec->encodeResult($method, $body)); + return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize())); } /** diff --git a/src/Server/Stateless/StatelessResult.php b/src/Server/Stateless/StatelessResult.php index 6870a21..32ff88c 100644 --- a/src/Server/Stateless/StatelessResult.php +++ b/src/Server/Stateless/StatelessResult.php @@ -17,10 +17,8 @@ use Mcp\Schema\JsonRpc\MessageInterface; /** * One modern-era answer, paired with the HTTP status that carries it. * - * The pairing is the point. SEP-2575 fixes specific HTTP statuses to specific - * JSON-RPC error codes — 404 for a method that does not exist, 400 for a - * request the server could parse but not accept — so the status is part of the - * answer rather than something the transport re-derives from the error code. + * SEP-2575 fixes specific statuses to specific JSON-RPC error codes, so the + * status is part of the answer rather than something the transport re-derives. * * @author Christopher Hertel */ @@ -40,11 +38,8 @@ final class StatelessResult } /** - * A successful answer whose result body the wire codec has already stamped. - * - * The body arrives as a plain array rather than a result object because the - * codec's job is to add fields the result classes deliberately do not model - * — once it has run, there is no object left that describes the payload. + * A successful answer whose body the wire codec has already stamped — a + * plain array, since no result class models the fields it added. * * @param array $body */ @@ -69,12 +64,9 @@ final class StatelessResult } /** - * A long-lived answer delivered as a sequence of frames rather than one - * message — `subscriptions/listen` is the only such method today. - * - * The generator is deferred rather than eager because the frames are - * produced over the life of the connection: building them up front would - * defeat the point of streaming and hold the whole subscription in memory. + * A long-lived answer delivered as frames — today only + * `subscriptions/listen`. Deferred, since the frames are produced over the + * life of the connection. * * @param \Closure(): \Generator $frames */ diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php index 96210ca..0301f15 100644 --- a/src/Server/Transport/StatelessHttpTransport.php +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -14,22 +14,23 @@ namespace Mcp\Server\Transport; use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; +use Mcp\Server\Transport\Http\MiddlewareRequestHandler; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\MiddlewareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** * Carries the modern (SEP-2575) lifecycle over HTTP. * - * Deliberately not a mode of {@see StreamableHttpTransport}: that transport's - * job is largely session management — minting an `Mcp-Session-Id`, resuming a - * suspended fiber against it, tearing it down on DELETE — and a modern request - * has no session to manage. What is left is a POST in, one JSON-RPC message - * out, and a status code the protocol has already chosen. + * Not a mode of {@see StreamableHttpTransport}, whose job is largely session + * management; without a session what is left is a POST in, one message out. * * @author Christopher Hertel */ @@ -44,25 +45,60 @@ final class StatelessHttpTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + /** @var list */ + private array $middleware; + + /** + * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware + */ public function __construct( private readonly StatelessProtocol $protocol, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, private readonly LoggerInterface $logger = new NullLogger(), private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, + ?iterable $middleware = null, ) { + $this->middleware = null === $middleware + ? self::defaultMiddleware() + : array_values([...$middleware]); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } + /** + * Browser-facing protections, era-independent. The protocol-version + * middleware is absent: here the version travels in `_meta`, so a + * header-only check would judge half the story. + * + * @return list + */ + public static function defaultMiddleware(): array + { + return [ + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware(), + ]; + } + public function handle(ServerRequestInterface $request): ResponseInterface + { + $handler = new MiddlewareRequestHandler( + $this->middleware, + \Closure::fromCallable([$this, 'dispatch']), + ); + + return $handler->handle($request); + } + + private function dispatch(ServerRequestInterface $request): ResponseInterface { if ('OPTIONS' === $request->getMethod()) { return $this->responseFactory->createResponse(204); } - // No GET-opened notification stream and no DELETE teardown: without a - // session there is nothing for either to address. + // No GET stream and no DELETE teardown: there is no session to address. if ('POST' !== $request->getMethod()) { return $this->json( json_encode(Error::forInvalidRequest(\sprintf('The modern lifecycle accepts POST only, got %s.', $request->getMethod())), \JSON_THROW_ON_ERROR), @@ -113,19 +149,16 @@ final class StatelessHttpTransport $callback = static function () use ($frames, $logger): void { try { foreach ($frames() as $frame) { - // A null frame is a keep-alive tick, not a message: it goes - // out as an SSE comment, which the client ignores and which - // gives PHP the write it needs to notice a dropped peer. + // A null frame is a keep-alive tick: an SSE comment the + // client ignores, and the write PHP needs to spot a drop. echo null === $frame ? ": keep-alive\n\n" : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; flush(); } } catch (\Throwable $e) { - // The status line and headers are long gone by the time a frame - // fails, so there is no way to turn this into an error response. - // Log it and let the stream end; the client sees a close without - // the graceful-closure frame, which is exactly what it means. + // Headers are long sent, so this cannot become an error + // response; the client sees a close without the closure frame. $logger->error('Subscription stream ended with an error.', ['exception' => $e]); } }; diff --git a/src/Server/Wire/Rev2025Codec.php b/src/Server/Wire/Rev2025Codec.php index 016a78b..7b3a228 100644 --- a/src/Server/Wire/Rev2025Codec.php +++ b/src/Server/Wire/Rev2025Codec.php @@ -14,12 +14,8 @@ namespace Mcp\Server\Wire; /** * The wire codec for every revision through 2025-11-25. * - * The identity transform, and deliberately so. These revisions have no - * `resultType`, no caching hints and no `_meta` serverInfo requirement, so the - * neutral result body *is* the wire body — there is nothing to stamp and, - * more importantly, nothing newer that may leak in. A server negotiating one - * of these revisions emits exactly the bytes it emitted before the modern era - * existed. + * The identity transform, deliberately: these revisions have nothing to stamp, + * and nothing newer may leak into them. * * @author Christopher Hertel */ diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php index d08ea06..ed75d2c 100644 --- a/src/Server/Wire/Rev2026Codec.php +++ b/src/Server/Wire/Rev2026Codec.php @@ -17,30 +17,17 @@ use Mcp\Schema\Implementation; use Mcp\Server\Stateless\RequestMeta; /** - * The wire codec for 2026-07-28. + * The wire codec for 2026-07-28: stamps `resultType`, then the SEP-2549 caching + * hints, then the `_meta` serverInfo identity. * - * Applies the three additions this revision makes to every outbound result, in - * a fixed order: - * - * 1. the `resultType` discriminator; - * 2. the SEP-2549 caching hints, on cacheable methods only; - * 3. the `_meta` serverInfo identity (spec PR #3002). - * - * The order is load-bearing rather than incidental: the cache fill only runs - * on a result that came out of step 1 as `complete`, so a result still asking - * for input never acquires a TTL for content it has not produced. + * The order matters — the cache fill only runs on a result that came out of the + * stamp as `complete`, so one still asking for input never gets a TTL. * * @author Christopher Hertel */ final class Rev2026Codec implements WireCodecInterface { - /** - * Methods whose result vocabulary goes beyond `complete`, because they can - * come back asking for input (MRTR). - * - * `subscriptions/listen` is deliberately absent: it never sends an ordinary - * result, so there is nothing for the stamp to reach. - */ + /** Methods that can come back asking for input (MRTR). */ public const EXTENDED_RESULT_TYPE_METHODS = [ 'tools/call', 'prompts/get', @@ -72,10 +59,8 @@ final class Rev2026Codec implements WireCodecInterface } /** - * A result that already names its own type keeps it, but only where the - * method's vocabulary allows more than `complete`. Elsewhere the value - * would be meaningless to the client, and quietly rewriting it would bury - * a server bug that is better surfaced by the handler. + * A result naming its own type keeps it, but only where the method's + * vocabulary allows more than `complete`. * * @param array $result * @@ -97,9 +82,8 @@ final class Rev2026Codec implements WireCodecInterface } /** - * Fills `ttlMs`/`cacheScope`, most-specific author first: a valid value the - * result already carries wins over the server's configured policy, which - * wins over the conservative default of "private, do not cache". + * Fills `ttlMs`/`cacheScope`, most-specific author first: an authored value, + * then configured policy, then "private, do not cache". * * @param array $result * @@ -118,8 +102,7 @@ final class Rev2026Codec implements WireCodecInterface $ttl = $result['ttlMs'] ?? null; $scope = $result['cacheScope'] ?? null; - // An invalid authored value is dropped rather than repaired: it cannot - // go on the wire, and the next author down already has a usable answer. + // Invalid authored values fall through to the next author down. if (!\is_int($ttl) || $ttl < 0) { $ttl = $this->defaultTtlMs; } @@ -132,9 +115,8 @@ final class Rev2026Codec implements WireCodecInterface } /** - * Servers SHOULD identify themselves on every response. A result that - * already carries an identity keeps it; without a configured one this is - * the identity function. + * Servers SHOULD identify themselves on every response; an identity the + * result already carries wins. * * @param array $result * diff --git a/src/Server/Wire/WireCodecInterface.php b/src/Server/Wire/WireCodecInterface.php index c34870a..72a5f6b 100644 --- a/src/Server/Wire/WireCodecInterface.php +++ b/src/Server/Wire/WireCodecInterface.php @@ -12,26 +12,15 @@ namespace Mcp\Server\Wire; /** - * Translates between the SDK's revision-neutral result model and the wire - * shape one protocol era expects. + * Translates the SDK's revision-neutral result model into the wire shape one + * protocol era expects. * - * The split this exists to protect: {@see \Mcp\Schema\Result} classes describe - * *what* a server answered, not what any particular revision requires on the - * wire. Fields that exist only from 2026-07-28 — the `resultType` - * discriminator, the SEP-2549 caching hints, the `_meta` serverInfo identity — - * are therefore not modelled on those classes at all. They are stamped here, - * on the way out, by the codec for the negotiated version. + * Fields that exist only from 2026-07-28 — `resultType`, the SEP-2549 caching + * hints, the `_meta` serverInfo identity — are stamped here rather than + * modelled on the result classes, so one set of results can serve every + * revision without older clients receiving vocabulary they have never seen. * - * Keeping them out of the model is what lets one set of result classes serve - * every revision without a server on 2025-11-25 suddenly emitting vocabulary - * its clients have never seen. The alternative — emitting the newer fields - * unconditionally — is legal per the spec's forward-compatibility rule, but it - * changes what every already-deployed client receives, which is not a decision - * a result class should be making on its own. - * - * Eras, not versions: every revision through 2025-11-25 shares one wire - * vocabulary and one codec. A new codec appears only where the vocabulary - * actually diverges. + * Keyed by era, not version: every revision through 2025-11-25 shares one. * * @author Christopher Hertel */ diff --git a/tests/Conformance/Fixtures/docker-compose.yml b/tests/Conformance/Fixtures/docker-compose.yml index 11a84a6..1d368f5 100644 --- a/tests/Conformance/Fixtures/docker-compose.yml +++ b/tests/Conformance/Fixtures/docker-compose.yml @@ -2,8 +2,7 @@ services: nginx: image: nginx:1.26-alpine ports: - # Overridable so the fixture can coexist with whatever else a developer - # already has on 8000; CI and the Makefile default keep using 8000. + # Overridable so the fixture can coexist with anything already on 8000. - "${CONFORMANCE_PORT:-8000}:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf index 4f2f414..269f55f 100644 --- a/tests/Conformance/Fixtures/nginx.conf +++ b/tests/Conformance/Fixtures/nginx.conf @@ -3,10 +3,8 @@ server { server_name localhost; root /app; - # The two lifecycles are served side by side so one conformance run can - # target either without restarting the fixture: / speaks the handshake era - # (2025-11-25 and earlier), /stateless speaks the modern era (SEP-2575). - # Declared before `location /` so the prefix match wins. + # / speaks the handshake era, /stateless the modern one (SEP-2575). + # Declared first so the prefix match wins. location /stateless { try_files $uri /tests/Conformance/server-stateless.php$is_args$args; } diff --git a/tests/Conformance/MrtrElements.php b/tests/Conformance/MrtrElements.php new file mode 100644 index 0000000..e32430a --- /dev/null +++ b/tests/Conformance/MrtrElements.php @@ -0,0 +1,212 @@ +getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY))))]); + } + + public static function sampling(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('capital')) { + return new InputRequiredResult([ + 'capital' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('What is the capital of France?'))], + maxTokens: 100, + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Sampling complete.')]); + } + + public static function listRoots(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('roots')) { + return new InputRequiredResult(['roots' => new ListRootsRequest()], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Roots received.')]); + } + + /** Asks for everything in one result, so the client retries once. */ + public static function multipleInputs(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('user_name')) { + return new InputRequiredResult([ + 'user_name' => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + 'greeting' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ), + 'client_roots' => new ListRootsRequest(), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('All inputs received.')]); + } + + /** + * Reads the round out of the state, not the answers: a client retrying + * round two sends only round two's answer, so counting answers would read + * that as a fresh start and loop forever. + */ + public static function multiRound(RequestContext $context): CallToolResult|InputRequiredResult + { + $round = $context->getInputContext()?->requestState()['round'] ?? 0; + + if ($round < 1) { + return new InputRequiredResult([ + 'step_one' => new ElicitRequest( + 'Step one: what is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['round' => 1])); + } + + if ($round < 2) { + return new InputRequiredResult([ + 'step_two' => new ElicitRequest( + 'Step two: what is your favourite colour?', + new ElicitationSchema(['color' => new StringSchemaDefinition('Colour')], ['color']), + ), + ], requestState: $context->mintRequestState(['round' => 2])); + } + + return new CallToolResult([new TextContent('All steps complete.')]); + } + + /** Reaches the second round only when the echoed state verified. */ + public static function tamperedState(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('confirm')) { + return new InputRequiredResult([ + 'confirm' => new ElicitRequest( + 'Confirm?', + new ElicitationSchema(['ok' => new StringSchemaDefinition('Ok')], ['ok']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-confirmation'])); + } + + return new CallToolResult([new TextContent('State verified.')]); + } + + /** + * Assembles the ask from the declared capabilities: a server MUST NOT + * request input the client never said it could supply. + */ + public static function capabilities(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null !== $input && [] !== $input->all()) { + return new CallToolResult([new TextContent('Input received.')]); + } + + $capabilities = $context->getClientCapabilities(); + $requests = []; + + if (true === $capabilities?->elicitation) { + $requests['user_name'] = new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ); + } + + if (true === $capabilities?->sampling) { + $requests['greeting'] = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ); + } + + // Nothing the client can service; asking would never come back. + if ([] === $requests) { + return new CallToolResult([new TextContent('No supported input capabilities.')]); + } + + return new InputRequiredResult($requests, requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + /** + * @return array>|InputRequiredResult + */ + public static function prompt(RequestContext $context): array|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY)))]]; + } + + /** A declined or cancelled answer has no content, so the greeting falls back. */ + private static function nameFrom(mixed $response): string + { + $content = \is_array($response) ? ($response['content'] ?? null) : null; + $name = \is_array($content) ? ($content['name'] ?? null) : null; + + return \is_string($name) ? $name : 'friend'; + } +} diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index a5af23f..96429e5 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -11,49 +11,37 @@ # # Run with: # make conformance-draft-server -# -# Absent by design, because they pass in full: server-stateless (the SEP-2575 -# lifecycle), caching (SEP-2549 hints), both SEP-2243 header-validation -# scenarios, and every wire-schema-valid check bar one noted below. server: - # --- SEP-2322: the MRTR retry half ----------------------------------------- - # InputRequiredResult exists and tools/call can answer with one — that is what - # makes server-stateless green. What is missing is the second round trip: the - # SDK does not read inputResponses, verify requestState integrity, or resume a - # call from it. Until it does, every scenario that retries fails at the retry. - - input-required-result-basic-elicitation:sep-2322-elicitation-incomplete - - input-required-result-basic-list-roots:sep-2322-list-roots-incomplete - - input-required-result-basic-sampling:sep-2322-sampling-incomplete - - input-required-result-capability-check:sep-2322-respect-client-capabilities - - input-required-result-multi-round:sep-2322-multi-round-r1 - - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete - - input-required-result-non-tool-request:sep-2322-non-tool-incomplete - - input-required-result-request-state:sep-2322-request-state-incomplete - - input-required-result-result-type:sep-2322-result-type-included - - input-required-result-tampered-state:sep-2322-reject-tampered-state + # --- Progress notifications on a response stream --------------------------- + # The one place the modern lifecycle is behind the handshake one, and a real + # gap rather than a fixture difference: this scenario passes at / and fails + # at /stateless. A tools/call answer is a single JSON response here, so + # notifications a handler emits mid-call have nowhere to go. Closing it means + # answering tools/call over a stream — the same seam MRTR's IncompleteResult + # chunks will need — rather than anything specific to progress. + - tools-call-with-progress:tools-call-with-progress # --- SHOULD-level warnings ------------------------------------------------- # These report WARNING rather than FAILURE (the severity follows the spec # keyword), so the run summary counts them as passed — but the baseline # evaluator still wants them declared, and declaring them is right: each is a - # SHOULD the SDK does not yet honour. - - input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params - - input-required-result-missing-input-response:sep-2322-missing-response-rerequests + # SHOULD the SDK does not yet honour. Both concern the resources/read + # not-found shape: SEP-2164 moved it to -32602 with the uri in error.data, + # and the SDK still answers with the older -32002 and no data. - sep-2164-resource-not-found:sep-2164-data-uri - sep-2164-resource-not-found:sep-2164-error-code # --- Pre-existing, not lifecycle-specific ---------------------------------- - # Each of these fails identically on the handshake endpoint at /, so none is - # caused by (or fixable in) the modern lifecycle. Listed so the draft run is - # honest, not to excuse them — resources-templates-read in particular is the - # single wire-schema failure left, a resource-template URI the fixture emits - # in a shape the schema rejects on both revisions. - - dns-rebinding-protection:localhost-host-rebinding-rejected + # Neither is caused by (or fixable in) the modern lifecycle; both reproduce + # on the handshake endpoint. Listed so the draft run is honest. + # + # json-schema-2020-12 wants a `json_schema_2020_12_tool` fixture that neither + # conformance server defines, so the scenario has nothing to exercise. + # resources-templates-read is the single remaining wire-schema failure: the + # fixture emits a resource-template URI in a shape the schema rejects, on + # both revisions. - json-schema-2020-12:json-schema-2020-12-tool-found - - prompts-get-embedded-resource:prompts-get-embedded-resource - - prompts-get-with-image:prompts-get-with-image - resources-templates-read:wire-schema-valid - - tools-call-with-progress:tools-call-with-progress client: [] diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php index 38a1f48..8bb69f2 100644 --- a/tests/Conformance/server-stateless.php +++ b/tests/Conformance/server-stateless.php @@ -10,11 +10,10 @@ */ /* - * Conformance fixture for the modern (SEP-2575) lifecycle, served at - * /stateless alongside the handshake-era fixture at /. The element set is - * deliberately the same as server.php's where the two eras overlap, so that a - * difference in conformance results points at the lifecycle rather than at the - * fixtures having drifted apart. + * Conformance fixture for the modern (SEP-2575) lifecycle, served at /stateless + * alongside the handshake-era fixture at /. The element set mirrors + * server.php's where the eras overlap, so a difference in results points at the + * lifecycle rather than at drifted fixtures. */ ini_set('display_errors', '0'); @@ -39,6 +38,7 @@ use Mcp\Server; use Mcp\Server\Transport\StatelessHttpTransport; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; +use Mcp\Tests\Conformance\MrtrElements; chdir(__DIR__); @@ -57,8 +57,7 @@ $protocol = Server::builder() ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') - // Exercises the -32021 path: the tool needs to call back into the client, - // which a client that never declared `sampling` cannot service. + // Exercises the -32021 path. ->addTool( static function (): never { throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); @@ -66,8 +65,7 @@ $protocol = Server::builder() name: 'test_missing_capability', description: 'Always reports a missing client capability, for testing -32021 handling', ) - // Asks for input the MRTR way: the ask travels back inside the result, so - // the response stream never carries a server-initiated request. + // The ask travels back inside the result, never as its own request. ->addTool( static fn (): InputRequiredResult => new InputRequiredResult( [ @@ -81,9 +79,7 @@ $protocol = Server::builder() name: 'test_streaming_elicitation', description: 'Returns an InputRequiredResult asking for elicitation input', ) - // The logger writes to the fixture's log file, never to the wire: a modern - // server emits notifications/message only when the request asked for a log - // level, and this one deliberately does not. + // Logs server-side only; no logLevel was requested, so nothing goes out. ->addTool( static function () use ($logger): string { $logger->info('test_logging_tool executed'); @@ -93,8 +89,7 @@ $protocol = Server::builder() name: 'test_logging_tool', description: 'Emits a server-side log message while returning normally', ) - // Mirrors its arguments into Mcp-Param-* headers (SEP-2243), so the header/ - // body agreement rules have something to be checked against. + // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). ->addTool( static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), name: 'test_custom_headers', @@ -108,6 +103,16 @@ $protocol = Server::builder() 'required' => ['region'], ], ) + ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + // Multi round-trip request tools (SEP-2322). + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') + ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') + ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') + ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') + ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') + ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') + ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') @@ -115,6 +120,11 @@ $protocol = Server::builder() // Prompts ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') + ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') + ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') + ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') + // Fixed so a retry landing on another process still verifies. + ->setRequestState(str_repeat('conformance-fixture-key-', 2)) ->buildStateless([ProtocolVersion::V2026_07_28]); $transport = new StatelessHttpTransport($protocol, logger: $logger); diff --git a/tests/Unit/Server/Stateless/RequestStateCodecTest.php b/tests/Unit/Server/Stateless/RequestStateCodecTest.php new file mode 100644 index 0000000..6b84b8f --- /dev/null +++ b/tests/Unit/Server/Stateless/RequestStateCodecTest.php @@ -0,0 +1,128 @@ + 2, 'tool' => 'do_thing', 'nested' => ['a' => 1]]; + + $this->assertSame($payload, $codec->verify($codec->mint($payload))); + } + + #[TestDox('a state signed by another key is refused')] + public function testForeignKeyRefused(): void + { + $state = (new RequestStateCodec(self::OTHER_KEY))->mint(['round' => 1]); + + $this->expectException(RequestStateException::class); + (new RequestStateCodec(self::KEY))->verify($state); + } + + #[TestDox('any edit to the wire value invalidates it')] + public function testTamperingIsDetected(): void + { + $codec = new RequestStateCodec(self::KEY); + $state = $codec->mint(['admin' => false]); + + foreach ([$state.'-TAMPERED', substr($state, 0, -2), str_replace('.', 'X.', $state)] as $tampered) { + try { + $codec->verify($tampered); + $this->fail(\sprintf('Expected "%s" to be rejected.', $tampered)); + } catch (RequestStateException) { + $this->addToAssertionCount(1); + } + } + } + + #[TestDox('a re-signed payload from a different key cannot be swapped in')] + public function testPayloadSwapRefused(): void + { + $mine = new RequestStateCodec(self::KEY); + $theirs = new RequestStateCodec(self::OTHER_KEY); + + // Body from one, MAC from the other: each half is well-formed alone. + [$body] = explode('.', $mine->mint(['admin' => false])); + [, $mac] = explode('.', $theirs->mint(['admin' => true])); + + $this->expectException(RequestStateException::class); + $mine->verify($body.'.'.$mac); + } + + #[TestDox('an expired state is refused even though its signature is good')] + public function testExpiryEnforced(): void + { + $codec = new RequestStateCodec(self::KEY, ttlSeconds: 60); + $state = $codec->mint(['round' => 1], now: 1_000); + + $this->assertSame(['round' => 1], $codec->verify($state, now: 1_059)); + + $this->expectException(RequestStateException::class); + $codec->verify($state, now: 1_061); + } + + #[TestDox('a malformed value is refused rather than parsed')] + public function testMalformedRefused(): void + { + $codec = new RequestStateCodec(self::KEY); + + foreach (['', 'not-a-state', 'one.two.three', '.', 'YQ.'] as $bad) { + try { + $codec->verify($bad); + $this->fail(\sprintf('Expected "%s" to be rejected.', $bad)); + } catch (RequestStateException) { + $this->addToAssertionCount(1); + } + } + } + + #[TestDox('the failure reason never says more than a category')] + public function testFailureReasonsAreOpaque(): void + { + $codec = new RequestStateCodec(self::KEY); + + try { + $codec->verify((new RequestStateCodec(self::OTHER_KEY))->mint(['secret' => 'value'])); + $this->fail('Expected rejection.'); + } catch (RequestStateException $e) { + // Anything beyond a category is a hint to whoever is probing. + $this->assertContains($e->getMessage(), ['malformed', 'mac', 'expired']); + $this->assertStringNotContainsString('secret', $e->getMessage()); + } + } + + #[TestDox('a key too short to be safe is refused at construction')] + public function testShortKeyRefused(): void + { + $this->expectException(InvalidArgumentException::class); + new RequestStateCodec('too-short'); + } + + #[TestDox('a non-positive TTL is refused at construction')] + public function testNonPositiveTtlRefused(): void + { + $this->expectException(InvalidArgumentException::class); + new RequestStateCodec(self::KEY, ttlSeconds: 0); + } +} diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index 2e3e28a..3914cc4 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -23,8 +23,7 @@ class StandardHeaderValidatorTest extends TestCase protected function setUp(): void { // No registry: the Mcp-Param dimension needs tool definitions and is - // covered by the conformance fixture, which has real ones. Everything - // here is registry-independent. + // covered by the conformance fixture. $this->validator = new StandardHeaderValidator(); } @@ -125,8 +124,6 @@ class StandardHeaderValidatorTest extends TestCase yield 'resources/read uses uri' => ['resources/read', ['uri' => 'test://c'], 'test://c']; yield 'tasks/get uses taskId' => ['tasks/get', ['taskId' => 'd'], 'd']; yield 'tools/list has no subject' => ['tools/list', [], null]; - // A non-string value is not a name: treating it as one would compare a - // header against something that was never a header value. yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null]; } @@ -146,10 +143,8 @@ class StandardHeaderValidatorTest extends TestCase #[DataProvider('malformedBase64')] public function testMalformedBase64IsRefused(string $encoded): void { - // PHP's decoder will happily return bytes for most of these. Accepting - // them would let a corrupted header silently compare equal — or, worse, - // silently unequal — instead of being reported as the transport-level - // fault it is. + // PHP's decoder returns bytes for most of these; accepting them would + // let a corrupted header silently compare equal. $this->assertNull(StandardHeaderValidator::decode('=?base64?'.$encoded.'?=')); } From 5e736830795dfabe2f7cff83d0079589b11cf6a0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:09:34 +0200 Subject: [PATCH 102/143] [Server] Close the conformance gaps in the modern lifecycle (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Let the client gateway see modern-era capabilities The six supports*() probes read session state only InitializeHandler wrote, so every request served statelessly reported no client capabilities at all — a tool guarding on them took the unsupported branch without a signal. StatelessProtocol now writes the request's declaration under the same keys. * [Server] Reject the RPCs 2026-07-28 removed resources/subscribe and resources/unsubscribe were still dispatched and answered 200 OK, recording subscriptions the modern era never reads. They join initialize, ping and logging/setLevel in the era guard; the handlers stay registered because the handshake era still serves them. * [Server] Decode a wrapped Mcp-Name before comparing it The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI or tool name outside the header-safe ASCII set — which the spec explicitly tells clients to wrap — was compared encoded and refused with -32020. * [Server] Require the MCP-Protocol-Version header It was only checked for contradicting the body, so omitting it entirely passed — leaving the value intermediaries route on unenforced. Tied to the header validator's presence, since that is what a header-bearing transport installs and stdio carries its metadata inline. * [Server] Read the modern-era request body whole A single read() takes whatever the stream cares to give — 64 bytes from a chunked transfer is legal — so an oversized-but-valid POST came back as a parse error. Shares the legacy transport's incremental read as a trait. * [Schema][Server] Answer a notification with a status, not a response A body with no id was dispatched as a request and came back as a JSON-RPC error carrying "id": "" — an id nobody issued, and on a path a client uses to tell a modern server from a legacy one. Notifications now get 202 and no body, and Error omits an id it could not read instead of emptying it. * [Server] Stop emitting the error codes 2026-07-28 reserved resources/read answered -32002, which this revision forbids; prompts/get and completion/complete answered it for an unknown *name*, which it never meant; tools/call answered -32601, which belongs to an unknown method. Only the resources/read code is revision-gated — older peers expect -32002 there, and nowhere else. Also routes the -32021 capability exception out of the prompt and resource handlers, which were swallowing it into -32603. --- CHANGELOG.md | 1 + src/Schema/Enum/ProtocolVersion.php | 15 + .../Handler/Request/CallToolHandler.php | 4 +- .../Request/CompletionCompleteHandler.php | 4 +- .../Handler/Request/GetPromptHandler.php | 9 +- .../Handler/Request/ReadResourceHandler.php | 13 +- .../Stateless/StandardHeaderValidator.php | 14 +- src/Server/Stateless/StatelessProtocol.php | 107 +++++- src/Server/Stateless/StatelessResult.php | 20 +- src/Server/Transport/ReadsBoundedBody.php | 61 ++++ .../Transport/StatelessHttpTransport.php | 42 +-- .../Transport/StreamableHttpTransport.php | 27 +- .../conformance-baseline-2026-07-28.yml | 10 - .../Unit/Schema/Enum/ProtocolVersionTest.php | 13 + .../Handler/Request/CallToolHandlerTest.php | 2 +- .../Handler/Request/GetPromptHandlerTest.php | 2 +- .../Stateless/StandardHeaderValidatorTest.php | 46 +++ .../Stateless/StatelessProtocolTest.php | 321 ++++++++++++++++++ .../Transport/Fixture/ShortReadStream.php | 110 ++++++ .../Transport/StatelessHttpTransportTest.php | 140 ++++++++ 20 files changed, 855 insertions(+), 106 deletions(-) create mode 100644 src/Server/Transport/ReadsBoundedBody.php create mode 100644 tests/Unit/Server/Stateless/StatelessProtocolTest.php create mode 100644 tests/Unit/Server/Transport/Fixture/ShortReadStream.php create mode 100644 tests/Unit/Server/Transport/StatelessHttpTransportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e21bb06..dc63a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. * Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index b62396b..03ebddf 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -115,6 +115,21 @@ enum ProtocolVersion: string return !$this->isAtLeast(self::V2026_07_28); } + /** + * Whether this revision answers a missing resource with `-32602`. + * + * SEP-2164, part of {@see self::V2026_07_28}, retired the bespoke `-32002` + * in favour of the JSON-RPC code that already meant this, and reserved + * `-32002` so it is never reused. Earlier revisions still expect it, and + * clients are told to keep accepting it from them. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes + */ + public function usesInvalidParamsForResourceNotFound(): bool + { + return $this->isAtLeast(self::V2026_07_28); + } + /** * Whether this revision is at least as new as $minimum. */ diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 8fa2ed7..616db3a 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -73,7 +73,9 @@ final class CallToolHandler implements RequestHandlerInterface } catch (ToolNotFoundException $e) { $this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]); - return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + // -32601 answers an unknown *method*; tools/call exists, it is the + // name in its params that does not. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } $inputSchema = $reference->tool->inputSchema; diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index b3c4d04..a1eab00 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -85,7 +85,9 @@ final class CompletionCompleteHandler implements RequestHandlerInterface return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); } catch (PromptNotFoundException|ResourceNotFoundException $e) { - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // The reference names something the server does not have, which is + // a bad parameter rather than a missing resource. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $e) { return Error::forInternalError('Error while handling completion request', $request->getId()); } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 6b9a708..71d6f8c 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,6 +13,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; @@ -70,6 +71,10 @@ final class GetPromptHandler implements RequestHandlerInterface $formatted = $reference->formatResult($result); return new Response($request->getId(), new GetPromptResult($formatted)); + } catch (MissingRequiredClientCapabilityException $e) { + // Not a handler failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. + throw $e; } catch (PromptGetException $e) { $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); @@ -77,7 +82,9 @@ final class GetPromptHandler implements RequestHandlerInterface } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // An unknown prompt name is a bad parameter, not a missing + // resource: -32002 was never the code for this. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index a9551ef..e3b2f0e 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -14,6 +14,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; @@ -21,6 +22,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -76,6 +78,10 @@ final class ReadResourceHandler implements RequestHandlerInterface } return new Response($request->getId(), new ReadResourceResult($formatted)); + } catch (MissingRequiredClientCapabilityException $e) { + // Not a handler failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. + throw $e; } catch (ResourceReadException $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); @@ -83,7 +89,12 @@ final class ReadResourceHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // SEP-2164 retired -32002 in favour of the JSON-RPC code that + // already meant this. Older peers still expect the old one, so the + // revision answering the request decides. + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index 85e7809..dcd4d37 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -89,8 +89,18 @@ final class StandardHeaderValidator return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected); } - if ($declared !== $expected) { - return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $declared, $expected); + // Tool and prompt names are only SHOULD-constrained to header-safe + // characters and a resource URI is not constrained at all, so the + // client wraps anything unsafe — decode before comparing or every + // conformant client carrying a non-ASCII subject is refused. + $decoded = self::decode($declared); + + if (null === $decoded) { + return \sprintf('%s header is not a well-formed Base64 wrapper.', self::NAME_HEADER); + } + + if ($decoded !== $expected) { + return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $decoded, $expected); } return null; diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index c9e7dcb..c322937 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -18,7 +18,6 @@ use Mcp\Exception\RequestStateException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Result\DiscoverResult; @@ -47,12 +46,22 @@ final class StatelessProtocol /** * Methods the modern era deleted. Answered as unknown methods, which is * what they are to a modern server. + * + * A deny-list rather than an allow-list on purpose: extensions add methods + * this class has never heard of, so an unlisted method has to reach + * dispatch. Every removal named in the 2026-07-28 changelog belongs here — + * the handlers behind them stay registered for the handshake era, which is + * why the era guard, and not the registration, is what turns them off. */ public const REMOVED_METHODS = [ 'initialize', 'notifications/initialized', 'ping', 'logging/setLevel', + // Replaced by the `resourceSubscriptions` filter of subscriptions/listen. + 'resources/subscribe', + 'resources/unsubscribe', + 'notifications/roots/list_changed', ]; public const DISCOVER_METHOD = 'server/discover'; @@ -85,6 +94,27 @@ final class StatelessProtocol private readonly ?RequestStateCodec $requestStateCodec = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo); + + if (null === $this->headerValidator) { + // Not fatal: a transport without a header layer — stdio — has + // nothing to validate. But on HTTP the headers are REQUIRED for + // compliance, so an absent validator there is a silently + // non-conformant server and worth saying out loud once. + $this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.'); + } + } + + /** + * Whether the transport carrying this dispatcher has a header layer whose + * required members must be present. + * + * The validator's presence is the signal: it is what a header-bearing + * transport installs, and stdio carries its metadata inline instead + * (see the stdio binding's "Request Metadata"). + */ + private function requiresTransportHeaders(): bool + { + return null !== $this->headerValidator; } /** @@ -111,7 +141,13 @@ final class StatelessProtocol // already knows to omit a null id instead of fabricating one, and // "id": "" would falsely claim the sender issued a request with an // empty-string id. + // + // An absent id and an unreadable one are different messages: the first + // is a notification, the second a malformed request. JSON-RPC 2.0 + // writes "no id" as an explicit null, so that counts as absent too. + $isNotification = !\array_key_exists('id', $decoded) || null === $decoded['id']; $id = $decoded['id'] ?? null; + if (!\is_string($id) && !\is_int($id)) { $id = null; } @@ -123,6 +159,19 @@ final class StatelessProtocol $params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null; + // No id is a notification. It gets an acknowledgment, never a response + // — answering one with a JSON-RPC message would invent a correlation + // the client has no request to match it against. Checked before the + // `_meta` parse: notification params carry `NotificationMetaObject`, + // which has none of a request's required members. + if ($isNotification) { + return $this->acknowledge($method); + } + + if (null === $id) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400); + } + try { $meta = RequestMeta::fromParams($params); } catch (MissingRequestMetaException $e) { @@ -140,14 +189,6 @@ final class StatelessProtocol } if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) { - // Both answer with a single response tied to this request's id, - // unlike a genuine notification — so unlike the general dispatch - // path below, a missing id here is this request being invalid - // rather than this request needing no answer at all. - if (null === $id) { - return StatelessResult::error(Error::forInvalidRequest(\sprintf('Method "%s" requires an "id".', $method)), 400); - } - if (self::DISCOVER_METHOD === $method) { return $this->encode($method, $id, $this->discover()); } @@ -165,6 +206,29 @@ final class StatelessProtocol return $this->dispatch($method, $decoded, $meta, $id); } + /** + * Answers a notification. + * + * This revision's core defines no client-to-server notification over HTTP — + * `notifications/cancelled` is stdio-only, since closing the response + * stream is the cancellation signal here — so anything arriving is either + * an extension's or a client still speaking an older revision. Accepting + * the former and refusing the latter both come out as a status with no + * body; what must not happen is a JSON-RPC response. + */ + private function acknowledge(string $method): StatelessResult + { + if (\in_array($method, self::REMOVED_METHODS, true)) { + $this->logger->debug('Refused a notification this revision removed.', ['method' => $method]); + + return StatelessResult::empty(400); + } + + $this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]); + + return StatelessResult::empty(202); + } + /** * Header and `_meta` must agree before the version can be judged supported: * when they disagree the server cannot know which the client meant, so a @@ -176,6 +240,19 @@ final class StatelessProtocol { $headerVersion = $this->header($headers, 'MCP-Protocol-Version'); + // REQUIRED on every POST. The 2025-03-26 fallback for a header-less + // request exists only for servers choosing to serve pre-2025-06-18 + // clients, which a modern-only endpoint is not. + if (null === $headerVersion && $this->requiresTransportHeaders()) { + return StatelessResult::error( + Error::forHeaderMismatch( + \sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion), + $id, + ), + 400, + ); + } + if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) { return StatelessResult::error( Error::forHeaderMismatch( @@ -273,12 +350,6 @@ final class StatelessProtocol $request = $messages[0] ?? null; - // A notification (no id) is never answered, successful or not — this is - // one of the SDK's own registered message classes, just not a Request. - if ($request instanceof Notification) { - return StatelessResult::accepted(); - } - // The factory hands back an exception object rather than throwing one, // distinguishing a genuinely unknown method (-32601, the client should // stop asking) from a known method the message could not otherwise be @@ -310,6 +381,12 @@ final class StatelessProtocol $session = new Session(new InMemorySessionStore()); $session->set(RequestMeta::class, $meta); + // Under the same keys the handshake era writes, so everything reading + // connection state — ClientGateway's capability probes above all — sees + // this request's declaration instead of an empty session. + $session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize()); + $session->set('protocol_version', $meta->protocolVersion); + try { $input = $this->liftInputContext($decoded['params'] ?? null); } catch (RequestStateException $e) { diff --git a/src/Server/Stateless/StatelessResult.php b/src/Server/Stateless/StatelessResult.php index 32ff88c..9e2b630 100644 --- a/src/Server/Stateless/StatelessResult.php +++ b/src/Server/Stateless/StatelessResult.php @@ -34,6 +34,7 @@ final class StatelessResult public readonly ?\Closure $frames = null, private readonly ?array $body = null, private readonly string|int|null $id = null, + private readonly bool $bodyless = false, ) { } @@ -54,13 +55,12 @@ final class StatelessResult } /** - * A notification never gets an answer, successful or not — per JSON-RPC, a - * message with no id must never receive a response. This is the "I read - * it, there is nothing to say back" answer: HTTP 202 with no body. + * A status with no body — what a notification gets, since it has no id to + * correlate a JSON-RPC message against. */ - public static function accepted(): self + public static function empty(int $httpStatus): self { - return new self(null, 202); + return new self(null, $httpStatus, bodyless: true); } /** @@ -80,13 +80,9 @@ final class StatelessResult return null !== $this->frames; } - /** - * An accepted notification: no message, no body, no frames — just the - * status code. - */ public function isEmpty(): bool { - return null === $this->message && null === $this->body && null === $this->frames; + return $this->bodyless; } public function isError(): bool @@ -104,6 +100,10 @@ final class StatelessResult ], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); } + if ($this->bodyless) { + throw new \LogicException('This result carries no body; send its status alone.'); + } + if (null === $this->message) { throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.'); } diff --git a/src/Server/Transport/ReadsBoundedBody.php b/src/Server/Transport/ReadsBoundedBody.php new file mode 100644 index 0000000..00f47b9 --- /dev/null +++ b/src/Server/Transport/ReadsBoundedBody.php @@ -0,0 +1,61 @@ + + */ +trait ReadsBoundedBody +{ + /** + * Returns the body contents, or `null` when the payload exceeds $maxBytes. + * + * A stream advertising its size is rejected up front; otherwise the read is + * incremental and stops at the cap, so an unbounded stream cannot exhaust + * memory. + */ + private function readBoundedBody(StreamInterface $body, int $maxBytes): ?string + { + if ($body->isSeekable()) { + $body->rewind(); + } + + $size = $body->getSize(); + if (null !== $size && $size > $maxBytes) { + return null; + } + + $contents = ''; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ('' === $chunk) { + break; + } + + $contents .= $chunk; + if (\strlen($contents) > $maxBytes) { + return null; + } + } + + return $contents; + } +} diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php index 0301f15..5348273 100644 --- a/src/Server/Transport/StatelessHttpTransport.php +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -21,7 +21,6 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\StreamInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -36,6 +35,8 @@ use Psr\Log\NullLogger; */ final class StatelessHttpTransport { + use ReadsBoundedBody; + /** * Upper bound on the request body read for a POST, guarding against memory * exhaustion from an oversized (or unbounded chunked) payload. @@ -106,11 +107,7 @@ final class StatelessHttpTransport ); } - $body = $request->getBody(); - if ($body->isSeekable()) { - $body->rewind(); - } - $payload = $this->readBody($body); + $payload = $this->readBoundedBody($request->getBody(), $this->maxBodyBytes); if (null === $payload) { $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); @@ -171,39 +168,6 @@ final class StatelessHttpTransport ->withBody(new CallbackStream($callback, $this->logger)); } - /** - * Reads the request body, bounded by {@see self::$maxBodyBytes}. - * - * Returns the body contents, or `null` when the payload exceeds the cap. - * A single `read()` call is not enough: PSR-7 allows it to return fewer - * bytes than requested, so a body arriving across more than one physical - * read would otherwise be silently truncated. Reading incrementally to - * EOF is what {@see StreamableHttpTransport::readBody()} already does for - * the same reason. - */ - private function readBody(StreamInterface $body): ?string - { - $size = $body->getSize(); - if (null !== $size && $size > $this->maxBodyBytes) { - return null; - } - - $contents = ''; - while (!$body->eof()) { - $chunk = $body->read(8192); - if ('' === $chunk) { - break; - } - - $contents .= $chunk; - if (\strlen($contents) > $this->maxBodyBytes) { - return null; - } - } - - return $contents; - } - private function json(string $payload, int $status): ResponseInterface { return $this->responseFactory->createResponse($status) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index cce2bd5..098376b 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -34,6 +34,8 @@ use Symfony\Component\Uid\Uuid; */ class StreamableHttpTransport extends BaseTransport { + use ReadsBoundedBody; + public const SESSION_HEADER = 'Mcp-Session-Id'; public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; @@ -293,33 +295,10 @@ class StreamableHttpTransport extends BaseTransport /** * Reads the request body, bounded by {@see self::$maxBodyBytes}. - * - * Returns the body contents, or `null` when the payload exceeds the cap. When - * the stream advertises a size we reject up-front; otherwise (e.g. chunked - * transfer with unknown size) we read incrementally and stop at the cap so an - * unbounded stream cannot exhaust memory. */ private function readBody(StreamInterface $body): ?string { - $size = $body->getSize(); - if (null !== $size && $size > $this->maxBodyBytes) { - return null; - } - - $contents = ''; - while (!$body->eof()) { - $chunk = $body->read(8192); - if ('' === $chunk) { - break; - } - - $contents .= $chunk; - if (\strlen($contents) > $this->maxBodyBytes) { - return null; - } - } - - return $contents; + return $this->readBoundedBody($body, $this->maxBodyBytes); } /** diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 96429e5..25eb207 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -22,16 +22,6 @@ server: # chunks will need — rather than anything specific to progress. - tools-call-with-progress:tools-call-with-progress - # --- SHOULD-level warnings ------------------------------------------------- - # These report WARNING rather than FAILURE (the severity follows the spec - # keyword), so the run summary counts them as passed — but the baseline - # evaluator still wants them declared, and declaring them is right: each is a - # SHOULD the SDK does not yet honour. Both concern the resources/read - # not-found shape: SEP-2164 moved it to -32602 with the uri in error.data, - # and the SDK still answers with the older -32002 and no data. - - sep-2164-resource-not-found:sep-2164-data-uri - - sep-2164-resource-not-found:sep-2164-error-code - # --- Pre-existing, not lifecycle-specific ---------------------------------- # Neither is caused by (or fixable in) the modern lifecycle; both reproduce # on the handshake endpoint. Listed so the draft run is honest. diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php index 00c50e9..ca5eb64 100644 --- a/tests/Unit/Schema/Enum/ProtocolVersionTest.php +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -141,4 +141,17 @@ class ProtocolVersionTest extends TestCase $this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent()); } + + #[TestDox('SEP-2164 moves resource-not-found from -32002 to -32602')] + public function testUsesInvalidParamsForResourceNotFound(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertFalse( + $version->usesInvalidParamsForResourceNotFound(), + \sprintf('%s predates SEP-2164 and still expects -32002.', $version->value), + ); + } + + $this->assertTrue(ProtocolVersion::V2026_07_28->usesInvalidParamsForResourceNotFound()); + } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 87a696b..47351dc 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -183,7 +183,7 @@ class CallToolHandlerTest extends TestCase $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + $this->assertEquals(Error::INVALID_PARAMS, $response->code); } public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 204f928..501fe1a 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -251,7 +251,7 @@ class GetPromptHandlerTest extends TestCase $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(Error::INVALID_PARAMS, $response->code); $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); } diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index 3914cc4..f0081d4 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -101,6 +101,52 @@ class StandardHeaderValidatorTest extends TestCase )); } + #[TestDox('a Base64-wrapped Mcp-Name is decoded before it is compared')] + #[DataProvider('wrappedNames')] + public function testWrappedNameHeaderIsDecoded(string $method, string $member, string $subject): void + { + $this->assertNull($this->validator->validate( + $method, + [$member => $subject], + [ + 'Mcp-Method' => $method, + 'Mcp-Name' => '=?base64?'.base64_encode($subject).'?=', + ], + )); + } + + /** + * @return iterable + */ + public static function wrappedNames(): iterable + { + yield 'non-ASCII tool name' => ['tools/call', 'name', 'grüße_welt']; + yield 'CJK prompt name' => ['prompts/get', 'name', '天気予報']; + yield 'resource URI with a non-ASCII path' => ['resources/read', 'uri', 'file:///projects/münchen/config.json']; + yield 'value padded with spaces' => ['tools/call', 'name', ' padded ']; + yield 'value matching the sentinel pattern' => ['tools/call', 'name', '=?base64?literal?=']; + } + + #[TestDox('a wrapped Mcp-Name that disagrees with the body is still rejected')] + public function testWrappedNameHeaderStillHasToMatch(): void + { + $this->assertStringContainsString('does not match', (string) $this->validator->validate( + 'tools/call', + ['name' => 'grüße_welt'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?'.base64_encode('etwas_anderes').'?='], + )); + } + + #[TestDox('a malformed Base64 wrapper on Mcp-Name is refused, not compared raw')] + public function testMalformedWrappedNameHeaderIsRefused(): void + { + $this->assertStringContainsString('well-formed Base64', (string) $this->validator->validate( + 'tools/call', + ['name' => 'do_thing'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?SGVsbG8!?='], + )); + } + #[TestDox('a method that carries no name does not require the header')] public function testNamelessMethodNeedsNoNameHeader(): void { diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php new file mode 100644 index 0000000..634d5f8 --- /dev/null +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -0,0 +1,321 @@ + $capabilities + */ + private static function protocol(array $capabilities = []): StatelessProtocol + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool(static fn (): string => 'ok', name: 'plain_tool', description: 'Returns a fixed string') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + return implode(',', array_keys(array_filter([ + 'roots' => $gateway->supportsRoots(), + 'sampling' => $gateway->supportsSampling(), + 'sampling.tools' => $gateway->supportsSamplingTools(), + 'elicitation' => $gateway->supportsElicitation(), + 'elicitation.url' => $gateway->supportsElicitationUrl(), + ]))) ?: 'none'; + }, + name: 'probe_capabilities', + description: 'Reports which client capabilities the gateway can see', + ) + ->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource') + ->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * @param array $extraHeaders + * + * @return array + */ + private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities, + ]; + + return self::callWithHeaders($protocol, $method, $params, [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => $method, + ...$extraHeaders, + ]); + } + + /** + * The header-exact variant: nothing is filled in, so a test can leave a + * required header out. + * + * @param array $params + * @param array $headers + * + * @return array + */ + private static function callWithHeaders( + StatelessProtocol $protocol, + string $method, + array $params = [], + array $headers = [], + ?string $metaVersion = null, + ): array { + $params['_meta'] ??= [ + RequestMeta::PROTOCOL_VERSION => $metaVersion ?? ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + $result = $protocol->handle( + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR), + $headers, + ); + + return [ + 'status' => $result->httpStatus, + 'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR), + ]; + } + + #[TestDox('the gateway sees the capabilities this request declared, not an empty session')] + public function testClientCapabilitiesReachTheGateway(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ['roots' => new \stdClass(), 'elicitation' => ['url' => new \stdClass()], 'sampling' => ['tools' => new \stdClass()]], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame( + 'roots,sampling,sampling.tools,elicitation,elicitation.url', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('a client declaring nothing is reported as declaring nothing')] + public function testEmptyCapabilitiesReportNone(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ); + + $this->assertSame('none', $answer['body']['result']['content'][0]['text']); + } + + /** + * @return iterable}> + */ + public static function removedMethods(): iterable + { + yield 'initialize' => ['initialize', []]; + yield 'ping' => ['ping', []]; + yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'info']]; + yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'test://static']]; + yield 'resources/unsubscribe' => ['resources/unsubscribe', ['uri' => 'test://static']]; + } + + /** + * @param array $params + */ + #[DataProvider('removedMethods')] + #[TestDox('a method this revision removed is answered as unknown')] + public function testRemovedMethodsAreUnknown(string $method, array $params): void + { + $answer = self::call(self::protocol(), $method, $params); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + } + + #[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')] + public function testResourceNotFoundUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://absent'], + ['Mcp-Name' => 'test://absent'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + $this->assertSame('test://absent', $answer['body']['error']['data']['uri']); + } + + #[TestDox('an unknown tool is a bad parameter, not an unknown method')] + public function testUnknownToolUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'no_such_tool', 'arguments' => []], + ['Mcp-Name' => 'no_such_tool'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + } + + #[TestDox('an unknown prompt is a bad parameter too')] + public function testUnknownPromptUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'prompts/get', + ['name' => 'no_such_prompt', 'arguments' => []], + ['Mcp-Name' => 'no_such_prompt'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + } + + #[TestDox('this revision never emits the codes it reserved')] + public function testReservedCodesAreNeverEmitted(): void + { + $protocol = self::protocol(); + + $answers = [ + self::call($protocol, 'resources/read', ['uri' => 'test://absent'], ['Mcp-Name' => 'test://absent']), + self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), + self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), + ]; + + foreach ($answers as $answer) { + // -32002 (resource not found) and -32042 (url elicitation required) + // are reserved by earlier revisions and never reused. + $this->assertNotContains($answer['body']['error']['code'], [-32002, -32042]); + } + } + + #[TestDox('a notification is acknowledged with no body, never answered')] + public function testNotificationIsAcknowledged(): void + { + $result = self::protocol()->handle( + json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/something', 'params' => []], \JSON_THROW_ON_ERROR), + ['Mcp-Method' => 'notifications/something'], + ); + + $this->assertTrue($result->isEmpty()); + $this->assertSame(202, $result->httpStatus); + } + + #[TestDox('a notification for a removed method is refused with no body')] + public function testRemovedNotificationIsRefused(): void + { + $result = self::protocol()->handle( + json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], \JSON_THROW_ON_ERROR), + ['Mcp-Method' => 'notifications/initialized'], + ); + + $this->assertTrue($result->isEmpty()); + $this->assertSame(400, $result->httpStatus); + } + + #[TestDox('an error for a request whose id could not be read omits the id')] + public function testUnreadableIdIsOmittedRatherThanEmptied(): void + { + $result = self::protocol()->handle('{"jsonrpc":"2.0","id":{"bad":1},"method":"tools/list"}', []); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertArrayNotHasKey('id', $body); + $this->assertSame(Error::INVALID_REQUEST, $body['error']['code']); + } + + #[TestDox('an error for a readable id still echoes it')] + public function testReadableIdIsEchoed(): void + { + $answer = self::callWithHeaders(self::protocol(), 'tools/list', [], ['Mcp-Method' => 'tools/list']); + + $this->assertSame(1, $answer['body']['id']); + } + + #[TestDox('a POST without the MCP-Protocol-Version header is refused')] + public function testMissingProtocolVersionHeaderIsRefused(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']); + $this->assertStringContainsString('MCP-Protocol-Version', $answer['body']['error']['message']); + } + + #[TestDox('a header contradicting the _meta version outranks an unsupported version')] + public function testContradictingProtocolVersionHeaderIsRefused(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '2025-11-25'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']); + } + + #[TestDox('an unsupported version carries the supported set the client can retry from')] + public function testUnsupportedProtocolVersionCarriesSupportedSet(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '1900-01-01'], + '1900-01-01', + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answer['body']['error']['code']); + $this->assertSame('1900-01-01', $answer['body']['error']['data']['requested']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']); + } + + #[TestDox('elicitation without a named mode reports form, not url')] + public function testElicitationDefaultsToFormMode(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame('elicitation', $answer['body']['result']['content'][0]['text']); + } +} diff --git a/tests/Unit/Server/Transport/Fixture/ShortReadStream.php b/tests/Unit/Server/Transport/Fixture/ShortReadStream.php new file mode 100644 index 0000000..98a7eda --- /dev/null +++ b/tests/Unit/Server/Transport/Fixture/ShortReadStream.php @@ -0,0 +1,110 @@ +contents, $this->offset, min($length, $this->chunkSize)); + $this->offset += \strlen($chunk); + + return $chunk; + } + + public function eof(): bool + { + return $this->offset >= \strlen($this->contents); + } + + public function getSize(): ?int + { + return $this->advertiseSize ? \strlen($this->contents) : null; + } + + public function isSeekable(): bool + { + return false; + } + + public function __toString(): string + { + return $this->contents; + } + + public function getContents(): string + { + return substr($this->contents, $this->offset); + } + + public function close(): void + { + } + + public function detach() + { + return null; + } + + public function tell(): int + { + return $this->offset; + } + + public function seek(int $offset, int $whence = \SEEK_SET): void + { + throw new \RuntimeException('Not seekable.'); + } + + public function rewind(): void + { + throw new \RuntimeException('Not seekable.'); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new \RuntimeException('Not writable.'); + } + + public function isReadable(): bool + { + return true; + } + + public function getMetadata(?string $key = null) + { + return null === $key ? [] : null; + } +} diff --git a/tests/Unit/Server/Transport/StatelessHttpTransportTest.php b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php new file mode 100644 index 0000000..08e96a4 --- /dev/null +++ b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php @@ -0,0 +1,140 @@ +factory = new Psr17Factory(); + } + + private function protocol(): StatelessProtocol + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument') + ->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * A `tools/call` body padded so it comfortably exceeds one 8 KiB read. + */ + private function callBody(int $padding = 0): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo_tool', + 'arguments' => ['text' => str_repeat('x', $padding)], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR); + } + + private function post(string $body): ServerRequestInterface + { + return $this->factory + ->createServerRequest('POST', 'http://localhost/mcp') + ->withHeader('Host', 'localhost') + ->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value) + ->withHeader('Mcp-Method', 'tools/call') + ->withHeader('Mcp-Name', 'echo_tool') + ->withBody($this->factory->createStream($body)); + } + + #[TestDox('a body arriving in short reads is assembled whole, not truncated')] + public function testShortReadsDoNotTruncateTheBody(): void + { + $body = $this->callBody(20_000); + + // PSR-7 promises only *up to* the requested length; a stream over + // php://input or a chunked transfer routinely returns less. + $request = $this->post('')->withBody(new ShortReadStream($body, 64)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + + $decoded = json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR); + $this->assertSame('echo:'.str_repeat('x', 20_000), $decoded['result']['content'][0]['text']); + } + + #[TestDox('a body over the cap is refused with 413')] + public function testOversizedBodyIsRefused(): void + { + $request = $this->post($this->callBody(2048)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request); + + $this->assertSame(413, $response->getStatusCode()); + } + + #[TestDox('an oversized body is refused even when its size is not advertised')] + public function testOversizedUnsizedBodyIsRefused(): void + { + $request = $this->post('')->withBody(new ShortReadStream($this->callBody(2048), 64, advertiseSize: false)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request); + + $this->assertSame(413, $response->getStatusCode()); + } + + #[TestDox('a notification POST is acknowledged with 202 and no body')] + public function testNotificationPostIsAccepted(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/something', + 'params' => [], + ], \JSON_THROW_ON_ERROR); + + $request = $this->post($body)->withHeader('Mcp-Method', 'notifications/something'); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + } + + #[TestDox('GET and DELETE are refused: there is no session to address')] + public function testOnlyPostIsAccepted(): void + { + $transport = new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory); + + foreach (['GET', 'DELETE', 'PUT'] as $method) { + $request = $this->factory + ->createServerRequest($method, 'http://localhost/mcp') + ->withHeader('Host', 'localhost'); + + $this->assertSame(405, $transport->handle($request)->getStatusCode(), $method); + } + } +} From dd3783a7203686b29e3d469dfc9276fe7887d1e7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:22:45 +0200 Subject: [PATCH 103/143] [Server] Stream a modern-era response when there is something to stream (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/call answered with one JSON object, so a handler's progress and log notifications had nowhere to go — and reached for a fiber that was not there, coming back as -32603. Handlers now run in a fiber and the stream opens after the first suspension, late enough that an error still gets the status the spec fixes for it. logLevel finally gates the log messages it was already being parsed for, and a server-initiated request is refused by name rather than as a FiberError. --- CHANGELOG.md | 2 + src/Capability/Logger/ClientLogger.php | 22 +- src/Schema/Enum/LoggingLevel.php | 27 +++ src/Server/Stateless/StatelessProtocol.php | 206 ++++++++++++++++-- .../conformance-baseline-2026-07-28.yml | 9 - .../Stateless/StatelessProtocolTest.php | 206 ++++++++++++++++++ 6 files changed, 427 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc63a20..38c80bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. +* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. * Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index ae0dd77..f94deb6 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -51,7 +51,7 @@ final class ClientLogger extends AbstractLogger $minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, ''); $minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning; - if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) { + if (!$mcpLevel->isAtLeast($minimumLevel)) { return; } @@ -79,24 +79,4 @@ final class ClientLogger extends AbstractLogger default => null, }; } - - /** - * Gets the severity index for this log level. - * Higher values indicate more severe log levels. - * - * @return int Severity index (0-7, where 7 is most severe) - */ - private function getSeverityIndex(LoggingLevel $level): int - { - return match ($level) { - LoggingLevel::Debug => 0, - LoggingLevel::Info => 1, - LoggingLevel::Notice => 2, - LoggingLevel::Warning => 3, - LoggingLevel::Error => 4, - LoggingLevel::Critical => 5, - LoggingLevel::Alert => 6, - LoggingLevel::Emergency => 7, - }; - } } diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index 7b03d13..d6031f9 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -32,4 +32,31 @@ enum LoggingLevel: string case Critical = 'critical'; case Alert = 'alert'; case Emergency = 'emergency'; + + /** + * RFC 5424 ordering, inverted so a larger number is more severe — which is + * the direction a minimum-level comparison reads in. + */ + public function severity(): int + { + return match ($this) { + self::Debug => 0, + self::Info => 1, + self::Notice => 2, + self::Warning => 3, + self::Error => 4, + self::Critical => 5, + self::Alert => 6, + self::Emergency => 7, + }; + } + + /** + * Whether a message at this level should be emitted when $minimum was + * requested. + */ + public function isAtLeast(self $minimum): bool + { + return $this->severity() >= $minimum->severity(); + } } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index c322937..cecd253 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -12,17 +12,22 @@ namespace Mcp\Server\Stateless; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\LogicException; use Mcp\Exception\MissingRequestMetaException; use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\RequestStateException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; +use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Result\DiscoverResult; use Mcp\Server\Configuration; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Protocol; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use Mcp\Server\Wire\Rev2026Codec; @@ -33,7 +38,7 @@ use Psr\Log\NullLogger; /** * Dispatches a single modern-era (SEP-2575) request. * - * Separate from {@see \Mcp\Server\Protocol} because the modern era has no + * Separate from {@see Protocol} because the modern era has no * session to resolve, replay or keep a fiber against; the two eras share * request handlers, not control flow. * @@ -203,7 +208,7 @@ final class StatelessProtocol ); } - return $this->dispatch($method, $decoded, $meta, $id); + return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers)); } /** @@ -338,7 +343,7 @@ final class StatelessProtocol /** * @param array $decoded */ - private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id): StatelessResult + private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false): StatelessResult { try { $messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR)); @@ -405,24 +410,47 @@ final class StatelessProtocol $session->set(RequestStateCodec::class, $this->requestStateCodec); } + // What ClientGateway::progress() reads to find the progress token, and + // the handshake era sets under the same key. + $session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + foreach ($this->requestHandlers as $handler) { if (!$handler->supports($request)) { continue; } - try { - $result = $handler->handle($request, $session); - } catch (MissingRequiredClientCapabilityException $e) { - return StatelessResult::error( - Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id), - 400, - ); - } catch (\InvalidArgumentException $e) { - return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); - } catch (\Throwable $e) { - $this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]); + $run = $this->run($handler, $request, $session, $meta); - return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500); + try { + // Runs the handler up to its first notification, or to the end + // if it emits none. Deciding here and not earlier is what keeps + // the status codes honest: a request that turns out to need + // -32021 has said nothing yet, so it can still be answered + // with 400 rather than an error frame under a 200. + $run->rewind(); + } catch (\Throwable $e) { + return $this->toErrorResult($method, $id, $e); + } + + if ($run->valid() && $wantsStream) { + return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id)); + } + + try { + // Stepped rather than foreach()ed: rewind() already advanced it, + // and a generator will not be traversed a second time. + while ($run->valid()) { + $this->logger->debug('Dropped a notification: the client did not accept a response stream.', [ + 'method' => $method, + 'notification' => $run->current()::getMethod(), + ]); + + $run->next(); + } + + $result = $run->getReturn(); + } catch (\Throwable $e) { + return $this->toErrorResult($method, $id, $e); } if ($result instanceof Error) { @@ -435,6 +463,134 @@ final class StatelessProtocol return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404); } + /** + * Runs a handler, yielding the notifications it emits as it emits them and + * returning its result. + * + * The fiber is what makes a handler's `$gateway->progress(...)` look + * synchronous while the caller decides where the notification goes. Server + * -to-client *requests* are refused rather than forwarded: this revision + * carries those in the result (MRTR), and putting one on a response stream + * is something the transport binding forbids outright. + * + * @param RequestHandlerInterface $handler + * + * @return \Generator|Error> + */ + private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator + { + $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); + + $suspended = $fiber->start(); + + while (!$fiber->isTerminated()) { + $notification = $this->readNotification($suspended, $meta); + + if (null !== $notification) { + yield $notification; + } + + $suspended = $fiber->resume(null); + } + + /** @var Response|Error $return */ + $return = $fiber->getReturn(); + + return $return; + } + + /** + * Reads one fiber suspension, or null when it carries nothing to send. + * + * @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with + */ + private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification + { + if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) { + if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) { + throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.'); + } + + return null; + } + + $notification = $suspended['notification'] ?? null; + + if (!$notification instanceof Notification) { + return null; + } + + // The client opts into logs per request; with no level named the server + // MUST NOT send any, which is why an absent level drops rather than + // defaults. + if ($notification instanceof LoggingMessageNotification) { + if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) { + return null; + } + } + + return $notification; + } + + /** + * The frames of a request-scoped response stream: the notifications the + * handler emits, then the response that ends it. + * + * @param \Generator|Error> $run + * + * @return \Generator + */ + private function streamFrames(\Generator $run, string $method, string|int $id): \Generator + { + try { + while ($run->valid()) { + yield $run->current()->jsonSerialize(); + + $run->next(); + } + + $result = $run->getReturn(); + } catch (\Throwable $e) { + // Headers left long ago, so the status is already 200 and the only + // way left to report this is a frame. + yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize(); + + return; + } + + yield $result instanceof Error + ? $result->jsonSerialize() + : ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())]; + } + + /** + * The one place a handler's exception becomes an answer, so the streaming + * and non-streaming paths cannot disagree about which code it earns. + */ + private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult + { + if ($e instanceof MissingRequiredClientCapabilityException) { + return StatelessResult::error( + Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id), + 400, + ); + } + + if ($e instanceof \InvalidArgumentException) { + return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); + } + + if ($e instanceof LogicException) { + // Guidance for the tool author, not a detail leaked from their + // code or a dependency's — safe to echo back verbatim. + return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500); + } + + $this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500); + } + /** * Reads the multi round-trip material off a retry, verifying the state * before any of it reaches a handler. Neither member means a first call, @@ -483,6 +639,26 @@ final class StatelessProtocol return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize())); } + /** + * Whether the client will read a response stream. + * + * Clients MUST offer both content types, so this is normally true; a client + * that does not gets its notifications dropped rather than a stream it + * cannot parse. + * + * @param array $headers + */ + private static function acceptsEventStream(array $headers): bool + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, 'Accept')) { + return str_contains(strtolower($value), 'text/event-stream'); + } + } + + return false; + } + /** * @param array $headers */ diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 25eb207..dfc892a 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -13,15 +13,6 @@ # make conformance-draft-server server: - # --- Progress notifications on a response stream --------------------------- - # The one place the modern lifecycle is behind the handshake one, and a real - # gap rather than a fixture difference: this scenario passes at / and fails - # at /stateless. A tools/call answer is a single JSON response here, so - # notifications a handler emits mid-call have nowhere to go. Closing it means - # answering tools/call over a stream — the same seam MRTR's IncompleteResult - # chunks will need — rather than anything specific to progress. - - tools-call-with-progress:tools-call-with-progress - # --- Pre-existing, not lifecycle-specific ---------------------------------- # Neither is caused by (or fixable in) the modern lifecycle; both reproduce # on the handshake endpoint. Listed so the draft run is honest. diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 634d5f8..5f4e495 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -11,12 +11,18 @@ namespace Mcp\Tests\Unit\Server\Stateless; +use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Server; use Mcp\Server\RequestContext; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Stateless\StatelessResult; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -46,6 +52,47 @@ class StatelessProtocolTest extends TestCase name: 'probe_capabilities', description: 'Reports which client capabilities the gateway can see', ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $gateway->progress(0, 100, 'starting'); + $gateway->progress(100, 100, 'done'); + + return 'progressed'; + }, + name: 'progress_tool', + description: 'Reports progress while it works', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + foreach ([LoggingLevel::Debug, LoggingLevel::Info, LoggingLevel::Warning, LoggingLevel::Error] as $level) { + $gateway->log($level, $level->value.' message'); + } + + return 'logged'; + }, + name: 'logging_tool', + description: 'Emits one message at each of four levels', + ) + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'needs sampling'); + }, + name: 'capability_tool', + description: 'Always reports a missing client capability', + ) + ->addTool( + static function (RequestContext $context): string { + // The pattern this revision replaced: kept as a fixture so + // the refusal has something to refuse. + $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'unreachable'; + }, + name: 'elicits_directly', + description: 'Asks the client directly, which this revision forbids', + ) ->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource') ->buildStateless([ProtocolVersion::V2026_07_28]); } @@ -158,6 +205,165 @@ class StatelessProtocolTest extends TestCase $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); } + /** + * Drains a streaming result into the frames it would write. + * + * @return list> + */ + private static function frames(StatelessResult $result): array + { + $frames = []; + + foreach (($result->frames)() as $frame) { + if (null !== $frame) { + $frames[] = $frame; + } + } + + return $frames; + } + + /** + * @param array $meta + */ + private static function callStreaming(StatelessProtocol $protocol, string $tool, array $meta = []): StatelessResult + { + return $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + 'params' => [ + 'name' => $tool, + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ...$meta, + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => $tool, + 'Accept' => 'application/json, text/event-stream', + ]); + } + + #[TestDox('progress notifications reach the client on the response stream')] + public function testProgressStreamsBeforeTheResponse(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + + $this->assertCount(3, $frames); + $this->assertSame('notifications/progress', $frames[0]['method']); + $this->assertSame('tok-1', $frames[0]['params']['progressToken']); + $this->assertSame('notifications/progress', $frames[1]['method']); + $this->assertSame(7, $frames[2]['id']); + $this->assertSame('complete', $frames[2]['result']['resultType']); + } + + #[TestDox('without a progress token the handler emits nothing and gets a plain response')] + public function testProgressWithoutATokenIsNotStreamed(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool'); + + $this->assertFalse($result->isStream()); + $this->assertSame(200, $result->httpStatus); + } + + #[TestDox('a client that will not read a stream gets its notifications dropped, not a stream')] + public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void + { + $result = self::protocol()->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'progress_tool', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'progressToken' => 'tok-1', + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'progress_tool', + 'Accept' => 'application/json', + ]); + + $this->assertFalse($result->isStream()); + $this->assertSame(200, $result->httpStatus); + } + + #[TestDox('a request naming no log level receives no log notifications')] + public function testLoggingIsSilentWithoutARequestedLevel(): void + { + $result = self::callStreaming(self::protocol(), 'logging_tool'); + + $this->assertFalse($result->isStream()); + } + + #[TestDox('a request naming a log level receives the messages at or above it')] + public function testLoggingHonoursTheRequestedLevel(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'warning'])); + + $levels = []; + foreach ($frames as $frame) { + if ('notifications/message' === ($frame['method'] ?? null)) { + $levels[] = $frame['params']['level']; + } + } + + // The tool emits debug, info, warning and error. + $this->assertSame(['warning', 'error'], $levels); + } + + #[TestDox('a lower requested level lets more through')] + public function testLoggingAtDebugLetsEverythingThrough(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'debug'])); + + $levels = []; + foreach ($frames as $frame) { + if ('notifications/message' === ($frame['method'] ?? null)) { + $levels[] = $frame['params']['level']; + } + } + + $this->assertSame(['debug', 'info', 'warning', 'error'], $levels); + } + + #[TestDox('an error raised before any notification keeps its own status')] + public function testEarlyFailureIsStillAStatusCode(): void + { + $result = self::callStreaming(self::protocol(), 'capability_tool'); + + $this->assertFalse($result->isStream()); + $this->assertSame(400, $result->httpStatus); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']); + } + + #[TestDox('a server-initiated request is refused with the pattern that replaced it')] + public function testServerInitiatedRequestIsRefused(): void + { + $result = self::callStreaming(self::protocol(), 'elicits_directly'); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertSame(500, $result->httpStatus); + $this->assertStringContainsString('InputRequiredResult', $body['error']['message']); + } + #[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')] public function testResourceNotFoundUsesInvalidParams(): void { From 7ffc739fe732b927bac934d0a73702ba37ba8e74 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:30:57 +0200 Subject: [PATCH 104/143] [CI] Matrix conformance jobs over both spec revisions (#458) * [CI] Matrix the conformance jobs over both spec revisions Runs server/client conformance against both 2025-11-25 and 2026-07-28, each against its own endpoint and baseline. PR pipeline pins an exact conformance package version; conformance-weekly tracks the moving target on both dist-tags. * [CI] Run the draft conformance suite from the alpha package The conformance repo now ships 2026-07-28 scenarios on its alpha dist-tag, so the draft targets no longer need a local checkout. Pinned to the same version the pipeline runs. * [CI] Baseline the failures --suite all newly exposes The old workflow ran without --suite all/--spec-version, so several pre-existing gaps never surfaced in CI. Now that both revisions run the full suite: - server: json-schema-2020-12 and resources-templates-read are fixture bugs shared by both revisions; 2026-07-28 also needs resources/subscribe and resources/unsubscribe declared removed under the stateless wire, which the server doesn't yet do - client: the example client doesn't call add_numbers or echo back a tool's inputSchema; against 2026-07-28 it doesn't speak the lifecycle or OAuth at all yet, so every such scenario fails Verified locally: all four conformance jobs (server/client x 2025-11-25/2026-07-28) pass against these baselines. --- .github/workflows/conformance-weekly.yaml | 68 +++++++++++++++---- .github/workflows/pipeline.yaml | 49 +++++++++++-- Makefile | 29 +++----- .../conformance-baseline-2026-07-28.yml | 41 ++++++++++- tests/Conformance/conformance-baseline.yml | 13 +++- 5 files changed, 161 insertions(+), 39 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index f12f379..ecc262f 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -1,9 +1,9 @@ name: conformance-weekly -# Runs the MCP conformance suite weekly against the latest -# @modelcontextprotocol/conformance release. The on:pull_request pipeline -# pins to whatever version is available at PR time; this schedule catches -# upstream releases that add scenarios between PRs. +# Runs the MCP conformance suite weekly against the newest published +# @modelcontextprotocol/conformance. The on:pull_request pipeline pins an exact +# version; this schedule catches upstream releases that add scenarios between +# PRs, so the pin never silently goes stale. # # It also scores each run and publishes the client/server pass-rate as # shields.io endpoint JSON to the orphan `badges` branch (consumed by the @@ -17,10 +17,26 @@ permissions: contents: write issues: write +# The PR pipeline pins an exact conformance version so a PR only goes red for +# reasons in the PR. That pin is only safe because this job tracks the moving +# target instead — on both revisions, since each rides a different dist-tag: +# the dated one is on `latest`, the draft one only on `alpha`. jobs: server: - name: conformance / server (latest) + name: conformance / server (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + dist-tag: latest + path: '/' + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + dist-tag: alpha + path: '/stateless' + baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -35,9 +51,17 @@ jobs: sleep 5 - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml --output-dir results + run: >- + npx --yes @modelcontextprotocol/conformance@${{ matrix.dist-tag }} server + --url http://localhost:8000${{ matrix.path }} + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} + --output-dir results + # The badge tracks the released revision, so only the dated entry feeds + # it; the draft entry runs purely to catch upstream drift. - name: Generate score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' run: php tests/Conformance/score.php server - name: Show docker logs on failure if: failure() @@ -46,20 +70,30 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: conformance-server-results + name: conformance-server-results-${{ matrix.spec-version }} path: | tests/Conformance/logs tests/Conformance/results - name: Upload score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' uses: actions/upload-artifact@v7 with: name: server-badge path: tests/Conformance/server-conformance.json client: - name: conformance / client (latest) + name: conformance / client (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + dist-tag: latest + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + dist-tag: alpha + baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 @@ -73,20 +107,26 @@ jobs: - run: mkdir -p tests/Conformance/logs - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results + run: >- + npx --yes @modelcontextprotocol/conformance@${{ matrix.dist-tag }} client + --command "php ${{ github.workspace }}/tests/Conformance/client.php" + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} + --output-dir results - name: Generate score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' run: php tests/Conformance/score.php client - name: Upload conformance results if: failure() uses: actions/upload-artifact@v7 with: - name: conformance-client-results + name: conformance-client-results-${{ matrix.spec-version }} path: | tests/Conformance/logs tests/Conformance/results - name: Upload score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' uses: actions/upload-artifact@v7 with: name: client-badge diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index f7396e6..402e22b 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -5,6 +5,12 @@ permissions: contents: read pull-requests: write +env: + # Pinned so a PR only goes red for reasons in the PR. The 2026-07-28 + # scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has none of them. + # conformance-weekly.yaml is what tracks upstream releases. + CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11" + jobs: unit: runs-on: ubuntu-latest @@ -95,8 +101,24 @@ jobs: run: vendor/bin/phpunit --testsuite=inspector conformance-server: - name: conformance / server + name: conformance / server (${{ matrix.spec-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + # One entry per lifecycle, not per spec revision: `--spec-version` is + # cumulative across the dated revisions, so 2025-11-25 already covers + # 2025-06-18 and 2025-03-26. 2026-07-28 is not cumulative with them — it + # is the SEP-2575 stateless wire, served from its own endpoint with its + # own baseline, so it needs its own run. + matrix: + include: + - spec-version: '2025-11-25' + path: '/' + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + path: '/stateless' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -118,7 +140,12 @@ jobs: - name: Run conformance tests working-directory: ./tests/Conformance - run: npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --expected-failures conformance-baseline.yml + run: >- + npx --yes ${{ env.CONFORMANCE_PKG }} server + --url http://localhost:8000${{ matrix.path }} + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} - name: Show logs on failure if: failure() @@ -146,8 +173,17 @@ jobs: run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: - name: conformance / client + name: conformance / client (${{ matrix.spec-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -171,7 +207,12 @@ jobs: - name: Run client conformance tests working-directory: ./tests/Conformance - run: npx @modelcontextprotocol/conformance client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml + run: >- + npx --yes ${{ env.CONFORMANCE_PKG }} client + --command "php ${{ github.workspace }}/tests/Conformance/client.php" + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} - name: Show logs on failure if: failure() diff --git a/Makefile b/Makefile index b019429..c756792 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client check-conformance-repo docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs -# The published conformance release carries no 2026-07-28 scenarios, so the -# draft targets run a local checkout instead. -# Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`. -CONFORMANCE_REPO ?= $(CURDIR)/../conformance -CONFORMANCE_DRAFT = node $(CONFORMANCE_REPO)/dist/index.js +# The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has +# none of them. Pinned to the same version CI runs (see +# .github/workflows/pipeline.yaml), so a local pass means a green pipeline. +CONFORMANCE_VERSION ?= 0.2.0-alpha.11 +CONFORMANCE = npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) deps-stable: composer update --prefer-stable @@ -47,29 +47,20 @@ conformance-client: php tests/Conformance/score.php client # --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ -# Local-checkout only until upstream publishes the draft scenarios. conformance-draft: conformance-draft-server conformance-draft-client -check-conformance-repo: - @test -f $(CONFORMANCE_REPO)/dist/index.js || { \ - echo "No conformance build at $(CONFORMANCE_REPO)/dist/index.js."; \ - echo "Clone modelcontextprotocol/conformance and run 'npm install && npm run build' there,"; \ - echo "or point CONFORMANCE_REPO at an existing checkout."; \ - exit 1; \ - } - -conformance-draft-server: check-conformance-repo +conformance-draft-server: docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results-2026-07-28 - cd tests/Conformance && $(CONFORMANCE_DRAFT) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true docker compose -f tests/Conformance/Fixtures/docker-compose.yml down -conformance-draft-client: check-conformance-repo +conformance-draft-client: rm -rf tests/Conformance/results-2026-07-28 - cd tests/Conformance && $(CONFORMANCE_DRAFT) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index dfc892a..faaa602 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -25,4 +25,43 @@ server: - json-schema-2020-12:json-schema-2020-12-tool-found - resources-templates-read:wire-schema-valid -client: [] +# The client does not speak 2026-07-28 at all yet: it still opens with +# `initialize` regardless of revision, so it never reaches the stateless wire +# (no per-request `_meta`, no Mcp-Method/Mcp-Name headers, no client-side +# requestState) — and separately, the SDK ships no OAuth client, so every +# scenario driving an authorization flow has nothing to exercise. +client: + - request-metadata + - http-standard-headers + - http-custom-headers + - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate + - http-invalid-tool-headers:sep-2243-client-reject-invalid-tool + - sep-2322-client-request-state + - tools_call:tool-add-numbers + - json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found + - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed + + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/metadata-issuer-mismatch + - auth/basic-cimd + - auth/pre-registration + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/offline-access-scope + - auth/offline-access-not-supported + - auth/authorization-server-migration:sep-2352-reregister-on-as-change + - auth/iss-supported + - auth/iss-not-advertised + - auth/iss-supported-missing + - auth/iss-wrong-issuer + - auth/iss-unexpected + - auth/iss-normalized diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index efda80a..cba53fd 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,8 +1,19 @@ -server: [] +# Both pre-existing fixture bugs, not lifecycle-specific: json-schema-2020-12 +# wants a `json_schema_2020_12_tool` fixture neither conformance server +# defines, and resources-templates-read emits a resource-template URI in a +# shape the schema rejects. Reproduce identically on the 2026-07-28 endpoint +# (see conformance-baseline-2026-07-28.yml). +server: + - json-schema-2020-12:json-schema-2020-12-tool-found + - resources-templates-read:wire-schema-valid client: - elicitation-sep1034-client-defaults - sse-retry + # The example client doesn't call add_numbers or echo the tool's inputSchema + # back, so these checks have nothing to exercise. + - tools_call:tool-add-numbers + - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 From cad686aba7d5531817006b2894203d032e85d5a6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:41:00 +0200 Subject: [PATCH 105/143] [Server] Complete the multi round-trip request surface (SEP-2322) (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Let resources/read ask for input, and stop caching retries The MRTR pattern names three methods and resources/read was the one whose handler dropped an InputRequiredResult into the resource formatter. A retry's answer also carried ttlMs and cacheScope, which the caching rules forbid: its inputs are not part of any cache key. * [Server] Refuse an ask the client cannot answer A handler could put an elicitation into inputRequests for a client that never declared one, and the only symptom was a retry that never came. Checked against the request's own capabilities now, and reported as -32021 with the missing set — which is the code the spec defines for exactly this. * [Server] Hand multi round-trip answers back typed response() returned whatever JSON arrived, so every handler re-implemented the same array poking. A malformed answer now reads as absent, which sends the handler back to asking rather than to failing. * [Server] Gate streamed input_required asks by capability too checkInputRequests() only ran on the non-streaming path, so a handler that emits a notification before returning InputRequiredResult could still leak an undeclared-capability ask under HTTP 200. --- CHANGELOG.md | 1 + .../Handler/Request/ReadResourceHandler.php | 16 +- src/Server/Stateless/InputContext.php | 62 +++++++ .../Stateless/InputRequestCapabilities.php | 96 +++++++++++ src/Server/Stateless/StatelessProtocol.php | 58 ++++++- src/Server/Wire/Rev2025Codec.php | 2 +- src/Server/Wire/Rev2026Codec.php | 6 +- src/Server/Wire/WireCodecInterface.php | 9 +- tests/Conformance/MrtrElements.php | 10 +- .../Server/Stateless/InputContextTest.php | 111 +++++++++++++ .../Stateless/StatelessProtocolTest.php | 156 ++++++++++++++++++ 11 files changed, 508 insertions(+), 19 deletions(-) create mode 100644 src/Server/Stateless/InputRequestCapabilities.php create mode 100644 tests/Unit/Server/Stateless/InputContextTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c80bf..14a4619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index e3b2f0e..879542a 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -21,6 +21,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; @@ -28,7 +29,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -47,7 +48,7 @@ final class ReadResourceHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -71,9 +72,20 @@ final class ReadResourceHandler implements RequestHandlerInterface $arguments = array_merge($arguments, $variables); $result = $this->referenceHandler->handle($reference, $arguments); + + // An ask is a result in its own right, not resource contents. + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); } else { $result = $this->referenceHandler->handle($reference, $arguments); + + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resource->mimeType); } diff --git a/src/Server/Stateless/InputContext.php b/src/Server/Stateless/InputContext.php index 9075109..d91197b 100644 --- a/src/Server/Stateless/InputContext.php +++ b/src/Server/Stateless/InputContext.php @@ -11,6 +11,12 @@ namespace Mcp\Server\Stateless; +use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\JsonRpc\ResultInterface; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\ListRootsResult; + /** * What a retry brought back: the client's answers to a previous * {@see \Mcp\Schema\Result\InputRequiredResult}, keyed as its `inputRequests` @@ -40,6 +46,62 @@ final class InputContext return $this->responses[$key] ?? null; } + /** + * The client's answer to an `elicitation/create` ask, typed. + * + * Null when the key is absent or the answer does not parse — which the + * caller should read as "not answered" and ask again, rather than as an + * error: the specification says a server SHOULD re-ask for information it + * still needs instead of failing the request. + * + * @param ElicitationMode $mode the mode of the ask this answers; url-mode + * answers carry no content, so an accepted one + * is complete without it + */ + public function elicitResult(string $key, ElicitationMode $mode = ElicitationMode::Form): ?ElicitResult + { + return $this->parse($key, static fn (array $data): ElicitResult => ElicitResult::fromArray($data, $mode)); + } + + /** + * The client's answer to a `sampling/createMessage` ask, typed. + */ + public function samplingResult(string $key): ?CreateSamplingMessageResult + { + return $this->parse($key, CreateSamplingMessageResult::fromArray(...)); + } + + /** + * The client's answer to a `roots/list` ask, typed. + */ + public function rootsResult(string $key): ?ListRootsResult + { + return $this->parse($key, ListRootsResult::fromArray(...)); + } + + /** + * @template T of ResultInterface + * + * @param callable(array): T $factory + * + * @return T|null + */ + private function parse(string $key, callable $factory): ?ResultInterface + { + $response = $this->responses[$key] ?? null; + + if (!\is_array($response)) { + return null; + } + + try { + return $factory($response); + } catch (\Throwable) { + // A malformed answer is one the client did not really give. + return null; + } + } + public function has(string $key): bool { return \array_key_exists($key, $this->responses); diff --git a/src/Server/Stateless/InputRequestCapabilities.php b/src/Server/Stateless/InputRequestCapabilities.php new file mode 100644 index 0000000..1f4287f --- /dev/null +++ b/src/Server/Stateless/InputRequestCapabilities.php @@ -0,0 +1,96 @@ + + */ +final class InputRequestCapabilities +{ + /** + * The capabilities $result needs that $declared does not offer, or null + * when the client can answer everything it is being asked. + */ + public static function missing(InputRequiredResult $result, ClientCapabilities $declared): ?ClientCapabilities + { + $roots = false; + $sampling = false; + $samplingTools = false; + $samplingContext = false; + $elicitationForm = false; + $elicitationUrl = false; + + foreach ($result->inputRequests as $request) { + if ($request instanceof ListRootsRequest) { + $roots = $roots || true !== $declared->roots; + + continue; + } + + if ($request instanceof CreateSamplingMessageRequest) { + $sampling = $sampling || true !== $declared->sampling; + + if (null !== $request->tools || null !== $request->toolChoice) { + $samplingTools = $samplingTools || true !== $declared->samplingTools; + } + + // `none` is the default and needs nothing; the other two are + // deprecated values that only a declaring client understands. + if (null !== $request->includeContext && SamplingContext::NONE !== $request->includeContext) { + $samplingContext = $samplingContext || true !== $declared->samplingContext; + } + + continue; + } + + if ($request instanceof ElicitRequest) { + if (ElicitationMode::Url === $request->mode) { + $elicitationUrl = $elicitationUrl || true !== $declared->elicitationUrl; + } else { + $elicitationForm = $elicitationForm || true !== $declared->elicitationForm; + } + } + } + + if (!$roots && !$sampling && !$samplingTools && !$samplingContext && !$elicitationForm && !$elicitationUrl) { + return null; + } + + return new ClientCapabilities( + roots: $roots, + sampling: $sampling ?: null, + elicitation: ($elicitationForm || $elicitationUrl) ?: null, + samplingContext: $samplingContext ?: null, + samplingTools: $samplingTools ?: null, + elicitationForm: $elicitationForm ?: null, + elicitationUrl: $elicitationUrl ?: null, + ); + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index cecd253..414a0a8 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -25,6 +25,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Result\DiscoverResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Configuration; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; @@ -433,7 +434,7 @@ final class StatelessProtocol } if ($run->valid() && $wantsStream) { - return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id)); + return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input)); } try { @@ -457,7 +458,11 @@ final class StatelessProtocol return StatelessResult::error($result, 400); } - return $this->encode($method, $id, $result->result); + if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) { + return $capabilityError; + } + + return $this->encode($method, $id, $result->result, null === $input); } return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404); @@ -540,7 +545,7 @@ final class StatelessProtocol * * @return \Generator */ - private function streamFrames(\Generator $run, string $method, string|int $id): \Generator + private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator { try { while ($run->valid()) { @@ -558,9 +563,50 @@ final class StatelessProtocol return; } + if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) { + yield $capabilityError->message?->jsonSerialize(); + + return; + } + yield $result instanceof Error ? $result->jsonSerialize() - : ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())]; + : ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)]; + } + + /** + * Refuses to send an ask the client cannot answer. + * + * The handler's mistake rather than the client's, but the client is the one + * that has to hear about it, and `-32021` is precisely the code for + * "processing this needs a capability you did not declare" — so it is + * reported as that, and logged as the server-side bug it is. + */ + private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult + { + if (!$result instanceof InputRequiredResult) { + return null; + } + + $missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities); + + if (null === $missing) { + return null; + } + + $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ + 'method' => $method, + 'required' => $missing->jsonSerialize(), + ]); + + return StatelessResult::error( + Error::forMissingRequiredClientCapability( + 'The server needs input this client did not declare it can provide.', + $missing, + $id, + ), + 400, + ); } /** @@ -634,9 +680,9 @@ final class StatelessProtocol * Runs a result through the wire codec. Passed as-is rather than via a * json round trip, which would turn a nested `{}` into `[]`. */ - private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult + private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult { - return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize())); + return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable)); } /** diff --git a/src/Server/Wire/Rev2025Codec.php b/src/Server/Wire/Rev2025Codec.php index 7b3a228..0e1472d 100644 --- a/src/Server/Wire/Rev2025Codec.php +++ b/src/Server/Wire/Rev2025Codec.php @@ -21,7 +21,7 @@ namespace Mcp\Server\Wire; */ final class Rev2025Codec implements WireCodecInterface { - public function encodeResult(string $method, array $result): array + public function encodeResult(string $method, array $result, bool $cacheable = true): array { return $result; } diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php index ed75d2c..b073e63 100644 --- a/src/Server/Wire/Rev2026Codec.php +++ b/src/Server/Wire/Rev2026Codec.php @@ -51,10 +51,12 @@ final class Rev2026Codec implements WireCodecInterface ) { } - public function encodeResult(string $method, array $result): array + public function encodeResult(string $method, array $result, bool $cacheable = true): array { + $stamped = $this->stampResultType($method, $result); + return $this->stampServerInfo( - $this->fillCacheHints($method, $this->stampResultType($method, $result)), + $cacheable ? $this->fillCacheHints($method, $stamped) : $stamped, ); } diff --git a/src/Server/Wire/WireCodecInterface.php b/src/Server/Wire/WireCodecInterface.php index 72a5f6b..e183c4b 100644 --- a/src/Server/Wire/WireCodecInterface.php +++ b/src/Server/Wire/WireCodecInterface.php @@ -29,10 +29,13 @@ interface WireCodecInterface /** * Stamps an already-serialized result with whatever this era requires. * - * @param string $method the request method the result answers - * @param array $result the neutral result body + * @param string $method the request method the result answers + * @param array $result the neutral result body + * @param bool $cacheable whether this answer may carry caching hints at all — false for one + * produced by a multi round-trip retry, whose inputs are not part of + * any cache key * * @return array */ - public function encodeResult(string $method, array $result): array; + public function encodeResult(string $method, array $result, bool $cacheable = true): array; } diff --git a/tests/Conformance/MrtrElements.php b/tests/Conformance/MrtrElements.php index e32430a..8d5cadd 100644 --- a/tests/Conformance/MrtrElements.php +++ b/tests/Conformance/MrtrElements.php @@ -20,6 +20,7 @@ use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; @@ -46,7 +47,7 @@ final class MrtrElements ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); } - return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY))))]); + return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY))))]); } public static function sampling(RequestContext $context): CallToolResult|InputRequiredResult @@ -198,14 +199,13 @@ final class MrtrElements ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); } - return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY)))]]; + return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY)))]]; } /** A declined or cancelled answer has no content, so the greeting falls back. */ - private static function nameFrom(mixed $response): string + private static function nameFrom(?ElicitResult $result): string { - $content = \is_array($response) ? ($response['content'] ?? null) : null; - $name = \is_array($content) ? ($content['name'] ?? null) : null; + $name = $result?->content['name'] ?? null; return \is_string($name) ? $name : 'friend'; } diff --git a/tests/Unit/Server/Stateless/InputContextTest.php b/tests/Unit/Server/Stateless/InputContextTest.php new file mode 100644 index 0000000..3425500 --- /dev/null +++ b/tests/Unit/Server/Stateless/InputContextTest.php @@ -0,0 +1,111 @@ + ['action' => 'accept', 'content' => ['name' => 'ada']]]); + + $result = $context->elicitResult('who'); + + $this->assertNotNull($result); + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertSame(['name' => 'ada'], $result->content); + } + + #[TestDox('an accepted url-mode answer needs no content')] + public function testUrlModeElicitResultNeedsNoContent(): void + { + $context = new InputContext(['consent' => ['action' => 'accept']]); + + $this->assertNull($context->elicitResult('consent')); + $this->assertNotNull($context->elicitResult('consent', ElicitationMode::Url)); + } + + #[TestDox('a sampling answer comes back typed')] + public function testSamplingResultIsParsed(): void + { + $context = new InputContext(['capital' => [ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Paris'], + 'model' => 'a-model', + 'stopReason' => 'endTurn', + ]]); + + $result = $context->samplingResult('capital'); + + $this->assertNotNull($result); + $this->assertSame('a-model', $result->model); + $blocks = $result->getContentBlocks(); + $this->assertInstanceOf(TextContent::class, $blocks[0]); + $this->assertSame('Paris', $blocks[0]->text); + } + + #[TestDox('a roots answer comes back typed')] + public function testRootsResultIsParsed(): void + { + $context = new InputContext(['roots' => ['roots' => [['uri' => 'file:///work', 'name' => 'work']]]]); + + $result = $context->rootsResult('roots'); + + $this->assertNotNull($result); + $this->assertCount(1, $result->roots); + $this->assertSame('file:///work', $result->roots[0]->uri); + } + + #[TestDox('an answer that was never given reads as absent')] + public function testMissingKeyIsNull(): void + { + $context = new InputContext(); + + $this->assertNull($context->elicitResult('who')); + $this->assertNull($context->samplingResult('who')); + $this->assertNull($context->rootsResult('who')); + $this->assertFalse($context->has('who')); + } + + #[TestDox('a malformed answer reads as absent, so the handler asks again rather than fails')] + public function testMalformedAnswerIsNull(): void + { + $context = new InputContext([ + 'who' => ['action' => 'not-an-action'], + 'capital' => ['role' => 'user', 'content' => []], + 'roots' => ['roots' => 'not a list'], + ]); + + $this->assertNull($context->elicitResult('who')); + $this->assertNull($context->samplingResult('capital')); + $this->assertNull($context->rootsResult('roots')); + + // Still present — the client did answer, just not with something usable. + $this->assertTrue($context->has('who')); + } + + #[TestDox('the verified request state is carried alongside the answers')] + public function testRequestStateIsCarried(): void + { + $context = new InputContext(['a' => ['action' => 'cancel']], ['round' => 2]); + + $this->assertSame(['round' => 2], $context->requestState()); + $this->assertSame(['a'], array_keys($context->all())); + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 5f4e495..b8a4e3c 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -18,6 +18,8 @@ use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server; use Mcp\Server\RequestContext; use Mcp\Server\Stateless\RequestMeta; @@ -93,7 +95,41 @@ class StatelessProtocolTest extends TestCase name: 'elicits_directly', description: 'Asks the client directly, which this revision forbids', ) + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult([ + 'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'), + ]), + name: 'asks_by_url', + description: 'Asks through a url-mode elicitation', + ) + ->addTool( + static function (RequestContext $context): InputRequiredResult { + $context->getClientGateway()->progress(0, 100, 'starting'); + + return new InputRequiredResult([ + 'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'), + ]); + }, + name: 'asks_by_url_after_progress', + description: 'Emits progress, then asks through a url-mode elicitation', + ) ->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource') + ->addResource( + static function (RequestContext $context): string|InputRequiredResult { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('who')) { + return new InputRequiredResult([ + 'who' => new ElicitRequest('Who is asking?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])), + ]); + } + + return 'body for '.($input->response('who')['content']['n'] ?? '?'); + }, + 'test://gated', + 'gated', + 'A resource that asks who is reading before it answers', + ) ->buildStateless([ProtocolVersion::V2026_07_28]); } @@ -422,6 +458,126 @@ class StatelessProtocolTest extends TestCase } } + #[TestDox('resources/read can ask for input before it answers')] + public function testResourceReadCanAskForInput(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://gated'], + ['Mcp-Name' => 'test://gated'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('who', $answer['body']['result']['inputRequests']); + + // Interim results are not cacheable and carry no hints. + $this->assertArrayNotHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayNotHasKey('cacheScope', $answer['body']['result']); + } + + #[TestDox('an ask the client cannot answer is refused with -32021, not sent')] + public function testUndeclaredInputRequestIsRefused(): void + { + // The client declared nothing, so it has no way to answer an + // elicitation — and a retry carrying one could never arrive. + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://gated'], + ['Mcp-Name' => 'test://gated'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + $this->assertArrayHasKey('elicitation', $answer['body']['error']['data']['requiredCapabilities']); + } + + #[TestDox('url-mode elicitation needs its own declaration, which form does not satisfy')] + public function testUrlElicitationNeedsItsOwnDeclaration(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'asks_by_url', 'arguments' => []], + ['Mcp-Name' => 'asks_by_url'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + $this->assertArrayHasKey('url', $answer['body']['error']['data']['requiredCapabilities']['elicitation']); + } + + #[TestDox('a client declaring url mode gets the ask')] + public function testUrlElicitationPassesWhenDeclared(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'asks_by_url', 'arguments' => []], + ['Mcp-Name' => 'asks_by_url'], + ['elicitation' => ['url' => new \stdClass()]], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + } + + #[TestDox('an ask the client cannot answer is refused with -32021 even mid-stream')] + public function testUndeclaredInputRequestIsRefusedWhenStreamed(): void + { + // The handler emits progress before its InputRequiredResult, so the + // answer goes out on the response stream, not a plain 400 — the gate + // still has to apply there. + $result = self::callStreaming(self::protocol(), 'asks_by_url_after_progress', ['progressToken' => 'tok-1']); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + $last = json_decode(json_encode($frames[array_key_last($frames)], \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $last['error']['code']); + $this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']); + } + + #[TestDox('a first-round read carries caching hints')] + public function testFirstRoundReadIsCacheable(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://static'], + ['Mcp-Name' => 'test://static'], + ); + + $this->assertArrayHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayHasKey('cacheScope', $answer['body']['result']); + } + + #[TestDox('a result produced by a multi round-trip retry carries no caching hints')] + public function testMrtrRetryResultIsNotCacheable(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + [ + 'uri' => 'test://gated', + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['n' => 'ada']]], + ], + ['Mcp-Name' => 'test://gated'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame('complete', $answer['body']['result']['resultType']); + // The inputs are not part of any cache key, so the answer must not be + // presented as reusable. + $this->assertArrayNotHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayNotHasKey('cacheScope', $answer['body']['result']); + } + #[TestDox('a notification is acknowledged with no body, never answered')] public function testNotificationIsAcknowledged(): void { From 78a8926f1765ba0028d26e56779d683c4bf1cebf Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:42:36 +0200 Subject: [PATCH 106/143] [CI] Version both baseline files and split conformance badges per spec (#459) - Rename conformance-baseline.yml to conformance-baseline-2025-11-25.yml so both baseline files carry their spec revision symmetrically, and bring conformance-server/conformance-client up to the same rigor (--suite all, pinned CONFORMANCE, baseline check) as the draft targets - score.php now takes a required spec-version and writes -conformance-.json, so 2025-11-25 and 2026-07-28 each get their own badge instead of only the dated one - conformance-weekly.yaml generates and publishes badges for both matrix entries instead of gating on 2025-11-25 only, and drops the now-stale unversioned badge files from the badges branch - README shows all four badges alongside the existing packagist/CI/PHP version/license row - Trimmed workflow comments to the minimum needed --- .github/workflows/conformance-weekly.yaml | 55 +++++++------------ .github/workflows/pipeline.yaml | 13 +---- Makefile | 10 ++-- README.md | 6 +- ...ml => conformance-baseline-2025-11-25.yml} | 15 +++++ .../conformance-baseline-2026-07-28.yml | 7 ++- tests/Conformance/score.php | 24 ++++---- 7 files changed, 66 insertions(+), 64 deletions(-) rename tests/Conformance/{conformance-baseline.yml => conformance-baseline-2025-11-25.yml} (70%) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index ecc262f..3d9b614 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -1,13 +1,8 @@ name: conformance-weekly -# Runs the MCP conformance suite weekly against the newest published -# @modelcontextprotocol/conformance. The on:pull_request pipeline pins an exact -# version; this schedule catches upstream releases that add scenarios between -# PRs, so the pin never silently goes stale. -# -# It also scores each run and publishes the client/server pass-rate as -# shields.io endpoint JSON to the orphan `badges` branch (consumed by the -# README); that branch is created on the first run. +# Tracks the newest published @modelcontextprotocol/conformance; the +# on:pull_request pipeline pins an exact version instead. Also publishes +# score badges to the orphan `badges` branch, consumed by the README. on: schedule: - cron: '0 6 * * 1' # Mondays 06:00 UTC @@ -17,10 +12,6 @@ permissions: contents: write issues: write -# The PR pipeline pins an exact conformance version so a PR only goes red for -# reasons in the PR. That pin is only safe because this job tracks the moving -# target instead — on both revisions, since each rides a different dist-tag: -# the dated one is on `latest`, the draft one only on `alpha`. jobs: server: name: conformance / server (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) @@ -32,7 +23,7 @@ jobs: - spec-version: '2025-11-25' dist-tag: latest path: '/' - baseline: conformance-baseline.yml + baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' dist-tag: alpha path: '/stateless' @@ -58,11 +49,9 @@ jobs: --spec-version ${{ matrix.spec-version }} --expected-failures ${{ matrix.baseline }} --output-dir results - # The badge tracks the released revision, so only the dated entry feeds - # it; the draft entry runs purely to catch upstream drift. - name: Generate score badge - if: always() && matrix.spec-version == '2025-11-25' - run: php tests/Conformance/score.php server + if: always() + run: php tests/Conformance/score.php server ${{ matrix.spec-version }} - name: Show docker logs on failure if: failure() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs @@ -75,11 +64,11 @@ jobs: tests/Conformance/logs tests/Conformance/results - name: Upload score badge - if: always() && matrix.spec-version == '2025-11-25' + if: always() uses: actions/upload-artifact@v7 with: - name: server-badge - path: tests/Conformance/server-conformance.json + name: badge-server-${{ matrix.spec-version }} + path: tests/Conformance/server-conformance-${{ matrix.spec-version }}.json client: name: conformance / client (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) @@ -90,7 +79,7 @@ jobs: include: - spec-version: '2025-11-25' dist-tag: latest - baseline: conformance-baseline.yml + baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' dist-tag: alpha baseline: conformance-baseline-2026-07-28.yml @@ -115,8 +104,8 @@ jobs: --expected-failures ${{ matrix.baseline }} --output-dir results - name: Generate score badge - if: always() && matrix.spec-version == '2025-11-25' - run: php tests/Conformance/score.php client + if: always() + run: php tests/Conformance/score.php client ${{ matrix.spec-version }} - name: Upload conformance results if: failure() uses: actions/upload-artifact@v7 @@ -126,11 +115,11 @@ jobs: tests/Conformance/logs tests/Conformance/results - name: Upload score badge - if: always() && matrix.spec-version == '2025-11-25' + if: always() uses: actions/upload-artifact@v7 with: - name: client-badge - path: tests/Conformance/client-conformance.json + name: badge-client-${{ matrix.spec-version }} + path: tests/Conformance/client-conformance-${{ matrix.spec-version }}.json notify: name: Open issue on failure @@ -156,26 +145,21 @@ jobs: - Run: $RUN_URL - 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 \`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: name: Publish conformance badges runs-on: ubuntu-latest needs: [server, client] - # Publish even when the suite regressed (the badge should reflect reality); - # skip on forks, which cannot push the `badges` branch. if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }} steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: - name: server-badge - path: badges-in - - uses: actions/download-artifact@v8 - with: - name: client-badge + pattern: badge-* path: badges-in + merge-multiple: true - name: Publish to badges branch run: | git config user.name "github-actions[bot]" @@ -190,7 +174,8 @@ jobs: git -C badges-wt rm -rf --quiet . >/dev/null 2>&1 || true fi - cp badges-in/server-conformance.json badges-in/client-conformance.json badges-wt/ + rm -f badges-wt/server-conformance.json badges-wt/client-conformance.json + cp badges-in/*.json badges-wt/ cd badges-wt git add -A diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 402e22b..2a5475d 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -6,9 +6,7 @@ permissions: pull-requests: write env: - # Pinned so a PR only goes red for reasons in the PR. The 2026-07-28 - # scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has none of them. - # conformance-weekly.yaml is what tracks upstream releases. + # Pinned exact version; conformance-weekly.yaml tracks upstream releases. CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11" jobs: @@ -105,16 +103,11 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - # One entry per lifecycle, not per spec revision: `--spec-version` is - # cumulative across the dated revisions, so 2025-11-25 already covers - # 2025-06-18 and 2025-03-26. 2026-07-28 is not cumulative with them — it - # is the SEP-2575 stateless wire, served from its own endpoint with its - # own baseline, so it needs its own run. matrix: include: - spec-version: '2025-11-25' path: '/' - baseline: conformance-baseline.yml + baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' path: '/stateless' baseline: conformance-baseline-2026-07-28.yml @@ -180,7 +173,7 @@ jobs: matrix: include: - spec-version: '2025-11-25' - baseline: conformance-baseline.yml + baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' baseline: conformance-baseline-2026-07-28.yml diff --git a/Makefile b/Makefile index c756792..7490515 100644 --- a/Makefile +++ b/Makefile @@ -37,14 +37,14 @@ conformance-server: @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --spec-version 2025-11-25 --output-dir results || true - php tests/Conformance/score.php server + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results || true + php tests/Conformance/score.php server 2025-11-25 docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline.yml --output-dir results || true - php tests/Conformance/score.php client + cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results || true + php tests/Conformance/score.php client 2025-11-25 # --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ @@ -56,11 +56,13 @@ conformance-draft-server: @sleep 5 rm -rf tests/Conformance/results-2026-07-28 cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + php tests/Conformance/score.php server 2026-07-28 results-2026-07-28 docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-draft-client: rm -rf tests/Conformance/results-2026-07-28 cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + php tests/Conformance/score.php client 2026-07-28 results-2026-07-28 coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/README.md b/README.md index 4e1a868..dfab676 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ [![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](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) -[![Client Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) +[![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) diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml similarity index 70% rename from tests/Conformance/conformance-baseline.yml rename to tests/Conformance/conformance-baseline-2025-11-25.yml index cba53fd..2450aa6 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,3 +1,18 @@ +# Expected conformance failures for spec revision 2025-11-25. +# +# Separate from conformance-baseline-2026-07-28.yml because the two revisions +# exercise different lifecycles against different endpoints: this file covers +# the handshake era served at /, the other the modern (SEP-2575) era served +# at /stateless. +# +# Entries are per-check (scenario:check-id) rather than whole scenarios, so a +# scenario that is mostly conformant keeps reporting the parts that work and a +# new regression inside it still fails the run. +# +# Run with: +# make conformance-server +# make conformance-client + # Both pre-existing fixture bugs, not lifecycle-specific: json-schema-2020-12 # wants a `json_schema_2020_12_tool` fixture neither conformance server # defines, and resources-templates-read emits a resource-template URI in a diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index faaa602..4b14d87 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,8 +1,8 @@ # Expected conformance failures for spec revision 2026-07-28. # -# Separate from conformance-baseline.yml because the two revisions exercise -# different lifecycles against different endpoints: the dated baseline covers -# the handshake era served at /, this one covers the modern (SEP-2575) era +# Separate from conformance-baseline-2025-11-25.yml because the two revisions +# exercise different lifecycles against different endpoints: the other file +# covers the handshake era served at /, this one the modern (SEP-2575) era # served at /stateless. # # Entries are per-check (scenario:check-id) rather than whole scenarios, so a @@ -11,6 +11,7 @@ # # Run with: # make conformance-draft-server +# make conformance-draft-client server: # --- Pre-existing, not lifecycle-specific ---------------------------------- diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php index ddad90f..06c3409 100644 --- a/tests/Conformance/score.php +++ b/tests/Conformance/score.php @@ -13,13 +13,14 @@ * Turns a conformance run's results into a shields.io endpoint badge JSON, so * the client/server conformance score can be rendered in the README. * - * php score.php + * php score.php [results-dir] * - * The conformance CLI (run with `--output-dir results`) writes one - * `checks.json` per scenario into the `results/` directory next 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`. + * The conformance CLI writes one `checks.json` per scenario into + * `[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. */ use Symfony\Component\Console\Command\Command; @@ -36,10 +37,13 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; ->setName('conformance-score') ->setDescription('Generates a shields.io endpoint badge from the conformance results') ->addArgument('suite', InputArgument::REQUIRED, 'Which conformance suite was run: "server" or "client"') + ->addArgument('spec-version', InputArgument::REQUIRED, 'The spec revision that was run, e.g. "2025-11-25"') + ->addArgument('results-dir', InputArgument::OPTIONAL, 'Directory the conformance CLI wrote results into, relative to this file', 'results') ->setCode(static function (InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $suite = $input->getArgument('suite'); + $specVersion = $input->getArgument('spec-version'); if (!in_array($suite, ['server', 'client'], true)) { $io->error(sprintf('Suite must be "server" or "client", got "%s".', $suite)); @@ -47,10 +51,10 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; return Command::INVALID; } - $resultsDir = __DIR__.'/results'; + $resultsDir = __DIR__.'/'.$input->getArgument('results-dir'); if (!is_dir($resultsDir)) { - $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir results` first.', $resultsDir)); + $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir` first.', $resultsDir)); return Command::FAILURE; } @@ -88,7 +92,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $badge = [ 'schemaVersion' => 1, - 'label' => $suite.' conformance', + 'label' => sprintf('%s %s', $suite, $specVersion), 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', 'color' => match (true) { 0 === $total => 'lightgrey', @@ -99,7 +103,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; }, ]; - $outputFile = __DIR__.'/'.$suite.'-conformance.json'; + $outputFile = __DIR__.'/'.$suite.'-conformance-'.$specVersion.'.json'; if (false === file_put_contents($outputFile, json_encode($badge, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n")) { $io->error(sprintf('Could not write badge file "%s".', $outputFile)); From 7dd72d82de9372d2ec2c0bd565aef43d3efd2d5a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 23:53:16 +0200 Subject: [PATCH 107/143] [Server] Let a server say how long its answers stay fresh (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cacheable result went out as ttlMs 0, private — the letter of SEP-2549 and none of its point, with no way to change it. CachePolicy adds a default and per-method overrides; a resource can override per read. The conservative default stands, because public means a shared cache may serve one caller's answer to another. --- CHANGELOG.md | 1 + phpstan-baseline.neon | 2 +- src/Schema/Result/ReadResourceResult.php | 37 +++++- src/Server/Builder.php | 26 ++++ .../Handler/Request/ReadResourceHandler.php | 8 +- src/Server/Stateless/StatelessProtocol.php | 4 +- src/Server/Wire/CachePolicy.php | 87 ++++++++++++++ src/Server/Wire/Rev2026Codec.php | 15 ++- tests/Conformance/server-stateless.php | 11 ++ .../Stateless/StatelessProtocolTest.php | 32 +++++ tests/Unit/Server/Wire/CachePolicyTest.php | 113 ++++++++++++++++++ 11 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 src/Server/Wire/CachePolicy.php create mode 100644 tests/Unit/Server/Wire/CachePolicyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a4619..01bb548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. * Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f5901b2..a84b39c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,7 +1,7 @@ parameters: ignoreErrors: - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\, ttlMs\?\: int, cacheScope\?\: string\} but returns array\{contents\: array\, ttlMs\?\: int\<0, max\>, cacheScope\?\: ''private''\|''public''\}\.$#' identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php index 7fd8000..7c6d0c5 100644 --- a/src/Schema/Result/ReadResourceResult.php +++ b/src/Schema/Result/ReadResourceResult.php @@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -30,16 +31,28 @@ class ReadResourceResult implements ResultInterface /** * Create a new ReadResourceResult. * - * @param ResourceContents[] $contents The contents of the resource + * @param ResourceContents[] $contents The contents of the resource + * @param ?int $ttlMs How long a client may consider this fresh, in milliseconds. Null + * leaves it to the server's configured {@see \Mcp\Server\Wire\CachePolicy}; + * set it when one resource's freshness differs from the rest. + * @param ?CacheScope $cacheScope Who may cache it. Null defers to the policy. `Private` for anything + * shaped by who asked. */ public function __construct( public readonly array $contents, + public readonly ?int $ttlMs = null, + public readonly ?CacheScope $cacheScope = null, ) { + if (null !== $this->ttlMs && $this->ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A resource "ttlMs" must be zero or more, got %d.', $this->ttlMs)); + } } /** * @param array{ * contents: array, + * ttlMs?: int, + * cacheScope?: string, * } $data */ public static function fromArray(array $data): self @@ -59,18 +72,32 @@ class ReadResourceResult implements ResultInterface } } - return new self($contents); + $scope = isset($data['cacheScope']) && \is_string($data['cacheScope']) ? CacheScope::tryFrom($data['cacheScope']) : null; + + return new self($contents, isset($data['ttlMs']) && \is_int($data['ttlMs']) ? $data['ttlMs'] : null, $scope); } /** * @return array{ * contents: array, + * ttlMs?: int, + * cacheScope?: string, * } */ public function jsonSerialize(): array { - return [ - 'contents' => $this->contents, - ]; + $data = ['contents' => $this->contents]; + + // Only what this result actually decided; the wire codec fills the rest + // from policy, and an absent member is the signal for it to do so. + if (null !== $this->ttlMs) { + $data['ttlMs'] = $this->ttlMs; + } + + if (null !== $this->cacheScope) { + $data['cacheScope'] = $this->cacheScope->value; + } + + return $data; } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index fe7fd33..62c8261 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -59,6 +59,7 @@ use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Wire\CachePolicy; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -107,6 +108,8 @@ final class Builder private ?ProtocolVersion $protocolVersion = null; + private ?CachePolicy $cachePolicy = null; + private ?string $requestStateKey = null; private int $requestStateTtl = 600; @@ -255,6 +258,28 @@ final class Builder return $this; } + /** + * Sets how long, and to whom, this server's answers may be cached (SEP-2549). + * + * The modern lifecycle must put `ttlMs` and `cacheScope` on every cacheable + * result; without a policy it says "private, immediately stale", which is + * conformant and forfeits the point. Build one with + * {@see CachePolicy::default()} and narrow it per method: + * + * ```php + * $builder->setCachePolicy( + * CachePolicy::default(60_000) + * ->withMethod('tools/list', 3_600_000, CacheScope::Public), + * ); + * ``` + */ + public function setCachePolicy(CachePolicy $policy): self + { + $this->cachePolicy = $policy; + + return $this; + } + /** * Sets the key signing the `requestState` carried across the rounds of a * multi round-trip request (SEP-2322). Without one, every echoed state is @@ -761,6 +786,7 @@ final class Builder requestStateCodec: null !== $this->requestStateKey ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) : null, + cachePolicy: $this->cachePolicy, ); } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 879542a..60755f8 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -73,8 +73,10 @@ final class ReadResourceHandler implements RequestHandlerInterface $result = $this->referenceHandler->handle($reference, $arguments); - // An ask is a result in its own right, not resource contents. - if ($result instanceof InputRequiredResult) { + // An ask is a result in its own right, not resource contents; + // and a handler that built the whole result keeps what it + // decided, caching hints included. + if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) { return new Response($request->getId(), $result); } @@ -82,7 +84,7 @@ final class ReadResourceHandler implements RequestHandlerInterface } else { $result = $this->referenceHandler->handle($reference, $arguments); - if ($result instanceof InputRequiredResult) { + if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) { return new Response($request->getId(), $result); } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 414a0a8..fc0f23c 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -31,6 +31,7 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; +use Mcp\Server\Wire\CachePolicy; use Mcp\Server\Wire\Rev2026Codec; use Mcp\Server\Wire\WireCodecInterface; use Psr\Log\LoggerInterface; @@ -98,8 +99,9 @@ final class StatelessProtocol ?WireCodecInterface $codec = null, private readonly ?StandardHeaderValidator $headerValidator = null, private readonly ?RequestStateCodec $requestStateCodec = null, + ?CachePolicy $cachePolicy = null, ) { - $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo); + $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); if (null === $this->headerValidator) { // Not fatal: a transport without a header layer — stdio — has diff --git a/src/Server/Wire/CachePolicy.php b/src/Server/Wire/CachePolicy.php new file mode 100644 index 0000000..e22fb21 --- /dev/null +++ b/src/Server/Wire/CachePolicy.php @@ -0,0 +1,87 @@ + + */ +final class CachePolicy +{ + /** + * @param array $perMethod + */ + private function __construct( + private readonly int $ttlMs, + private readonly CacheScope $scope, + private readonly array $perMethod = [], + ) { + } + + /** + * The conservative default: nothing is fresh, nothing is shared. + */ + public static function none(): self + { + return new self(0, CacheScope::Private); + } + + /** + * @param int $ttlMs how long an answer stays fresh, in milliseconds + */ + public static function default(int $ttlMs, CacheScope $scope = CacheScope::Private): self + { + if ($ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs)); + } + + return new self($ttlMs, $scope); + } + + /** + * Overrides the policy for one method. + * + * Only the methods the specification makes cacheable are worth naming; any + * other is accepted and simply never consulted. + */ + public function withMethod(string $method, int $ttlMs, CacheScope $scope = CacheScope::Private): self + { + if ($ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs)); + } + + return new self($this->ttlMs, $this->scope, [...$this->perMethod, $method => [$ttlMs, $scope]]); + } + + public function ttlFor(string $method): int + { + return $this->perMethod[$method][0] ?? $this->ttlMs; + } + + public function scopeFor(string $method): CacheScope + { + return $this->perMethod[$method][1] ?? $this->scope; + } +} diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php index b073e63..d3b21ef 100644 --- a/src/Server/Wire/Rev2026Codec.php +++ b/src/Server/Wire/Rev2026Codec.php @@ -44,11 +44,13 @@ final class Rev2026Codec implements WireCodecInterface 'resources/read', ]; + private readonly CachePolicy $cachePolicy; + public function __construct( private readonly ?Implementation $serverInfo = null, - private readonly CacheScope $defaultCacheScope = CacheScope::Private, - private readonly int $defaultTtlMs = 0, + ?CachePolicy $cachePolicy = null, ) { + $this->cachePolicy = $cachePolicy ?? CachePolicy::none(); } public function encodeResult(string $method, array $result, bool $cacheable = true): array @@ -84,8 +86,9 @@ final class Rev2026Codec implements WireCodecInterface } /** - * Fills `ttlMs`/`cacheScope`, most-specific author first: an authored value, - * then configured policy, then "private, do not cache". + * Fills `ttlMs`/`cacheScope`, most-specific author first: a value the + * result itself carries, then the configured {@see CachePolicy}, whose own + * default is "private, do not cache". * * @param array $result * @@ -106,11 +109,11 @@ final class Rev2026Codec implements WireCodecInterface // Invalid authored values fall through to the next author down. if (!\is_int($ttl) || $ttl < 0) { - $ttl = $this->defaultTtlMs; + $ttl = $this->cachePolicy->ttlFor($method); } if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) { - $scope = $this->defaultCacheScope->value; + $scope = $this->cachePolicy->scopeFor($method)->value; } return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope]; diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php index 8bb69f2..fb5e7ba 100644 --- a/tests/Conformance/server-stateless.php +++ b/tests/Conformance/server-stateless.php @@ -30,12 +30,14 @@ use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server; use Mcp\Server\Transport\StatelessHttpTransport; +use Mcp\Server\Wire\CachePolicy; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; use Mcp\Tests\Conformance\MrtrElements; @@ -125,6 +127,15 @@ $protocol = Server::builder() ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') // Fixed so a retry landing on another process still verifies. ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + // Lists are the same for everyone here; a read is not. + ->setCachePolicy( + CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('prompts/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) ->buildStateless([ProtocolVersion::V2026_07_28]); $transport = new StatelessHttpTransport($protocol, logger: $logger); diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index b8a4e3c..2cb8a67 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -13,18 +13,22 @@ namespace Mcp\Tests\Unit\Server\Stateless; use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server; use Mcp\Server\RequestContext; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Stateless\StatelessResult; +use Mcp\Server\Wire\CachePolicy; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -543,6 +547,34 @@ class StatelessProtocolTest extends TestCase $this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']); } + #[TestDox('a resource can set its own freshness, overriding the policy')] + public function testResourceAuthoredCacheHintsWin(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCachePolicy(CachePolicy::default(60_000, CacheScope::Public)) + ->addResource( + static fn (): ReadResourceResult => new ReadResourceResult( + [new TextResourceContents('test://volatile', 'text/plain', 'now')], + ttlMs: 250, + cacheScope: CacheScope::Private, + ), + 'test://volatile', + 'volatile', + 'A resource that decides its own freshness', + ) + ->addResource(static fn (): string => 'body', 'test://plain', 'plain', 'A resource that defers to policy') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $volatile = self::call($protocol, 'resources/read', ['uri' => 'test://volatile'], ['Mcp-Name' => 'test://volatile']); + $this->assertSame(250, $volatile['body']['result']['ttlMs']); + $this->assertSame('private', $volatile['body']['result']['cacheScope']); + + $plain = self::call($protocol, 'resources/read', ['uri' => 'test://plain'], ['Mcp-Name' => 'test://plain']); + $this->assertSame(60_000, $plain['body']['result']['ttlMs']); + $this->assertSame('public', $plain['body']['result']['cacheScope']); + } + #[TestDox('a first-round read carries caching hints')] public function testFirstRoundReadIsCacheable(): void { diff --git a/tests/Unit/Server/Wire/CachePolicyTest.php b/tests/Unit/Server/Wire/CachePolicyTest.php new file mode 100644 index 0000000..4a65aa5 --- /dev/null +++ b/tests/Unit/Server/Wire/CachePolicyTest.php @@ -0,0 +1,113 @@ +assertSame(0, $policy->ttlFor('tools/list')); + $this->assertSame(CacheScope::Private, $policy->scopeFor('tools/list')); + } + + #[TestDox('a default applies to every method')] + public function testDefaultApplies(): void + { + $policy = CachePolicy::default(60_000, CacheScope::Public); + + $this->assertSame(60_000, $policy->ttlFor('tools/list')); + $this->assertSame(60_000, $policy->ttlFor('resources/read')); + $this->assertSame(CacheScope::Public, $policy->scopeFor('resources/read')); + } + + #[TestDox('a per-method override wins, and leaves the others alone')] + public function testPerMethodOverride(): void + { + $policy = CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public); + + $this->assertSame(3_600_000, $policy->ttlFor('tools/list')); + $this->assertSame(CacheScope::Public, $policy->scopeFor('tools/list')); + $this->assertSame(60_000, $policy->ttlFor('resources/read')); + $this->assertSame(CacheScope::Private, $policy->scopeFor('resources/read')); + } + + #[TestDox('the policy is immutable')] + public function testWithMethodDoesNotMutate(): void + { + $base = CachePolicy::default(1_000); + $narrowed = $base->withMethod('tools/list', 5_000); + + $this->assertSame(1_000, $base->ttlFor('tools/list')); + $this->assertSame(5_000, $narrowed->ttlFor('tools/list')); + } + + #[TestDox('a negative TTL is refused: the spec requires zero or more')] + public function testNegativeTtlIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + CachePolicy::default(-1); + } + + #[TestDox('a negative per-method TTL is refused too')] + public function testNegativePerMethodTtlIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + CachePolicy::default(0)->withMethod('tools/list', -5); + } + + #[TestDox('the codec stamps the policy onto a cacheable result')] + public function testCodecAppliesThePolicy(): void + { + $codec = new Rev2026Codec(null, CachePolicy::default(60_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)); + + $tools = $codec->encodeResult('tools/list', ['tools' => []]); + $this->assertSame(3_600_000, $tools['ttlMs']); + $this->assertSame('public', $tools['cacheScope']); + + $read = $codec->encodeResult('resources/read', ['contents' => []]); + $this->assertSame(60_000, $read['ttlMs']); + $this->assertSame('private', $read['cacheScope']); + } + + #[TestDox('a value the result carries wins over the policy')] + public function testAuthoredValueWins(): void + { + $codec = new Rev2026Codec(null, CachePolicy::default(60_000, CacheScope::Public)); + + $encoded = $codec->encodeResult('resources/read', ['contents' => [], 'ttlMs' => 500, 'cacheScope' => 'private']); + + $this->assertSame(500, $encoded['ttlMs']); + $this->assertSame('private', $encoded['cacheScope']); + } + + #[TestDox('a method the spec does not make cacheable is left alone')] + public function testNonCacheableMethodIsUntouched(): void + { + $encoded = (new Rev2026Codec(null, CachePolicy::default(60_000)))->encodeResult('tools/call', ['content' => []]); + + $this->assertArrayNotHasKey('ttlMs', $encoded); + $this->assertArrayNotHasKey('cacheScope', $encoded); + } +} From daac88d626b20947e92ebb40e47964cd9b72f252 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:08:02 +0200 Subject: [PATCH 108/143] [Server] Make subscriptions/listen actually deliver (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream acknowledged, slept thirty seconds and closed, so tools and resource changes never reached a client — and with resources/subscribe gone, that left the revision with no server-push at all. A notification bus carries them: in-memory where publisher and stream share a process, PSR-16 where php-fpm puts them in different ones. The conformance server proves the latter — a tools/call in one worker reaches a stream held in another. --- CHANGELOG.md | 1 + src/Server/Builder.php | 69 +++++- src/Server/Stateless/NotificationFilter.php | 23 ++ src/Server/Stateless/StatelessProtocol.php | 59 ++++- .../Subscription/InMemoryNotificationBus.php | 74 ++++++ .../Subscription/NotificationBusInterface.php | 53 +++++ .../Subscription/Psr16NotificationBus.php | 113 +++++++++ .../PublishingEventDispatcher.php | 53 +++++ .../Subscription/RegistryChangePublisher.php | 81 +++++++ tests/Conformance/server-stateless.php | 59 +++++ .../Stateless/StatelessProtocolTest.php | 118 ++++++++++ .../Subscription/NotificationBusTest.php | 217 ++++++++++++++++++ 12 files changed, 907 insertions(+), 13 deletions(-) create mode 100644 src/Server/Subscription/InMemoryNotificationBus.php create mode 100644 src/Server/Subscription/NotificationBusInterface.php create mode 100644 src/Server/Subscription/Psr16NotificationBus.php create mode 100644 src/Server/Subscription/PublishingEventDispatcher.php create mode 100644 src/Server/Subscription/RegistryChangePublisher.php create mode 100644 tests/Unit/Server/Subscription/NotificationBusTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bb548..16a7f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". * Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. * Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 62c8261..2cd24aa 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -59,6 +59,10 @@ use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\NotificationBusInterface; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Wire\CachePolicy; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -110,6 +114,10 @@ final class Builder private ?CachePolicy $cachePolicy = null; + private ?NotificationBusInterface $notificationBus = null; + + private float $subscriptionLifetime = 30.0; + private ?string $requestStateKey = null; private int $requestStateTtl = 600; @@ -258,6 +266,45 @@ final class Builder return $this; } + /** + * Sets the bus carrying server-initiated notifications to open + * `subscriptions/listen` streams (SEP-2575). + * + * Without one, a listen stream acknowledges and then carries nothing: there + * is no safe default, because the right implementation depends on whether + * the publisher and the stream share a process. + * {@see InMemoryNotificationBus} is correct for stdio and persistent + * runtimes; under PHP-FPM, where they are different workers, use + * {@see Psr16NotificationBus} or an implementation over your own broker. + * + * Registry changes are published automatically when an event dispatcher is + * configured; anything else — `notifications/resources/updated` above all — + * is published by the application calling + * {@see NotificationBusInterface::publish()}. + */ + public function setNotificationBus(NotificationBusInterface $bus): self + { + $this->notificationBus = $bus; + + return $this; + } + + /** + * Sets how long a `subscriptions/listen` stream is held open before the + * server closes it gracefully. + * + * The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive + * `max_execution_time`, and a value above it buys a killed worker instead + * of a longer subscription. Pass `0` for "until the client or the runtime + * ends it", which is what a persistent runtime wants. + */ + public function setSubscriptionLifetime(float $seconds): self + { + $this->subscriptionLifetime = max(0.0, $seconds); + + return $this; + } + /** * Sets how long, and to whom, this server's answers may be cached (SEP-2549). * @@ -757,7 +804,7 @@ final class Builder messageFactory: $parts['messageFactory'], sessionManager: $parts['sessionManager'], logger: $parts['logger'], - eventDispatcher: $this->eventDispatcher, + eventDispatcher: $parts['eventDispatcher'], ); return new Server($protocol, $parts['logger']); @@ -782,11 +829,13 @@ final class Builder configuration: $parts['configuration'], supportedVersions: $supportedVersions, logger: $parts['logger'], + subscriptionLifetime: $this->subscriptionLifetime, headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, requestStateCodec: null !== $this->requestStateKey ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) : null, cachePolicy: $this->cachePolicy, + notificationBus: $this->notificationBus, ); } @@ -795,6 +844,7 @@ final class Builder * * @return array{ * logger: LoggerInterface, + * eventDispatcher: ?EventDispatcherInterface, * configuration: Configuration, * messageFactory: MessageFactory, * sessionManager: SessionManagerInterface, @@ -807,6 +857,12 @@ final class Builder { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); + + // A configured bus needs the registry's change events, and PSR-14 hands + // the SDK a dispatcher it cannot register listeners on — so it wraps. + $eventDispatcher = null !== $this->notificationBus + ? new PublishingEventDispatcher($this->notificationBus, $this->eventDispatcher) + : $this->eventDispatcher; $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), @@ -845,7 +901,7 @@ final class Builder $chainLoader->load($registry); $eagerlyLoaded = true; } else { - $registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader); + $registry = new Registry($eventDispatcher, $logger, loader: $chainLoader); if (!$this->lazyLoading) { $registry->load(); } @@ -854,7 +910,7 @@ final class Builder $messageFactory = MessageFactory::make(additional: $this->extensionMessages); - $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); + $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded, $eventDispatcher); // Extensions enabled via enableExtension() are folded into caller-supplied // capabilities too, so setCapabilities() does not silently drop them. @@ -895,6 +951,7 @@ final class Builder return [ 'logger' => $logger, + 'eventDispatcher' => $eventDispatcher, 'registry' => $registry, 'configuration' => $configuration, 'messageFactory' => $messageFactory, @@ -909,9 +966,11 @@ final class Builder * the load, so they are advertised from the configured sources instead — opaque sources (custom * loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics. */ - private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities + private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded, ?EventDispatcherInterface $eventDispatcher): ServerCapabilities { - $listChanged = $this->eventDispatcher instanceof EventDispatcherInterface; + // Without a dispatcher the registry announces nothing, so there is no + // list-changed notification to advertise. + $listChanged = $eventDispatcher instanceof EventDispatcherInterface; if ($eagerlyLoaded) { $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); diff --git a/src/Server/Stateless/NotificationFilter.php b/src/Server/Stateless/NotificationFilter.php index ae80628..4649126 100644 --- a/src/Server/Stateless/NotificationFilter.php +++ b/src/Server/Stateless/NotificationFilter.php @@ -11,6 +11,11 @@ namespace Mcp\Server\Stateless; +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Schema\Notification\PromptListChangedNotification; +use Mcp\Schema\Notification\ResourceListChangedNotification; +use Mcp\Schema\Notification\ResourceUpdatedNotification; +use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\ServerCapabilities; /** @@ -65,6 +70,24 @@ final class NotificationFilter ); } + /** + * Whether this filter admits $notification onto the stream. + * + * An allow-list decision, so an unrecognized notification is declined: the + * server MUST NOT send a type the client did not ask for, and "did not ask + * for" includes types it has never heard of. + */ + public function carries(Notification $notification): bool + { + return match (true) { + $notification instanceof ToolListChangedNotification => $this->toolsListChanged, + $notification instanceof PromptListChangedNotification => $this->promptsListChanged, + $notification instanceof ResourceListChangedNotification => $this->resourcesListChanged, + $notification instanceof ResourceUpdatedNotification => \in_array($notification->uri, $this->resourceSubscriptions, true), + default => false, + }; + } + /** * Agreed types only; declined ones are omitted rather than sent as `false`. * diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index fc0f23c..2ed5b21 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -31,6 +31,7 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; +use Mcp\Server\Subscription\NotificationBusInterface; use Mcp\Server\Wire\CachePolicy; use Mcp\Server\Wire\Rev2026Codec; use Mcp\Server\Wire\WireCodecInterface; @@ -100,6 +101,7 @@ final class StatelessProtocol private readonly ?StandardHeaderValidator $headerValidator = null, private readonly ?RequestStateCodec $requestStateCodec = null, ?CachePolicy $cachePolicy = null, + private readonly ?NotificationBusInterface $notificationBus = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); @@ -295,8 +297,12 @@ final class StatelessProtocol $agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities); $lifetime = $this->subscriptionLifetime; + $bus = $this->notificationBus; + $codec = $this->codec; - return StatelessResult::stream(static function () use ($agreed, $id, $lifetime): \Generator { + return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator { + // MUST be the first message carrying this subscription's id, and + // MUST precede any notification on it. yield [ 'jsonrpc' => '2.0', 'method' => self::ACKNOWLEDGED_NOTIFICATION, @@ -306,12 +312,27 @@ final class StatelessProtocol ], ]; - // Cross-request notifications need a channel this SDK does not - // define yet, so the stream only waits. The tick is not optional: - // PHP spots a dropped peer by writing, and a sleeping loop would - // pin an FPM worker for the full lifetime. - $deadline = microtime(true) + $lifetime; + // From now, not from the beginning: a subscriber wants what happens + // next, not a replay of the server's history. + $cursor = $bus?->cursor() ?? 0; + + // The tick is not optional: PHP spots a dropped peer by writing, + // and a sleeping loop would pin an FPM worker for the full lifetime. + $deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime; + while (microtime(true) < $deadline) { + if (null !== $bus) { + [$notifications, $cursor] = $bus->since($cursor); + + foreach ($notifications as $notification) { + if (!$agreed->carries($notification)) { + continue; + } + + yield self::tagWithSubscription($notification, $id); + } + } + yield null; if (connection_aborted()) { @@ -326,14 +347,36 @@ final class StatelessProtocol yield [ 'jsonrpc' => '2.0', 'id' => $id, - 'result' => [ + 'result' => $codec->encodeResult(self::LISTEN_METHOD, [ 'resultType' => 'complete', '_meta' => [RequestMeta::SUBSCRIPTION_ID => $id], - ], + ], false), ]; }); } + /** + * Every message on a listen stream carries the id of the subscription it + * belongs to, which is how a client demultiplexes them on stdio — where + * they all share one channel. + * + * @return array + */ + private static function tagWithSubscription(Notification $notification, string|int $id): array + { + /** @var array $frame */ + $frame = $notification->jsonSerialize(); + + $params = \is_array($frame['params'] ?? null) ? $frame['params'] : []; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $meta[RequestMeta::SUBSCRIPTION_ID] = $id; + $params['_meta'] = $meta; + $frame['params'] = $params; + + return $frame; + } + private function discover(): DiscoverResult { return new DiscoverResult( diff --git a/src/Server/Subscription/InMemoryNotificationBus.php b/src/Server/Subscription/InMemoryNotificationBus.php new file mode 100644 index 0000000..af93cef --- /dev/null +++ b/src/Server/Subscription/InMemoryNotificationBus.php @@ -0,0 +1,74 @@ + + */ +final class InMemoryNotificationBus implements NotificationBusInterface +{ + /** @var array */ + private array $entries = []; + + private int $next = 0; + + /** + * @param int $backlog how many notifications to keep before dropping the oldest + */ + public function __construct( + private readonly int $backlog = 256, + ) { + if ($this->backlog < 1) { + throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog)); + } + } + + public function publish(Notification $notification): void + { + $this->entries[$this->next] = $notification; + ++$this->next; + + // A stream that went away must not make this grow forever. + if (\count($this->entries) > $this->backlog) { + $this->entries = \array_slice($this->entries, -$this->backlog, preserve_keys: true); + } + } + + public function cursor(): int + { + return $this->next; + } + + public function since(int $cursor): array + { + $found = []; + + foreach ($this->entries as $sequence => $notification) { + if ($sequence >= $cursor) { + $found[] = $notification; + } + } + + return [$found, $this->next]; + } +} diff --git a/src/Server/Subscription/NotificationBusInterface.php b/src/Server/Subscription/NotificationBusInterface.php new file mode 100644 index 0000000..8569366 --- /dev/null +++ b/src/Server/Subscription/NotificationBusInterface.php @@ -0,0 +1,53 @@ + + */ +interface NotificationBusInterface +{ + /** + * Publishes a notification to every stream currently reading forward. + */ + public function publish(Notification $notification): void; + + /** + * The cursor a stream opening now should start from. + * + * Deliberately "now" and not "the beginning": a client that subscribes + * wants what happens next, not a replay of everything the server has done. + */ + public function cursor(): int; + + /** + * Notifications published after $cursor, and the cursor to read from next. + * + * @return array{list, int} + */ + public function since(int $cursor): array; +} diff --git a/src/Server/Subscription/Psr16NotificationBus.php b/src/Server/Subscription/Psr16NotificationBus.php new file mode 100644 index 0000000..2f9b63a --- /dev/null +++ b/src/Server/Subscription/Psr16NotificationBus.php @@ -0,0 +1,113 @@ + + */ +final class Psr16NotificationBus implements NotificationBusInterface +{ + private const CURSOR_KEY = 'cursor'; + + private readonly MessageFactory $messageFactory; + + /** + * @param string $prefix namespace for this bus's keys, so one cache can carry several + * @param int $ttl how long an entry stays readable, in seconds + * @param int $backlog how many entries a reader will look back over + */ + public function __construct( + private readonly CacheInterface $cache, + private readonly string $prefix = 'mcp.notifications.', + private readonly int $ttl = 120, + private readonly int $backlog = 256, + private readonly LoggerInterface $logger = new NullLogger(), + ?MessageFactory $messageFactory = null, + ) { + if ($this->backlog < 1) { + throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog)); + } + + $this->messageFactory = $messageFactory ?? MessageFactory::make(); + } + + public function publish(Notification $notification): void + { + $sequence = $this->cursor(); + + $this->cache->set($this->key((string) $sequence), json_encode($notification, \JSON_THROW_ON_ERROR), $this->ttl); + $this->cache->set($this->key(self::CURSOR_KEY), $sequence + 1, $this->ttl); + } + + public function cursor(): int + { + $cursor = $this->cache->get($this->key(self::CURSOR_KEY), 0); + + return \is_int($cursor) ? $cursor : 0; + } + + public function since(int $cursor): array + { + $head = $this->cursor(); + + // A reader that fell far behind reads what is still there, not + // everything it missed. + $from = max($cursor, $head - $this->backlog); + + $found = []; + + for ($sequence = $from; $sequence < $head; ++$sequence) { + $raw = $this->cache->get($this->key((string) $sequence)); + + if (!\is_string($raw)) { + // Expired, or lost to a concurrent publisher taking the same + // number. Neither is worth failing the stream over. + continue; + } + + try { + foreach ($this->messageFactory->create($raw) as $message) { + if ($message instanceof Notification) { + $found[] = $message; + } + } + } catch (\Throwable $e) { + $this->logger->warning('Dropped an unreadable notification from the bus.', ['sequence' => $sequence, 'exception' => $e]); + } + } + + return [$found, $head]; + } + + private function key(string $suffix): string + { + return $this->prefix.$suffix; + } +} diff --git a/src/Server/Subscription/PublishingEventDispatcher.php b/src/Server/Subscription/PublishingEventDispatcher.php new file mode 100644 index 0000000..a1c4e68 --- /dev/null +++ b/src/Server/Subscription/PublishingEventDispatcher.php @@ -0,0 +1,53 @@ + + */ +final class PublishingEventDispatcher implements EventDispatcherInterface +{ + /** @var array */ + private readonly array $listeners; + + public function __construct( + NotificationBusInterface $bus, + private readonly ?EventDispatcherInterface $inner = null, + ) { + $this->listeners = (new RegistryChangePublisher($bus))->listeners(); + } + + public function dispatch(object $event): object + { + $listener = $this->listeners[$event::class] ?? null; + + if (null !== $listener) { + $listener($event); + } + + return $this->inner?->dispatch($event) ?? $event; + } +} diff --git a/src/Server/Subscription/RegistryChangePublisher.php b/src/Server/Subscription/RegistryChangePublisher.php new file mode 100644 index 0000000..f54a356 --- /dev/null +++ b/src/Server/Subscription/RegistryChangePublisher.php @@ -0,0 +1,81 @@ + + */ +final class RegistryChangePublisher +{ + public function __construct( + private readonly NotificationBusInterface $bus, + ) { + } + + public function onToolListChanged(ToolListChangedEvent $event): void + { + $this->bus->publish(new ToolListChangedNotification()); + } + + public function onPromptListChanged(PromptListChangedEvent $event): void + { + $this->bus->publish(new PromptListChangedNotification()); + } + + public function onResourceListChanged(ResourceListChangedEvent $event): void + { + $this->bus->publish(new ResourceListChangedNotification()); + } + + public function onResourceTemplateListChanged(ResourceTemplateListChangedEvent $event): void + { + $this->bus->publish(new ResourceListChangedNotification()); + } + + /** + * Every event this publisher handles, keyed by event class. + * + * Shaped for a dispatcher that wants a map; a framework's own subscriber + * conventions can read it too rather than restating the list. + * + * @return array + */ + public function listeners(): array + { + return [ + ToolListChangedEvent::class => $this->onToolListChanged(...), + PromptListChangedEvent::class => $this->onPromptListChanged(...), + ResourceListChangedEvent::class => $this->onResourceListChanged(...), + ResourceTemplateListChangedEvent::class => $this->onResourceTemplateListChanged(...), + ]; + } +} diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php index fb5e7ba..ccdfdf3 100644 --- a/tests/Conformance/server-stateless.php +++ b/tests/Conformance/server-stateless.php @@ -22,6 +22,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Capability\Registry; use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\AudioContent; @@ -32,15 +33,21 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Prompt; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Tool; use Mcp\Server; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Transport\StatelessHttpTransport; use Mcp\Server\Wire\CachePolicy; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; use Mcp\Tests\Conformance\MrtrElements; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; chdir(__DIR__); @@ -49,9 +56,22 @@ $logger = new FileLogger(__DIR__.'/logs/conformance-stateless.log', true); $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); +// Explicit rather than builder-built, so the diagnostic hooks below can mutate +// the live registry and the change reaches an open subscription. +// +// Filesystem-backed and not in-memory: under php-fpm the worker holding the +// listen stream open and the worker serving the tools/call that mutates the +// registry are different processes, so the only thing they share is storage. +$bus = new Psr16NotificationBus( + new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), + logger: $logger, +); +$registry = new Registry(new PublishingEventDispatcher($bus), $logger); + $protocol = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') ->setLogger($logger) + ->setRegistry($registry) // Tools ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') @@ -106,6 +126,38 @@ $protocol = Server::builder() ], ) ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + // Diagnostic hooks the subscription scenarios call to make the lists change + // while a listen stream is open. + ->addTool( + static function () use ($registry): string { + $registry->registerTool( + new Tool( + name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), + title: null, + inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], + description: 'Registered to trigger a list change', + annotations: null, + ), + static fn (): string => 'ephemeral', + ); + + return 'Tool list mutated.'; + }, + name: 'test_trigger_tool_change', + description: 'Registers a tool so the tool list changes', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerPrompt( + new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), + static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], + ); + + return 'Prompt list mutated.'; + }, + name: 'test_trigger_prompt_change', + description: 'Registers a prompt so the prompt list changes', + ) // Multi round-trip request tools (SEP-2322). ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') @@ -127,6 +179,13 @@ $protocol = Server::builder() ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') // Fixed so a retry landing on another process still verifies. ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + // So a listen stream carries the registry's changes rather than only + // acknowledging. In-memory is right here: the conformance server is one + // FrankenPHP-less php-fpm pool, and the scenarios publish within a request. + ->setNotificationBus($bus) + // Short, so a listen stream cannot tie up an fpm worker for the length of + // a whole run. + ->setSubscriptionLifetime(5.0) // Lists are the same for everyone here; a read is not. ->setCachePolicy( CachePolicy::default(60_000) diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 2cb8a67..bc3e53c 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -20,14 +20,19 @@ use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Notification\PromptListChangedNotification; +use Mcp\Schema\Notification\ResourceUpdatedNotification; +use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\RequestContext; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Stateless\StatelessResult; +use Mcp\Server\Subscription\InMemoryNotificationBus; use Mcp\Server\Wire\CachePolicy; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -610,6 +615,119 @@ class StatelessProtocolTest extends TestCase $this->assertArrayNotHasKey('cacheScope', $answer['body']['result']); } + #[TestDox('a listen stream acknowledges, then carries what the client asked for')] + public function testListenStreamDeliversSubscribedNotifications(): void + { + $bus = new InMemoryNotificationBus(); + + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(toolsListChanged: true, resourcesListChanged: true, resourcesSubscribe: true)) + ->setNotificationBus($bus) + ->setSubscriptionLifetime(0.8) + ->addTool(static fn (): string => 'ok', name: 'a_tool', description: 'x') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 42, + 'method' => 'subscriptions/listen', + 'params' => [ + 'notifications' => ['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'subscriptions/listen', + ]); + + $this->assertTrue($result->isStream()); + + $frames = []; + $published = false; + + foreach (($result->frames)() as $frame) { + if (null === $frame) { + // Publish once the stream is established, so the notifications + // arrive the way a concurrent request would deliver them. + if (!$published) { + $bus->publish(new ToolListChangedNotification()); + $bus->publish(new PromptListChangedNotification()); + $bus->publish(new ResourceUpdatedNotification('file:///watched')); + $bus->publish(new ResourceUpdatedNotification('file:///ignored')); + $published = true; + } + + continue; + } + + $frames[] = $frame; + } + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + // The acknowledgment MUST come first, the declined types must not come + // at all, and the graceful closure ends it. + $this->assertSame([ + 'notifications/subscriptions/acknowledged', + 'notifications/tools/list_changed', + 'notifications/resources/updated', + 'response', + ], $methods); + + $this->assertSame(['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']], (array) $frames[0]['params']['notifications']); + $this->assertSame('file:///watched', $frames[2]['params']['uri']); + + // Every message on the stream names the subscription it belongs to. + foreach ([$frames[0], $frames[1], $frames[2]] as $frame) { + $this->assertSame(42, $frame['params']['_meta'][RequestMeta::SUBSCRIPTION_ID]); + } + + $this->assertSame(42, $frames[3]['id']); + $this->assertSame(42, $frames[3]['result']['_meta'][RequestMeta::SUBSCRIPTION_ID]); + $this->assertSame('complete', $frames[3]['result']['resultType']); + } + + #[TestDox('the acknowledgment drops types the server cannot honour')] + public function testAcknowledgmentReflectsWhatTheServerCanDo(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(toolsListChanged: true, promptsListChanged: false)) + ->setNotificationBus(new InMemoryNotificationBus()) + ->setSubscriptionLifetime(0.05) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'subscriptions/listen', + 'params' => [ + 'notifications' => ['toolsListChanged' => true, 'promptsListChanged' => true], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'subscriptions/listen', + ]); + + $first = null; + foreach (($result->frames)() as $frame) { + if (null !== $frame) { + $first = $frame; + break; + } + } + + $this->assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']); + } + #[TestDox('a notification is acknowledged with no body, never answered')] public function testNotificationIsAcknowledged(): void { diff --git a/tests/Unit/Server/Subscription/NotificationBusTest.php b/tests/Unit/Server/Subscription/NotificationBusTest.php new file mode 100644 index 0000000..7c16200 --- /dev/null +++ b/tests/Unit/Server/Subscription/NotificationBusTest.php @@ -0,0 +1,217 @@ + + */ + public static function buses(): iterable + { + yield 'in memory' => [new InMemoryNotificationBus()]; + yield 'psr-16' => [new Psr16NotificationBus(self::arrayCache())]; + } + + #[DataProvider('buses')] + #[TestDox('a subscriber reads what is published after it opened')] + public function testReadsForwardFromItsCursor(NotificationBusInterface $bus): void + { + $bus->publish(new ToolListChangedNotification()); + + // Opened after the first notification, so it must not see it. + $cursor = $bus->cursor(); + + $bus->publish(new PromptListChangedNotification()); + $bus->publish(new ResourceUpdatedNotification('file:///a')); + + [$found, $next] = $bus->since($cursor); + + $this->assertCount(2, $found); + $this->assertInstanceOf(PromptListChangedNotification::class, $found[0]); + $this->assertInstanceOf(ResourceUpdatedNotification::class, $found[1]); + $this->assertSame('file:///a', $found[1]->uri); + + // Reading again from the returned cursor yields nothing new. + [$again] = $bus->since($next); + $this->assertSame([], $again); + } + + #[DataProvider('buses')] + #[TestDox('a quiet bus hands back nothing')] + public function testQuietBusIsEmpty(NotificationBusInterface $bus): void + { + [$found] = $bus->since($bus->cursor()); + + $this->assertSame([], $found); + } + + #[DataProvider('buses')] + #[TestDox('two subscribers at different cursors each get their own view')] + public function testCursorsAreIndependent(NotificationBusInterface $bus): void + { + $early = $bus->cursor(); + $bus->publish(new ToolListChangedNotification()); + $late = $bus->cursor(); + $bus->publish(new PromptListChangedNotification()); + + $this->assertCount(2, $bus->since($early)[0]); + $this->assertCount(1, $bus->since($late)[0]); + } + + #[TestDox('the backlog is bounded, so a stream that went away cannot grow it forever')] + public function testBacklogIsBounded(): void + { + $bus = new InMemoryNotificationBus(backlog: 3); + + $cursor = $bus->cursor(); + for ($i = 0; $i < 10; ++$i) { + $bus->publish(new ToolListChangedNotification()); + } + + $this->assertCount(3, $bus->since($cursor)[0]); + } + + #[TestDox('a backlog below one entry is refused')] + public function testZeroBacklogIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + new InMemoryNotificationBus(backlog: 0); + } + + #[TestDox('a registry change event becomes a notification on the bus')] + public function testRegistryEventsArePublished(): void + { + $bus = new InMemoryNotificationBus(); + $cursor = $bus->cursor(); + + (new PublishingEventDispatcher($bus))->dispatch(new ToolListChangedEvent()); + + [$found] = $bus->since($cursor); + + $this->assertCount(1, $found); + $this->assertInstanceOf(ToolListChangedNotification::class, $found[0]); + } + + #[TestDox('an event the publisher does not know is passed on untouched')] + public function testUnknownEventIsPassedThrough(): void + { + $bus = new InMemoryNotificationBus(); + $cursor = $bus->cursor(); + $event = new \stdClass(); + + $this->assertSame($event, (new PublishingEventDispatcher($bus))->dispatch($event)); + $this->assertSame([], $bus->since($cursor)[0]); + } + + #[TestDox('an inner dispatcher still sees every event')] + public function testInnerDispatcherStillRuns(): void + { + $seen = []; + $inner = new class($seen) implements \Psr\EventDispatcher\EventDispatcherInterface { + /** @param array $seen */ + public function __construct(public array &$seen) + { + } + + public function dispatch(object $event): object + { + $this->seen[] = $event; + + return $event; + } + }; + + (new PublishingEventDispatcher(new InMemoryNotificationBus(), $inner))->dispatch(new ToolListChangedEvent()); + + $this->assertCount(1, $inner->seen); + } + + private static function arrayCache(): CacheInterface + { + return new class implements CacheInterface { + /** @var array */ + private array $values = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + + return true; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->values); + } + }; + } +} From f6a995bc4472fdbacd40e169cd7737db99c23564 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:14:46 +0200 Subject: [PATCH 109/143] [Tests] Fix resources-templates-read to return a resolved URI (#460) The fixture returned the literal template pattern test://template/{id}/data instead of substituting id, failing the wire-schema uri format check. Also switches the json-schema-2020-12 baseline entry to a bare scenario name: the latest (0.1.x) conformance CLI doesn't parse scenario:check-id syntax, only alpha (0.2.x) does, and the two need to agree since conformance-weekly runs 2025-11-25 against latest while pipeline.yaml pins alpha. Verified locally against both dist-tags and both spec revisions. --- tests/Conformance/Elements.php | 2 +- .../conformance-baseline-2025-11-25.yml | 26 +-------------- .../conformance-baseline-2026-07-28.yml | 33 +------------------ 3 files changed, 3 insertions(+), 58 deletions(-) diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 6d65fd2..844ebbf 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -146,7 +146,7 @@ final class Elements public function resourceTemplate(string $id): TextResourceContents { return new TextResourceContents( - uri: 'test://template/{id}/data', + uri: \sprintf('test://template/%s/data', $id), mimeType: 'application/json', text: json_encode([ 'id' => $id, diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index 2450aa6..9a55b83 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,32 +1,9 @@ -# Expected conformance failures for spec revision 2025-11-25. -# -# Separate from conformance-baseline-2026-07-28.yml because the two revisions -# exercise different lifecycles against different endpoints: this file covers -# the handshake era served at /, the other the modern (SEP-2575) era served -# at /stateless. -# -# Entries are per-check (scenario:check-id) rather than whole scenarios, so a -# scenario that is mostly conformant keeps reporting the parts that work and a -# new regression inside it still fails the run. -# -# Run with: -# make conformance-server -# make conformance-client - -# Both pre-existing fixture bugs, not lifecycle-specific: json-schema-2020-12 -# wants a `json_schema_2020_12_tool` fixture neither conformance server -# defines, and resources-templates-read emits a resource-template URI in a -# shape the schema rejects. Reproduce identically on the 2026-07-28 endpoint -# (see conformance-baseline-2026-07-28.yml). server: - - json-schema-2020-12:json-schema-2020-12-tool-found - - resources-templates-read:wire-schema-valid + - json-schema-2020-12 client: - elicitation-sep1034-client-defaults - sse-retry - # The example client doesn't call add_numbers or echo the tool's inputSchema - # back, so these checks have nothing to exercise. - tools_call:tool-add-numbers - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default @@ -50,4 +27,3 @@ client: - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow - diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 4b14d87..42051df 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,36 +1,6 @@ -# Expected conformance failures for spec revision 2026-07-28. -# -# Separate from conformance-baseline-2025-11-25.yml because the two revisions -# exercise different lifecycles against different endpoints: the other file -# covers the handshake era served at /, this one the modern (SEP-2575) era -# served at /stateless. -# -# Entries are per-check (scenario:check-id) rather than whole scenarios, so a -# scenario that is mostly conformant keeps reporting the parts that work and a -# new regression inside it still fails the run. -# -# Run with: -# make conformance-draft-server -# make conformance-draft-client - server: - # --- Pre-existing, not lifecycle-specific ---------------------------------- - # Neither is caused by (or fixable in) the modern lifecycle; both reproduce - # on the handshake endpoint. Listed so the draft run is honest. - # - # json-schema-2020-12 wants a `json_schema_2020_12_tool` fixture that neither - # conformance server defines, so the scenario has nothing to exercise. - # resources-templates-read is the single remaining wire-schema failure: the - # fixture emits a resource-template URI in a shape the schema rejects, on - # both revisions. - - json-schema-2020-12:json-schema-2020-12-tool-found - - resources-templates-read:wire-schema-valid + - json-schema-2020-12 -# The client does not speak 2026-07-28 at all yet: it still opens with -# `initialize` regardless of revision, so it never reaches the stateless wire -# (no per-request `_meta`, no Mcp-Method/Mcp-Name headers, no client-side -# requestState) — and separately, the SDK ships no OAuth client, so every -# scenario driving an authorization flow has nothing to exercise. client: - request-metadata - http-standard-headers @@ -41,7 +11,6 @@ client: - tools_call:tool-add-numbers - json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 From 855011b8dc6796c17a3d0c5b6497333ebe9a70d8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:20:32 +0200 Subject: [PATCH 110/143] [Server] Carry trace context through a request (#452) traceparent, tracestate and baggage reach handlers through RequestContext and are echoed onto the notifications the request causes, so a span is not broken by the response stream. Passed through verbatim: what they mean is the tracing library's business, and no dependency is taken for it. --- CHANGELOG.md | 1 + src/Server/RequestContext.php | 17 +++++ src/Server/Stateless/RequestMeta.php | 34 ++++++++++ src/Server/Stateless/StatelessProtocol.php | 27 +++++++- .../Stateless/StatelessProtocolTest.php | 63 +++++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a7f2b..f1db766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. * Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". * Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. * Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 10df2b1..f322a39 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -111,6 +111,23 @@ final class RequestContext return $meta instanceof RequestMeta ? $meta->clientCapabilities : null; } + /** + * The W3C trace context this request carried, if any. + * + * Values are passed through exactly as they arrived — validating or + * regenerating them is the tracing library's job, not the protocol's. The + * SDK echoes them onto the notifications a handler emits so a span stays + * joined across the stream. + * + * @return array keyed by `traceparent`, `tracestate`, `baggage` + */ + public function getTraceContext(): array + { + $meta = $this->session->get(RequestMeta::class); + + return $meta instanceof RequestMeta ? $meta->traceContext : []; + } + /** * Seals handler context into the string an * {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client. diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php index 23b691c..f418db5 100644 --- a/src/Server/Stateless/RequestMeta.php +++ b/src/Server/Stateless/RequestMeta.php @@ -35,11 +35,23 @@ final class RequestMeta public const SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; public const SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'; + /** + * OpenTelemetry trace context, exempt from the prefix rule by name so it + * matches what the wider ecosystem already puts on the wire. + * + * @see https://www.w3.org/TR/trace-context/ + */ + public const TRACE_KEYS = ['traceparent', 'tracestate', 'baggage']; + + /** + * @param array $traceContext W3C trace context carried through from the request + */ public function __construct( public readonly string $protocolVersion, public readonly ClientCapabilities $clientCapabilities, public readonly ?Implementation $clientInfo = null, public readonly ?LoggingLevel $logLevel = null, + public readonly array $traceContext = [], ) { } @@ -74,6 +86,7 @@ final class RequestMeta ClientCapabilities::fromArray((array) $capabilities), \is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null, self::parseLogLevel($meta[self::LOG_LEVEL] ?? null), + self::parseTraceContext($meta), ); } @@ -85,4 +98,25 @@ final class RequestMeta { return \is_string($level) ? LoggingLevel::tryFrom($level) : null; } + + /** + * Carried through opaquely: the values are the tracing ecosystem's to + * interpret, and a malformed one is not this server's to reject. + * + * @param array $meta + * + * @return array + */ + private static function parseTraceContext(array $meta): array + { + $context = []; + + foreach (self::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $context[$key] = $meta[$key]; + } + } + + return $context; + } } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 2ed5b21..ba1721e 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -594,7 +594,7 @@ final class StatelessProtocol { try { while ($run->valid()) { - yield $run->current()->jsonSerialize(); + yield self::withTraceContext($run->current()->jsonSerialize(), $meta->traceContext); $run->next(); } @@ -654,6 +654,31 @@ final class StatelessProtocol ); } + /** + * Puts the request's trace context back onto a notification it caused, so a + * collector can join the two without the handler carrying it by hand. + * + * @param array $frame + * @param array $traceContext + * + * @return array + */ + private static function withTraceContext(array $frame, array $traceContext): array + { + if ([] === $traceContext) { + return $frame; + } + + $params = \is_array($frame['params'] ?? null) ? $frame['params'] : []; + $frameMeta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + // Anything the notification set itself wins: it knows its own span. + $params['_meta'] = [...$traceContext, ...$frameMeta]; + $frame['params'] = $params; + + return $frame; + } + /** * The one place a handler's exception becomes an answer, so the streaming * and non-streaming paths cannot disagree about which code it earns. diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index bc3e53c..71735ab 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -122,6 +122,18 @@ class StatelessProtocolTest extends TestCase name: 'asks_by_url_after_progress', description: 'Emits progress, then asks through a url-mode elicitation', ) + ->addTool( + static function (RequestContext $context): string { + $pairs = []; + foreach ($context->getTraceContext() as $key => $value) { + $pairs[] = $key.'='.$value; + } + + return implode(';', $pairs) ?: 'none'; + }, + name: 'probe_trace', + description: 'Reports the trace context it was called with', + ) ->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource') ->addResource( static function (RequestContext $context): string|InputRequiredResult { @@ -150,9 +162,11 @@ class StatelessProtocolTest extends TestCase */ private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array { + // Merged, not replaced: a test may add its own `_meta` members. $params['_meta'] = [ RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities, + ...($params['_meta'] ?? []), ]; return self::callWithHeaders($protocol, $method, $params, [ @@ -409,6 +423,55 @@ class StatelessProtocolTest extends TestCase $this->assertStringContainsString('InputRequiredResult', $body['error']['message']); } + #[TestDox('a request\'s trace context reaches the handler')] + public function testTraceContextReachesTheHandler(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'probe_trace', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'baggage' => 'tenant=acme', + ], + ], + ['Mcp-Name' => 'probe_trace'], + ); + + $this->assertSame( + 'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;baggage=tenant=acme', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('notifications caused by a traced request carry its trace context')] + public function testNotificationsCarryTheTraceContext(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool', [ + 'progressToken' => 'tok-1', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + ]); + + $frames = self::frames($result); + + $this->assertSame( + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + $frames[0]['params']['_meta']['traceparent'], + ); + } + + #[TestDox('an untraced request adds no trace metadata')] + public function testUntracedRequestAddsNothing(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1'])); + + $this->assertArrayNotHasKey('traceparent', $frames[0]['params']['_meta'] ?? []); + } + #[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')] public function testResourceNotFoundUsesInvalidParams(): void { From be9fad41153cc0be8d87c238384e111195a5427d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:30:10 +0200 Subject: [PATCH 111/143] Add badge linebreak and v1.0 roadmap items (#461) --- README.md | 1 + ROADMAP.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index dfab676..235d3f5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![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) diff --git a/ROADMAP.md b/ROADMAP.md index 5b67e14..5dfb9ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,12 @@ This roadmap is a living document that outlines the planned features and improve - **Client** - [x] Implement client-side support - [x] Implement client examples and documentation +- [ ] Implement OAuth2 authentication for client - **Schema** - [ ] Implement schema generation based on TS or JSON Schema +- [ ] Achieve full spec conformance +- **Extensions** +- [ ] Implement the Tasks extension +- **Architecture** +- [ ] Split the SDK into multiple packages From 7e38c94954da9b30ff2002220bfb4843543fc6af Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:42:40 +0200 Subject: [PATCH 112/143] [Server] Enforce x-mcp-header where a request uses it (SEP-2243) (#453) * [Server] Enforce x-mcp-header where a request uses it Only top-level properties were inspected, so an annotation on a nested property was silently unenforced - the header could say anything. The walk now follows a chain of properties keys to any depth and reads the argument at that exact path. Integer values compare numerically, so a client writing 42.0 for a body value of 42 is no longer rejected. * [Server] Stop comparing numeric-looking strings as numbers is_numeric() and loose == both treat a string like "042" as equal to "42", so a string argument could pass header validation against a header that doesn't match it exactly. Gate the numeric comparison on the argument's actual type and a decimal-only header instead. --- .../Stateless/StandardHeaderValidator.php | 92 +++++++++-- .../Stateless/StandardHeaderValidatorTest.php | 145 ++++++++++++++++++ 2 files changed, 225 insertions(+), 12 deletions(-) diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index dcd4d37..07e1a17 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -149,22 +149,13 @@ final class StandardHeaderValidator return null; } - $properties = $tool->inputSchema['properties'] ?? []; - if (!\is_array($properties)) { - return null; - } - $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; - foreach ($properties as $property => $definition) { - if (!\is_array($definition) || !\is_string($definition['x-mcp-header'] ?? null)) { - continue; - } - + foreach (self::mirroredProperties($tool->inputSchema) as $name => $path) { $error = $this->checkParam( - self::PARAM_HEADER_PREFIX.$definition['x-mcp-header'], + self::PARAM_HEADER_PREFIX.$name, $headers, - \array_key_exists($property, $arguments) ? $arguments[$property] : null, + self::valueAt($arguments, $path), ); if (null !== $error) { @@ -175,6 +166,70 @@ final class StandardHeaderValidator return null; } + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count: the chain from the root must + * be `properties` keys the whole way. A chain through `items`, a + * composition keyword, `if`/`then`/`else` or a `$ref` is not extractable + * without evaluating the instance, so the specification puts an annotation + * there out of bounds — and this walk simply never reaches one. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + public static function mirroredProperties(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::mirroredProperties($definition, $here)]; + } + + return $found; + } + + /** + * Reads the instance value at an exact property path, or null when the path + * is not present — which the specification reads as "no header expected". + * + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } + /** * @param array $headers */ @@ -204,10 +259,23 @@ final class StandardHeaderValidator default => null, }; + // A non-scalar cannot be mirrored at all, so the annotation on it is + // the tool definition's problem rather than this request's. if (null === $expected) { return null; } + // Numerically for numbers, so "42.0" and "42" agree — the spec asks for + // this, and a client's JSON writer is free to pick either. Gated on the + // argument's actual type (not is_numeric, which a numeric-looking + // string like "042" would also satisfy) and a decimal header (not + // "4e1"), so a string argument keeps its exact-match comparison. + if ((\is_int($argument) || \is_float($argument)) && 1 === preg_match('/^-?\d+(?:\.\d+)?$/', $decoded)) { + return (float) $decoded === (float) $argument + ? null + : \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); + } + if ($decoded !== $expected) { return \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); } diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index f0081d4..43b4cc0 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -11,6 +11,9 @@ namespace Mcp\Tests\Unit\Server\Stateless; +use Mcp\Capability\Registry; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Tool; use Mcp\Server\Stateless\StandardHeaderValidator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -173,6 +176,148 @@ class StandardHeaderValidatorTest extends TestCase yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null]; } + #[TestDox('an annotation on a nested property is found through the properties chain')] + public function testNestedMirroredPropertyIsFound(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'target' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + ], + ], + 'top' => ['type' => 'string', 'x-mcp-header' => 'Top'], + ], + ]; + + $this->assertSame( + ['Region' => ['target', 'region'], 'Top' => ['top']], + StandardHeaderValidator::mirroredProperties($schema), + ); + } + + #[TestDox('an annotation the chain cannot reach statically is not mirrored')] + #[DataProvider('unreachableAnnotations')] + public function testUnreachableAnnotationsAreNotMirrored(array $properties): void + { + $this->assertSame([], StandardHeaderValidator::mirroredProperties([ + 'type' => 'object', + 'properties' => $properties, + ])); + } + + /** + * @return iterable}> + */ + public static function unreachableAnnotations(): iterable + { + yield 'under items' => [['a' => ['type' => 'array', 'items' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + yield 'under anyOf' => [['a' => ['anyOf' => [['type' => 'string', 'x-mcp-header' => 'X']]]]]; + yield 'under if' => [['a' => ['if' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + } + + #[TestDox('an integer is compared numerically, so 42 and 42.0 agree')] + public function testIntegerParamsCompareNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42.0'], + )); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '43'], + )); + } + + #[TestDox('a numeric-looking string argument keeps its exact-match comparison')] + public function testNumericLookingStringArgumentIsNotComparedNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42'], + )); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '042'], + )); + } + + #[TestDox('a scientific-notation header is not accepted as a decimal number')] + public function testScientificNotationHeaderIsRejected(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 40]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '4e1'], + )); + } + + #[TestDox('a nested mirrored argument is read at its exact path')] + public function testNestedMirroredArgumentIsChecked(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Region' => 'us-west1'], + )); + + $this->assertStringContainsString('Missing required Mcp-Param-Region', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + + // Absent at that path, so no header is expected. + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + } + + private static function registryWithMirroredTool(): RegistryInterface + { + $registry = new Registry(); + $registry->registerTool( + new Tool( + name: 'mirrored', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'required' => null, + ], + description: 'x', + annotations: null, + ), + static fn (): string => 'ok', + ); + + return $registry; + } + #[TestDox('a plain header value decodes to itself')] public function testPlainValuePassesThroughDecode(): void { From b97389325fcd5ac6e1442c7ce0c8bae13c9c2237 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 01:02:22 +0200 Subject: [PATCH 113/143] [Server] Serve an extension's methods under the modern lifecycle (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Serve an extension's methods under the modern lifecycle The modern dispatcher takes the method-to-extension map the builder collects, so a method belonging to an extension this server does not serve is answered -32601 naming the extension instead of a bare "no handler found". It is still an unknown method - the server genuinely does not implement it - but the caller can now act on the answer. * [Server] Reject two extensions claiming the same RPC method The message factory resolves a contested method to whichever class registered first, while extensionMethods kept the last one — so error messages could name the wrong extension. Also fixes a test that claimed to prove a method gets named by its extension while asserting the opposite; the case it meant to cover (an enabled extension with no handler for one of its methods) had no coverage at all. --- src/Server/Builder.php | 19 ++++- src/Server/Stateless/StatelessProtocol.php | 29 +++++++- tests/Unit/Server/BuilderTest.php | 9 +++ .../Extension/UnservedThingExtension.php | 42 +++++++++++ .../Stateless/StatelessProtocolTest.php | 70 +++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Server/Extension/UnservedThingExtension.php diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 2cd24aa..765ae19 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -236,6 +236,9 @@ final class Builder /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ private array $extensionMessages = []; + /** @var array RPC method to the extension identifier defining it */ + private array $extensionMethods = []; + /** * @var LoaderInterface[] */ @@ -389,7 +392,8 @@ final class Builder * for extensions that only announce a capability. * * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix - * @throws LogicException if the same extension is enabled more than once + * @throws LogicException if the same extension is enabled more than once, or + * two enabled extensions define the same RPC method */ public function enableExtension(ExtensionInterface ...$extensions): self { @@ -405,7 +409,19 @@ final class Builder // Without this the method cannot be decoded at all, so nothing // downstream ever sees it. foreach ($extension->getMessages() as $message) { + $method = $message::getMethod(); + + // The message factory resolves a method to whichever class was + // registered first, so a second owner here would silently lose + // the dispatch race while still being named in error messages. + if (isset($this->extensionMethods[$method]) && $this->extensionMethods[$method] !== $id) { + throw new LogicException(\sprintf('Method "%s" is already claimed by extension "%s", so extension "%s" cannot also define it.', $method, $this->extensionMethods[$method], $id)); + } + $this->extensionMessages[] = $message; + // Recorded even though the handler answers it, so a server with + // the extension *off* can say so instead of "no such method". + $this->extensionMethods[$method] = $id; } foreach ($extension->getRequestHandlers() as $handler) { @@ -836,6 +852,7 @@ final class Builder : null, cachePolicy: $this->cachePolicy, notificationBus: $this->notificationBus, + extensionMethods: $this->extensionMethods, ); } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index ba1721e..39cc023 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -89,6 +89,7 @@ final class StatelessProtocol /** * @param iterable> $requestHandlers * @param list $supportedVersions + * @param array $extensionMethods RPC method to the extension identifier defining it */ public function __construct( private readonly iterable $requestHandlers, @@ -102,6 +103,7 @@ final class StatelessProtocol private readonly ?RequestStateCodec $requestStateCodec = null, ?CachePolicy $cachePolicy = null, private readonly ?NotificationBusInterface $notificationBus = null, + private readonly array $extensionMethods = [], ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); @@ -396,7 +398,7 @@ final class StatelessProtocol } catch (\Throwable $e) { $this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]); - return StatelessResult::error(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404); + return StatelessResult::error($this->unknownMethod($method, $id), 404); } $request = $messages[0] ?? null; @@ -410,7 +412,7 @@ final class StatelessProtocol return StatelessResult::error( $unknownMethod - ? Error::forMethodNotFound($request->getMessage(), $id) + ? $this->unknownMethod($method, $id) : Error::forInvalidRequest($request->getMessage(), $id), $unknownMethod ? 404 : 400, ); @@ -510,7 +512,28 @@ final class StatelessProtocol return $this->encode($method, $id, $result->result, null === $input); } - return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404); + return StatelessResult::error($this->unknownMethod($method, $id), 404); + } + + /** + * A method with no handler, said as precisely as the server can. + * + * An extension's method is still `-32601` when the extension is off — the + * server genuinely does not implement it — but naming the extension turns + * an opaque refusal into something the caller can act on. + */ + private function unknownMethod(string $method, string|int $id): Error + { + $extension = $this->extensionMethods[$method] ?? null; + + if (null !== $extension) { + return Error::forMethodNotFound( + \sprintf('Method "%s" belongs to the "%s" extension, which this server does not serve.', $method, $extension), + $id, + ); + } + + return Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id); } /** diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 63bef73..5ddb551 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -203,6 +203,15 @@ final class BuilderTest extends TestCase $this->assertInstanceOf(ThingListRequest::class, $decoded[0]); } + #[TestDox('enableExtension() throws when two enabled extensions define the same RPC method')] + public function testEnableExtensionRejectsClaimedMethod(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('com.example/things.list'); + + Server::builder()->enableExtension(new ThingExtension('com.example/things-a'), new ThingExtension('com.example/things-b')); + } + #[TestDox('A method-providing extension contributes the handlers serving its methods')] public function testEnableExtensionRegistersItsHandlers(): void { diff --git a/tests/Unit/Server/Extension/UnservedThingExtension.php b/tests/Unit/Server/Extension/UnservedThingExtension.php new file mode 100644 index 0000000..43a9207 --- /dev/null +++ b/tests/Unit/Server/Extension/UnservedThingExtension.php @@ -0,0 +1,42 @@ +assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']); } + #[TestDox('an extension method is served by the extension that claims it')] + public function testExtensionMethodIsServed(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame(['a', 'b'], $answer['body']['result']['things']); + } + + #[TestDox('the extension is advertised under capabilities.extensions')] + public function testExtensionIsAdvertised(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::call($protocol, 'server/discover'); + + $this->assertSame(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']); + } + + #[TestDox('a method of an extension this server has never heard of stays generic')] + public function testUnknownExtensionMethodStaysGeneric(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + // The extension was never enabled, so it never entered the method map + // — there is nothing to name it by. + $this->assertStringContainsString('com.example/things.list', $answer['body']['error']['message']); + $this->assertStringNotContainsString('extension', $answer['body']['error']['message']); + } + + #[TestDox('a method of an extension this server does not serve says so by name')] + public function testUnservedExtensionMethodNamesItsExtension(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new UnservedThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + $this->assertStringContainsString('com.example/unserved-things', $answer['body']['error']['message']); + } + #[TestDox('a notification is acknowledged with no body, never answered')] public function testNotificationIsAcknowledged(): void { From cbceef06eb978d61011bd6a2f91651d06c6de966 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 01:36:56 +0200 Subject: [PATCH 114/143] [Server] Serve both protocol eras from one endpoint (#455) * [Server] Serve both protocol eras from one endpoint The two lifecycles share a transport, not a dispatcher. StreamableHttpTransport classifies each request - a 2026-07-28 envelope, an initialize handshake, or a session-bound follow-up - through InboundClassifier and routes it to the dispatcher that owns it, so one URL answers a modern client and a handshake-era one alike. Builder::build() carries both; withoutModernEra() opts out and setModernVersions() narrows what the modern leg answers for. InputRequiredShim lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has - so a handler is written once rather than twice. The conformance fixture collapses into one server for the same reason: both legs now hit the same URL. * Trim redundant comments, fix stale in-memory claim in conformance fixture * Drop stale json-schema-2020-12 baseline entries The new fixture tool makes this scenario pass in both eras now. * Reject handshake revisions in setModernVersions(), warn on unsafe custom middleware Copilot review: setModernVersions() accepted revisions InboundClassifier would never route to the modern leg. StreamableHttpTransport now warns when a custom middleware list carries ProtocolVersionMiddleware, since it runs before era classification and rejects modern-era traffic by default. Also fixed a misleading docblock claim on the same middleware. --- CHANGELOG.md | 1 + examples/server/bootstrap.php | 7 + src/Server.php | 14 + src/Server/Builder.php | 177 +++++++++-- src/Server/ClientGateway.php | 8 +- src/Server/InputRequiredShim.php | 228 ++++++++++++++ src/Server/Protocol.php | 24 +- src/Server/Stateless/StatelessProtocol.php | 31 +- .../Middleware/ProtocolVersionMiddleware.php | 2 +- .../Transport/Http/StatelessResponder.php | 99 +++++++ .../StatelessAwareTransportInterface.php | 29 ++ .../Transport/StatelessHttpTransport.php | 51 +--- .../Transport/StreamableHttpTransport.php | 155 +++++++++- src/Server/Wire/EraClassification.php | 61 ++++ src/Server/Wire/InboundClassifier.php | 222 ++++++++++++++ tests/Conformance/Elements.php | 48 +++ tests/Conformance/Fixtures/nginx.conf | 10 +- .../conformance-baseline-2025-11-25.yml | 3 - .../conformance-baseline-2026-07-28.yml | 3 - tests/Conformance/server-stateless.php | 202 ------------- tests/Conformance/server.php | 167 ++++++++++- tests/Unit/Server/BuilderTest.php | 63 ++++ tests/Unit/Server/InputRequiredShimTest.php | 229 ++++++++++++++ .../Server/Transport/DualEraRoutingTest.php | 280 ++++++++++++++++++ .../Transport/StreamableHttpTransportTest.php | 17 ++ .../Server/Wire/InboundClassifierTest.php | 254 ++++++++++++++++ 26 files changed, 2061 insertions(+), 324 deletions(-) create mode 100644 src/Server/InputRequiredShim.php create mode 100644 src/Server/Transport/Http/StatelessResponder.php create mode 100644 src/Server/Transport/StatelessAwareTransportInterface.php create mode 100644 src/Server/Wire/EraClassification.php create mode 100644 src/Server/Wire/InboundClassifier.php delete mode 100644 tests/Conformance/server-stateless.php create mode 100644 tests/Unit/Server/InputRequiredShimTest.php create mode 100644 tests/Unit/Server/Transport/DualEraRoutingTest.php create mode 100644 tests/Unit/Server/Wire/InboundClassifierTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f1db766..5467968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. * Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. * Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". * Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index cbe3fb5..99fcfda 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -28,6 +28,13 @@ set_exception_handler(static function (Throwable $t): never { }); /** + * The transport every example runs on. + * + * Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport` + * classifies each request and routes it to the lifecycle it belongs to, so every + * example here answers an `initialize` handshake and a 2026-07-28 envelope alike. + * Over stdio there is no such choice to make — that binding carries the handshake era. + * * @return TransportInterface|TransportInterface */ function transport(): TransportInterface diff --git a/src/Server.php b/src/Server.php index 8657610..a19e859 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,8 @@ namespace Mcp; use Mcp\Server\Builder; use Mcp\Server\Protocol; +use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Transport\StatelessAwareTransportInterface; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -23,9 +25,14 @@ use Psr\Log\NullLogger; */ final class Server { + /** + * @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a + * server that serves the handshake era alone + */ public function __construct( private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?StatelessProtocol $statelessProtocol = null, ) { } @@ -47,6 +54,13 @@ final class Server $this->protocol->connect($transport); + // The eras share the transport, not the dispatcher: a transport that + // can tell them apart takes both and picks per request. One that + // cannot — stdio — carries the handshake era alone. + if (null !== $this->statelessProtocol && $transport instanceof StatelessAwareTransportInterface) { + $transport->connectStateless($this->statelessProtocol); + } + $this->logger->info('Running server...'); try { diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 765ae19..e38e683 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -74,6 +74,17 @@ use Symfony\Component\Finder\Finder; /** * @phpstan-import-type Handler from ElementReference * + * @phpstan-type AssembledParts array{ + * logger: LoggerInterface, + * eventDispatcher: ?EventDispatcherInterface, + * configuration: Configuration, + * messageFactory: MessageFactory, + * sessionManager: SessionManagerInterface, + * registry: RegistryInterface, + * requestHandlers: list>, + * notificationHandlers: list, + * } + * * @author Kyrian Obikwelu */ final class Builder @@ -118,6 +129,12 @@ final class Builder private float $subscriptionLifetime = 30.0; + /** @var array RPC method to the extension identifier defining it */ + private array $extensionMethods = []; + + /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ + private array $extensionMessages = []; + private ?string $requestStateKey = null; private int $requestStateTtl = 600; @@ -233,12 +250,6 @@ final class Builder */ private array $extensions = []; - /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ - private array $extensionMessages = []; - - /** @var array RPC method to the extension identifier defining it */ - private array $extensionMethods = []; - /** * @var LoaderInterface[] */ @@ -250,6 +261,22 @@ final class Builder private bool $headerValidation = true; + /** @var list|null null defaults to every modern revision, [] serves none */ + private ?array $modernVersions = null; + + private bool $inputRequiredShim = true; + + private int $inputRequiredRounds = InputRequiredShim::DEFAULT_MAX_ROUNDS; + + private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT; + + /** + * @see self::assemble() for why this is memoized + * + * @var AssembledParts|null + */ + private ?array $parts = null; + /** * Sets the server's identity. Required. * @@ -807,8 +834,100 @@ final class Builder return $this; } + /** + * Stop serving multi round-trip handlers to handshake-era clients. + * + * A handler that returns an {@see \Mcp\Schema\Result\InputRequiredResult} + * is written for the modern era, where the client answers the embedded + * requests and retries the call. On a handshake-era connection the SDK + * fulfils it instead, by sending those requests over that connection's own + * channel and re-entering the handler with the answers — so one handler + * serves both eras. See {@see InputRequiredShim} for what re-entry costs. + * + * Turn it off to have such a handler fail on a handshake-era connection + * rather than be fulfilled behind your back. + */ + public function withoutInputRequiredShim(): self + { + $this->inputRequiredShim = false; + + return $this; + } + + /** + * Bounds on the shim's loop: how many times a handler may be re-entered for + * one request, and how long one answer is waited for. + * + * The wait holds the originating request open, so on a process-per-request + * runtime it holds a worker too. Size it against your pool, not against a + * user's patience. + */ + public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self + { + if ($maxRounds < 1) { + throw new InvalidArgumentException('maxRounds must be at least 1.'); + } + + if ($roundTimeout < 1) { + throw new InvalidArgumentException('roundTimeout must be at least 1 second.'); + } + + $this->inputRequiredRounds = $maxRounds; + $this->inputRequiredTimeout = $roundTimeout; + + return $this; + } + + private function requestStateCodec(): ?RequestStateCodec + { + return null !== $this->requestStateKey + ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) + : null; + } + + /** + * Serve only the handshake era, refusing modern-era traffic. + * + * The default is to serve both from whatever the server is run on, because + * an endpoint that turns a client away for speaking the newer revision is + * almost never what anyone wants. Call this when it is: a deployment that + * has to stay on the handshake wire, or one whose tools call back into the + * client and would fail the modern half anyway. + */ + public function withoutModernEra(): self + { + $this->modernVersions = []; + + return $this; + } + + /** + * Revisions the modern-era leg answers for. Defaults to every modern + * revision this SDK knows. + * + * @param list $versions + * + * @throws InvalidArgumentException if a version is not one {@see Wire\InboundClassifier} routes to this leg + */ + public function setModernVersions(array $versions): self + { + foreach ($versions as $version) { + if (!$version->isModern()) { + throw new InvalidArgumentException(\sprintf('"%s" is a handshake-era revision; a request claiming it never reaches the modern leg to be served.', $version->value)); + } + } + + $this->modernVersions = $versions; + + return $this; + } + /** * Builds the fully configured Server instance. + * + * The result carries a dispatcher for each era. Which one answers is a + * per-request decision the transport makes, so one server object — and one + * endpoint — serves handshake-era and modern-era clients alike. */ public function build(): Server { @@ -821,17 +940,28 @@ final class Builder sessionManager: $parts['sessionManager'], logger: $parts['logger'], eventDispatcher: $parts['eventDispatcher'], + inputRequiredShim: $this->inputRequiredShim + ? new InputRequiredShim($this->inputRequiredRounds, $this->inputRequiredTimeout, $parts['logger']) + : null, + requestStateCodec: $this->requestStateCodec(), ); - return new Server($protocol, $parts['logger']); + $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); + + return new Server( + $protocol, + $parts['logger'], + [] === $modernVersions ? null : $this->buildStateless($modernVersions), + ); } /** - * Builds a dispatcher for the modern (SEP-2575) lifecycle. + * Builds a dispatcher for the modern (SEP-2575) lifecycle on its own. * * Tools, prompts, resources and their handlers are era-independent, so one - * builder configuration drives either lifecycle and a server can offer both - * by mounting each on its own endpoint. + * builder configuration drives either lifecycle. {@see self::build()} wires + * both together; this is the modern era by itself, for an endpoint that + * serves nothing else. * * @param list $supportedVersions revisions this dispatcher will answer for */ @@ -847,9 +977,7 @@ final class Builder logger: $parts['logger'], subscriptionLifetime: $this->subscriptionLifetime, headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, - requestStateCodec: null !== $this->requestStateKey - ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) - : null, + requestStateCodec: $this->requestStateCodec(), cachePolicy: $this->cachePolicy, notificationBus: $this->notificationBus, extensionMethods: $this->extensionMethods, @@ -859,18 +987,21 @@ final class Builder /** * Resolves the builder's configuration into the parts both lifecycles need. * - * @return array{ - * logger: LoggerInterface, - * eventDispatcher: ?EventDispatcherInterface, - * configuration: Configuration, - * messageFactory: MessageFactory, - * sessionManager: SessionManagerInterface, - * registry: RegistryInterface, - * requestHandlers: list>, - * notificationHandlers: list, - * } + * Memoized: the two eras share one registry, one session manager and one + * set of handler instances, so they answer for the same server rather than + * for two that merely started from the same configuration. + * + * @return AssembledParts */ private function assemble(): array + { + return $this->parts ??= $this->resolve(); + } + + /** + * @return AssembledParts + */ + private function resolve(): array { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 16a80f0..12756fc 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -403,14 +403,20 @@ class ClientGateway * This suspends the Fiber and waits for the client to respond. The transport * handles polling the session for the response and resuming the Fiber when ready. * + * Public for {@see InputRequiredShim}, which sends the requests a handler + * embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows + * nothing about their kinds. Prefer the typed methods above. + * * @param Request $request The request to send * @param int $timeout Maximum time to wait for response (seconds) * * @return Response>|Error The client's response message * * @throws RuntimeException If Fiber support is not available + * + * @internal */ - private function request(Request $request, int $timeout = 120): Response|Error + public function request(Request $request, int $timeout = 120): Response|Error { $response = \Fiber::suspend([ 'type' => 'request', diff --git a/src/Server/InputRequiredShim.php b/src/Server/InputRequiredShim.php new file mode 100644 index 0000000..9dff786 --- /dev/null +++ b/src/Server/InputRequiredShim.php @@ -0,0 +1,228 @@ + + */ +final class InputRequiredShim +{ + /** + * Re-entries per originating request. Deliberately below the modern + * client driver's allowance: this loop holds a live request open. + */ + public const DEFAULT_MAX_ROUNDS = 8; + + /** Seconds to wait for one answer. Legs are human-paced, so the protocol's 120s default is wrong here. */ + public const DEFAULT_ROUND_TIMEOUT = 600; + + public function __construct( + private readonly int $maxRounds = self::DEFAULT_MAX_ROUNDS, + private readonly int $roundTimeout = self::DEFAULT_ROUND_TIMEOUT, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Runs a handler to a result the client can be given. + * + * Returns whatever the handler returned when it asks for nothing, which is + * every call that is not multi round-trip. + * + * @param Response|Error $result what the handler returned on its first entry + * @param RequestHandlerInterface> $handler the handler that produced it + * + * @return Response|Error + */ + public function fulfill( + Response|Error $result, + RequestHandlerInterface $handler, + Request $request, + SessionInterface $session, + ?RequestStateCodec $codec, + ): Response|Error { + $round = 0; + + while (($ask = self::askOf($result)) instanceof InputRequiredResult) { + if (++$round > $this->maxRounds) { + $this->logger->warning('A handler kept asking for input past the round limit; the call was failed instead.', [ + 'method' => $request::getMethod(), + 'rounds' => $this->maxRounds, + ]); + + return Error::forInternalError( + \sprintf('The server asked for input more than %d times without reaching a result.', $this->maxRounds), + $request->getId(), + ); + } + + if (null !== $refusal = $this->refuseUndeclared($ask, $request, $session)) { + return $refusal; + } + + try { + $session->set(InputContext::class, new InputContext( + $this->collect($ask, $session), + self::payloadOf($ask, $codec), + )); + } catch (RequestStateException $e) { + $this->logger->error('A handler minted a requestState this server cannot verify.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + + $result = $handler->handle($request, $session); + } + + return $result; + } + + /** + * Sends each embedded request and keeps the answer under the key it was + * asked under. + * + * Answers are stored as the raw result arrays {@see InputContext} parses, + * so this needs to know nothing about the kinds it is carrying — which is + * also why an extension's future kind rides through unchanged. + * + * @return array + */ + private function collect(InputRequiredResult $ask, SessionInterface $session): array + { + $gateway = new ClientGateway($session); + $responses = []; + + foreach ($ask->inputRequests as $key => $embedded) { + $answer = $gateway->request($embedded, $this->roundTimeout); + + if ($answer instanceof Error) { + // Not fatal to the call: a client that refuses one ask has + // answered it, and the handler decides what that means. + $this->logger->info('The client failed an input request; the handler is re-entered without it.', [ + 'key' => $key, + 'error' => $answer->message, + ]); + + continue; + } + + $responses[$key] = $answer->result; + } + + return $responses; + } + + /** + * Refuses an ask the client never said it could answer, the way the modern + * era does — rather than sending a request that can only come back as an + * error. + */ + private function refuseUndeclared(InputRequiredResult $ask, Request $request, SessionInterface $session): ?Error + { + $declared = ClientCapabilities::fromArray((array) $session->get('client_capabilities', [])); + $missing = InputRequestCapabilities::missing($ask, $declared); + + if (null === $missing) { + return null; + } + + $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ + 'method' => $request::getMethod(), + 'required' => $missing->jsonSerialize(), + ]); + + return Error::forMissingRequiredClientCapability( + 'The server needs input this client did not declare it can provide.', + $missing, + $request->getId(), + ); + } + + /** + * The ask a handler returned, if it returned one. + * + * @param Response|Error $result + */ + private static function askOf(Response|Error $result): ?InputRequiredResult + { + return $result instanceof Response && $result->result instanceof InputRequiredResult + ? $result->result + : null; + } + + /** + * The state the handler sealed last round, verified. + * + * Verified rather than trusted even though it never left this process: the + * handler reads it back through the same accessor either era, so it has to + * have been through the same check. + * + * @return array + * + * @throws RequestStateException when a state is present but does not verify + */ + private static function payloadOf(InputRequiredResult $ask, ?RequestStateCodec $codec): array + { + if (null === $ask->requestState) { + return []; + } + + if (null === $codec) { + throw new RequestStateException('mac'); + } + + return $codec->verify($ask->requestState); + } +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index a6e9f1a..4d6e5f5 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -27,6 +27,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; +use Mcp\Server\Stateless\InputContext; +use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Transport\TransportInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -78,6 +80,8 @@ class Protocol private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly ?InputRequiredShim $inputRequiredShim = null, + private readonly ?RequestStateCodec $requestStateCodec = null, ) { } @@ -257,6 +261,15 @@ class Protocol $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + // A request starts with nothing behind it: the shim fills this in as it + // collects answers, and clearing it here is what keeps one request's + // round from being read as another's. + $session->set(InputContext::class, null); + + if (null !== $this->requestStateCodec) { + $session->set(RequestStateCodec::class, $this->requestStateCodec); + } + $event = $this->dispatchEvent(new RequestEvent($request, $session)); $request = $event->getRequest(); @@ -270,8 +283,17 @@ class Protocol $handlerFound = true; try { + $shim = $this->inputRequiredShim; + $codec = $this->requestStateCodec; + + // One fiber for the whole exchange: with the shim, the handler + // re-enters inside it each round rather than needing a new one. /** @var McpFiber $fiber */ - $fiber = new \Fiber(static fn () => $handler->handle($request, $session)); + $fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec): Response|Error { + $result = $handler->handle($request, $session); + + return $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result; + }); $result = $fiber->start(); diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 39cc023..b746957 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -33,6 +33,7 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use Mcp\Server\Subscription\NotificationBusInterface; use Mcp\Server\Wire\CachePolicy; +use Mcp\Server\Wire\InboundClassifier; use Mcp\Server\Wire\Rev2026Codec; use Mcp\Server\Wire\WireCodecInterface; use Psr\Log\LoggerInterface; @@ -116,6 +117,16 @@ final class StatelessProtocol } } + /** + * The modern revisions this dispatcher answers for. + * + * @return list + */ + public function supportedVersions(): array + { + return $this->supportedVersions; + } + /** * Whether the transport carrying this dispatcher has a header layer whose * required members must be present. @@ -265,14 +276,10 @@ final class StatelessProtocol ); } - if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) { - return StatelessResult::error( - Error::forHeaderMismatch( - \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $meta->protocolVersion), - $id, - ), - 400, - ); + // The same check the HTTP entry runs before routing, so the edge and + // this dispatcher cannot disagree about what a request claims. + if (null !== $mismatch = InboundClassifier::crossCheckVersion($headerVersion, $meta->protocolVersion)) { + return StatelessResult::error(Error::forHeaderMismatch($mismatch, $id), 400); } $version = ProtocolVersion::tryFrom($meta->protocolVersion); @@ -803,12 +810,6 @@ final class StatelessProtocol */ private function header(array $headers, string $name): ?string { - foreach ($headers as $key => $value) { - if (0 === strcasecmp($key, $name)) { - return $value; - } - } - - return null; + return InboundClassifier::header($headers, $name); } } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index e00a849..e4ec015 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -54,7 +54,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface private readonly array $supported; /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}. {@see StreamableHttpTransport::handshakeMiddleware()} always uses that default: it runs only on traffic already classified as handshake-era, so this middleware never needs to know about modern revisions there. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ diff --git a/src/Server/Transport/Http/StatelessResponder.php b/src/Server/Transport/Http/StatelessResponder.php new file mode 100644 index 0000000..f752fb8 --- /dev/null +++ b/src/Server/Transport/Http/StatelessResponder.php @@ -0,0 +1,99 @@ + + */ +final class StatelessResponder +{ + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function respond(StatelessResult $result): ResponseInterface + { + if ($result->isStream()) { + \assert(null !== $result->frames); + + return $this->sse($result->frames); + } + + if ($result->isEmpty()) { + return $this->responseFactory->createResponse($result->httpStatus); + } + + return $this->json($result->toJson(), $result->httpStatus); + } + + public function error(Error $error, int $httpStatus): ResponseInterface + { + return $this->json(json_encode($error, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), $httpStatus); + } + + public function json(string $payload, int $status): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } + + /** + * @param \Closure(): \Generator $frames + */ + private function sse(\Closure $frames): ResponseInterface + { + $logger = $this->logger; + + $callback = static function () use ($frames, $logger): void { + try { + foreach ($frames() as $frame) { + // A null frame is a keep-alive tick: an SSE comment the + // client ignores, and the write PHP needs to spot a drop. + echo null === $frame + ? ": keep-alive\n\n" + : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; + flush(); + } + } catch (\Throwable $e) { + // Headers are long sent, so this cannot become an error + // response; the client sees a close without the closure frame. + $logger->error('Subscription stream ended with an error.', ['exception' => $e]); + } + }; + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody(new CallbackStream($callback, $this->logger)); + } +} diff --git a/src/Server/Transport/StatelessAwareTransportInterface.php b/src/Server/Transport/StatelessAwareTransportInterface.php new file mode 100644 index 0000000..52815b3 --- /dev/null +++ b/src/Server/Transport/StatelessAwareTransportInterface.php @@ -0,0 +1,29 @@ + + */ +interface StatelessAwareTransportInterface +{ + public function connectStateless(StatelessProtocol $protocol): void; +} diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php index 5348273..c55caed 100644 --- a/src/Server/Transport/StatelessHttpTransport.php +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -17,6 +17,7 @@ use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\MiddlewareRequestHandler; +use Mcp\Server\Transport\Http\StatelessResponder; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -45,6 +46,7 @@ final class StatelessHttpTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; /** @var list */ private array $middleware; @@ -66,6 +68,7 @@ final class StatelessHttpTransport $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); } /** @@ -123,55 +126,11 @@ final class StatelessHttpTransport $headers[$name] = implode(', ', $values); } - $result = $this->protocol->handle($payload, $headers); - - if ($result->isStream()) { - return $this->sse($result->frames); - } - - if ($result->isEmpty()) { - return $this->responseFactory->createResponse($result->httpStatus); - } - - return $this->json($result->toJson(), $result->httpStatus); - } - - /** - * @param \Closure(): \Generator $frames - */ - private function sse(\Closure $frames): ResponseInterface - { - $logger = $this->logger; - - $callback = static function () use ($frames, $logger): void { - try { - foreach ($frames() as $frame) { - // A null frame is a keep-alive tick: an SSE comment the - // client ignores, and the write PHP needs to spot a drop. - echo null === $frame - ? ": keep-alive\n\n" - : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; - flush(); - } - } catch (\Throwable $e) { - // Headers are long sent, so this cannot become an error - // response; the client sees a close without the closure frame. - $logger->error('Subscription stream ended with an error.', ['exception' => $e]); - } - }; - - return $this->responseFactory->createResponse(200) - ->withHeader('Content-Type', 'text/event-stream') - ->withHeader('Cache-Control', 'no-cache') - ->withHeader('Connection', 'keep-alive') - ->withHeader('X-Accel-Buffering', 'no') - ->withBody(new CallbackStream($callback, $this->logger)); + return $this->responder->respond($this->protocol->handle($payload, $headers)); } private function json(string $payload, int $status): ResponseInterface { - return $this->responseFactory->createResponse($status) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($payload)); + return $this->responder->json($payload, $status); } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 098376b..2bf088b 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -13,11 +13,15 @@ namespace Mcp\Server\Transport; use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Server\Stateless\StatelessProtocol; 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\Http\MiddlewareRequestHandler; +use Mcp\Server\Transport\Http\StatelessResponder; +use Mcp\Server\Wire\InboundClassifier; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,11 +32,24 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; /** + * Carries MCP over HTTP, in either protocol era. + * + * Every request is classified once, before anything else looks at it, and + * routed to the lifecycle it belongs to: a per-request envelope claiming a + * modern revision goes to {@see StatelessProtocol}, everything else — the + * `initialize` handshake, its session's later requests, its `DELETE` teardown — + * goes to the session machinery below. One endpoint, both eras, nothing for the + * client to pick. + * + * A server run without a modern-era dispatcher (see + * {@see \Mcp\Server\Builder::withoutModernEra()}) serves the handshake era + * alone and refuses modern claims, naming the revisions it does serve. + * * @extends BaseTransport * * @author Kyrian Obikwelu */ -class StreamableHttpTransport extends BaseTransport +class StreamableHttpTransport extends BaseTransport implements StatelessAwareTransportInterface { use ReadsBoundedBody; @@ -47,12 +64,16 @@ class StreamableHttpTransport extends BaseTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; + private InboundClassifier $classifier; + + private ?StatelessProtocol $stateless = null; private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; - /** @var list */ - private array $middleware; + /** @var list|null null until {@see self::listen()} resolves the defaults */ + private ?array $middleware; /** * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware @@ -73,20 +94,42 @@ class StreamableHttpTransport extends BaseTransport $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); + $this->classifier = new InboundClassifier(); if (null === $middleware) { - $this->middleware = self::defaultMiddleware(); + // Left unresolved: the default stack's version middleware has to + // know which revisions this endpoint serves, and the modern + // dispatcher arrives after the constructor. + $this->middleware = null; } else { $this->middleware = self::normalizeMiddleware($middleware); if ([] === $this->middleware) { $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); } + + // Custom middleware runs before the request's era is classified, so a + // ProtocolVersionMiddleware here rejects every modern-era request by + // default — it only accepts the handshake versions it was built for. + foreach ($this->middleware as $entry) { + if ($entry instanceof ProtocolVersionMiddleware) { + $this->logger->warning('A custom middleware list includes ProtocolVersionMiddleware. It runs before the modern (2026-07-28) era is classified and rejects that era\'s requests by default, since it only recognises handshake revisions. Remove it from the custom list — the transport already applies it to handshake-era traffic on its own via self::handshakeMiddleware().'); + + break; + } + } } } /** * Secure default middleware stack applied when no `$middleware` is provided to the constructor. * + * These run at the edge, before the request's era is known, because what + * they enforce — origin policy, DNS rebinding — is true of both eras. The + * `MCP-Protocol-Version` header rule is not: it belongs to the handshake + * era, so {@see self::handshakeMiddleware()} carries it instead and the + * modern leg answers for its own revisions. + * * @return list */ public static function defaultMiddleware(): array @@ -94,10 +137,26 @@ class StreamableHttpTransport extends BaseTransport return [ new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), + ]; + } + + /** + * Middleware applied only to requests classified as handshake-era traffic. + * + * @return list + */ + public static function handshakeMiddleware(): array + { + return [ new ProtocolVersionMiddleware(), ]; } + public function connectStateless(StatelessProtocol $protocol): void + { + $this->stateless = $protocol; + } + public function send(string $data, array $context): void { $this->immediateResponse = $data; @@ -107,7 +166,7 @@ class StreamableHttpTransport extends BaseTransport public function listen(): ResponseInterface { $handler = new MiddlewareRequestHandler( - $this->middleware, + $this->middleware ??= self::defaultMiddleware(), \Closure::fromCallable([$this, 'handleRequest']), ); @@ -119,15 +178,11 @@ class StreamableHttpTransport extends BaseTransport return $this->responseFactory->createResponse(204); } - protected function handlePostRequest(): ResponseInterface + /** + * @param string $body the request body, already read and bounded by {@see self::handleRequest()} + */ + protected function handlePostRequest(string $body): ResponseInterface { - $body = $this->readBody($this->request->getBody()); - if (null === $body) { - $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); - - return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); - } - $this->handleMessage($body, $this->sessionId); if (null !== $this->immediateResponse) { @@ -322,6 +377,48 @@ class StreamableHttpTransport extends BaseTransport private function handleRequest(ServerRequestInterface $request): ResponseInterface { $this->request = $request; + + if ('OPTIONS' === $request->getMethod()) { + return $this->handleOptionsRequest(); + } + + // Read once, here: the era decision needs the body, and so does + // whichever leg it routes to. A PSR-7 stream over `php://input` cannot + // be read twice. + $body = null; + if ('POST' === $request->getMethod()) { + $body = $this->readBody($request->getBody()); + + if (null === $body) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); + } + } + + $classification = $this->classifier->classify($request->getMethod(), $body, self::headers($request)); + + if ($classification->isRejected()) { + \assert(null !== $classification->error); + + return $this->responder->error($classification->error, $classification->httpStatus); + } + + if ($classification->modern) { + return $this->handleModernRequest($body ?? '', $classification->claimedVersion ?? ''); + } + + // The version-header rule only reaches the traffic it is about. Running + // it at the edge would let it answer a modern claim with the handshake + // era's revision list, ahead of the leg that knows better. + return (new MiddlewareRequestHandler( + self::handshakeMiddleware(), + fn (ServerRequestInterface $handshake): ResponseInterface => $this->handleHandshakeRequest($handshake, $body), + ))->handle($request); + } + + private function handleHandshakeRequest(ServerRequestInterface $request, ?string $body): ResponseInterface + { $sessionIdHeaders = $request->getHeader(self::SESSION_HEADER); if (\count($sessionIdHeaders) > 1) { return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400); @@ -337,10 +434,38 @@ class StreamableHttpTransport extends BaseTransport } return match ($request->getMethod()) { - 'OPTIONS' => $this->handleOptionsRequest(), - 'POST' => $this->handlePostRequest(), + 'POST' => $this->handlePostRequest($body ?? ''), 'DELETE' => $this->handleDeleteRequest(), default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), }; } + + /** + * Answers a request that claimed the modern era's per-request envelope. + */ + private function handleModernRequest(string $body, string $claimedVersion): ResponseInterface + { + if (null === $this->stateless) { + return $this->responder->error( + Error::forUnsupportedProtocolVersion($claimedVersion, ProtocolVersion::handshakeVersions()), + 400, + ); + } + + return $this->responder->respond($this->stateless->handle($body, self::headers($this->request))); + } + + /** + * @return array + */ + private static function headers(ServerRequestInterface $request): array + { + $headers = []; + + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return $headers; + } } diff --git a/src/Server/Wire/EraClassification.php b/src/Server/Wire/EraClassification.php new file mode 100644 index 0000000..20c0aae --- /dev/null +++ b/src/Server/Wire/EraClassification.php @@ -0,0 +1,61 @@ + + */ +final class EraClassification +{ + private function __construct( + public readonly bool $modern, + public readonly ?string $claimedVersion, + public readonly ?Error $error, + public readonly int $httpStatus, + ) { + } + + /** + * The handshake era: everything that makes no per-request envelope claim. + */ + public static function legacy(?string $claimedVersion = null): self + { + return new self(false, $claimedVersion, null, 200); + } + + /** + * The modern era, claiming $version — which may be one this server does not + * serve. Routing and support are separate questions, and the dispatcher + * owns the second one so its answer can name what it does support. + */ + public static function modern(string $version): self + { + return new self(true, $version, null, 200); + } + + public static function reject(Error $error, int $httpStatus): self + { + return new self(false, null, $error, $httpStatus); + } + + public function isRejected(): bool + { + return null !== $this->error; + } +} diff --git a/src/Server/Wire/InboundClassifier.php b/src/Server/Wire/InboundClassifier.php new file mode 100644 index 0000000..826284d --- /dev/null +++ b/src/Server/Wire/InboundClassifier.php @@ -0,0 +1,222 @@ + + */ +final class InboundClassifier +{ + public const PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version'; + + /** + * @param string $httpMethod the request's HTTP method + * @param string|null $body the request body, already read + * @param array $headers request headers, case-insensitively matched + */ + public function classify(string $httpMethod, ?string $body, array $headers = []): EraClassification + { + if ('POST' !== strtoupper($httpMethod)) { + return EraClassification::legacy(); + } + + if (null === $body || '' === trim($body)) { + return EraClassification::legacy(); + } + + try { + $decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Unreadable to both eras. Routed to the handshake leg so the one + // parse error the client sees is the one that leg already writes. + return EraClassification::legacy(); + } + + if (!\is_array($decoded)) { + return EraClassification::legacy(); + } + + $headerVersion = self::header($headers, self::PROTOCOL_VERSION_HEADER); + + if (array_is_list($decoded)) { + return $this->classifyBatch($decoded, $headerVersion); + } + + /* @var array $decoded */ + return $this->classifyMessage($decoded, $headerVersion); + } + + /** + * The header-against-body check both eras' entries share. + * + * Kept here rather than in the dispatcher so the edge and the leg it routes + * to cannot disagree about what a request claims. + * + * @return string|null the disagreement, or null when the two agree + */ + public static function crossCheckVersion(?string $headerVersion, string $claimedVersion): ?string + { + if (null === $headerVersion || $headerVersion === $claimedVersion) { + return null; + } + + return \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $claimedVersion); + } + + /** + * Case-insensitive header lookup, since PSR-7 preserves the sender's casing. + * + * @param array $headers + */ + public static function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return '' === $value ? null : $value; + } + } + + return null; + } + + /** + * @param list $messages + */ + private function classifyBatch(array $messages, ?string $headerVersion): EraClassification + { + foreach ($messages as $message) { + if (!\is_array($message) || array_is_list($message)) { + continue; + } + + /** @var array $message */ + $classification = $this->classifyMessage($message, $headerVersion); + + if ($classification->isRejected()) { + return $classification; + } + + if ($classification->modern) { + return EraClassification::reject( + Error::forInvalidRequest(\sprintf('Protocol revision %s removed JSON-RPC batching; send one message per request.', $classification->claimedVersion)), + 400, + ); + } + } + + return EraClassification::legacy(); + } + + /** + * @param array $message + */ + private function classifyMessage(array $message, ?string $headerVersion): EraClassification + { + $id = $message['id'] ?? null; + $isNotification = !\array_key_exists('id', $message) || null === $id; + + if (!\is_string($id) && !\is_int($id)) { + $id = null; + } + + $params = \is_array($message['params'] ?? null) ? $message['params'] : null; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : null; + $claim = $meta[RequestMeta::PROTOCOL_VERSION] ?? null; + + if (null !== $claim) { + if (!\is_string($claim) || '' === $claim) { + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Request "_meta" member "%s" must be a non-empty string.', RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + if (null !== $mismatch = self::crossCheckVersion($headerVersion, $claim)) { + return EraClassification::reject(Error::forHeaderMismatch($mismatch, $id), 400); + } + + return self::eraOf($claim); + } + + if (!self::namesModern($headerVersion)) { + return EraClassification::legacy(); + } + + // The header names a revision that has no handshake, so the body has to + // carry the envelope. A notification is the exception: it has no claim + // to carry under this revision, so there the header is all there is. + if ($isNotification) { + return EraClassification::modern($headerVersion); + } + + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Protocol revision %s requires the "%s" member in "params._meta".', $headerVersion, RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + /** + * The era a claimed revision belongs to. + * + * An unknown revision counts as modern: it cannot be negotiated through a + * handshake, and the modern leg is the one that can name what it does serve. + */ + private static function eraOf(string $version): EraClassification + { + $known = ProtocolVersion::tryFrom($version); + + if (null !== $known && !$known->isModern()) { + return EraClassification::legacy($version); + } + + return EraClassification::modern($version); + } + + /** + * Only a *known* modern revision counts here. An unrecognised header with + * nothing in the body to back it up is not evidence of an era — it is a + * version this endpoint does not serve, and the handshake leg's version + * middleware is what says so, naming everything the endpoint does serve. + */ + private static function namesModern(?string $version): bool + { + return null !== $version && true === ProtocolVersion::tryFrom($version)?->isModern(); + } +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 844ebbf..d7eac2b 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -34,6 +34,54 @@ final class Elements public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + /** + * The focal `inputSchema` the `json-schema-2020-12` scenario expects a + * server to advertise verbatim. + * + * It is handed to `addTool()` raw rather than generated from a signature, + * because the point is that the SDK passes an author-supplied 2020-12 + * schema through `tools/list` untouched: `$schema`/`$defs`/ + * `additionalProperties` (SEP-1613) and the composition, conditional and + * `$anchor` keywords (SEP-2106) must all survive. + * + * Mirrors `JSON_SCHEMA_2020_12_FIXTURE` in the conformance suite; keep the + * two in sync. + * + * @return array + */ + public static function jsonSchema2020_12Fixture(): array + { + return [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + '$defs' => [ + 'address' => [ + '$anchor' => 'addressDef', + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'], + ], + ], + ], + 'properties' => [ + 'name' => ['type' => 'string'], + 'address' => ['$ref' => '#/$defs/address'], + 'contactMethod' => ['type' => 'string', 'enum' => ['phone', 'email']], + 'phone' => ['type' => 'string'], + 'email' => ['type' => 'string'], + ], + 'allOf' => [['anyOf' => [['required' => ['phone']], ['required' => ['email']]]]], + 'if' => [ + 'properties' => ['contactMethod' => ['const' => 'phone']], + 'required' => ['contactMethod'], + ], + 'then' => ['required' => ['phone']], + 'else' => ['required' => ['email']], + 'additionalProperties' => false, + ]; + } + public function toolMultipleTypes(): CallToolResult { return new CallToolResult([ diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf index 269f55f..b481038 100644 --- a/tests/Conformance/Fixtures/nginx.conf +++ b/tests/Conformance/Fixtures/nginx.conf @@ -3,12 +3,10 @@ server { server_name localhost; root /app; - # / speaks the handshake era, /stateless the modern one (SEP-2575). - # Declared first so the prefix match wins. - location /stateless { - try_files $uri /tests/Conformance/server-stateless.php$is_args$args; - } - + # One entry, both protocol eras: the fixture is built through + # Builder::build(), which carries a dispatcher for each, and the transport + # routes every request to the one it belongs to. Both conformance suites + # run against this same location. location / { try_files $uri /tests/Conformance/server.php$is_args$args; } diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index 9a55b83..eb9db4c 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,6 +1,3 @@ -server: - - json-schema-2020-12 - client: - elicitation-sep1034-client-defaults - sse-retry diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 42051df..b41bdc9 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,6 +1,3 @@ -server: - - json-schema-2020-12 - client: - request-metadata - http-standard-headers diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php deleted file mode 100644 index ccdfdf3..0000000 --- a/tests/Conformance/server-stateless.php +++ /dev/null @@ -1,202 +0,0 @@ -createServerRequestFromGlobals(); - -// Explicit rather than builder-built, so the diagnostic hooks below can mutate -// the live registry and the change reaches an open subscription. -// -// Filesystem-backed and not in-memory: under php-fpm the worker holding the -// listen stream open and the worker serving the tools/call that mutates the -// registry are different processes, so the only thing they share is storage. -$bus = new Psr16NotificationBus( - new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), - logger: $logger, -); -$registry = new Registry(new PublishingEventDispatcher($bus), $logger); - -$protocol = Server::builder() - ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setLogger($logger) - ->setRegistry($registry) - // Tools - ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') - ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') - ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') - ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') - // Exercises the -32021 path. - ->addTool( - static function (): never { - throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); - }, - name: 'test_missing_capability', - description: 'Always reports a missing client capability, for testing -32021 handling', - ) - // The ask travels back inside the result, never as its own request. - ->addTool( - static fn (): InputRequiredResult => new InputRequiredResult( - [ - 'conformance_probe' => new ElicitRequest( - 'Please provide a value for the conformance probe.', - new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), - ), - ], - requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), - ), - name: 'test_streaming_elicitation', - description: 'Returns an InputRequiredResult asking for elicitation input', - ) - // Logs server-side only; no logLevel was requested, so nothing goes out. - ->addTool( - static function () use ($logger): string { - $logger->info('test_logging_tool executed'); - - return 'Logged.'; - }, - name: 'test_logging_tool', - description: 'Emits a server-side log message while returning normally', - ) - // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). - ->addTool( - static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), - name: 'test_custom_headers', - description: 'Tests custom header mirroring via x-mcp-header', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], - ], - 'required' => ['region'], - ], - ) - ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') - // Diagnostic hooks the subscription scenarios call to make the lists change - // while a listen stream is open. - ->addTool( - static function () use ($registry): string { - $registry->registerTool( - new Tool( - name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), - title: null, - inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], - description: 'Registered to trigger a list change', - annotations: null, - ), - static fn (): string => 'ephemeral', - ); - - return 'Tool list mutated.'; - }, - name: 'test_trigger_tool_change', - description: 'Registers a tool so the tool list changes', - ) - ->addTool( - static function () use ($registry): string { - $registry->registerPrompt( - new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), - static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], - ); - - return 'Prompt list mutated.'; - }, - name: 'test_trigger_prompt_change', - description: 'Registers a prompt so the prompt list changes', - ) - // Multi round-trip request tools (SEP-2322). - ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') - ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') - ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') - ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') - ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') - ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') - ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') - ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') - // Resources - ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') - ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') - ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') - // Prompts - ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') - ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') - ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') - ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') - ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') - // Fixed so a retry landing on another process still verifies. - ->setRequestState(str_repeat('conformance-fixture-key-', 2)) - // So a listen stream carries the registry's changes rather than only - // acknowledging. In-memory is right here: the conformance server is one - // FrankenPHP-less php-fpm pool, and the scenarios publish within a request. - ->setNotificationBus($bus) - // Short, so a listen stream cannot tie up an fpm worker for the length of - // a whole run. - ->setSubscriptionLifetime(5.0) - // Lists are the same for everyone here; a read is not. - ->setCachePolicy( - CachePolicy::default(60_000) - ->withMethod('tools/list', 3_600_000, CacheScope::Public) - ->withMethod('prompts/list', 3_600_000, CacheScope::Public) - ->withMethod('resources/list', 3_600_000, CacheScope::Public) - ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) - ->withMethod('server/discover', 3_600_000, CacheScope::Public), - ) - ->buildStateless([ProtocolVersion::V2026_07_28]); - -$transport = new StatelessHttpTransport($protocol, logger: $logger); - -(new SapiEmitter())->emit($transport->handle($request)); diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 02c78c2..b233d49 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,22 +9,49 @@ * file that was distributed with this source code. */ +/* + * The conformance fixture, for both protocol eras, on one endpoint. + * + * A server built through Builder::build() carries a dispatcher for each era and + * StreamableHttpTransport routes every request to the one it belongs to, so + * there is nothing here that a revision has to be told about. The element set + * is the union of what both suites ask for: where they overlap they share the + * registration, so a difference in results points at the lifecycle rather than + * at drifted fixtures. + */ + ini_set('display_errors', '0'); require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Capability\Registry; +use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Prompt; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Tool; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Wire\CachePolicy; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; +use Mcp\Tests\Conformance\MrtrElements; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; chdir(__DIR__); @@ -33,25 +60,133 @@ $logger = new FileLogger(__DIR__.'/logs/conformance.log', true); $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); -$transport = new StreamableHttpTransport($request, logger: $logger); +// Explicit rather than builder-built, so the diagnostic hooks below can mutate +// the live registry and the change reaches an open subscription. +// +// Filesystem-backed and not in-memory: under php-fpm the worker holding the +// listen stream open and the worker serving the tools/call that mutates the +// registry are different processes, so the only thing they share is storage. +$bus = new Psr16NotificationBus( + new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), + logger: $logger, +); +$registry = new Registry(new PublishingEventDispatcher($bus), $logger); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger($logger) + ->setRegistry($registry) + // Only the handshake leg keeps one; the modern leg is sessionless either way. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) // Tools ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') - ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') - ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + ->addTool( + static fn () => 'ok', + name: 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: Elements::jsonSchema2020_12Fixture(), + ) + // Exercises the -32021 path. + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); + }, + name: 'test_missing_capability', + description: 'Always reports a missing client capability, for testing -32021 handling', + ) + // The ask travels back inside the result, never as its own request. + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult( + [ + 'conformance_probe' => new ElicitRequest( + 'Please provide a value for the conformance probe.', + new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), + ), + ], + requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), + ), + name: 'test_streaming_elicitation', + description: 'Returns an InputRequiredResult asking for elicitation input', + ) + // Logs server-side only; no logLevel was requested, so nothing goes out. + ->addTool( + static function () use ($logger): string { + $logger->info('test_logging_tool executed'); + + return 'Logged.'; + }, + name: 'test_logging_tool', + description: 'Emits a server-side log message while returning normally', + ) + // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). + ->addTool( + static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), + name: 'test_custom_headers', + description: 'Tests custom header mirroring via x-mcp-header', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + ], + 'required' => ['region'], + ], + ) + ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + // Handshake-era elements. The scenarios that drive them are 2025-11-25 + // ones — server-initiated requests and session-scoped logging both went + // away in 2026-07-28 — but they are registered once, like everything else. + ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') + ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') + // Diagnostic hooks the subscription scenarios call to make the lists change + // while a listen stream is open. + ->addTool( + static function () use ($registry): string { + $registry->registerTool( + new Tool( + name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), + title: null, + inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], + description: 'Registered to trigger a list change', + annotations: null, + ), + static fn (): string => 'ephemeral', + ); + + return 'Tool list mutated.'; + }, + name: 'test_trigger_tool_change', + description: 'Registers a tool so the tool list changes', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerPrompt( + new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), + static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], + ); + + return 'Prompt list mutated.'; + }, + name: 'test_trigger_prompt_change', + description: 'Registers a prompt so the prompt list changes', + ) + // Multi round-trip request tools (SEP-2322). + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') + ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') + ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') + ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') + ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') + ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') + ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') @@ -62,8 +197,24 @@ $server = Server::builder() ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') + ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') + // Fixed so a retry landing on another process still verifies. + ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + // So a listen stream carries the registry's changes rather than only + // acknowledging; see $bus above for why it is filesystem-backed. + ->setNotificationBus($bus) + // Short, so a listen stream cannot tie up an fpm worker for the length of + // a whole run. + ->setSubscriptionLifetime(5.0) + // Lists are the same for everyone here; a read is not. + ->setCachePolicy( + CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('prompts/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) ->build(); -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request, logger: $logger))); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 5ddb551..9cdf329 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -18,6 +18,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Response; @@ -30,6 +31,7 @@ use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\ThingListHandler; use Mcp\Tests\Unit\Server\Extension\ThingListRequest; @@ -178,6 +180,67 @@ final class BuilderTest extends TestCase $this->assertSame($builder, $builder->setLazyLoading(false)); } + #[TestDox('One builder configuration is resolved once, however many dispatchers come out of it')] + public function testAssembledPartsAreSharedAcrossEras(): void + { + $loader = $this->createMock(LoaderInterface::class); + // Twice would mean two registries behind one endpoint, and a change + // made through one of them invisible to the other. + $loader->expects($this->once())->method('load'); + + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setLazyLoading(false) + ->addLoader($loader); + + $builder->build(); + $builder->buildStateless(); + } + + #[TestDox('A built server carries a dispatcher for each era, so one endpoint serves both')] + public function testBuildProducesBothEras(): void + { + $server = Server::builder()->setServerInfo('test', '1.0.0')->build(); + + $this->assertInstanceOf(StatelessProtocol::class, self::statelessProtocol($server)); + } + + #[TestDox('withoutModernEra() leaves the server with the handshake era alone')] + public function testWithoutModernEra(): void + { + $builder = Server::builder()->setServerInfo('test', '1.0.0'); + + $this->assertSame($builder, $builder->withoutModernEra()); + $this->assertNull(self::statelessProtocol($builder->build())); + } + + #[TestDox('setModernVersions() narrows what the modern leg answers for')] + public function testSetModernVersions(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); + + $this->assertSame([ProtocolVersion::V2026_07_28], self::statelessProtocol($server)?->supportedVersions()); + } + + #[TestDox('setModernVersions() rejects a handshake-era revision, which the classifier would never route there')] + public function testSetModernVersionsRejectsHandshakeRevision(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(ProtocolVersion::V2025_11_25->value); + + Server::builder()->setModernVersions([ProtocolVersion::V2025_11_25]); + } + + private static function statelessProtocol(Server $server): ?StatelessProtocol + { + $property = new \ReflectionProperty(Server::class, 'statelessProtocol'); + + return $property->getValue($server); + } + #[TestDox('An extension identifier must be a valid _meta prefix')] public function testEnableExtensionRejectsUnprefixedIdentifier(): void { diff --git a/tests/Unit/Server/InputRequiredShimTest.php b/tests/Unit/Server/InputRequiredShimTest.php new file mode 100644 index 0000000..137270f --- /dev/null +++ b/tests/Unit/Server/InputRequiredShimTest.php @@ -0,0 +1,229 @@ + new Response(1, new CallToolResult([new TextContent('done')]))); + $expected = $handler->handle(self::request(), self::session()); + + $answer = $this->drive($handler, $expected, self::session()); + + $this->assertSame('done', self::text($answer)); + } + + #[TestDox('an ask is sent to the client and the handler re-entered with the answer')] + public function testOneRoundTrip(): void + { + $entries = 0; + $handler = self::handler(static function (Request $request, SessionInterface $session) use (&$entries): Response { + ++$entries; + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent('Hello, '.($answer->content['name'] ?? '?').'!')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']]), + ]); + + $this->assertSame('Hello, Ada!', self::text($answer)); + $this->assertSame(2, $entries, 're-entry is re-execution: the handler runs once per round'); + } + + #[TestDox('a client that declines is an answer, and the handler decides what it means')] + public function testDeclineReachesTheHandler(): void + { + $handler = self::handler(static function (Request $request, SessionInterface $session): Response { + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent($answer->isDeclined() ? 'declined' : 'accepted')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'decline']), + ]); + + $this->assertSame('declined', self::text($answer)); + } + + #[TestDox('a handler that never stops asking is failed rather than looped forever')] + public function testRoundLimit(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + $answer = $this->drive( + $handler, + $handler->handle(self::request(), $session = self::session()), + $session, + array_fill(0, 3, new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']])), + new InputRequiredShim(maxRounds: 2), + ); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertStringContainsString('more than 2 times', $answer->message); + } + + #[TestDox('an ask the client never declared it could answer is refused, and nothing is sent')] + public function testUndeclaredCapabilityIsRefused(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + // No `elicitation` in the session's declared capabilities. + $session = self::session(declares: []); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session), $session); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertSame(-32021, $answer->jsonSerialize()['error']['code']); + } + + /** + * Runs the shim the way the transport does: inside a fiber, answering each + * suspension with the next queued client response. + * + * @param RequestHandlerInterface $handler + * @param Response|Error $first + * @param list|Error> $answers + * + * @return Response|Error + */ + private function drive( + RequestHandlerInterface $handler, + Response|Error $first, + SessionInterface $session, + array $answers = [], + ?InputRequiredShim $shim = null, + ): Response|Error { + $shim ??= new InputRequiredShim(); + $request = self::request(); + + $fiber = new \Fiber(static fn (): Response|Error => $shim->fulfill($first, $handler, $request, $session, null)); + + $suspended = $fiber->start(); + + while ($fiber->isSuspended()) { + $this->assertIsArray($suspended); + $this->assertSame('request', $suspended['type'], 'the shim only ever suspends to send a client request'); + $this->assertNotEmpty($answers, 'the shim sent more requests than the test queued answers for'); + + $suspended = $fiber->resume(array_shift($answers)); + } + + /** @var Response|Error $return */ + $return = $fiber->getReturn(); + + return $return; + } + + private static function ask(): ElicitRequest + { + return new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name'])); + } + + private static function request(): CallToolRequest + { + return (new CallToolRequest('greet', []))->withId(1); + } + + /** + * @param array $declares + */ + private static function session(array $declares = ['elicitation' => []]): SessionInterface + { + $session = new Session(new InMemorySessionStore()); + $session->set('client_capabilities', $declares); + + return $session; + } + + private static function inputContext(SessionInterface $session): ?InputContext + { + $context = $session->get(InputContext::class); + + return $context instanceof InputContext ? $context : null; + } + + /** + * @param \Closure(Request, SessionInterface): (Response|Error) $handle + * + * @return RequestHandlerInterface + */ + private static function handler(\Closure $handle): RequestHandlerInterface + { + return new class($handle) implements RequestHandlerInterface { + public function __construct(private readonly \Closure $handle) + { + } + + public function supports(Request $request): bool + { + return true; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + return ($this->handle)($request, $session); + } + }; + } + + /** + * @param Response|Error $result + */ + private static function text(Response|Error $result): string + { + self::assertInstanceOf(Response::class, $result); + self::assertInstanceOf(CallToolResult::class, $result->result); + $first = $result->result->content[0] ?? null; + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } +} diff --git a/tests/Unit/Server/Transport/DualEraRoutingTest.php b/tests/Unit/Server/Transport/DualEraRoutingTest.php new file mode 100644 index 0000000..d73d94b --- /dev/null +++ b/tests/Unit/Server/Transport/DualEraRoutingTest.php @@ -0,0 +1,280 @@ +factory = new Psr17Factory(); + } + + #[TestDox('the initialize handshake is answered on the endpoint that also serves the modern era')] + public function testHandshakeStillWorks(): void + { + $answer = $this->post($this->server(), $this->handshake()); + + $this->assertSame(200, $answer['status']); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answer['body']['result']['protocolVersion']); + } + + #[TestDox('server/discover is answered on that same endpoint, with no handshake before it')] + public function testDiscoverOnTheSameEndpoint(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['result']['supportedVersions']); + } + + #[TestDox('one tool answers both eras, from one registry')] + public function testOneToolServesBothEras(): void + { + $server = $this->server(); + + $handshake = $this->post($server, $this->handshake()); + $session = $handshake['session']; + $this->assertNotSame('', $session, 'the handshake leg still mints a session'); + + $legacy = $this->post($server, json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => ['name' => 'echo_tool', 'arguments' => ['text' => 'legacy']], + ], \JSON_THROW_ON_ERROR), [ + 'Mcp-Session-Id' => $session, + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $modern = $this->post($server, $this->enveloped('tools/call', [ + 'name' => 'echo_tool', + 'arguments' => ['text' => 'modern'], + ]), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'echo_tool', + ]); + + $this->assertSame('echo:legacy', $legacy['body']['result']['content'][0]['text']); + $this->assertSame('echo:modern', $modern['body']['result']['content'][0]['text']); + + // The modern answer carries the wire fields its revision adds; the + // handshake one does not. Same handler, two codecs. + $this->assertSame('complete', $modern['body']['result']['resultType']); + $this->assertArrayNotHasKey('resultType', $legacy['body']['result']); + } + + #[TestDox('a modern claim contradicted by the header is refused before either leg sees it')] + public function testHeaderContradictingTheClaimIsRefused(): void + { + $answer = $this->post($this->server(), $this->enveloped('tools/list'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32020, $answer['body']['error']['code']); + } + + #[TestDox('a modern header the body does not back up is refused, naming the member it wants')] + public function testModernHeaderWithoutAnEnvelopeIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32602, $answer['body']['error']['code']); + $this->assertStringContainsString(RequestMeta::PROTOCOL_VERSION, $answer['body']['error']['message']); + } + + #[TestDox('the version middleware lets a modern header through instead of turning it away')] + public function testVersionMiddlewareAcceptsModernRevisions(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + } + + #[TestDox('an unknown header with no claim behind it is refused by the handshake leg, offering its revisions')] + public function testUnknownVersionHeaderIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => '2030-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $answer['body']['error']['data']['supported'], + ); + } + + #[TestDox('an unknown revision claimed in the envelope is answered by the modern leg, offering its own')] + public function testUnknownClaimReachesTheModernLeg(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => '2099-01-01', + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]], + ], \JSON_THROW_ON_ERROR); + + $answer = $this->post($this->server(), $body, ['MCP-Protocol-Version' => '2099-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + // The claim named the envelope mechanism, so the answer names the + // revisions that mechanism has — not the handshake ones it cannot use. + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']); + } + + #[TestDox('a server built without the modern era refuses a modern claim, naming what it does serve')] + public function testHandshakeOnlyServerRefusesModernTraffic(): void + { + $answer = $this->post($this->server(modern: false), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertNotContains(ProtocolVersion::V2026_07_28->value, $answer['body']['error']['data']['supported']); + } + + #[TestDox('a DELETE still ends a handshake-era session')] + public function testDeleteReachesTheHandshakeLeg(): void + { + $server = $this->server(); + $session = $this->post($server, $this->handshake())['session']; + + $request = $this->factory->createServerRequest('DELETE', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Mcp-Session-Id', $session); + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + $this->assertSame(200, $response->getStatusCode()); + } + + private function server(bool $modern = true): Server + { + $builder = Server::builder() + ->setServerInfo('dual-era-server', '1.0.0') + ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument'); + + if (!$modern) { + $builder->withoutModernEra(); + } + + return $builder->build(); + } + + private function handshake(): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'handshake-client', 'version' => '1.0.0'], + ], + ], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + */ + private function enveloped(string $method, array $params = []): string + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $headers + * + * @return array{status: int, session: string, body: array} + */ + private function post(Server $server, string $body, array $headers = []): array + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->factory->createStream($body)); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + return [ + 'status' => $response->getStatusCode(), + 'session' => $response->getHeaderLine('Mcp-Session-Id'), + 'body' => self::decode($response), + ]; + } + + /** + * @return array + */ + private static function decode(ResponseInterface $response): array + { + $payload = (string) $response->getBody(); + + if ('' === $payload) { + return []; + } + + return json_decode($payload, true, flags: \JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 22b7fef..932f5c7 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -147,6 +147,23 @@ final class StreamableHttpTransportTest extends TestCase $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); } + #[TestDox('a custom middleware list carrying ProtocolVersionMiddleware warns that it will reject the modern era')] + public function testCustomMiddlewareWithProtocolVersionMiddlewareWarns(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('ProtocolVersionMiddleware')); + + new StreamableHttpTransport( + $this->factory->createServerRequest('POST', 'http://localhost/')->withHeader('Host', 'localhost'), + $this->factory, + $this->factory, + $logger, + [new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), new ProtocolVersionMiddleware()], + ); + } + #[TestDox('null middleware does not trigger the empty-list warning')] public function testNullMiddlewareDoesNotWarn(): void { diff --git a/tests/Unit/Server/Wire/InboundClassifierTest.php b/tests/Unit/Server/Wire/InboundClassifierTest.php new file mode 100644 index 0000000..282fa0a --- /dev/null +++ b/tests/Unit/Server/Wire/InboundClassifierTest.php @@ -0,0 +1,254 @@ + $headers + */ + #[DataProvider('provideLegacyTraffic')] + #[TestDox('$_dataName is handshake-era traffic')] + public function testClassifiesAsLegacy(string $method, ?string $body, array $headers = []): void + { + $classification = (new InboundClassifier())->classify($method, $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertFalse($classification->modern); + } + + /** + * @return iterable}> + */ + public static function provideLegacyTraffic(): iterable + { + yield 'the initialize handshake' => ['POST', self::message('initialize')]; + + yield 'a request with no envelope claim' => ['POST', self::message('tools/list')]; + + yield 'a notification with no envelope claim' => ['POST', self::notification('notifications/initialized')]; + + yield 'a GET, which the modern era has no use for' => ['GET', null]; + + yield 'a DELETE ending a session' => ['DELETE', null]; + + yield 'a claim naming a handshake revision' => [ + 'POST', + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + yield 'a header naming a handshake revision' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + // The endpoint's version middleware is what answers this, naming every + // revision the endpoint serves. Routing it modern would answer with the + // modern leg's shorter list instead. + yield 'an unknown header with nothing in the body to back it' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => '2030-01-01'], + ]; + + yield 'a batch of handshake-era messages' => [ + 'POST', + '['.self::message('tools/list').','.self::message('prompts/list').']', + ]; + + yield 'an empty body' => ['POST', '']; + + yield 'a body that is not JSON' => ['POST', 'not json at all']; + + yield 'a JSON body that is not an object' => ['POST', '"a string"']; + } + + /** + * @param array $headers + */ + #[DataProvider('provideModernTraffic')] + #[TestDox('$_dataName is modern-era traffic')] + public function testClassifiesAsModern(string $body, array $headers, string $expectedVersion): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertTrue($classification->modern); + $this->assertSame($expectedVersion, $classification->claimedVersion); + } + + /** + * @return iterable, string}> + */ + public static function provideModernTraffic(): iterable + { + yield 'a request claiming the modern revision' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // The header is a cross-check, not the decision: a claim on its own is + // enough evidence, and the leg it routes to is what says the header was + // required. + yield 'a claim with no header at all' => [ + self::message('server/discover', ProtocolVersion::V2026_07_28->value), + [], + ProtocolVersion::V2026_07_28->value, + ]; + + // Routed rather than refused so the answer can name what this endpoint + // does serve, which only the modern leg knows. + yield 'a claim naming a revision this SDK has never heard of' => [ + self::message('tools/list', '2099-01-01'), + [self::VERSION_HEADER => '2099-01-01'], + '2099-01-01', + ]; + + // `initialize` is handshake-era by definition — but a claim outranks the + // method name, and the modern leg answers it with method-not-found. + yield 'an initialize carrying a modern claim' => [ + self::message('initialize', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // A notification has no claim of its own under this revision, so the + // header is all the evidence there is. + yield 'a notification under a modern header' => [ + self::notification('notifications/progress'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + } + + /** + * @param array $headers + */ + #[DataProvider('provideRejectedTraffic')] + #[TestDox('$_dataName is refused at the edge')] + public function testRejects(string $body, array $headers, int $code, int $status): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertTrue($classification->isRejected()); + $this->assertSame($code, $classification->error?->jsonSerialize()['error']['code']); + $this->assertSame($status, $classification->httpStatus); + } + + /** + * @return iterable, int, int}> + */ + public static function provideRejectedTraffic(): iterable + { + yield 'a header contradicting the claim' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + -32020, + 400, + ]; + + yield 'a handshake claim contradicted by a modern header' => [ + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32020, + 400, + ]; + + yield 'a modern header on a request carrying no envelope' => [ + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32602, + 400, + ]; + + yield 'a claim that is not a string' => [ + '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"'.RequestMeta::PROTOCOL_VERSION.'":42}}}', + [], + -32602, + 400, + ]; + + yield 'a batch holding a modern claim' => [ + '['.self::message('tools/list').','.self::message('prompts/list', ProtocolVersion::V2026_07_28->value).']', + [], + -32600, + 400, + ]; + } + + #[TestDox('the header cross-check is case-insensitive, as HTTP field names are')] + public function testHeaderLookupIgnoresCase(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + ['mcp-protocol-version' => ProtocolVersion::V2025_11_25->value], + ); + + $this->assertTrue($classification->isRejected()); + } + + #[TestDox('an empty header value counts as no header, not as a contradiction')] + public function testEmptyHeaderIsAbsent(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ''], + ); + + $this->assertFalse($classification->isRejected()); + $this->assertTrue($classification->modern); + } + + #[TestDox('the shared cross-check reports only a genuine disagreement')] + public function testCrossCheckVersion(): void + { + $this->assertNull(InboundClassifier::crossCheckVersion(null, '2026-07-28')); + $this->assertNull(InboundClassifier::crossCheckVersion('2026-07-28', '2026-07-28')); + $this->assertNotNull(InboundClassifier::crossCheckVersion('2025-11-25', '2026-07-28')); + } + + private static function message(string $method, ?string $claim = null): string + { + $params = null === $claim ? [] : ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => $claim, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + private static function notification(string $method): string + { + return json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => []], \JSON_THROW_ON_ERROR); + } +} From 8c06dcecc6a012b38f5bda4e1e0389c5315a279b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 02:41:02 +0200 Subject: [PATCH 115/143] [Client] Speak the 2026-07-28 lifecycle (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Speak the 2026-07-28 lifecycle On that revision the client opens with server/discover instead of initialize, stamps each request's _meta with the protocol version, its own capabilities and client info, and sends the standard Mcp-Method / Mcp-Name / Mcp-Param-* headers an intermediary routes on. ToolCatalog is what knows, from the tool list, which arguments a call has to mirror. An input_required result is answered rather than surfaced: InputRequestResolver asks the host's elicitation, sampling and roots handlers and retries the same request carrying inputResponses and the requestState the server sent. McpHeader holds the header names and the =?base64?…?= sentinel, so the two sides no longer keep their own copies of the same rules. * Fix CI baseline drift and address Copilot review findings Drop stale expected-failure entries the new client fixture now passes. Copilot review: fix McpHeader round-trip for a wrapper-shaped literal, encode empty/numeric-keyed inputResponses as a JSON object, stop the incident example repeating side effects on MRTR retry, and reset ToolCatalog on reconnect instead of carrying the previous server's verdicts. * Skip DualEraExampleTestCase on PHP 8.1 PHP_CLI_SERVER_WORKERS does not reliably fork multiple php -S workers there (php/php-src#9400), reproducing the single-worker deadlock the extra workers exist to avoid. --- CHANGELOG.md | 1 + .../ClientAwareService.php | 71 +++- .../elicitation/ElicitationHandlers.php | 82 +++- src/Client.php | 20 +- src/Client/Protocol.php | 392 ++++++++++++++++-- src/Client/Stateless/HeaderFactory.php | 75 ++++ src/Client/Stateless/InputRequestResolver.php | 204 +++++++++ src/Client/Stateless/RequestEnvelope.php | 78 ++++ src/Client/Stateless/ToolCatalog.php | 206 +++++++++ .../HeaderAwareTransportInterface.php | 37 ++ src/Client/Transport/HttpTransport.php | 38 +- src/Schema/Request/DiscoverRequest.php | 43 ++ src/Schema/Tool.php | 86 +--- src/Schema/Wire/McpHeader.php | 217 ++++++++++ .../Stateless/StandardHeaderValidator.php | 38 +- tests/Conformance/client.php | 133 +++++- .../conformance-baseline-2025-11-25.yml | 2 - .../conformance-baseline-2026-07-28.yml | 8 - tests/Integration/DualEraElicitationTest.php | 105 +++++ tests/Integration/DualEraExampleTestCase.php | 144 +++++++ tests/Integration/HandshakeTest.php | 13 +- tests/Unit/Client/ProtocolTest.php | 250 ++++++++++- .../Client/Stateless/HeaderFactoryTest.php | 124 ++++++ .../Stateless/InputRequestResolverTest.php | 119 ++++++ .../Unit/Client/Stateless/ToolCatalogTest.php | 124 ++++++ tests/Unit/Schema/Wire/McpHeaderTest.php | 93 +++++ 26 files changed, 2449 insertions(+), 254 deletions(-) create mode 100644 src/Client/Stateless/HeaderFactory.php create mode 100644 src/Client/Stateless/InputRequestResolver.php create mode 100644 src/Client/Stateless/RequestEnvelope.php create mode 100644 src/Client/Stateless/ToolCatalog.php create mode 100644 src/Client/Transport/HeaderAwareTransportInterface.php create mode 100644 src/Schema/Request/DiscoverRequest.php create mode 100644 src/Schema/Wire/McpHeader.php create mode 100644 tests/Integration/DualEraElicitationTest.php create mode 100644 tests/Integration/DualEraExampleTestCase.php create mode 100644 tests/Unit/Client/Stateless/HeaderFactoryTest.php create mode 100644 tests/Unit/Client/Stateless/InputRequestResolverTest.php create mode 100644 tests/Unit/Client/Stateless/ToolCatalogTest.php create mode 100644 tests/Unit/Schema/Wire/McpHeaderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5467968..06280c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Speak the 2026-07-28 lifecycle from the client: `Client` opens with `server/discover` instead of `initialize` on that revision, stamps each request's `_meta` with the protocol version, its own capabilities and client info, and sends the standard `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers an intermediary routes on — the last from the new `Mcp\Client\Stateless\ToolCatalog`, which knows from the tool list which arguments a call must mirror. An `input_required` result is answered automatically by `InputRequestResolver`, which asks the host's elicitation, sampling and roots handlers and retries the same request with `inputResponses` and the `requestState` the server sent. `Mcp\Schema\Wire\McpHeader` holds the header names and the `=?base64?…?=` sentinel both sides share. * Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. * Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. * Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index 1e895c4..01cfc07 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -12,8 +12,13 @@ namespace Mcp\Example\Server\ClientCommunication; use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -31,10 +36,14 @@ final class ClientAwareService * Demonstrates the server side of the "roots" client capability: the tool * issues a roots/list request that the client answers from its own handler. * - * @return array{status: string, message: string, roots?: list} + * Written the 2026-07-28 way: the ask is returned and the answer read off + * the retry. A handshake-era client reaches the same tool — the SDK sends + * the `roots/list` request over that connection and re-enters this method. + * + * @return array{status: string, message: string, roots?: list}|InputRequiredResult */ #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] - public function inspectWorkspaceRoots(RequestContext $context): array + public function inspectWorkspaceRoots(RequestContext $context): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); @@ -45,7 +54,11 @@ final class ClientAwareService ]; } - $result = $clientGateway->listRoots(); + $result = $context->getInputContext()?->rootsResult('roots'); + + if (null === $result) { + return new InputRequiredResult(['roots' => new ListRootsRequest()]); + } $roots = []; foreach ($result->roots as $root) { @@ -62,36 +75,48 @@ final class ClientAwareService } /** - * @return array{incident: string, recommended_actions: string, model: string} + * @return array{incident: string, recommended_actions: string, model: string}|InputRequiredResult */ #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] - public function coordinateIncident(RequestContext $context, string $incidentTitle): array + public function coordinateIncident(RequestContext $context, string $incidentTitle): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); - $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; + // A retry re-enters this method from the top, so the triage work below + // must run only once: check for the answer first, before repeating logs, + // progress notifications and simulated work the client already saw. + $result = $context->getInputContext()?->samplingResult('recommendation'); - foreach ($steps as $index => $step) { - $progress = ($index + 1) / \count($steps); + if (null === $result) { + $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); - $clientGateway->progress($progress, 1, $step); + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; - usleep(180_000); // Simulate work being done + foreach ($steps as $index => $step) { + $progress = ($index + 1) / \count($steps); + + $clientGateway->progress($progress, 1, $step); + + usleep(180_000); // Simulate work being done + } + + $prompt = \sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + return new InputRequiredResult(['recommendation' => new CreateSamplingMessageRequest( + messages: [new SamplingMessage(Role::User, new TextContent($prompt))], + maxTokens: 350, + temperature: 0.5, + )]); } - $prompt = \sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); - - $result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]); - $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; $clientGateway->log(LoggingLevel::Info, \sprintf('Incident triage completed for %s', $incidentTitle)); diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index aaa1328..95a19d7 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,6 +17,9 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -43,10 +46,10 @@ final class ElicitationHandlers * - String field with date format for reservation date * - Enum field for dietary restrictions with human-readable labels * - * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult */ #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] - public function bookRestaurant(RequestContext $context, string $restaurantName): array + public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -55,8 +58,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName)); $schema = new ElicitationSchema( @@ -85,12 +86,19 @@ final class ElicitationHandlers required: ['party_size', 'date'], ); - $result = $client->elicit( - message: \sprintf('Please provide your reservation details for %s:', $restaurantName), - requestedSchema: $schema, - timeout: 120, + $result = $this->ask( + $context, + 'details', + \sprintf('Please provide your reservation details for %s:', $restaurantName), + $schema, ); + // Modern era, first round: the ask travels back as the result and the + // client retries this whole call carrying the answer. + if ($result instanceof InputRequiredResult) { + return $result; + } + if ($result->isDeclined()) { $this->logger->info('User declined to provide reservation details.'); @@ -154,10 +162,10 @@ final class ElicitationHandlers * * Demonstrates the simplest elicitation pattern - a yes/no confirmation. * - * @return array{status: string, message: string} + * @return array{status: string, message: string}|InputRequiredResult */ #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] - public function confirmAction(RequestContext $context, string $actionDescription): array + public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -166,8 +174,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'confirm' => new BooleanSchemaDefinition( @@ -179,11 +185,17 @@ final class ElicitationHandlers required: ['confirm'], ); - $result = $client->elicit( - message: \sprintf('Are you sure you want to: %s?', $actionDescription), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'confirmation', + \sprintf('Are you sure you want to: %s?', $actionDescription), + $schema, ); + if ($result instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'not_confirmed', @@ -222,10 +234,10 @@ final class ElicitationHandlers * * Demonstrates elicitation with optional fields and enum with labels. * - * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult */ #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] - public function collectFeedback(RequestContext $context, string $topic): array + public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -234,8 +246,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'rating' => new EnumSchemaDefinition( @@ -253,11 +263,17 @@ final class ElicitationHandlers required: ['rating'], ); - $result = $client->elicit( - message: \sprintf('Please provide your feedback about: %s', $topic), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'feedback', + \sprintf('Please provide your feedback about: %s', $topic), + $schema, ); + if ($result instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'skipped', @@ -288,4 +304,26 @@ final class ElicitationHandlers ], ]; } + + /** + * Ask the user one question. + * + * Written the way revision 2026-07-28 asks: the question is *returned*, the + * client answers it and retries the whole call, and the answer comes back + * through the input context under the same key. Nothing here names an era — + * on a handshake-era connection the SDK fulfils the same ask over that + * connection's own channel and re-enters the tool with the answer. + * + * The caller gets an {@see ElicitResult} once there is one, or an + * {@see InputRequiredResult} to hand straight back to its own caller. + */ + private function ask( + RequestContext $context, + string $key, + string $message, + ElicitationSchema $schema, + ): ElicitResult|InputRequiredResult { + return $context->getInputContext()?->elicitResult($key) + ?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]); + } } diff --git a/src/Client.php b/src/Client.php index ed5abc6..532f60a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -175,7 +175,16 @@ class Client $response = $this->sendRequest($request); - return ListToolsResult::fromArray($response->result); + $result = $response->result; + + // Filtered before parsing, because parsing is where a malformed + // `x-mcp-header` annotation throws. One broken definition must cost the + // caller that tool, not the whole listing (SEP-2243). + if (\is_array($result['tools'] ?? null)) { + $result['tools'] = $this->protocol->getToolCatalog()->record($result['tools']); + } + + return ListToolsResult::fromArray($result); } /** @@ -188,6 +197,15 @@ class Client */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { + $catalog = $this->protocol->getToolCatalog(); + + // A tool the listing showed to be malformed is refused here rather than + // sent: the client cannot produce the headers its annotations demand, + // so the call could only go out misdescribed (SEP-2243). + if ($catalog->isRejected($name)) { + throw new RuntimeException(\sprintf('Refusing to call tool "%s": its "x-mcp-header" annotations are invalid (%s).', $name, $catalog->reasonFor($name))); + } + $request = new CallToolRequest($name, $arguments); $response = $this->sendRequest($request, $onProgress); diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 47acde7..281aedc 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -16,16 +16,25 @@ use Mcp\Client\Handler\Notification\ProgressNotificationHandler; use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\State\ClientState; use Mcp\Client\State\ClientStateInterface; +use Mcp\Client\Stateless\HeaderFactory; +use Mcp\Client\Stateless\InputRequestResolver; +use Mcp\Client\Stateless\RequestEnvelope; +use Mcp\Client\Stateless\ToolCatalog; +use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; +use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; +use Mcp\Server\Stateless\RequestMeta; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -41,6 +50,16 @@ use Psr\Log\NullLogger; */ class Protocol { + /** + * How many times a request may be re-sent before the client gives up. + * + * Both loops that re-send are bounded by it: a server that keeps asking for + * input, and one that keeps rejecting the offered revision. Neither is + * expected to run more than a round or two, so the cap is only there to + * stop a broken or hostile server from spinning the client forever. + */ + private const MAX_ROUND_TRIPS = 10; + private ?TransportInterface $transport = null; private ClientStateInterface $state; private MessageFactory $messageFactory; @@ -49,6 +68,21 @@ class Protocol /** @var NotificationHandlerInterface[] */ private array $notificationHandlers; + /** Set only when the configured revision has no handshake. */ + private ?RequestEnvelope $envelope = null; + + private ?HeaderFactory $headers = null; + + private ToolCatalog $tools; + + private readonly InputRequestResolver $inputRequests; + + /** + * Progress tokens are only required to be unique within a connection, and + * a retry keeps the caller's one — the work being reported on is the same. + */ + private int $progressTokens = 0; + /** * @param RequestHandlerInterface[] $requestHandlers * @param NotificationHandlerInterface[] $notificationHandlers @@ -67,6 +101,20 @@ class Protocol new ProgressNotificationHandler($this->state), ...$notificationHandlers, ]; + + $this->tools = new ToolCatalog($this->logger); + $this->inputRequests = new InputRequestResolver($this->requestHandlers, $this->logger); + } + + /** + * What the client knows about the server's tools, from `tools/list`. + * + * Kept on the protocol rather than the facade because it is what makes the + * SEP-2243 headers derivable at send time. + */ + public function getToolCatalog(): ToolCatalog + { + return $this->tools; } /** @@ -80,18 +128,57 @@ class Protocol public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; + + // A fresh catalog per connection: it is what a server told this client + // about its tools, and a server reached by reconnecting — the same one + // or another — has said nothing yet. + $this->tools = new ToolCatalog($this->logger); + + if ($config->protocolVersion->isModern()) { + $this->envelope = new RequestEnvelope( + $config->protocolVersion, + $config->capabilities, + $config->clientInfo, + ); + $this->headers = new HeaderFactory($this->tools); + } + $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + if ($transport instanceof HeaderAwareTransportInterface) { + $transport->onHeaders($this->headersFor(...)); + } + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } /** - * Perform the MCP initialization handshake. + * The headers belonging to an encoded message, for a transport that has any. * - * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * @return array + */ + private function headersFor(string $payload): array + { + if (null === $this->headers || null === $this->envelope) { + return []; + } + + $decoded = json_decode($payload, true); + + return \is_array($decoded) + ? $this->headers->forMessage($decoded, $this->envelope->protocolVersion()) + : []; + } + + /** + * Ready the connection for use. + * + * Up to 2025-11-25 that means the `initialize` handshake: offer a revision, + * take the server's answer, confirm with `notifications/initialized`. From + * 2026-07-28 there is no handshake at all — see {@see self::discover()}. * * @param Configuration $config The client configuration * @@ -99,18 +186,12 @@ class Protocol */ public function initialize(Configuration $config): Response|Error { - $offered = $config->protocolVersion; - if ($offered->isModern()) { - // Only handshake era spec versions need the initialize call, so if we - // end up here, we fall back to the latest handshake version. - $offered = ProtocolVersion::latestHandshake(); - - $this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [ - 'configured' => $config->protocolVersion->value, - 'offered' => $offered->value, - ]); + if (null !== $this->envelope) { + return $this->discover($config); } + $offered = $config->protocolVersion; + $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -156,12 +237,127 @@ class Protocol return $response; } + /** + * Stand in for the handshake in the modern era. + * + * There is nothing to negotiate: the revision travels on every request, so + * the connection is usable the moment the transport is. `server/discover` + * is only asked because the facade exposes `getServerInfo()`, and a server + * that will not answer it still serves every other method — so a failure + * here is logged and the connection proceeds. + * + * @return Response> + */ + private function discover(Configuration $config): Response + { + $this->state->setProtocolVersion($config->protocolVersion); + $this->state->setInitialized(true); + + $response = $this->request(new DiscoverRequest(), $config->initTimeout); + + if ($response instanceof Error) { + $this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [ + 'code' => $response->code, + 'message' => $response->message, + ]); + + return new Response(0, []); + } + + $this->readDiscovery($response->result); + + return $response; + } + + /** + * Read defensively: `server/discover` is optional, so a server may answer + * with something that is not a DiscoverResult at all, and none of it is + * load-bearing for the requests that follow. + * + * @param array $result + */ + private function readDiscovery(array $result): void + { + // Identity is wire vocabulary in this revision, so it rides in `_meta` + // rather than the result body. The top level is read as a fallback + // because that is where the handshake era put it. + $meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : []; + $serverInfo = $meta[RequestMeta::SERVER_INFO] ?? $result['serverInfo'] ?? null; + + if (\is_array($serverInfo)) { + try { + $this->state->setServerInfo(Implementation::fromArray($serverInfo)); + } catch (\Throwable $e) { + $this->logger->debug('Ignoring unreadable serverInfo from "server/discover".', ['exception' => $e]); + } + } + + if (\is_string($result['instructions'] ?? null)) { + $this->state->setInstructions($result['instructions']); + } + + $this->reconcileVersion($result['supportedVersions'] ?? null); + + $this->logger->info('Discovery complete', [ + 'supportedVersions' => $result['supportedVersions'] ?? null, + ]); + } + + /** + * Move to a revision the server actually speaks, if it said which. + * + * `server/discover` reports rather than negotiates, so a client that asked + * for something the server does not list learns it here — and learning it + * now is far better than a stream of refusals later. A server that stays + * silent about its versions is left alone; the method is optional and + * saying nothing is not the same as saying no. + */ + private function reconcileVersion(mixed $supportedVersions): void + { + if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) { + return; + } + + $current = $this->envelope->protocolVersion(); + + if (\in_array($current->value, $supportedVersions, true)) { + return; + } + + foreach ($supportedVersions as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + if (null === $version || !$version->isModern()) { + continue; + } + + $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ + 'configured' => $current->value, + 'using' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return; + } + + // Everything it offers is handshake era, which this connection cannot + // reach — it has already skipped the handshake. + throw new ConnectionException(\sprintf('Server does not support any modern protocol revision (it advertises %s); the configured "%s" cannot be used against it.', implode(', ', array_map(strval(...), $supportedVersions)), $current->value)); + } + /** * Send a request to the server and wait for response. * * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. * + * In the modern era this is also where the two loops that re-send live: + * answering a server's request for input (SEP-2322), and retrying under a + * revision the server accepts (SEP-2575). Both re-send the same call, so + * they belong together and above the single exchange. + * * @param Request $request The request to send * @param int $timeout The timeout in seconds * @param bool $withProgress Whether to attach a progress token to the request @@ -170,18 +366,126 @@ class Protocol */ public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error { - $requestId = $this->state->nextRequestId(); - $request = $request->withId($requestId); + $payload = $request->withId(0)->jsonSerialize(); + unset($payload['id']); if ($withProgress) { - $progressToken = "prog-{$requestId}"; - $request = $request->withMeta(['progressToken' => $progressToken]); + $payload = self::withMeta($payload, ['progressToken' => 'prog-'.++$this->progressTokens]); } + if (null === $this->envelope) { + return $this->exchange($payload, $timeout); + } + + for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) { + $response = $this->exchange($payload, $timeout); + + if ($response instanceof Error) { + $retry = $this->withAcceptedVersion($response); + + if (null === $retry) { + return $response; + } + + continue; + } + + $asked = InputRequestResolver::asked($response->result); + + if (null === $asked) { + return $response; + } + + // A fresh `inputResponses`/`requestState` pair every round, never + // merged with the last: the answers belong to the ask that just + // arrived, and carrying an old one forward is how state leaks + // between rounds. + // + // Cast to object: inputResponses is a JSON object keyed by the + // server's ids, but a PHP array with no entries or with sequential + // numeric-string keys encodes as a JSON array instead. + $payload['params'] = [ + ...($payload['params'] ?? []), + 'inputResponses' => (object) $this->inputRequests->resolve($asked), + ]; + + unset($payload['params']['requestState']); + + // Echoed byte-for-byte, and only when the server sent one: the + // value is the server's to read, and inventing or reshaping it + // would break whatever it encodes. + if (\is_string($response->result['requestState'] ?? null)) { + $payload['params']['requestState'] = $response->result['requestState']; + } + + $this->logger->debug('Retrying request with resolved input', [ + 'method' => $payload['method'] ?? null, + 'round' => $attempt + 1, + ]); + } + + return Error::forInternalError(\sprintf('Server asked for input more than %d times without completing the request.', self::MAX_ROUND_TRIPS)); + } + + /** + * Switches the offered revision when the server refuses the current one, + * or null when there is nothing to retry with. + * + * @param Error $error the server's refusal + */ + private function withAcceptedVersion(Error $error): ?ProtocolVersion + { + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code || null === $this->envelope) { + return null; + } + + $data = \is_array($error->data) ? $error->data : []; + $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; + $current = $this->envelope->protocolVersion(); + + foreach ($supported as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + // Only another modern revision is reachable from here: falling back + // to a handshake era one would mean opening a connection this + // transport already decided it was not going to open. + if (null === $version || !$version->isModern() || $version === $current) { + continue; + } + + $this->logger->info('Server rejected the offered protocol revision, retrying with one it supports.', [ + 'offered' => $current->value, + 'retrying' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return $version; + } + + return null; + } + + /** + * One request on the wire: assign an id, send, and wait for its answer. + * + * A retry gets a new id, because the previous one is spent — the server has + * already answered it, and reusing it would make the two indistinguishable. + * + * @param array $payload + * + * @return Response>|Error + */ + private function exchange(array $payload, int $timeout): Response|Error + { + $requestId = $this->state->nextRequestId(); + $payload['id'] = $requestId; + $this->state->addPendingRequest($requestId, $timeout); try { - $this->sendRequest($request); + $this->send($payload, 'request'); $immediate = $this->state->consumeResponse($requestId); if (null !== $immediate) { @@ -204,29 +508,49 @@ class Protocol } } - /** - * Send a request to the server. - */ - private function sendRequest(Request $request): void - { - $this->logger->debug('Sending request', [ - 'id' => $request->getId(), - 'method' => $request::getMethod(), - ]); - - $encoded = json_encode($request, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); - } - /** * Send a notification to the server (fire and forget). */ public function sendNotification(Notification $notification): void { - $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + $this->send($notification->jsonSerialize(), 'notification'); + } - $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + /** + * Encode and hand a message to the transport, stamping the per-request + * envelope on the way out when the revision calls for one. + * + * @param array $payload + */ + private function send(array $payload, string $kind): void + { + if (null !== $this->envelope) { + $payload = $this->envelope->stamp($payload); + } + + $this->logger->debug('Sending '.$kind, [ + 'id' => $payload['id'] ?? null, + 'method' => $payload['method'] ?? null, + ]); + + $this->transport?->send(json_encode($payload, \JSON_THROW_ON_ERROR)); + } + + /** + * @param array $payload + * @param array $meta + * + * @return array + */ + private static function withMeta(array $payload, array $meta): array + { + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $existing = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $params['_meta'] = [...$existing, ...$meta]; + $payload['params'] = $params; + + return $payload; } /** diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php new file mode 100644 index 0000000..3640cf9 --- /dev/null +++ b/src/Client/Stateless/HeaderFactory.php @@ -0,0 +1,75 @@ + + */ +final class HeaderFactory +{ + public function __construct( + private readonly ToolCatalog $tools, + ) { + } + + /** + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function forMessage(array $payload, ProtocolVersion $protocolVersion): array + { + $method = $payload['method'] ?? null; + + // A response to a server-initiated request carries no method to mirror; + // the version header is unconditional and still applies. + if (!\is_string($method)) { + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value]; + } + + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; + + $headers = [ + McpHeader::PROTOCOL_VERSION => $protocolVersion->value, + McpHeader::METHOD => $method, + ]; + + if (null !== $name = McpHeader::nameFor($method, $params)) { + // Tool and prompt names are only SHOULD-constrained to header-safe + // characters and a resource URI is not constrained at all, so + // anything unsafe is wrapped rather than dropped or mangled. + $headers[McpHeader::NAME] = McpHeader::encode($name) ?? $name; + } + + if ('tools/call' === $method && \is_string($params['name'] ?? null)) { + $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + + foreach ($this->tools->headersFor($params['name'], $arguments) as $suffix => $value) { + $headers[McpHeader::PARAM_PREFIX.$suffix] = $value; + } + } + + return $headers; + } +} diff --git a/src/Client/Stateless/InputRequestResolver.php b/src/Client/Stateless/InputRequestResolver.php new file mode 100644 index 0000000..a592542 --- /dev/null +++ b/src/Client/Stateless/InputRequestResolver.php @@ -0,0 +1,204 @@ + + */ +final class InputRequestResolver +{ + /** + * The only requests a server may park in `inputRequests`. Anything else is + * a server fault, and answering it would be inventing protocol. + * + * @var array> + */ + private const RESOLVABLE = [ + 'elicitation/create' => ElicitRequest::class, + 'sampling/createMessage' => CreateSamplingMessageRequest::class, + 'roots/list' => ListRootsRequest::class, + ]; + + /** + * @param RequestHandlerInterface[] $handlers + */ + public function __construct( + private readonly array $handlers, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Reads the `inputRequests` map off a result, or null when the result is + * not an ask. + * + * A result with no `resultType` MUST be read as complete, so an absent + * member is not an ask — only the explicit `input_required` is. + * + * @param array $result + * + * @return array|null + */ + public static function asked(array $result): ?array + { + if (($result['resultType'] ?? null) !== 'input_required') { + return null; + } + + return \is_array($result['inputRequests'] ?? null) ? $result['inputRequests'] : []; + } + + /** + * Resolves every ask into the `inputResponses` map the retry carries. + * + * Keys are the server's, and each answer goes back under the key it was + * asked under — the client never reorders or renames them, since that map + * is how the server correlates answers to questions. + * + * @param array $inputRequests + * + * @return array + * + * @throws RuntimeException when an ask cannot be answered at all + */ + public function resolve(array $inputRequests): array + { + $responses = []; + + foreach ($inputRequests as $key => $ask) { + $responses[(string) $key] = $this->answer((string) $key, $ask); + } + + return $responses; + } + + /** + * @return array + */ + private function answer(string $key, mixed $ask): array + { + if (!\is_array($ask) || !\is_string($ask['method'] ?? null)) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" without a method to answer.', $key)); + } + + $method = $ask['method']; + $class = self::RESOLVABLE[$method] ?? null; + + if (null === $class) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" using "%s", which is not a request a client can answer.', $key, $method)); + } + + $params = $ask['params'] ?? null; + if ($params instanceof \stdClass) { + $params = (array) $params; + } + + // The ask is a bare method/params pair, but the handlers speak in + // messages. The id never reaches the wire — the answer is keyed by + // $key — so any id will do. + $request = $class::fromArray([ + 'jsonrpc' => '2.0', + 'id' => $key, + 'method' => $method, + 'params' => \is_array($params) ? $params : null, + ]); + + $this->logger->debug('Resolving multi round-trip input request', [ + 'key' => $key, + 'method' => $method, + ]); + + $result = $this->dispatch($request); + + if ($result instanceof Error) { + throw new RuntimeException(\sprintf('Cannot answer the server\'s "%s" input request under "%s": %s', $method, $key, $result->message)); + } + + return $result; + } + + /** + * @return array|Error + */ + private function dispatch(Request $request): array|Error + { + foreach ($this->handlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + try { + $response = $handler->handle($request); + } catch (\Throwable $e) { + $this->logger->error('Input request handler failed', [ + 'method' => $request::getMethod(), + 'exception' => $e, + ]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } + + if ($response instanceof Error) { + return $response; + } + + return self::resultOf($response); + } + + return Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $request::getMethod()), + $request->getId(), + ); + } + + /** + * @param Response $response + * + * @return array + */ + private static function resultOf(Response $response): array + { + $result = $response->result; + + if ($result instanceof \JsonSerializable) { + $result = $result->jsonSerialize(); + } + + if ($result instanceof \stdClass) { + $result = (array) $result; + } + + return \is_array($result) ? $result : []; + } +} diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php new file mode 100644 index 0000000..fcc1cac --- /dev/null +++ b/src/Client/Stateless/RequestEnvelope.php @@ -0,0 +1,78 @@ + + */ +final class RequestEnvelope +{ + public function __construct( + private readonly ProtocolVersion $protocolVersion, + private readonly ClientCapabilities $capabilities, + private readonly Implementation $clientInfo, + ) { + } + + public function protocolVersion(): ProtocolVersion + { + return $this->protocolVersion; + } + + public function withProtocolVersion(ProtocolVersion $protocolVersion): self + { + return new self($protocolVersion, $this->capabilities, $this->clientInfo); + } + + /** + * Merges the envelope into an encoded message, preserving whatever `_meta` + * the caller already put there — a `progressToken` most of all, which would + * otherwise be dropped and take every progress notification with it. + * + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function stamp(array $payload): array + { + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $params['_meta'] = [ + ...$meta, + RequestMeta::PROTOCOL_VERSION => $this->protocolVersion->value, + // ClientCapabilities encodes an empty set as `{}` already; `[]` + // would reach the server as a JSON array and fail its check. + RequestMeta::CLIENT_CAPABILITIES => $this->capabilities, + RequestMeta::CLIENT_INFO => $this->clientInfo, + ]; + + $payload['params'] = $params; + + return $payload; + } +} diff --git a/src/Client/Stateless/ToolCatalog.php b/src/Client/Stateless/ToolCatalog.php new file mode 100644 index 0000000..3348db0 --- /dev/null +++ b/src/Client/Stateless/ToolCatalog.php @@ -0,0 +1,206 @@ + + */ +final class ToolCatalog +{ + /** @var array> tool name to input schema */ + private array $schemas = []; + + /** @var array tool name to the reason it was refused */ + private array $rejected = []; + + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Records a listing page and returns the tools a caller may actually use. + * + * A tool whose annotations are malformed is dropped from the result, which + * is how the client "rejects" it: it never reaches the caller, so it cannot + * be called, and the tools listed beside it are untouched. + * + * @param list> $tools raw `tools/list` entries + * + * @return list> + */ + public function record(array $tools): array + { + $usable = []; + + foreach ($tools as $tool) { + $name = $tool['name'] ?? null; + $schema = $tool['inputSchema'] ?? null; + + if (!\is_string($name) || !\is_array($schema)) { + $usable[] = $tool; + + continue; + } + + unset($this->rejected[$name], $this->schemas[$name]); + + if (null !== $reason = McpHeader::checkAnnotations($schema)) { + $this->rejected[$name] = $reason; + + $this->logger->warning('Excluding tool with an invalid "x-mcp-header" annotation', [ + 'tool' => $name, + 'reason' => $reason, + ]); + + continue; + } + + $this->schemas[$name] = $schema; + $usable[] = $tool; + } + + return $usable; + } + + /** + * Whether the client refuses to call this tool. + * + * Only a tool that was listed and failed validation is refused; an unknown + * name is not, since the client may legitimately call a tool it never + * listed and the server is the authority on whether it exists. + */ + public function isRejected(string $name): bool + { + return isset($this->rejected[$name]); + } + + public function reasonFor(string $name): ?string + { + return $this->rejected[$name] ?? null; + } + + /** + * The `Mcp-Param-*` headers a call to $name must carry, given its arguments. + * + * An argument that is absent or null contributes no header — the + * specification reads a missing header as a missing value, so sending an + * empty one would assert something different. + * + * @param array $arguments + * + * @return array + */ + public function headersFor(string $name, array $arguments): array + { + $schema = $this->schemas[$name] ?? null; + + if (null === $schema) { + return []; + } + + $headers = []; + + foreach (self::annotations($schema) as $header => $path) { + $value = self::valueAt($arguments, $path); + + if (null === $value) { + continue; + } + + $encoded = McpHeader::encode($value); + + if (null === $encoded) { + continue; + } + + $headers[$header] = $encoded; + } + + return $headers; + } + + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count, matching the server's reader: + * a chain through `items`, a composition keyword or a `$ref` cannot be + * resolved without the instance, so an annotation there is out of bounds. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + private static function annotations(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::annotations($definition, $here)]; + } + + return $found; + } + + /** + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } +} diff --git a/src/Client/Transport/HeaderAwareTransportInterface.php b/src/Client/Transport/HeaderAwareTransportInterface.php new file mode 100644 index 0000000..998a044 --- /dev/null +++ b/src/Client/Transport/HeaderAwareTransportInterface.php @@ -0,0 +1,37 @@ + + */ +interface HeaderAwareTransportInterface extends TransportInterface +{ + /** + * Register the source of per-message headers. + * + * @param callable(string $payload): array $callback receives the encoded message + */ + public function onHeaders(callable $callback): void; +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index ddb662f..b5499e8 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface; * * @author Kyrian Obikwelu */ -class HttpTransport extends BaseTransport +class HttpTransport extends BaseTransport implements HeaderAwareTransportInterface { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -40,6 +40,9 @@ class HttpTransport extends BaseTransport private ?string $sessionId = null; + /** @var (callable(string): array)|null */ + private $headerCallback; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; @@ -113,6 +116,11 @@ class HttpTransport extends BaseTransport $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); } + public function onHeaders(callable $callback): void + { + $this->headerCallback = $callback; + } + public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) @@ -124,6 +132,13 @@ class HttpTransport extends BaseTransport $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); } + // Protocol-derived first, so an explicitly configured header still wins: + // the caller passing one is making a deliberate choice about this + // connection, and a proxy credential is the usual reason. + foreach ($this->protocolHeaders($data) as $name => $value) { + $request = $request->withHeader($name, $value); + } + foreach ($this->headers as $name => $value) { $request = $request->withHeader($name, $value); } @@ -199,6 +214,27 @@ class HttpTransport extends BaseTransport $this->handleClose('Transport closed'); } + /** + * @return array + */ + private function protocolHeaders(string $payload): array + { + if (!\is_callable($this->headerCallback)) { + return []; + } + + try { + return ($this->headerCallback)($payload); + } catch (\Throwable $e) { + // Headers mirror the body; failing to derive them is a bug worth + // reporting, but dropping the request would be a worse outcome than + // sending it the way an earlier revision would have. + $this->logger->error('Could not derive protocol headers', ['exception' => $e]); + + return []; + } + } + private function tick(): void { $this->processSSEStream(); diff --git a/src/Schema/Request/DiscoverRequest.php b/src/Schema/Request/DiscoverRequest.php new file mode 100644 index 0000000..24852bc --- /dev/null +++ b/src/Schema/Request/DiscoverRequest.php @@ -0,0 +1,43 @@ + + */ +final class DiscoverRequest extends Request +{ + public static function getMethod(): string + { + return 'server/discover'; + } + + protected static function fromParams(?array $params): static + { + return new self(); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 4fd87f4..1e939f6 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -12,6 +12,7 @@ namespace Mcp\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Wire\McpHeader; /** * Definition for a tool the client can call. @@ -130,94 +131,11 @@ class Tool implements \JsonSerializable // the whole tool definition invalid, so it is refused where the tool // is defined rather than discovered when a header comparison // mysteriously fails. - if (null !== $reason = $this->checkHeaderAnnotations()) { + if (null !== $reason = McpHeader::checkAnnotations($this->inputSchema)) { throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); } } - /** - * Validates every `x-mcp-header` annotation reachable through `properties` - * in an input schema (SEP-2243). - * - * The value becomes an HTTP field name, so it has to be one; it has to be - * unique case-insensitively, or two arguments would fight over one header; - * and it may only sit on a primitive that is not `number`, because a float - * has no single decimal spelling for a receiver to compare against. - * - * @return string|null the reason it is invalid, or null when every annotation is well-formed - */ - private function checkHeaderAnnotations(): ?string - { - $seen = []; - - foreach ($this->headerAnnotations($this->inputSchema) as [$name, $type, $path]) { - if ('' === $name) { - return \sprintf('the annotation at "%s" is empty', $path); - } - - // RFC 9110 tchar; excludes CR, LF and every other control character. - if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { - return \sprintf('"%s" is not a valid HTTP field name', $name); - } - - $folded = strtolower($name); - if (isset($seen[$folded])) { - return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); - } - $seen[$folded] = $path; - - if ('number' === $type) { - return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); - } - - if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { - return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); - } - } - - return null; - } - - /** - * Every annotation reachable through `properties` alone, as name, declared - * type and dotted path. - * - * @param array $schema - * - * @return list - */ - private function headerAnnotations(array $schema, string $prefix = ''): array - { - $properties = $schema['properties'] ?? null; - - if (!\is_array($properties)) { - return []; - } - - $found = []; - - foreach ($properties as $property => $definition) { - if (!\is_array($definition)) { - continue; - } - - $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; - $annotation = $definition['x-mcp-header'] ?? null; - - if (null !== $annotation) { - if (!\is_string($annotation)) { - $found[] = ['', null, $path]; - } else { - $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; - } - } - - $found = [...$found, ...$this->headerAnnotations($definition, $path)]; - } - - return $found; - } - /** * @param ToolData $data */ diff --git a/src/Schema/Wire/McpHeader.php b/src/Schema/Wire/McpHeader.php new file mode 100644 index 0000000..e73907b --- /dev/null +++ b/src/Schema/Wire/McpHeader.php @@ -0,0 +1,217 @@ + + */ +final class McpHeader +{ + public const METHOD = 'Mcp-Method'; + public const NAME = 'Mcp-Name'; + public const PARAM_PREFIX = 'Mcp-Param-'; + public const PROTOCOL_VERSION = 'MCP-Protocol-Version'; + + /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ + private const BASE64_PREFIX = '=?base64?'; + private const BASE64_SUFFIX = '?='; + + /** + * The subject of a request, per method. Anything unlisted is exempt. + * + * @param array|null $params + */ + public static function nameFor(string $method, ?array $params): ?string + { + $value = match ($method) { + 'tools/call', 'prompts/get' => $params['name'] ?? null, + 'resources/read' => $params['uri'] ?? null, + 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, + default => null, + }; + + return \is_string($value) ? $value : null; + } + + /** + * Renders a mirrored argument as a header value, wrapping it when it is not + * header-safe. + * + * Booleans travel as `true`/`false` and integers as decimal digits, both of + * which are always safe. A string is wrapped when it carries a control + * character, anything outside US-ASCII, or leading or trailing whitespace — + * the last because a receiver is entitled to trim the value (RFC 9110 + * §5.5), which would otherwise silently change it. + * + * Returns null for a value that cannot be mirrored at all. + */ + public static function encode(mixed $value): ?string + { + $rendered = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + \is_int($value) => (string) $value, + \is_string($value) => $value, + default => null, + }; + + if (null === $rendered) { + return null; + } + + return self::isSafe($rendered) ? $rendered : self::wrap($rendered); + } + + /** + * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. + * + * Strict: PHP's decoder accepts mispadded input and returns plausible + * bytes, which would turn a corrupted header into a silent mismatch. + * Null when the wrapper is present but its contents are not valid Base64. + */ + public static function decode(string $value): ?string + { + if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { + return $value; + } + + $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); + + $decoded = base64_decode($encoded, true); + + if (false === $decoded || base64_encode($decoded) !== $encoded) { + return null; + } + + return $decoded; + } + + /** + * Validates every `x-mcp-header` annotation reachable through `properties` + * in an input schema (SEP-2243). + * + * The value becomes an HTTP field name, so it has to be one; it has to be + * unique case-insensitively, or two arguments would fight over one header; + * and it may only sit on a primitive that is not `number`, because a float + * has no single decimal spelling for a receiver to compare against. + * + * @param array $schema + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + public static function checkAnnotations(array $schema): ?string + { + $seen = []; + + foreach (self::annotations($schema) as [$name, $type, $path]) { + if ('' === $name) { + return \sprintf('the annotation at "%s" is empty', $path); + } + + // RFC 9110 tchar; excludes CR, LF and every other control character. + if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + return \sprintf('"%s" is not a valid HTTP field name', $name); + } + + $folded = strtolower($name); + if (isset($seen[$folded])) { + return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); + } + $seen[$folded] = $path; + + if ('number' === $type) { + return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); + } + + if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { + return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); + } + } + + return null; + } + + /** + * Every annotation reachable through `properties` alone, as name, declared + * type and dotted path. + * + * @param array $schema + * + * @return list + */ + public static function annotations(array $schema, string $prefix = ''): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; + $annotation = $definition['x-mcp-header'] ?? null; + + if (null !== $annotation) { + if (!\is_string($annotation)) { + $found[] = ['', null, $path]; + } else { + $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; + } + } + + $found = [...$found, ...self::annotations($definition, $path)]; + } + + return $found; + } + + private static function wrap(string $value): string + { + return self::BASE64_PREFIX.base64_encode($value).self::BASE64_SUFFIX; + } + + /** + * Printable US-ASCII with no leading or trailing whitespace. Interior + * spaces are fine; a tab is not, since it is a control character that + * field parsers are allowed to fold. + * + * A literal that already has the wrapper's shape is not safe either: sent + * unchanged, {@see self::decode()} would unwrap it as if it were Base64 + * and hand back something other than the literal. + */ + private static function isSafe(string $value): bool + { + if ($value !== trim($value)) { + return false; + } + + if (str_starts_with($value, self::BASE64_PREFIX) && str_ends_with($value, self::BASE64_SUFFIX)) { + return false; + } + + return 1 === preg_match('/^[\x20-\x7E]*$/', $value); + } +} diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index 07e1a17..0d54873 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -13,6 +13,7 @@ namespace Mcp\Server\Stateless; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Wire\McpHeader; /** * Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243). @@ -25,13 +26,9 @@ use Mcp\Exception\ToolNotFoundException; */ final class StandardHeaderValidator { - public const METHOD_HEADER = 'Mcp-Method'; - public const NAME_HEADER = 'Mcp-Name'; - public const PARAM_HEADER_PREFIX = 'Mcp-Param-'; - - /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ - private const BASE64_PREFIX = '=?base64?'; - private const BASE64_SUFFIX = '?='; + public const METHOD_HEADER = McpHeader::METHOD; + public const NAME_HEADER = McpHeader::NAME; + public const PARAM_HEADER_PREFIX = McpHeader::PARAM_PREFIX; public function __construct( private readonly ?RegistryInterface $registry = null, @@ -113,14 +110,7 @@ final class StandardHeaderValidator */ public static function nameFor(string $method, ?array $params): ?string { - $value = match ($method) { - 'tools/call', 'prompts/get' => $params['name'] ?? null, - 'resources/read' => $params['uri'] ?? null, - 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, - default => null, - }; - - return \is_string($value) ? $value : null; + return McpHeader::nameFor($method, $params); } /** @@ -285,26 +275,10 @@ final class StandardHeaderValidator /** * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. - * - * Strict: PHP's decoder accepts mispadded input and returns plausible - * bytes, which would turn a corrupted header into a silent mismatch. - * Null when the wrapper is present but its contents are not valid Base64. */ public static function decode(string $value): ?string { - if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { - return $value; - } - - $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); - - $decoded = base64_decode($encoded, true); - - if (false === $decoded || base64_encode($decoded) !== $encoded) { - return null; - } - - return $decoded; + return McpHeader::decode($value); } /** diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 74ac8e9..9832f47 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -16,6 +16,7 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ElicitRequest; @@ -30,35 +31,53 @@ if (!$url || !$scenario) { exit(1); } +// The runner names the revision it is testing; without honouring it the client +// would open every scenario with `initialize` and never reach the modern wire. +$version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '') + ?? ProtocolVersion::V2025_11_25; + +// Scenario-specific data (tool arguments, credentials) the runner passes in. +$context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); +$context = is_array($context) ? $context : []; + @mkdir(__DIR__.'/logs', 0777, true); $logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true); -$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url)); +$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s, version=%s', $scenario, $url, $version->value)); $builder = Client::builder() ->setClientInfo('mcp-conformance-test-client', '1.0.0') + ->setProtocolVersion($version) ->setInitTimeout(30) ->setRequestTimeout(60) ->setLogger($logger); -if ('elicitation-sep1034-client-defaults' === $scenario) { +/** + * Accepts every elicitation with an empty payload. + * + * Enough for the scenarios here, which check that the client asked and echoed + * correctly rather than what a user would have typed. + */ +$acceptElicitation = new class($logger) implements RequestHandlerInterface { + public function __construct(private readonly Psr\Log\LoggerInterface $logger) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + $this->logger->info('Received elicitation request, accepting with empty content'); + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } +}; + +if (in_array($scenario, ['elicitation-sep1034-client-defaults', 'sep-2322-client-request-state'], true)) { $builder->setCapabilities(new ClientCapabilities(elicitation: true)); - $builder->addRequestHandler(new class($logger) implements RequestHandlerInterface { - public function __construct(private readonly Psr\Log\LoggerInterface $logger) - { - } - - public function supports(Request $request): bool - { - return $request instanceof ElicitRequest; - } - - public function handle(Request $request): Response - { - $this->logger->info('Received elicitation request, accepting with empty content'); - - return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); - } - }); + $builder->addRequestHandler($acceptElicitation); } $client = $builder->build(); @@ -76,9 +95,11 @@ try { break; case 'tools_call': - $toolName = $toolsResult->tools[0]->name ?? 'test-tool'; - $client->callTool($toolName, []); - $logger->info(sprintf('Called tool: %s', $toolName)); + // The scenario asserts both arguments arrive as numbers, so the + // call has to be made by name with real values rather than + // whatever tool happens to be listed first. + $client->callTool('add_numbers', ['a' => 2, 'b' => 3]); + $logger->info('Called tool: add_numbers'); break; case 'elicitation-sep1034-client-defaults': @@ -87,6 +108,74 @@ try { $logger->info(sprintf('Called tool: %s', $toolName)); break; + case 'json-schema-2020-12-preservation': + // Round-trips the focal tool's inputSchema back through the echo + // tool so the harness can diff what survived the client's parsing + // (SEP-1613 keywords, plus the SEP-2106 vocabulary). + $focal = null; + foreach ($toolsResult->tools as $tool) { + if ('json_schema_2020_12_tool' === $tool->name) { + $focal = $tool; + break; + } + } + + if (null === $focal) { + throw new RuntimeException('Mock server did not advertise json_schema_2020_12_tool.'); + } + + $client->callTool('json_schema_echo', ['schema' => $focal->inputSchema]); + $logger->info('Echoed the observed inputSchema back via json_schema_echo'); + break; + + case 'http-standard-headers': + // Exercises every method that carries an Mcp-Method or Mcp-Name + // header, including the ones whose subject needs Base64 wrapping. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, []); + } + + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + $client->readResource($resource->uri); + } + + $prompts = $client->listPrompts(); + foreach ($prompts->prompts as $prompt) { + $client->getPrompt($prompt->name, []); + } + + $logger->info('Exercised every header-carrying method'); + break; + + case 'http-custom-headers': + // The runner supplies the exact argument values, each chosen to hit + // a different corner of the encoding rules. + foreach ($context['toolCalls'] ?? [] as $call) { + $client->callTool($call['name'], $call['arguments'] ?? []); + $logger->info(sprintf('Called tool: %s', $call['name'])); + } + break; + + case 'http-invalid-tool-headers': + // Only the tools that survived the listing are callable; calling + // any of the malformed ones is the failure this scenario looks for. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, ['region' => 'us-west1']); + $logger->info(sprintf('Called tool: %s', $tool->name)); + } + break; + + case 'sep-2322-client-request-state': + // Each tool drives one rule: echo the state back, omit it when none + // was sent, keep an unrelated call clean, and treat a result with + // no resultType as complete. + foreach (['test_mrtr_echo_state', 'test_mrtr_unrelated', 'test_mrtr_no_state', 'test_mrtr_no_result_type'] as $tool) { + $client->callTool($tool, []); + $logger->info(sprintf('Called tool: %s', $tool)); + } + break; + default: $logger->warning(sprintf('Unknown scenario: %s', $scenario)); break; diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index eb9db4c..c57855e 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,8 +1,6 @@ client: - elicitation-sep1034-client-defaults - sse-retry - - tools_call:tool-add-numbers - - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index b41bdc9..1d19102 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,13 +1,5 @@ client: - - request-metadata - - http-standard-headers - - http-custom-headers - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate - - http-invalid-tool-headers:sep-2243-client-reject-invalid-tool - - sep-2322-client-request-state - - tools_call:tool-add-numbers - - json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found - - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 diff --git a/tests/Integration/DualEraElicitationTest.php b/tests/Integration/DualEraElicitationTest.php new file mode 100644 index 0000000..b2a443d --- /dev/null +++ b/tests/Integration/DualEraElicitationTest.php @@ -0,0 +1,105 @@ + true, + 'party_size' => 4, + 'date' => '2026-09-01', + 'dietary' => 'vegan', + 'rating' => '5', + 'comments' => 'Excellent', + ]; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/elicitation/server.php'; + } + + protected static function portBase(): int + { + return 9500; + } + + #[DataProvider('provideEras')] + #[TestDox('a confirmation is collected on $_dataName')] + public function testConfirmAction(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']); + + $this->assertStringContainsString('Action confirmed', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a multi-field form is collected on $_dataName')] + public function testBookRestaurant(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']); + + $this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('feedback with an optional field is collected on $_dataName')] + public function testCollectFeedback(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']); + + $this->assertStringContainsString('Thank you for your feedback', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')] + public function testWithoutTheCapability(ProtocolVersion $era): void + { + $client = $this->connect($era); + + // Both eras refuse an ask the client cannot answer. The handshake era + // finds out from the capability the client declared at initialize, the + // modern era from the envelope on this very request — and the example + // says the same thing either way. + try { + $answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything'])); + $this->assertStringContainsString('does not support elicitation', $answer); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + $client->disconnect(); + } +} diff --git a/tests/Integration/DualEraExampleTestCase.php b/tests/Integration/DualEraExampleTestCase.php new file mode 100644 index 0000000..b7671b8 --- /dev/null +++ b/tests/Integration/DualEraExampleTestCase.php @@ -0,0 +1,144 @@ + + */ +abstract class DualEraExampleTestCase extends TestCase +{ + /** Answers whatever the server elicits, so a tool that asks can complete. */ + protected const ANSWERS = []; + + private ?Process $server = null; + private int $port; + + /** Absolute path to the example's server script. */ + abstract protected static function server(): string; + + /** Port range base, so concurrent test classes do not collide. */ + abstract protected static function portBase(): int; + + protected function setUp(): void + { + // PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on + // PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the + // extra workers exist to avoid: every request below hangs to the test + // timeout instead of running. Skip until either PHP 8.1 is dropped or + // this suite stops depending on `php -S` for multi-worker concurrency. + if (\PHP_VERSION_ID < 80200) { + $this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.'); + } + + $this->port = static::portBase() + (getmypid() % 200); + + // More than one worker because the handshake era needs it: a tool that + // asks the client mid-call holds its SSE response open while the client + // POSTs the answer on a second connection. One worker deadlocks there — + // a property of `php -S`, not of the server. + $this->server = new Process( + ['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()], + env: ['PHP_CLI_SERVER_WORKERS' => '4'], + ); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server?->stop(); + } + + /** + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + } + + protected static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + protected function connect(ProtocolVersion $era, bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('dual-era-integration-client', '1.0.0') + ->setProtocolVersion($era) + ->setRequestTimeout(10); + + if ($elicitation) { + $answers = static::ANSWERS; + + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class($answers) implements RequestHandlerInterface { + /** @param array $answers */ + public function __construct(private readonly array $answers) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers)); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5e0455f..5d21320 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -59,11 +59,16 @@ final class HandshakeTest extends IntegrationTestCase yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; - // Neither side reaches the modern era through `initialize`, so - // configuring it falls back to the handshake set on both ends. - yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; + // A modern client does not negotiate at all: it skips the handshake and + // states its revision on every request, so what it was configured with + // is what it reports. This server never answers `server/discover`, so + // there is nothing to reconcile against either. + yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2026_07_28]; + yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28]; + + // The server end still falls back: a handshake-era client offered a + // revision, and `initialize` cannot answer with a modern one. yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; - yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; } #[TestDox('the handshake carries the server identity to the client')] diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 4ac40fd..ee42125 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Configuration; use Mcp\Client\Protocol; use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; @@ -22,6 +23,8 @@ use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\MessageInterface; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\PingRequest; +use Mcp\Server\Stateless\RequestMeta; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -42,20 +45,64 @@ final class ProtocolTest extends TestCase $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); } - #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] - public function testDoesNotOfferModernVersionOverHandshake(): void + #[TestDox('never sends "initialize" on a modern revision, which removed it')] + public function testModernRevisionSkipsTheHandshake(): void { - $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); - $protocol = new Protocol(logger: $logger = new CollectingLogger()); + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); $protocol->initialize($config); - $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); - $this->assertSame([[ - 'configured' => ProtocolVersion::V2026_07_28->value, - 'offered' => ProtocolVersion::latestHandshake()->value, - ]], $logger->warnings); + $this->assertNotContains('initialize', $transport->methods); + $this->assertNotContains('notifications/initialized', $transport->methods); + $this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('carries the revision, capabilities and client info on every modern request')] + public function testModernRequestsCarryTheEnvelope(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertNotSame([], $transport->metas); + + foreach ($transport->metas as $meta) { + $this->assertSame(ProtocolVersion::V2026_07_28->value, $meta[RequestMeta::PROTOCOL_VERSION] ?? null); + $this->assertArrayHasKey(RequestMeta::CLIENT_CAPABILITIES, $meta); + $this->assertSame('client-app', $meta[RequestMeta::CLIENT_INFO]['name'] ?? null); + } + } + + #[TestDox('a server that refuses "server/discover" still leaves a usable connection')] + public function testDiscoveryFailureIsNotFatal(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('refuses to continue when discovery shows the server has no modern revision')] + public function testDiscoveryWithoutAModernRevisionFails(): void + { + // Advertising only handshake revisions leaves nothing this connection + // can use: it has already skipped the handshake. + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('does not support any modern protocol revision'); + + $protocol->initialize($config); } #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] @@ -126,6 +173,41 @@ final class ProtocolTest extends TestCase $this->assertSame(Error::METHOD_NOT_FOUND, $response->code); } + #[TestDox('reconnecting starts with a fresh tool catalog, not the previous server\'s verdicts')] + public function testReconnectResetsToolCatalog(): void + { + $protocol = new Protocol(); + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $protocol->getToolCatalog()->record([[ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ]]); + + $this->assertTrue($protocol->getToolCatalog()->isRejected('broken')); + + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config); + + $this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect'); + } + + #[TestDox('an empty inputResponses map is retried as a JSON object, never an array')] + public function testEmptyInputResponsesEncodesAsJsonObject(): void + { + $transport = new InputRequiredRoundTripTransport(); + $protocol = new Protocol(); + $protocol->connect($transport, $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $result = $protocol->request(new PingRequest(), 5); + + $this->assertInstanceOf(Response::class, $result); + $this->assertStringContainsString('"inputResponses":{}', $transport->retryBody); + $this->assertStringNotContainsString('"inputResponses":[]', $transport->retryBody); + } + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration { return new Configuration( @@ -137,38 +219,164 @@ final class ProtocolTest extends TestCase } /** - * Transport that answers the `initialize` request inline with a canned - * `protocolVersion`, so the handshake resolves without a Fiber round-trip. + * Answers the first request with an empty `input_required` ask and the retry + * with success, capturing the retry's raw body so the test can inspect how + * `inputResponses` was actually encoded on the wire. + */ +final class InputRequiredRoundTripTransport implements TransportInterface +{ + public string $retryBody = ''; + + private int $calls = 0; + private ClientStateInterface $state; + + public function setState(ClientStateInterface $state): void + { + $this->state = $state; + } + + public function send(string $data): void + { + /** @var array{id: int} $message */ + $message = json_decode($data, true); + $id = $message['id']; + + if (0 === $this->calls++) { + $this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]); + + return; + } + + $this->retryBody = $data; + + $this->answer($id, ['resultType' => 'complete']); + } + + /** + * @param array $result + */ + private function answer(int $id, array $result): void + { + $this->state->storeResponse($id, [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, + ]); + } + + public function connect(): void + { + } + + public function close(): void + { + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + throw new LogicException('Not used in this test.'); + } + + public function onInitialize(callable $callback): void + { + } + + public function onMessage(callable $callback): void + { + } + + public function onError(callable $callback): void + { + } + + public function onClose(callable $callback): void + { + } +} + +/** + * Transport that answers inline, so a request resolves without a Fiber + * round-trip: `initialize` with a canned `protocolVersion`, and + * `server/discover` with a minimal modern-era answer. */ final class RecordingTransport implements TransportInterface { public ?string $offeredVersion = null; + /** @var list every method that reached the wire, in order */ + public array $methods = []; + + /** @var list> the `_meta` each request carried */ + public array $metas = []; + private ClientStateInterface $state; - public function __construct(private readonly string $counterOffer) - { + public function __construct( + private readonly string $counterOffer, + private readonly bool $refuseDiscovery = false, + ) { } public function send(string $data): void { - /** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */ + /** @var array{id?: int|string, method?: string, params?: array} $message */ $message = json_decode($data, true); + $method = $message['method'] ?? null; - if ('initialize' !== ($message['method'] ?? null)) { + if (!\is_string($method)) { return; } - $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + $this->methods[] = $method; + $this->metas[] = $message['params']['_meta'] ?? []; - $this->state->storeResponse($message['id'], [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'result' => [ + if (!isset($message['id'])) { + return; + } + + if ('initialize' === $method) { + $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + + $this->answer($message['id'], [ 'protocolVersion' => $this->counterOffer, 'capabilities' => [], 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], - ], + ]); + + return; + } + + if ('server/discover' !== $method) { + return; + } + + if ($this->refuseDiscovery) { + $this->state->storeResponse($message['id'], [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $message['id'], + 'error' => ['code' => -32601, 'message' => 'Method not found'], + ]); + + return; + } + + $this->answer($message['id'], [ + 'resultType' => 'complete', + 'supportedVersions' => [$this->counterOffer], + 'capabilities' => [], + 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], + ]); + } + + /** + * @param array $result + */ + private function answer(int|string $id, array $result): void + { + $this->state->storeResponse($id, [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, ]); } diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php new file mode 100644 index 0000000..ea4a3a4 --- /dev/null +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -0,0 +1,124 @@ +headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertSame('2026-07-28', $headers['MCP-Protocol-Version']); + $this->assertSame('tools/list', $headers['Mcp-Method']); + $this->assertArrayNotHasKey('Mcp-Name', $headers); + } + + #[TestDox('names the subject of a request that addresses one')] + public function testNameHeader(): void + { + $payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []]]; + + $this->assertSame('search', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('wraps a subject that is not header-safe, and the server unwraps it')] + public function testUnsafeNameRoundTrips(): void + { + $payload = ['method' => 'resources/read', 'params' => ['uri' => 'file:///café.txt']]; + + $this->assertSame('=?base64?ZmlsZTovLy9jYWbDqS50eHQ=?=', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('mirrors annotated tool arguments the server can verify')] + public function testMirroredArguments(): void + { + $payload = [ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search', + 'arguments' => ['region' => ' padded ', 'priority' => 7], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('=?base64?IHBhZGRlZCA=?=', $headers['Mcp-Param-Region']); + $this->assertSame('7', $headers['Mcp-Param-Priority']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('a response carries the revision but nothing to mirror')] + public function testResponseCarriesOnlyTheVersion(): void + { + $headers = $this->headersFor(['id' => 1, 'result' => []]); + + $this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers); + } + + /** + * @param array $payload + * + * @return array + */ + private function headersFor(array $payload): array + { + return (new HeaderFactory($this->catalog()))->forMessage($payload, ProtocolVersion::V2026_07_28); + } + + /** + * @param array $payload + * + * @return string|null the server's reason to reject, or null when it agrees + */ + private function validate(array $payload): ?string + { + return (new StandardHeaderValidator())->validate( + $payload['method'], + $payload['params'] ?? null, + $this->headersFor($payload), + ); + } + + private function catalog(): ToolCatalog + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + ], + ], + ]]); + + return $catalog; + } +} diff --git a/tests/Unit/Client/Stateless/InputRequestResolverTest.php b/tests/Unit/Client/Stateless/InputRequestResolverTest.php new file mode 100644 index 0000000..536aa31 --- /dev/null +++ b/tests/Unit/Client/Stateless/InputRequestResolverTest.php @@ -0,0 +1,119 @@ +assertNull(InputRequestResolver::asked(['content' => []])); + $this->assertNull(InputRequestResolver::asked(['resultType' => 'complete'])); + } + + #[TestDox('an input_required result is an ask, even with an empty map')] + public function testInputRequiredIsAnAsk(): void + { + $this->assertSame([], InputRequestResolver::asked(['resultType' => 'input_required'])); + $this->assertSame( + ['confirm' => ['method' => 'elicitation/create']], + InputRequestResolver::asked([ + 'resultType' => 'input_required', + 'inputRequests' => ['confirm' => ['method' => 'elicitation/create']], + ]), + ); + } + + #[TestDox('answers each ask under the key the server asked it under')] + public function testAnswersAreKeyedByTheServersKey(): void + { + $responses = $this->resolver()->resolve([ + 'confirm' => self::elicitation('Confirm?'), + 'name' => self::elicitation('Your name?'), + ]); + + $this->assertSame(['confirm', 'name'], array_keys($responses)); + $this->assertSame('accept', $responses['confirm']['action']); + } + + #[TestDox('refuses an ask the client has no handler for')] + public function testUnhandledAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Client does not handle "elicitation/create" requests.'); + + (new InputRequestResolver([]))->resolve(['confirm' => self::elicitation('Confirm?')]); + } + + #[TestDox('refuses an ask that is not a request a client can answer')] + public function testUnanswerableMethodIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('which is not a request a client can answer'); + + $this->resolver()->resolve(['x' => ['method' => 'tools/call', 'params' => []]]); + } + + #[TestDox('refuses an ask with no method to answer')] + public function testMethodlessAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('without a method to answer'); + + $this->resolver()->resolve(['x' => ['params' => []]]); + } + + /** + * @return array + */ + private static function elicitation(string $message): array + { + return [ + 'method' => 'elicitation/create', + 'params' => [ + 'message' => $message, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['confirmed' => ['type' => 'boolean']], + ], + ], + ]; + } + + private function resolver(): InputRequestResolver + { + return new InputRequestResolver([ + new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } + }, + ]); + } +} diff --git a/tests/Unit/Client/Stateless/ToolCatalogTest.php b/tests/Unit/Client/Stateless/ToolCatalogTest.php new file mode 100644 index 0000000..2b87837 --- /dev/null +++ b/tests/Unit/Client/Stateless/ToolCatalogTest.php @@ -0,0 +1,124 @@ +record([self::annotatedTool()]); + + $this->assertSame( + ['Region' => 'us-west1', 'Priority' => '7', 'Verbose' => 'false'], + $catalog->headersFor('search', ['region' => 'us-west1', 'priority' => 7, 'verbose' => false]), + ); + } + + #[TestDox('an argument that is absent or null contributes no header')] + public function testOmittedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + // An omitted header is how "no value" is said; an empty one would + // assert that the argument was present and empty. + $this->assertSame( + ['Region' => 'us-west1'], + $catalog->headersFor('search', ['region' => 'us-west1', 'verbose' => null]), + ); + } + + #[TestDox('an unannotated argument is never mirrored')] + public function testUnannotatedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + $this->assertSame([], $catalog->headersFor('search', ['query' => 'SELECT 1'])); + } + + #[TestDox('a tool the client never listed is not second-guessed')] + public function testUnknownToolIsNeitherMirroredNorRejected(): void + { + $catalog = new ToolCatalog(); + + $this->assertSame([], $catalog->headersFor('unlisted', ['region' => 'x'])); + $this->assertFalse($catalog->isRejected('unlisted')); + } + + #[TestDox('a malformed annotation drops that tool and leaves the rest usable')] + public function testMalformedToolIsDroppedAlone(): void + { + $catalog = new ToolCatalog(); + + $usable = $catalog->record([ + self::annotatedTool(), + [ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ], + ]); + + $this->assertSame(['search'], array_column($usable, 'name')); + $this->assertTrue($catalog->isRejected('broken')); + $this->assertFalse($catalog->isRejected('search')); + $this->assertStringContainsString('only string, integer and boolean', (string) $catalog->reasonFor('broken')); + } + + #[TestDox('a later listing replaces what was known about a tool')] + public function testRelistingReplacesTheVerdict(): void + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'object', 'x-mcp-header' => 'Region']], + ], + ]]); + + $this->assertTrue($catalog->isRejected('search')); + + $catalog->record([self::annotatedTool()]); + + $this->assertFalse($catalog->isRejected('search')); + $this->assertSame(['Region' => 'eu'], $catalog->headersFor('search', ['region' => 'eu'])); + } + + /** + * @return array + */ + private static function annotatedTool(): array + { + return [ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + 'verbose' => ['type' => 'boolean', 'x-mcp-header' => 'Verbose'], + 'query' => ['type' => 'string'], + ], + ], + ]; + } +} diff --git a/tests/Unit/Schema/Wire/McpHeaderTest.php b/tests/Unit/Schema/Wire/McpHeaderTest.php new file mode 100644 index 0000000..7b8d98e --- /dev/null +++ b/tests/Unit/Schema/Wire/McpHeaderTest.php @@ -0,0 +1,93 @@ + + */ + public static function provideValues(): iterable + { + yield 'plain ascii' => ['us-west1', 'us-west1']; + yield 'empty string' => ['', '']; + yield 'interior spaces stay plain' => ['us west 1', 'us west 1']; + yield 'integer' => [42, '42']; + yield 'boolean true' => [true, 'true']; + yield 'boolean false' => [false, 'false']; + yield 'non-ascii is wrapped' => ['Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?=']; + yield 'leading space is wrapped' => [' us-west1', '=?base64?IHVzLXdlc3Qx?=']; + yield 'trailing space is wrapped' => ['us-west1 ', '=?base64?dXMtd2VzdDEg?=']; + yield 'newline is wrapped' => ["line1\nline2", '=?base64?bGluZTEKbGluZTI=?=']; + yield 'tab is wrapped' => ["\tindented", '=?base64?CWluZGVudGVk?=']; + yield 'a literal already shaped like the wrapper is wrapped again' => ['=?base64?SGVsbG8=?=', '=?base64?PT9iYXNlNjQ/U0dWc2JHOD0/PQ==?=']; + } + + #[DataProvider('provideValues')] + #[TestDox('renders a mirrored argument as a header value')] + public function testEncode(mixed $value, string $expected): void + { + $this->assertSame($expected, McpHeader::encode($value)); + } + + #[DataProvider('provideValues')] + #[TestDox('what the client wraps, the server recovers unchanged')] + public function testRoundTrip(mixed $value, string $encoded): void + { + $expected = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + default => (string) $value, + }; + + $this->assertSame($expected, McpHeader::decode($encoded)); + } + + #[TestDox('a value that cannot be mirrored gets no header at all')] + public function testUnmirrorableValue(): void + { + // A float has no single decimal spelling for a receiver to compare + // against, which is why SEP-2243 forbids the annotation on `number`. + $this->assertNull(McpHeader::encode(3.14159)); + $this->assertNull(McpHeader::encode(['a'])); + $this->assertNull(McpHeader::encode(null)); + } + + #[TestDox('a corrupted wrapper is refused rather than silently decoded')] + public function testCorruptWrapperIsRefused(): void + { + $this->assertNull(McpHeader::decode('=?base64?not valid base64!?=')); + } + + #[TestDox('names the subject of the methods that address one, and nothing else')] + public function testNameFor(): void + { + $this->assertSame('my-tool', McpHeader::nameFor('tools/call', ['name' => 'my-tool'])); + $this->assertSame('my-prompt', McpHeader::nameFor('prompts/get', ['name' => 'my-prompt'])); + $this->assertSame('file:///x', McpHeader::nameFor('resources/read', ['uri' => 'file:///x'])); + $this->assertSame('t-1', McpHeader::nameFor('tasks/get', ['taskId' => 't-1'])); + + $this->assertNull(McpHeader::nameFor('tools/list', [])); + $this->assertNull(McpHeader::nameFor('tools/call', null)); + } +} From 7fe88677ad27c79836617616c60203d7cb0eadaa Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 03:24:17 +0200 Subject: [PATCH 116/143] [Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457) docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras. --- .github/workflows/conformance-weekly.yaml | 2 +- .github/workflows/pipeline.yaml | 2 +- .gitignore | 2 +- Makefile | 2 +- docs/index.md | 1 + docs/server-client-communication.md | 9 +- docs/stateless-lifecycle.md | 391 ++++++++++++++++++ docs/transports.md | 8 +- examples/client/README.md | 13 + .../client/stateless_lifecycle_client.php | 109 +++++ examples/server/README.md | 27 ++ .../server/stateless-lifecycle/server.php | 157 +++++++ tests/Integration/DualEraEndpointTest.php | 115 ++++++ tests/Integration/StatelessClientTest.php | 171 ++++++++ tests/Integration/StatelessLifecycleTest.php | 297 +++++++++++++ 15 files changed, 1300 insertions(+), 6 deletions(-) create mode 100644 docs/stateless-lifecycle.md create mode 100644 examples/client/stateless_lifecycle_client.php create mode 100644 examples/server/stateless-lifecycle/server.php create mode 100644 tests/Integration/DualEraEndpointTest.php create mode 100644 tests/Integration/StatelessClientTest.php create mode 100644 tests/Integration/StatelessLifecycleTest.php diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 3d9b614..d301237 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -26,7 +26,7 @@ jobs: baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' dist-tag: alpha - path: '/stateless' + path: '/' baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 2a5475d..b018543 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -109,7 +109,7 @@ jobs: path: '/' baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' - path: '/stateless' + path: '/' baseline: conformance-baseline-2026-07-28.yml steps: diff --git a/.gitignore b/.gitignore index 5ea477c..c5d87ce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ examples/**/cache examples/**/sessions tests/Conformance/client-conformance.json tests/Conformance/server-conformance.json -tests/Conformance/results +tests/Conformance/results* tests/Conformance/sessions tests/Conformance/logs/*.log diff --git a/Makefile b/Makefile index 7490515..93fbf1f 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ conformance-draft-server: @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results-2026-07-28 - cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true php tests/Conformance/score.php server 2026-07-28 results-2026-07-28 docker compose -f tests/Conformance/Fixtures/docker-compose.yml down diff --git a/docs/index.md b/docs/index.md index 9116229..a1f6f66 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ - [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. - [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. - [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. +- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. - [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 10195eb..084f51d 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -1,6 +1,13 @@ # Client Communication -MCP supports various ways a server can communicate back to a server on top of the main request-response flow. +MCP supports various ways a server can communicate back to a client on top of the main request-response flow. + +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own +> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are +> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` +> raise a `LogicException` there. Logging and progress still work as described below — they simply travel +> on the request's own response stream, and the client opts into each. See +> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). ## Table of Contents diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md new file mode 100644 index 0000000..45e3cb2 --- /dev/null +++ b/docs/stateless-lifecycle.md @@ -0,0 +1,391 @@ +# The 2026-07-28 lifecycle + +Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a +server needs to answer a request now travels *in* that request, which means any process can answer any +request and none of them need to share state. + +This guide covers what changes for a server author. Tools, resources, prompts and their handlers are +unaffected — the same registrations serve either lifecycle. + +- [The two eras](#the-two-eras) +- [Building a stateless server](#building-a-stateless-server) +- [Per-request metadata](#per-request-metadata) +- [Multi round-trip requests](#multi-round-trip-requests) +- [Progress and logging](#progress-and-logging) +- [Caching](#caching) +- [Subscriptions](#subscriptions) +- [Serving both eras](#serving-both-eras) +- [What was removed](#what-was-removed) + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` +is where the boundary sits. + +## Building a stateless server + +There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for +each era, and `StreamableHttpTransport` decides per request which of them answers: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. See +[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it. + +Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is +routed as one. + +A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php). + +## Per-request metadata + +Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into +headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`. +A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with +`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from. + +Handlers read the metadata through `RequestContext`: + +```php +$context->getProtocolVersion(); // the revision serving this request +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and +the sub-capability variants — read the same declaration, so they work in both eras. + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the +server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer` +or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks +any of those rather than letting it fail later as a header mismatch. + +## Multi round-trip requests + +There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots +**returns** the ask, and the client retries the original call carrying the answers. + +This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the +same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing +to a handler: ask again. `response()` is still there for the raw array. + +**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so +it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that +fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random value works only +for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. + +**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation +needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only. + +**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the +handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the +replacement. + +## Progress and logging + +Both travel on the request's own response stream, and both are opt-in by the client: + +- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing. +- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must + not** emit `notifications/message` at all, and does not. + +```php +static function (RequestContext $context): string { + $client = $context->getClientGateway(); + $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); + $client->progress(1, 3, 'Shard 1 of 3'); + + return 'done'; +} +``` + +The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it +does — so an error that has to carry a specific status still gets one, and a handler that talks gets a +stream. Trace context from the request is echoed onto every notification it causes. + +## Caching + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The +default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. +Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not +vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. +Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key. + +## Subscriptions + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a +long-lived POST whose response stream carries the notification types it asked for; the server acknowledges +first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour. + +Delivery needs a bus, because the process that publishes and the process holding the stream open are often +not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else — +`notifications/resources/updated` above all — is published by the application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it +gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`. +Pass `0` for "until the client or the runtime ends it". + +## Serving both eras + +One endpoint serves both, and the client picks nothing. Every request is classified once, before anything +else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose +header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen +at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its +own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg +answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming +the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler instances, one session +manager. A tool registered once is reachable from both, and a change made through one is visible to the +other. + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve. +`setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its +own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` + +### What a handler forks on + +Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, +and neither does the one thing that looks like it should: **asking the user something**. + +Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off +`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's +input-required shim fulfils the same ask over that connection's own channel: each embedded request goes +out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is +re-entered with the answers under the keys it asked for. It is on by default; +[`examples/server/elicitation`](../examples/server/elicitation) and +[`examples/server/client-communication`](../examples/server/client-communication) are written this way and +name no era anywhere. + +Two things to know about it. + +**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where +it is from what came back rather than from anything it kept. That is already true of the modern era — the +client retries the whole call there — so a portable handler is written that way regardless. It is only new +if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps +working untouched, since nothing here runs unless a handler *returns* an ask. + +**Each round holds the request open.** The shim waits for the client's answer inside the originating +request, which on a process-per-request runtime means it holds a worker for as long as the user takes. +That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable +from handlers that never mention it — so size `setInputRequiredLimits()` against your pool. + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + // Re-entries per request, and seconds to wait for one answer. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + ->build(); +``` + +`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of +being fulfilled behind your back. + +## Writing a client for this revision + +One line selects the lifecycle; nothing else about the API changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's + identity, and a server that does not answer it still yields a usable connection — the method is + optional. If discovery *does* report `supportedVersions` and the configured revision is not among + them, the client moves to a modern revision the server lists, or refuses the connection outright + rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the client + identity. The capabilities are what let a server decide, per request, whether it may ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method + addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which + requires the client to have listed the tool first; `tools/list` is what populates that knowledge. + A tool whose annotations are malformed is dropped from the listing and refused if called, since the + client cannot produce the headers it demands. +- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is + resolved through the same request handlers that served server-initiated requests in the handshake era, + and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for + byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; +`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the +skipped handshake, the round-trip loop — applies to both. + +See `examples/client/stateless_lifecycle_client.php` for a runnable version. + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of + `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken +response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a +server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era +client still gets `-32002`. + +Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least +twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots, +integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/transports.md b/docs/transports.md index 4caee51..ffa1496 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -146,7 +146,6 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst |-------|------------|---------| | 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | | 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | -| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | ```php // Zero-config, secure-by-default — local servers get full protection automatically. @@ -159,6 +158,13 @@ The default stack can be inspected and recomposed via the public factory: $middleware = StreamableHttpTransport::defaultMiddleware(); ``` +These run at the edge, before the request's protocol era is known, because what they enforce is true of +both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-Version` header rule belongs +to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, +and the modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. +See [Serving both eras](stateless-lifecycle.md#serving-both-eras). + ### CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it diff --git a/examples/client/README.md b/examples/client/README.md index 3e3bc09..c212171 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -22,6 +22,19 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php php examples/client/http_discovery_calculator.php ``` +## Modern-era client (2026-07-28) + +Speaks the stateless lifecycle: no `initialize`, a `_meta` envelope and SEP-2243 headers on every +request, and multi round-trip calls answered by the client without the caller noticing. + +```bash +# First, start the matching server +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php + +# Then run the client +php examples/client/stateless_lifecycle_client.php +``` + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/stateless_lifecycle_client.php b/examples/client/stateless_lifecycle_client.php new file mode 100644 index 0000000..917028e --- /dev/null +++ b/examples/client/stateless_lifecycle_client.php @@ -0,0 +1,109 @@ +message}\n"; + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } +}; + +$client = Client::builder() + ->setClientInfo('stateless-example-client', '1.0.0') + // The only line that selects the modern lifecycle. + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + // Declared in the envelope of every request, so the server knows what it + // may ask for before it decides how to answer. + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($answerWithAName) + ->build(); + +$client->connect(new HttpTransport('http://127.0.0.1:8000/')); + +printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value); + +echo "Tools:\n"; +foreach ($client->listTools()->tools as $tool) { + printf(" %-12s %s\n", $tool->name, $tool->description ?? ''); +} + +echo "\nA plain call:\n"; +echo ' '.text($client->callTool('get_weather', ['city' => 'Munich']))."\n"; + +echo "\nA call the server cannot finish in one round:\n"; +// One call from here. Two on the wire: the server returns its question, the +// handler above answers it, and the client retries carrying both the answer and +// the server's sealed `requestState`. +echo ' '.text($client->callTool('greet', []))."\n"; + +$client->disconnect(); + +/** The first block of text in a tool result. */ +function text(CallToolResult $result): string +{ + $first = $result->content[0] ?? null; + + return $first instanceof TextContent ? $first->text : '(no text)'; +} diff --git a/examples/server/README.md b/examples/server/README.md index a932639..779d8a4 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -22,6 +22,33 @@ Run with Inspector: npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` +## The 2026-07-28 lifecycle + +`stateless-lifecycle/server.php` speaks protocol revision `2026-07-28`, which removed the `initialize` +handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which +opens with `initialize`: + +```bash +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php +``` + +Every request carries its own protocol version and client capabilities, so a call is a single POST with +no handshake before it: + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +`tests/Integration/StatelessLifecycleTest.php` drives this example end to end — discovery, a tool call, +the multi round-trip flow, and the response stream carrying progress and log notifications. + ## Debugging You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by diff --git a/examples/server/stateless-lifecycle/server.php b/examples/server/stateless-lifecycle/server.php new file mode 100644 index 0000000..e08a870 --- /dev/null +++ b/examples/server/stateless-lifecycle/server.php @@ -0,0 +1,157 @@ +setServerInfo('Stateless Lifecycle Demo', '1.0.0', title: 'Stateless Lifecycle Demo') + ->setLogger(logger()) + ->setCapabilities(new ServerCapabilities(tools: true, toolsListChanged: true, resources: true, logging: true)) + + // Only the handshake leg has anything to keep here: its clients open a + // session and come back to it. The modern leg never mints one, so under + // `php -S` — where nothing survives between requests — this is what lets + // both eras reach the same tools. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + + // How long an answer stays fresh. Lists are the same for every caller here, + // so they are public and long-lived; anything user-shaped stays private. + ->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) + + // Signs the `requestState` a multi round-trip answer carries. The same key + // must reach every process that might serve the retry — a per-process + // random value only works for a single-process deployment. + ->setRequestState(getenv('MCP_REQUEST_STATE_KEY') ?: str_repeat('example-development-key-', 2)) + + // Carries change notifications to open `subscriptions/listen` streams. + // In-memory suits `php -S` and worker runtimes; under PHP-FPM use + // Psr16NotificationBus, since the publisher and the stream are different + // processes there. + ->setNotificationBus($bus) + ->setSubscriptionLifetime(20.0) + + // An ordinary tool: nothing about it is lifecycle-specific. + ->addTool( + static fn (string $city = 'Berlin'): string => sprintf('It is 17°C and cloudy in %s.', $city), + name: 'get_weather', + description: 'Reports the weather for a city', + ) + + // Progress and logging both travel on this request's own response stream. + // The client opts into each: progress by sending `_meta.progressToken`, + // logging by sending `_meta["io.modelcontextprotocol/logLevel"]`. Without + // those the server must stay silent, and does. + ->addTool( + static function (RequestContext $context, int $steps = 3): string { + $client = $context->getClientGateway(); + + for ($step = 1; $step <= $steps; ++$step) { + $client->log(LoggingLevel::Info, sprintf('Reindexing shard %d of %d', $step, $steps)); + $client->progress($step, $steps, sprintf('Shard %d of %d', $step, $steps)); + } + + return sprintf('Reindexed %d shards.', $steps); + }, + name: 'reindex', + description: 'Reindexes shards, reporting progress as it goes', + ) + + // A tool that needs something from the user. There are no server-initiated + // requests in this revision: instead of asking the client and waiting, the + // server *returns* the ask and the client retries the whole call with the + // answer. Nothing is kept between the two rounds — what the server needs to + // remember it seals into `requestState`, which comes back verified. + // + // No fork for the handshake era, and none needed: there the SDK fulfils the + // same ask over that connection's own channel and re-enters this closure + // with the answer. Which is why it re-derives where it is from what came + // back rather than keeping anything of its own. + ->addTool( + static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest( + 'What name should the greeting use?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + )], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent(sprintf('Hello, %s!', $answer->content['name'] ?? 'friend'))]); + }, + name: 'greet', + description: 'Greets you by name, asking for it first if it has to', + ) + ->build(); + +shutdown($server->run(transport())); diff --git a/tests/Integration/DualEraEndpointTest.php b/tests/Integration/DualEraEndpointTest.php new file mode 100644 index 0000000..754d8a7 --- /dev/null +++ b/tests/Integration/DualEraEndpointTest.php @@ -0,0 +1,115 @@ + 'Ada']; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/stateless-lifecycle/server.php'; + } + + protected static function portBase(): int + { + return 9200; + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName connects to the one endpoint')] + public function testConnects(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertTrue($client->isConnected()); + $this->assertSame($era, $client->getProtocolVersion()); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName sees the same tools')] + public function testListsTheSameTools(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools); + sort($names); + + // One registry behind both legs, so the catalogue cannot drift. + $this->assertSame(['get_weather', 'greet', 'reindex'], $names); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName gets the same answer from the same tool')] + public function testCallsTheSameTool(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertSame( + 'It is 17°C and cloudy in Munich.', + self::text($client->callTool('get_weather', ['city' => 'Munich'])), + ); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a tool that has to ask the user completes on $_dataName')] + public function testAsksForInput(ProtocolVersion $era): void + { + // The one place the eras genuinely differ on the wire: the handshake era + // is asked mid-call over its session's stream, the modern era is handed + // the question as a result and retries. The example forks on exactly + // that; the caller here does not. + $client = $this->connect($era, elicitation: true); + + $this->assertSame('Hello, Ada!', self::text($client->callTool('greet', []))); + + $client->disconnect(); + } + + #[TestDox('both eras are served in turn without restarting anything')] + public function testBothErasAgainstOneRunningServer(): void + { + $handshake = $this->connect(ProtocolVersion::V2025_11_25); + $modern = $this->connect(ProtocolVersion::V2026_07_28); + + // Interleaved on purpose: the handshake client's session stays open + // while the modern one is served, and neither disturbs the other. + $first = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + $second = self::text($modern->callTool('get_weather', ['city' => 'Berlin'])); + $third = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + + $this->assertSame($first, $second); + $this->assertSame($first, $third); + + $handshake->disconnect(); + $modern->disconnect(); + } +} diff --git a/tests/Integration/StatelessClientTest.php b/tests/Integration/StatelessClientTest.php new file mode 100644 index 0000000..fbd3d59 --- /dev/null +++ b/tests/Integration/StatelessClientTest.php @@ -0,0 +1,171 @@ +port = 8900 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('connects without a handshake and learns who it is talking to')] + public function testConnectWithoutHandshake(): void + { + $client = $this->connect(); + + $this->assertTrue($client->isConnected()); + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + $this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name); + + $client->disconnect(); + } + + #[TestDox('lists and calls a tool, which the server accepts on the first try')] + public function testToolCall(): void + { + $client = $this->connect(); + + $tools = $client->listTools(); + $this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools)); + + // A header the server disagreed with would come back as -32020, so a + // plain result is also the assertion that the mirroring was right. + $result = $client->callTool('get_weather', ['city' => 'Munich']); + + $this->assertStringContainsString('Munich', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('completes a multi round-trip call by answering the server itself')] + public function testMultiRoundTripIsTransparentToTheCaller(): void + { + $client = $this->connect(elicitation: true); + + // One call from here; two on the wire. The server asks for a name, the + // client answers from its own handler and retries with the sealed + // `requestState`, and the caller only ever sees the finished result. + $result = $client->callTool('greet', []); + + $this->assertStringContainsString('Hello, Ada!', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('a capability the client never declared is refused up front, and costs only that call')] + public function testUndeclaredCapabilityIsRefused(): void + { + // No elicitation capability, and the envelope says so on every request, + // so the server refuses rather than asking for something that could + // never be answered. + $client = $this->connect(); + + try { + $client->callTool('greet', []); + $this->fail('Expected the server to refuse the undeclared capability.'); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + // The connection is stateless, so a failed call costs nothing. + $this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich']))); + + $client->disconnect(); + } + + private static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + private function connect(bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('stateless-integration-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setRequestTimeout(10); + + if ($elicitation) { + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/StatelessLifecycleTest.php b/tests/Integration/StatelessLifecycleTest.php new file mode 100644 index 0000000..1983d23 --- /dev/null +++ b/tests/Integration/StatelessLifecycleTest.php @@ -0,0 +1,297 @@ +port = 8600 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('server/discover reports the versions, capabilities, identity and caching hints')] + public function testDiscover(): void + { + $result = $this->call('server/discover', [])['result']; + + $this->assertSame([ProtocolVersion::V2026_07_28->value], $result['supportedVersions']); + $this->assertSame('complete', $result['resultType']); + $this->assertSame('Stateless Lifecycle Demo', $result['_meta']['io.modelcontextprotocol/serverInfo']['name']); + $this->assertSame(3_600_000, $result['ttlMs']); + $this->assertSame('public', $result['cacheScope']); + } + + #[TestDox('a tool call needs no handshake before it')] + public function testToolCallWithoutAHandshake(): void + { + $result = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => ['city' => 'Munich']], name: 'get_weather')['result']; + + $this->assertStringContainsString('Munich', $result['content'][0]['text']); + $this->assertSame('complete', $result['resultType']); + } + + #[TestDox('initialize is gone, and says so')] + public function testInitializeIsRefused(): void + { + $answer = $this->call('initialize', []); + + $this->assertSame(-32601, $answer['error']['code']); + } + + #[TestDox('a request whose header contradicts its body is refused with -32020')] + public function testHeaderMismatchIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else'); + + $this->assertSame(-32020, $answer['error']['code']); + } + + #[TestDox('an unsupported version comes back with the set to retry from')] + public function testUnsupportedVersion(): void + { + $answer = $this->call('tools/list', [], version: '1900-01-01'); + + $this->assertSame(-32022, $answer['error']['code']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']); + } + + #[TestDox('a multi round-trip tool asks, then completes on the retry')] + public function testMultiRoundTrip(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('input_required', $asked['resultType']); + $this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']); + $this->assertNotEmpty($asked['requestState']); + + // An interim result is not cacheable and carries no hints. + $this->assertArrayNotHasKey('ttlMs', $asked); + + $done = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $asked['requestState'], + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('Hello, Ada!', $done['content'][0]['text']); + $this->assertSame('complete', $done['resultType']); + } + + #[TestDox('a tampered requestState is refused')] + public function testTamperedRequestStateIsRefused(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + [$body] = explode('.', $asked['requestState']); + + $answer = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'), + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()]); + + $this->assertSame(-32602, $answer['error']['code']); + } + + #[TestDox('asking a client that declared no elicitation is refused with -32021')] + public function testUndeclaredCapabilityIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet'); + + $this->assertSame(-32021, $answer['error']['code']); + $this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']); + } + + #[TestDox('progress and log notifications arrive on the response stream, before the response')] + public function testResponseStreamCarriesNotifications(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + 'io.modelcontextprotocol/logLevel' => 'info', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertSame([ + 'notifications/message', + 'notifications/progress', + 'notifications/message', + 'notifications/progress', + 'response', + ], $methods); + + $this->assertSame('p1', $frames[1]['params']['progressToken']); + $this->assertSame('Reindexed 2 shards.', $frames[4]['result']['content'][0]['text']); + } + + #[TestDox('a request naming no log level receives no log messages')] + public function testLoggingIsSilentWithoutALevel(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertNotContains('notifications/message', $methods); + $this->assertContains('notifications/progress', $methods); + } + + /** + * @param array $params + * @param array $capabilities + * + * @return array + */ + private function call(string $method, array $params, ?string $name = null, ?string $version = null, array $capabilities = []): array + { + $body = $this->body($method, $params, $version, $capabilities); + + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, $version)), + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $raw = file_get_contents($this->url(), false, $context); + + $this->assertIsString($raw, 'no response from the example server'); + + return json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + * @param array $meta + * + * @return list> + */ + private function stream(string $method, array $params, ?string $name = null, array $meta = []): array + { + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, null)), + 'content' => $this->body($method, $params, null, [], $meta), + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $handle = fopen($this->url(), 'r', false, $context); + $this->assertIsResource($handle); + + $frames = []; + while (false !== $line = fgets($handle)) { + $line = trim($line); + + // SSE comments are keep-alives and carry no event data. + if ('' === $line || str_starts_with($line, ':')) { + continue; + } + + if (str_starts_with($line, 'data: ')) { + $frames[] = json_decode(substr($line, 6), true, flags: \JSON_THROW_ON_ERROR); + } + } + + fclose($handle); + + return $frames; + } + + /** + * @param array $params + * @param array $capabilities + * @param array $meta + */ + private function body(string $method, array $params, ?string $version, array $capabilities = [], array $meta = []): string + { + $params['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => $version ?? ProtocolVersion::V2026_07_28->value, + 'io.modelcontextprotocol/clientCapabilities' => (object) $capabilities, + 'io.modelcontextprotocol/clientInfo' => ['name' => 'integration-test', 'version' => '1.0.0'], + ...$meta, + ]; + + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ], \JSON_THROW_ON_ERROR); + } + + /** + * @return list + */ + private function headers(string $method, ?string $name, ?string $version): array + { + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + 'MCP-Protocol-Version: '.($version ?? ProtocolVersion::V2026_07_28->value), + 'Mcp-Method: '.$method, + ]; + + if (null !== $name) { + $headers[] = 'Mcp-Name: '.$name; + } + + return $headers; + } + + private function url(): string + { + return \sprintf('http://127.0.0.1:%d/', $this->port); + } +} From 0cd5d5616438fea9ef804be07d54913530350634 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 20 Aug 2026 00:03:54 +0200 Subject: [PATCH 117/143] [Docs] Render the guides with Zensical instead of phpDocumentor (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Docs] Render guides with Zensical, deploy via GitHub Pages actions phpDocumentor's guide renderer copies the markdown through mostly verbatim: relative links between pages keep pointing at `*.md` targets that do not exist in the built site, and nothing validates them, so the published guides are full of dead links. Zensical (the Material for MkDocs team's successor to MkDocs) resolves internal links against the page tree and fails the build on a broken one. `zensical build --strict` already found four dead links on the first run — three repo-relative links escaping docs/ (fixed to point at GitHub) and one wrong in-page anchor. phpDocumentor stays on for the class-level API reference only; `make docs` builds the guides into site/ and mounts the reference at site/api/, so its two header links back to the guides now target the site root. Deployment moves from pushing a gh-pages branch to the official GitHub Pages actions, and from release-only to every push on main, with pull requests building (but not deploying) so a broken link fails review instead of the site. NOTE: this needs the repository's Pages source switched to "GitHub Actions" (Settings -> Pages) once. * [Docs] Adopt the Python SDK's documentation look and feel The docs site now uses the same theme configuration as https://py.sdk.modelcontextprotocol.io/ so the language SDKs read as one set of docs: the MCP mark as logo and favicon, Inter/JetBrains Mono, the black/slate palette with a three-way (system/light/dark) toggle, instant navigation, code copy/annotate, and a right-hand table of contents. The markdown extension set is widened to the same list (tabbed blocks, task lists, footnotes, emoji/icons, mermaid fences), which the content restructure builds on. Styling is otherwise stock: no custom stylesheet, and Zensical's own `modern` theme variant, pinned explicitly rather than left to the default. The one departure is code highlighting, which is broken out of the box here: Pygments only highlights PHP after a ` [!IMPORTANT]` blockquotes became admonitions, which Zensical renders as callouts rather than plain quotes. Every code sample and factual claim was then checked against src/ and the runnable examples, which turned up long-standing errors in the carried-over prose. Samples that could not run: a prompt using a `system` role (MCP has only user/assistant), `Mcp\Schema\PromptMessage` (it is under `Schema\Content`) constructed with an array instead of a single content object, `Mcp\Capability\Prompt\Completion\ProviderInterface` (it is `Mcp\Capability\Completion\ProviderInterface`), `new EmbeddedResource(type:, resource: [...])` (neither parameter exists), a `: resource` return type (not a PHP type), `#[McpResource]` with a `{path}` variable (that is a template), a stray quote in the builder example, a `middlewares:` argument (it is `middleware:`), and `getRequest()->getAttribute()` in the OAuth guide (no such method — the values arrive on the request meta). Claims corrected: sampling's `system_prompt` option (it is `systemPrompt`), `SampleMessage` (it is `SamplingMessage`), `Notification` called an interface (abstract class), "parameter order matters" for URI templates (bound by name), full RFC 6570 support (only simple `{var}`), the tool description fallback chain, `void` returning empty content, a non-zero STDIO exit code, `ErrorEvent` being null for parse errors, handlers being "prepended", `Psr16StoreSession`, and PSR-3 log context being sent to the client (it is dropped). Sixteen `examples/` paths were missing their `server/` segment. README, the OAuth ADR and one source comment now point at the published site instead of at markdown files that moved. * [Docs] Run the docs workflow unfiltered, keep README links relative The `paths:` filters were mostly noise: `src/**` had to be in the list for the phpDocumentor build, which meant the workflow ran on nearly every PR anyway. They also missed two real inputs — `.phpdoc/template/**` and `composer.lock` — so a template tweak or a phpDocumentor bump could change the rendered site without triggering a build. README links go back to relative repo paths. The README is not part of the Zensical site (`docs_dir` is `docs/`), so those links are only ever resolved by GitHub and Packagist, where absolute URLs break in-repo navigation for no gain. The site pointer and the generated API reference stay absolute. * [Docs] Adopt the doc changes that landed on main into the new structure Twelve commits landed while the restructure was open, seven of them touching the flat guides this branch replaces. Renames merged on their own; the three files that were split apart needed their new sections placed by hand: - Connection retries, protocol version negotiation, sampling with tools and roots from `client.md` into `client/connecting.md` and `client/server-requests.md` - `ResourceLink` and structured output from `mcp-elements.md` into `servers/tools.md` - Protocol version negotiation from `server-builder.md` into `run/server-builder.md` The `setMaxRetries()` note claiming the value is never acted on is gone — #413 implemented it. Callouts became admonitions and cross-links were repointed at the new paths, as everywhere else in this branch. * [Docs] Document the elicitation and roots client examples Both were missing from the examples guide: the elicitation client predates this branch, the roots client arrived with #395. * [Docs] Fold the 2026-07-28 lifecycle into the new structure Main's stateless-lifecycle.md becomes a docs/lifecycle/ section, and the pages main touched — deprecations, the middleware split, the client's modern era — land where the restructure moved them. * [Docs] Link the Inspector to its documentation, not its repo * [Docs] Distribute the 2026-07-28 material into the task sections The Python and TypeScript SDKs file each feature where the task lives and keep only an era comparison on its own page. Follow them: input-required moves next to the other handler concerns, caching, subscriptions and era routing to "Running your server", and one Protocol versions page carries the rest. * [Docs] Move protocol version negotiation onto the Protocol versions page The builder page keeps the setProtocolVersion() knob and links out; how a revision is agreed now sits beside the era it belongs to. * [Docs] Slim the README to a funnel, turn Examples into an index * [Docs] Fix the code samples and API listings flagged by the audit * [Docs] Link the negotiation spec section at latest, not draft --- .gitattributes | 2 + .github/workflows/docs.yml | 53 +- .github/workflows/pipeline.yaml | 4 +- .gitignore | 4 + .phpdoc/template/base.html.twig | 2 +- .../components/header-title.html.twig | 2 +- CHANGELOG.md | 2 +- Makefile | 27 +- README.md | 272 +----- ...oauth-authorization-server-out-of-scope.md | 2 +- docs/.overrides/.icons/mcp.svg | 1 + docs/CNAME | 1 + docs/advanced/custom-handlers.md | 98 ++ docs/{ => advanced}/events.md | 13 +- docs/{ => advanced}/extensions.md | 11 +- docs/advanced/index.md | 10 + docs/client.md | 879 ----------------- docs/client/capabilities.md | 161 ++++ docs/client/connecting.md | 222 +++++ docs/client/errors.md | 155 +++ docs/client/index.md | 41 + docs/client/server-requests.md | 265 ++++++ docs/client/transports.md | 61 ++ docs/examples.md | 559 ++--------- docs/favicon.svg | 11 + docs/get-started/first-server.md | 84 ++ docs/get-started/index.md | 27 + docs/get-started/inspector.md | 88 ++ docs/get-started/installation.md | 44 + .../client-communication.md} | 59 +- docs/handlers/index.md | 42 + docs/handlers/input-required.md | 111 +++ docs/handlers/logging.md | 39 + docs/index.md | 112 ++- docs/mcp-elements.md | 892 ------------------ docs/protocol-versions.md | 178 ++++ docs/{ => run}/authorization.md | 37 +- docs/run/caching.md | 28 + docs/run/framework-integration.md | 202 ++++ docs/{transports.md => run/http.md} | 352 +------ docs/run/index.md | 41 + docs/run/protocol-eras.md | 136 +++ docs/run/server-builder.md | 336 +++++++ docs/run/sessions.md | 127 +++ docs/run/stdio.md | 64 ++ docs/run/subscriptions.md | 40 + docs/server-builder.md | 747 --------------- docs/servers/completions.md | 98 ++ docs/servers/index.md | 30 + docs/servers/prompts.md | 126 +++ docs/servers/registration.md | 247 +++++ docs/servers/resource-templates.md | 49 + docs/servers/resources.md | 152 +++ docs/servers/schemas.md | 111 +++ docs/servers/tools.md | 236 +++++ docs/stateless-lifecycle.md | 391 -------- examples/client/README.md | 19 + examples/client/http_discovery_calculator.php | 2 +- examples/server/README.md | 6 +- mkdocs.yml | 169 ++++ phpdoc.dist.xml | 17 +- requirements-docs.txt | 3 + .../ProtectedResourceMetadataHandler.php | 2 +- 63 files changed, 4239 insertions(+), 4063 deletions(-) create mode 100644 docs/.overrides/.icons/mcp.svg create mode 100644 docs/CNAME create mode 100644 docs/advanced/custom-handlers.md rename docs/{ => advanced}/events.md (91%) rename docs/{ => advanced}/extensions.md (90%) create mode 100644 docs/advanced/index.md delete mode 100644 docs/client.md create mode 100644 docs/client/capabilities.md create mode 100644 docs/client/connecting.md create mode 100644 docs/client/errors.md create mode 100644 docs/client/index.md create mode 100644 docs/client/server-requests.md create mode 100644 docs/client/transports.md create mode 100644 docs/favicon.svg create mode 100644 docs/get-started/first-server.md create mode 100644 docs/get-started/index.md create mode 100644 docs/get-started/inspector.md create mode 100644 docs/get-started/installation.md rename docs/{server-client-communication.md => handlers/client-communication.md} (68%) create mode 100644 docs/handlers/index.md create mode 100644 docs/handlers/input-required.md create mode 100644 docs/handlers/logging.md delete mode 100644 docs/mcp-elements.md create mode 100644 docs/protocol-versions.md rename docs/{ => run}/authorization.md (91%) create mode 100644 docs/run/caching.md create mode 100644 docs/run/framework-integration.md rename docs/{transports.md => run/http.md} (50%) create mode 100644 docs/run/index.md create mode 100644 docs/run/protocol-eras.md create mode 100644 docs/run/server-builder.md create mode 100644 docs/run/sessions.md create mode 100644 docs/run/stdio.md create mode 100644 docs/run/subscriptions.md delete mode 100644 docs/server-builder.md create mode 100644 docs/servers/completions.md create mode 100644 docs/servers/index.md create mode 100644 docs/servers/prompts.md create mode 100644 docs/servers/registration.md create mode 100644 docs/servers/resource-templates.md create mode 100644 docs/servers/resources.md create mode 100644 docs/servers/schemas.md create mode 100644 docs/servers/tools.md delete mode 100644 docs/stateless-lifecycle.md create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt diff --git a/.gitattributes b/.gitattributes index 27224ae..b7b2a18 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ /tests export-ignore /.php-cs-fixer.dist.php export-ignore /Makefile export-ignore +/mkdocs.yml export-ignore +/requirements-docs.txt export-ignore /phpdoc.dist.xml /phpstan* export-ignore /phpunit.xml.dist export-ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a1c3c83..20b6e08 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,25 @@ -name: Deploy Documentation +name: Documentation + +# The site is built by `make docs`: Zensical renders the guides under docs/ +# (see mkdocs.yml) and phpDocumentor renders the API reference into /api/. +# Pull requests build only — the build is strict, so a broken internal link +# fails CI instead of shipping a dead link to the site. on: - release: - types: [published] + push: + branches: [main] + pull_request: workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -21,16 +31,33 @@ jobs: php-version: '8.4' coverage: "none" - - name: Install Composer + - name: Install Composer dependencies uses: "ramsey/composer-install@v4" - - name: Generate Documentation + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + + - name: Build documentation run: make docs - - name: Deploy to gh-pages branch - uses: peaceiris/actions-gh-pages@v4 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./.phpdoc/build - enable_jekyll: false - cname: php.sdk.modelcontextprotocol.io + path: ./site + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b018543..e9c9e83 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -244,5 +244,7 @@ jobs: - name: PHPStan run: vendor/bin/phpstan analyse + # Only the phpDocumentor half: this job is PHP-only, and the Zensical + # guides are built (strictly) by the Documentation workflow. - name: Documentation - run: make docs + run: make docs-api diff --git a/.gitignore b/.gitignore index c5d87ce..ca3a13f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ tests/Conformance/logs/*.log # phpDocumentor .phpdoc/build/ .phpdoc/cache/ + +# Documentation site (make docs) +/site/ +/.cache/ diff --git a/.phpdoc/template/base.html.twig b/.phpdoc/template/base.html.twig index 760f165..f8feb3c 100644 --- a/.phpdoc/template/base.html.twig +++ b/.phpdoc/template/base.html.twig @@ -2,7 +2,7 @@ {% set topMenu = { "menu": [ - { "name": "Guides", "url": "docs/index.html"}, + { "name": "Guides", "url": "/"}, { "name": "Specification", "url": "https://modelcontextprotocol.io/" } ], "social": [ diff --git a/.phpdoc/template/components/header-title.html.twig b/.phpdoc/template/components/header-title.html.twig index fe8d091..ece437c 100644 --- a/.phpdoc/template/components/header-title.html.twig +++ b/.phpdoc/template/components/header-title.html.twig @@ -1,5 +1,5 @@

- + diff --git a/CHANGELOG.md b/CHANGELOG.md index 06280c6..6e3cb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. -* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. +* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestState()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. diff --git a/Makefile b/Makefile index 93fbf1f..f1d78ef 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve # The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has # none of them. Pinned to the same version CI runs (see @@ -6,6 +6,11 @@ CONFORMANCE_VERSION ?= 0.2.0-alpha.11 CONFORMANCE = npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) +# The documentation toolchain is Python (Zensical, see requirements-docs.txt), +# run through uv so no virtualenv has to be managed by hand: +# https://docs.astral.sh/uv/getting-started/installation/ +DOCS_RUN = uv run --no-project --with-requirements requirements-docs.txt -- + deps-stable: composer update --prefer-stable @@ -73,7 +78,19 @@ ci-stable: deps-stable cs phpstan tests ci-lowest: deps-low cs phpstan tests -docs: - vendor/bin/phpdoc - @grep -q 'No errors have been found' .phpdoc/build/reports/errors.html || \ - (echo "Documentation errors found. See build/docs/reports/errors.html" && exit 1) +# The published site is the guides (Zensical) with the phpDocumentor API +# reference mounted at /api/. `zensical build` wipes site/, so it runs first. +docs: docs-guides docs-api + rm -rf site/api + cp -a .phpdoc/build/api site/api + +docs-guides: + $(DOCS_RUN) zensical build --strict + +docs-api: + vendor/bin/phpdoc --no-interaction + @grep -q 'No errors have been found' .phpdoc/build/api/reports/errors.html || \ + (echo "Documentation errors found. See .phpdoc/build/api/reports/errors.html" && exit 1) + +docs-serve: + $(DOCS_RUN) zensical serve diff --git a/README.md b/README.md index 235d3f5..194230a 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ -The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers -and clients in PHP. +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). This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the @@ -24,47 +25,27 @@ 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. -## Table of Contents - -- [Installation](#installation) -- [Overview](#overview) -- [Server SDK](#server-sdk) -- [Client SDK](#client-sdk) -- [Documentation](#documentation) -- [External Resources](#external-resources) -- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk) -- [Contributing](#contributing) -- [Credits](#credits) -- [License](#license) - ## Installation ```bash composer require mcp/sdk ``` -## Overview +## Build a server -The MCP PHP SDK provides both **server** and **client** implementations for the Model Context Protocol, enabling you to: - -- **Build MCP Servers**: Expose your PHP application's functionality (tools, resources, prompts) to AI agents -- **Build MCP Clients**: Connect to and interact with MCP servers from your PHP applications - -## Server SDK - -Build MCP servers to expose your PHP application's capabilities to AI agents like Claude, Codex, and others. - -### Quick Start +A server is a plain PHP class plus three lines of wiring: ```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Attribute\McpResource; -// Define capabilities using PHP attributes -class CalculatorCapabilities +class Calculator { + /** + * Adds two numbers. + */ #[McpTool] public function add(int $a, int $b): int { @@ -72,237 +53,56 @@ class CalculatorCapabilities } #[McpResource(uri: 'config://calculator/settings')] - public function getSettings(): array + public function settings(): array { return ['precision' => 2]; } } -// Build and run the server -$server = Server::builder() - ->setServerInfo('Calculator Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) // Auto-discover attributes - ->build(); - -$transport = new StdioTransport(); -$server->run($transport); +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); ``` -### Server Capabilities +The walkthrough in [First server](docs/get-started/first-server.md) explains each piece, and +[Try it with the Inspector](docs/get-started/inspector.md) shows it running. -- **Tools**: Executable functions that AI agents can call -- **Resources**: Data sources that can be read (files, configs, databases) -- **Resource Templates**: Dynamic resources with URI parameters -- **Prompts**: Pre-defined templates for AI interactions -- **Server-Initiated Communication**: Elicitations, sampling, logging, progress notifications - -### Registration Methods - -There are multiple ways to register your MCP capabilities—choose the approach that best fits your application's architecture: - -**1. Attribute-Based Discovery** — Define capabilities using PHP attributes for automatic discovery: -```php -#[McpTool] -public function generateReport(): string { /* ... */ } - -#[McpResource(uri: 'config://app/settings')] -public function getConfig(): array { /* ... */ } -``` - -**2. Manual Registration** — Register capabilities programmatically without attributes: -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->build(); -``` - -**3. Hybrid Approach** — Combine both methods for maximum flexibility: -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) - ->addTool([ExternalService::class, 'process'], 'external') - ->build(); -``` - -### Transports - -Choose the transport that matches your deployment environment: - -**1. STDIO Transport** — For command-line integration and local processes: -```php -$transport = new StdioTransport(); -$server->run($transport); -``` - -**2. HTTP Transport** — For web-based servers and distributed systems: -```php -$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$response = $server->run($transport); -``` - -### Session Management - -Configure session storage to maintain state between requests. Choose the backend that fits your infrastructure: - -**In-Memory** (default, suitable for STDIO): -```php -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); -``` - -**File-Based** (suitable for single-server HTTP deployments): -```php -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); -``` - -**PSR-16 Cache** (for example with Redis for scaled deployments): -```php -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); -``` - -[→ Server Documentation](docs/server-builder.md) - -## Client SDK - -Connect to MCP servers from your PHP applications to access their tools, resources, and prompts. - -### Quick Start +## Build a client ```php use Mcp\Client; use Mcp\Client\Transport\StdioTransport; -// Build the client $client = Client::builder() ->setClientInfo('My Application', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) ->build(); -// Connect to a server -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); +$client->connect(new StdioTransport(command: 'php', args: ['/path/to/server.php'])); -$client->connect($transport); - -// Discover and use capabilities $tools = $client->listTools(); $result = $client->callTool('add', ['a' => 5, 'b' => 3]); -$resources = $client->listResources(); -$content = $client->readResource('config://calculator/settings'); - $client->disconnect(); ``` -### Client Capabilities - -- **Tool Calling**: List and execute tools from any MCP server -- **Resource Access**: Read static and dynamic resources -- **Prompt Management**: List and retrieve prompt templates -- **Completion Support**: Request argument completion suggestions -- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests - -### Advanced Features - -- **Progress Tracking**: Real-time progress during long operations -```php -$result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: function (float $progress, ?float $total, ?string $message) { - echo "Progress: {$progress}/{$total} - {$message}\n"; - } -); -``` - -- **Sampling Support**: Handle server LLM sampling requests -```php -$samplingHandler = new SamplingRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler($samplingHandler) - ->build(); -``` - -- **Elicitation Support**: Respond to server requests for user input -```php -$elicitationHandler = new ElicitationRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($elicitationHandler) - ->build(); -``` - -- **Roots Support**: Expose `file://` workspace folders to the server -```php -$rootsHandler = new ListRootsRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler($rootsHandler) - ->build(); -``` - -- **Logging Notifications**: Receive server log messages -```php -$loggingHandler = new LoggingNotificationHandler($myCallback); -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Transports - -Connect to MCP servers using the transport that matches your setup: - -**1. STDIO Transport** — Connect to local server processes: -```php -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); -``` - -**2. HTTP Transport** — Connect to remote or web-based servers: -```php -$transport = new HttpTransport('http://localhost:8000'); - -$client->connect($transport); -``` - -[→ Client Documentation](docs/client.md) +See [Connecting to a server](docs/client/connecting.md) for transports, timeouts, and the +handlers that answer server-initiated requests. ## Documentation -### Core Concepts +The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. -- **[Server Builder](docs/server-builder.md)** — Complete ServerBuilder reference and configuration -- **[Client](docs/client.md)** — Client SDK for connecting to and communicating with MCP servers -- **[Transports](docs/transports.md)** — STDIO and HTTP transport setup and usage -- **[MCP Elements](docs/mcp-elements.md)** — Creating tools, resources, prompts, and templates -- **[Server-Client Communication](docs/server-client-communication.md)** — Sampling, logging, progress, and notifications -- **[Protocol Extensions](docs/extensions.md)** — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources) -- **[Authorization](docs/authorization.md)** — OAuth and authorization setup for HTTP transport -- **[Events](docs/events.md)** — Hooking into server lifecycle with events - -### Learning & Examples - -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients -- **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap +- **[Get started](docs/get-started/index.md)** — Install the SDK and build your first server +- **[Servers](docs/servers/index.md)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](docs/handlers/index.md)** — Talking back to the client, logging, and asking for input +- **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers +- **[Protocol versions](docs/protocol-versions.md)** — The two protocol eras, and what revision `2026-07-28` changed +- **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[Examples](docs/examples.md)** — Runnable server and client examples +- **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ## External Resources @@ -324,9 +124,9 @@ Building something on top of the SDK? Open a pull request to add it to this list ## Contributing -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. - -See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in +the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or +[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md index fa5b091..ebb5947 100644 --- a/adr/0001-oauth-authorization-server-out-of-scope.md +++ b/adr/0001-oauth-authorization-server-out-of-scope.md @@ -93,5 +93,5 @@ If you need an authorization server (token issuance, client registration, login, validator seams. The MCP server validates the tokens it issues; it does not issue them itself. -See [`../docs/authorization.md`](../docs/authorization.md) for the supported Resource Server +See [`../docs/run/authorization.md`](../docs/run/authorization.md) for the supported Resource Server and delegation setup. diff --git a/docs/.overrides/.icons/mcp.svg b/docs/.overrides/.icons/mcp.svg new file mode 100644 index 0000000..67d800b --- /dev/null +++ b/docs/.overrides/.icons/mcp.svg @@ -0,0 +1 @@ + diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..c7735ad --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +php.sdk.modelcontextprotocol.io diff --git a/docs/advanced/custom-handlers.md b/docs/advanced/custom-handlers.md new file mode 100644 index 0000000..a7fd28f --- /dev/null +++ b/docs/advanced/custom-handlers.md @@ -0,0 +1,98 @@ +# Custom Message Handlers + +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass +those dependencies in yourself. + +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +## Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; handlers are collected in call order, and the whole custom list runs before the +built-in defaults: + +```php +$server = Server::builder() + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ + new CustomCallToolHandler(), + new CustomGetPromptHandler(), + ]) + ->build(); +``` + +Request handlers implement `RequestHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} +``` + +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) + +## Notification Handlers + +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +// Handlers are your own classes implementing NotificationHandlerInterface; +// the SDK ships only its internal ones. +$server = Server::builder() + ->addNotificationHandler(new AuditNotificationHandler($auditLog)) + ->addNotificationHandlers([ + new MetricsNotificationHandler($metrics), + new CancellationNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +## Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +## Example + +Check out [`examples/server/custom-method-handlers/server.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/custom-method-handlers/server.php) for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. diff --git a/docs/events.md b/docs/advanced/events.md similarity index 91% rename from docs/events.md rename to docs/advanced/events.md index ebd70ed..590ad6e 100644 --- a/docs/events.md +++ b/docs/advanced/events.md @@ -2,21 +2,12 @@ The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable request/response modification, and other user-defined behaviors. -## Table of Contents - -- [Setup](#setup) -- [Protocol Events](#protocol-events) - - [RequestEvent](#requestevent) - - [ResponseEvent](#responseevent) - - [ErrorEvent](#errorevent) - - [NotificationEvent](#notificationevent) -- [List Change Events](#list-change-events) - ## Setup Configure an event dispatcher when building your server: ```php +use Mcp\Event\RequestEvent; use Mcp\Server; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -67,7 +58,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Properties**: - `getError(): Error` - The error being sent - `setError(Error $error): void` - Modify the error before sending -- `getRequest(): Request` - The original request (null for parse errors) +- `getRequest(): Request` - The original request. Messages that fail to parse are rejected before this event, so a listener never sees them. - `getThrowable(): ?\Throwable` - The exception that caused the error (if any) - `getSession(): SessionInterface` - The current session diff --git a/docs/extensions.md b/docs/advanced/extensions.md similarity index 90% rename from docs/extensions.md rename to docs/advanced/extensions.md index aff06ab..041f986 100644 --- a/docs/extensions.md +++ b/docs/advanced/extensions.md @@ -1,7 +1,9 @@ # Protocol Extensions -MCP protocol extensions advertise additional, optional capabilities during the initialize handshake. -A server opts in via `Builder::enableExtension()`: +MCP protocol extensions advertise additional, optional capabilities alongside the regular ones — +during the `initialize` handshake, or on revision `2026-07-28` (which has no handshake) inside the +capabilities that travel with every request. A server opts in via `Builder::enableExtension()` and +the SDK places the advertisement correctly for whichever era the client speaks: ```php use Mcp\Schema\Extension\Apps\McpApps; @@ -19,7 +21,8 @@ be enabled in a single call. Enabling the same extension twice throws a Clients (hosts) advertise the extensions they support the same way, via `Client\Builder::enableExtension()`; the payload lands under -`capabilities.extensions` in the initialize request. +`capabilities.extensions` in the initialize request — or, on a modern revision, +under the client capabilities carried in each request's `_meta` envelope. > Note: extensions enabled via `enableExtension()` are merged into the > `extensions` capability even when you supply your own `ServerCapabilities` / @@ -152,6 +155,6 @@ handshake: See the [`ext-apps` repository][ext-apps] for the full protocol, official TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in -[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). +[`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/mcp-apps/weather-app.html). [ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 0000000..2a79a3c --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,10 @@ +# Advanced + +Everything here is optional. A working server needs none of it. + +* **[Events](events.md)** — PSR-14 events dispatched around every request, response, + error, and notification. Useful for metrics, audit logs, and debugging. +* **[Protocol extensions](extensions.md)** — opt-in extensions announced during + capability negotiation, including MCP Apps (HTML UI resources). +* **[Custom message handlers](custom-handlers.md)** — taking over a JSON-RPC method the + SDK does not implement, or overriding one it does. diff --git a/docs/client.md b/docs/client.md deleted file mode 100644 index f17b855..0000000 --- a/docs/client.md +++ /dev/null @@ -1,879 +0,0 @@ -# Client - -The MCP Client SDK provides a synchronous, framework-agnostic API for communicating with MCP servers from PHP applications. -It handles connection management, request/response correlation, server-initiated requests (sampling), and real-time notifications. - -## Table of Contents - -- [Overview](#overview) -- [Client Builder](#client-builder) -- [Transports](#transports) -- [Connecting to Servers](#connecting-to-servers) -- [Server Information](#server-information) -- [Working with Tools](#working-with-tools) -- [Working with Resources](#working-with-resources) -- [Working with Prompts](#working-with-prompts) -- [Server-Initiated Communication](#server-initiated-communication) -- [Error Handling](#error-handling) -- [Complete Example](#complete-example) - -## Overview - -The client follows a builder pattern for configuration and provides a synchronous API for all operations: - -```php -use Mcp\Client; -use Mcp\Client\Transport\StdioTransport; - -// Build and configure the client -$client = Client::builder() - ->setClientInfo('My Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->build(); - -// Create a transport -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -// Connect and use the server -$client->connect($transport); -$tools = $client->listTools(); -$client->disconnect(); -``` - -## Client Builder - -The `Client\Builder` provides fluent configuration of client instances. - -### Basic Configuration - -```php -use Mcp\Client; - -$client = Client::builder() - ->setClientInfo('My Application', '1.0.0', 'Description of my client') - ->setInitTimeout(30) // Seconds to wait for initialization - ->setRequestTimeout(120) // Seconds to wait for request responses - ->setMaxRetries(3) // Retries for failed connections - ->build(); -``` - -### Connection Retries - -`setMaxRetries()` controls how often `connect()` retries a failed connection. It -counts retries rather than attempts, so the default of `3` means one initial -attempt plus up to three retries — four in total — before the `ConnectionException` -of the last attempt is rethrown: - -```php -$client = Client::builder() - ->setMaxRetries(0) // Fail on the first failed attempt - ->build(); -``` - -Between two attempts the transport is closed, so a retry never reuses a -half-established connection: a `StdioTransport` spawns a fresh server process and -an `HttpTransport` discards the session ID of the failed attempt. Each retry is -preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). - -Only the connection handshake is retried. Individual requests such as -`callTool()` are always sent once — retrying them is unsafe as tool calls are not -necessarily idempotent. - -### Client Information - -Set the client's identity reported to servers during initialization: - -```php -$client = Client::builder() - ->setClientInfo( - name: 'AI Assistant Client', - version: '2.1.0', - description: 'Client for automated AI workflows' - ) - ->build(); -``` - -### Protocol Version - -Specify the MCP protocol version to offer during the handshake (defaults to the latest): - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$client = Client::builder() - ->setProtocolVersion(ProtocolVersion::V2025_11_25) - ->build(); -``` - -This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as -described in the specification's -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK -cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed -on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. - -Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. -Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning -saying so. - -See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange. - -### Capabilities - -Declare client capabilities to enable server features: - -```php -use Mcp\Schema\ClientCapabilities; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, // Enable LLM sampling requests from server - roots: true, // Enable filesystem root listing - )) - ->build(); -``` - -### Notification Handlers - -Register handlers for server-initiated notifications: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - echo "[{$notification->level->value}] {$notification->data}\n"; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Request Handlers - -Register handlers for server-initiated requests (e.g., sampling): - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}; - -$client = Client::builder() - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); -``` - -### Logger - -Configure PSR-3 logging for debugging: - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -$logger = new Logger('mcp-client'); -$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); - -$client = Client::builder() - ->setLogger($logger) - ->build(); -``` - -## Transports - -Transports handle the communication layer between client and server. - -### STDIO Transport - -Spawns a server process and communicates via standard input/output: - -```php -use Mcp\Client\Transport\StdioTransport; - -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], - cwd: '/working/directory', // Optional working directory - env: ['KEY' => 'value'], // Optional environment variables -); -``` - -**Parameters:** -- `command` (string): The command to execute -- `args` (array): Command arguments -- `cwd` (string|null): Working directory for the process -- `env` (array|null): Environment variables -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -### HTTP Transport - -Communicates with remote MCP servers over HTTP: - -```php -use Mcp\Client\Transport\HttpTransport; - -$transport = new HttpTransport( - endpoint: 'http://localhost:8000', - headers: ['Authorization' => 'Bearer token'], -); -``` - -**Parameters:** -- `endpoint` (string): The MCP server URL -- `headers` (array): Additional HTTP headers -- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) -- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) -- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -**PSR-18 Auto-Discovery:** - -The transport automatically discovers PSR-18 HTTP clients from: -- `php-http/guzzle7-adapter` -- `php-http/curl-client` -- `symfony/http-client` -- And other PSR-18 compatible implementations - -```bash -# Install any PSR-18 client - discovery works automatically -composer require php-http/guzzle7-adapter -``` - - -## Connecting to Servers - -### Establishing Connection - -```php -$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 -4. Sends InitializedNotification - -> [!IMPORTANT] -> Always wrap connection in try/catch to handle `ConnectionException` for failed connections. - -### Checking Connection State - -```php -if ($client->isConnected()) { - // Client is connected and initialized -} -``` - -### Disconnecting - -```php -$client->disconnect(); -``` - -Always disconnect when finished to clean up resources: - -```php -try { - $client->connect($transport); - // ... use the client ... -} finally { - $client->disconnect(); -} -``` - -## Server Information - -After successful connection, retrieve server metadata: - -```php -// Get server implementation info -$serverInfo = $client->getServerInfo(); -echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; - -// Get server instructions -$instructions = $client->getInstructions(); -if ($instructions) { - echo "Instructions: {$instructions}\n"; -} -``` - -## Working with Tools - -### Listing Tools - -```php -$toolsResult = $client->listTools(); - -foreach ($toolsResult->tools as $tool) { - echo "- {$tool->name}: {$tool->description}\n"; -} - -// Handle pagination -if ($toolsResult->nextCursor) { - $moreTools = $client->listTools($toolsResult->nextCursor); -} -``` - -### Calling Tools - -```php -$result = $client->callTool( - name: 'calculate', - arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], -); - -// Access results -foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text; - } -} -``` - -### Progress Notifications - -Hook into tool execution progress (if server supports it): - -```php -$result = $client->callTool( - name: 'long_running_task', - arguments: ['data' => 'large_dataset'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo "Progress: {$percent}% - {$message}\n"; - } -); -``` - -> [!NOTE] -> Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. - -## Working with Resources - -### Listing Resources - -```php -$resourcesResult = $client->listResources(); - -foreach ($resourcesResult->resources as $resource) { - echo "- {$resource->uri}: {$resource->name}\n"; -} -``` - -### Listing Resource Templates - -```php -$templatesResult = $client->listResourceTemplates(); - -foreach ($templatesResult->resourceTemplates as $template) { - echo "- {$template->uriTemplate}: {$template->name}\n"; -} -``` - -### Reading Resources - -```php -$resourceResult = $client->readResource('config://app/settings'); - -foreach ($resourceResult->contents as $content) { - if ($content instanceof TextResourceContents) { - echo "Text: {$content->text}\n"; - } elseif ($content instanceof BlobResourceContents) { - echo "Binary data (base64): {$content->blob}\n"; - } -} -``` - -Resources also support progress notifications: - -```php -$result = $client->readResource( - uri: 'file://large-file.bin', - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Reading: {$progress}/{$total} bytes\n"; - } -); -``` - -## Working with Prompts - -### Listing Prompts - -```php -$promptsResult = $client->listPrompts(); - -foreach ($promptsResult->prompts as $prompt) { - echo "- {$prompt->name}: {$prompt->description}\n"; -} -``` - -### Getting Prompts - -```php -$promptResult = $client->getPrompt( - name: 'code_review', - arguments: ['language' => 'php', 'code' => '...'], -); - -foreach ($promptResult->messages as $message) { - echo "{$message->role->value}: {$message->content->text}\n"; -} -``` - -Prompts also support progress notifications: - -```php -$result = $client->getPrompt( - name: 'generate_report', - arguments: ['topic' => 'quarterly_analysis'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Generating: {$message}\n"; - } -); -``` - -### Requesting Completions - -Request auto-completion suggestions for prompt or resource arguments: - -```php -use Mcp\Schema\PromptReference; - -$completionResult = $client->complete( - ref: new PromptReference('code_review'), - argument: ['name' => 'language', 'value' => 'ph'], -); - -foreach ($completionResult->values as $value) { - echo "Suggestion: {$value}\n"; -} -``` - -## Server-Initiated Communication - -The client can receive requests and notifications from the server when configured with appropriate handlers. - -### Logging Notifications - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -Receive structured log messages from the server: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; -use Mcp\Schema\Enum\LoggingLevel; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - // Route to your application's logging system - $level = $notification->level; - $message = $notification->data; - - match ($level) { - LoggingLevel::Debug => logger()->debug($message), - LoggingLevel::Info => logger()->info($message), - LoggingLevel::Warning => logger()->warning($message), - LoggingLevel::Error => logger()->error($message), - default => logger()->info($message), - }; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); - -// Set minimum log level (optional) -$client->setLoggingLevel(LoggingLevel::Info); -``` - -### Sampling (LLM Requests) - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. - -Handle server requests for LLM completions: - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Exception\SamplingException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Enum\Role; - -class LlmSamplingCallback implements SamplingCallbackInterface -{ - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - try { - // Call your LLM provider - $response = $this->llmClient->complete( - messages: $request->messages, - maxTokens: $request->maxTokens, - temperature: $request->temperature ?? 0.7, - ); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response->text), - model: $response->model, - stopReason: $response->stopReason, - ); - } catch (\Throwable $e) { - // Throw SamplingException to surface error to server - throw new SamplingException( - "LLM sampling failed: {$e->getMessage()}", - (int) $e->getCode(), - $e - ); - } - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) - ->build(); -``` - -#### Sampling with Tools - -Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and -`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more -`ToolUseContent` blocks: - -```php -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Content\ToolUseContent; -use Mcp\Schema\Enum\Role; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, - samplingContext: true, - samplingTools: true, - )) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Inside the sampling callback, after invoking the LLM provider: -return new CreateSamplingMessageResult( - role: Role::Assistant, - content: array_map( - static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), - $providerResponse->toolCalls, - ), - model: $providerResponse->model, - stopReason: 'toolUse', -); -``` - -The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` -blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. - -> [!IMPORTANT] -> **Error Handling in Sampling Callbacks:** -> -> When implementing sampling callbacks, error handling is critical: -> -> - **Throw `SamplingException`** to forward specific error messages to the server -> - **Any other exception** will be logged but return a generic error to the server -> -> This distinction allows you to control what error information the server receives: -> -> ```php -> // Good: Server receives "Rate limit exceeded" message -> throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); -> -> // Bad: Server receives generic "Error while sampling LLM" message -> throw new \RuntimeException('Rate limit exceeded'); -> ``` - -### Elicitation (User Input Requests) - -Handle server requests to elicit additional information from the user during tool -execution. The server sends an `elicitation/create` request describing the fields it -needs; your callback presents them to the user and returns an `ElicitResult` with one of -three actions — accept (with the collected content), decline, or cancel: - -```php -use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Client\Handler\Request\ElicitationCallbackInterface; -use Mcp\Exception\ElicitationException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Enum\ElicitAction; -use Mcp\Schema\Request\ElicitRequest; -use Mcp\Schema\Result\ElicitResult; - -class ConsoleElicitationCallback implements ElicitationCallbackInterface -{ - public function __invoke(ElicitRequest $request): ElicitResult - { - echo $request->message.\PHP_EOL; - - // Present $request->requestedSchema->properties to the user and collect input. - $content = []; - foreach ($request->requestedSchema->properties as $name => $definition) { - $answer = readline($definition->title.': '); - - if (false === $answer) { - // No input available — let the server know the user cancelled. - return new ElicitResult(ElicitAction::Cancel); - } - - $content[$name] = $answer; - } - - return new ElicitResult(ElicitAction::Accept, $content); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) - ->build(); -``` - -Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the -information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. -Only the `Accept` action carries content. - -> [!IMPORTANT] -> **Error Handling in Elicitation Callbacks:** -> -> - **Throw `ElicitationException`** to forward a specific error message to the server -> - **Any other exception** is logged but returns a generic error to the server -> -> ```php -> // Good: Server receives "No interactive console available" message -> throw new ElicitationException('No interactive console available'); -> -> // Bad: Server receives generic "Error while processing elicitation" message -> throw new \RuntimeException('No interactive console available'); -> ``` - -See `examples/client/stdio_elicitation.php` for a runnable example against the -elicitation demo server. - -### Roots - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. - -Roots let the client expose a list of `file://` "workspace folders" that the server -is allowed to operate on. Advertise the `roots` capability and register a handler -that answers server `roots/list` requests: - -```php -use Mcp\Client\Handler\Request\ListRootsRequestHandler; -use Mcp\Client\Handler\Request\RootsCallbackInterface; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\ListRootsRequest; -use Mcp\Schema\Result\ListRootsResult; -use Mcp\Schema\Root; - -class WorkspaceRootsCallback implements RootsCallbackInterface -{ - public function __invoke(ListRootsRequest $request): ListRootsResult - { - return new ListRootsResult([ - new Root('file:///home/user/projects/app', 'Application'), - new Root('file:///home/user/projects/library', 'Library'), - ]); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) - ->build(); -``` - -When the client's roots change, notify the server so it can request the updated -list via `roots/list`. This requires advertising the `roots.listChanged` -capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` -throws a `RuntimeException`. On a client that is not connected it throws a -`ConnectionException`: - -```php -$client->sendRootsListChanged(); -``` - -See `examples/client/stdio_roots.php` for a runnable example: it calls the -`inspect_workspace_roots` tool of the client-communication demo server, which -answers by issuing the `roots/list` request back to the client. - -## Error Handling - -The client throws exceptions for various error conditions: - -### ConnectionException - -Thrown when connection or initialization fails: - -```php -use Mcp\Exception\ConnectionException; - -try { - $client->connect($transport); -} catch (ConnectionException $e) { - echo "Failed to connect: {$e->getMessage()}\n"; -} -``` - -### RequestException - -Thrown when a request returns an error response: - -```php -use Mcp\Exception\RequestException; - -try { - $result = $client->callTool('unknown_tool', []); -} catch (RequestException $e) { - echo "Request failed: {$e->getMessage()}\n"; - echo "Error code: {$e->getCode()}\n"; -} -``` - -## Complete Example - -Here's a comprehensive example demonstrating client usage: - -```php -level->value}] {$notification->data}\n"; - } -); - -// Configure sampling callback -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; - - try { - // Integration with your LLM provider - $response = "This is a mock LLM response for: " . - json_encode($request->messages); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response), - model: 'mock-llm', - stopReason: 'endTurn', - ); - } catch (\Throwable $e) { - throw new SamplingException( - "Sampling failed: {$e->getMessage()}", - 0, - $e - ); - } - } -}; - -// Build client -$client = Client::builder() - ->setClientInfo('Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Create transport -$transport = new StdioTransport( - command: 'php', - args: [__DIR__ . '/server.php'], -); - -// Connect and use server -try { - echo "Connecting to server...\n"; - $client->connect($transport); - - // Get server info - $serverInfo = $client->getServerInfo(); - echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; - - // List capabilities - echo "Available tools:\n"; - $tools = $client->listTools(); - foreach ($tools->tools as $tool) { - echo " - {$tool->name}\n"; - } - - echo "\nAvailable resources:\n"; - $resources = $client->listResources(); - foreach ($resources->resources as $resource) { - echo " - {$resource->uri}\n"; - } - - // Set logging level - $client->setLoggingLevel(LoggingLevel::Debug); - - // Call tool with progress - echo "\nCalling tool with progress...\n"; - $result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo " Progress: {$percent}% - {$message}\n"; - } - ); - - echo "\nResult:\n"; - foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text . "\n"; - } - } - -} catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; -} finally { - $client->disconnect(); - echo "\nDisconnected.\n"; -} -``` diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md new file mode 100644 index 0000000..5ba082e --- /dev/null +++ b/docs/client/capabilities.md @@ -0,0 +1,161 @@ +# Tools, resources & prompts + +Once connected, everything the server exposes is reachable through the client: list what +is there, then call it. Each list method returns the server's own descriptions and +schemas, so a generic client can build its UI from them. + +## Working with Tools + +### Listing Tools + +```php +$toolsResult = $client->listTools(); + +foreach ($toolsResult->tools as $tool) { + echo "- {$tool->name}: {$tool->description}\n"; +} + +// Handle pagination +if ($toolsResult->nextCursor) { + $moreTools = $client->listTools($toolsResult->nextCursor); +} +``` + +### Calling Tools + +```php +use Mcp\Schema\Content\TextContent; + +$result = $client->callTool( + name: 'calculate', + arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], +); + +// Access results +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } +} +``` + +### Progress Notifications + +Hook into tool execution progress (if server supports it): + +```php +$result = $client->callTool( + name: 'long_running_task', + arguments: ['data' => 'large_dataset'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo "Progress: {$percent}% - {$message}\n"; + } +); +``` + +!!! note + Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. + +## Working with Resources + +### Listing Resources + +```php +$resourcesResult = $client->listResources(); + +foreach ($resourcesResult->resources as $resource) { + echo "- {$resource->uri}: {$resource->name}\n"; +} +``` + +### Listing Resource Templates + +```php +$templatesResult = $client->listResourceTemplates(); + +foreach ($templatesResult->resourceTemplates as $template) { + echo "- {$template->uriTemplate}: {$template->name}\n"; +} +``` + +### Reading Resources + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +$resourceResult = $client->readResource('config://app/settings'); + +foreach ($resourceResult->contents as $content) { + if ($content instanceof TextResourceContents) { + echo "Text: {$content->text}\n"; + } elseif ($content instanceof BlobResourceContents) { + echo "Binary data (base64): {$content->blob}\n"; + } +} +``` + +Resources also support progress notifications: + +```php +$result = $client->readResource( + uri: 'file://large-file.bin', + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Reading: {$progress}/{$total} bytes\n"; + } +); +``` + +## Working with Prompts + +### Listing Prompts + +```php +$promptsResult = $client->listPrompts(); + +foreach ($promptsResult->prompts as $prompt) { + echo "- {$prompt->name}: {$prompt->description}\n"; +} +``` + +### Getting Prompts + +```php +$promptResult = $client->getPrompt( + name: 'code_review', + arguments: ['language' => 'php', 'code' => '...'], +); + +foreach ($promptResult->messages as $message) { + echo "{$message->role->value}: {$message->content->text}\n"; +} +``` + +Prompts also support progress notifications: + +```php +$result = $client->getPrompt( + name: 'generate_report', + arguments: ['topic' => 'quarterly_analysis'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Generating: {$message}\n"; + } +); +``` + +### Requesting Completions + +Request auto-completion suggestions for prompt or resource arguments: + +```php +use Mcp\Schema\PromptReference; + +$completionResult = $client->complete( + ref: new PromptReference('code_review'), + argument: ['name' => 'language', 'value' => 'ph'], +); + +foreach ($completionResult->values as $value) { + echo "Suggestion: {$value}\n"; +} +``` diff --git a/docs/client/connecting.md b/docs/client/connecting.md new file mode 100644 index 0000000..b060916 --- /dev/null +++ b/docs/client/connecting.md @@ -0,0 +1,222 @@ +# Connecting to a server + +A client is configured once through its builder, then connected to a +[transport](transports.md). Connecting performs the MCP initialization handshake, after +which the server's capabilities are known and its elements can be used. On protocol +revision `2026-07-28` there is no handshake to perform — see +[Clients on this revision](../protocol-versions.md). + +## Client Builder + +The `Client\Builder` provides fluent configuration of client instances. + +### Basic Configuration + +```php +use Mcp\Client; + +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0', 'Description of my client') + ->setInitTimeout(30) // Seconds to wait for initialization + ->setRequestTimeout(120) // Seconds to wait for request responses + ->setMaxRetries(3) // Retries for failed connections + ->build(); +``` + +### Connection Retries + +`setMaxRetries()` controls how often `connect()` retries a failed connection. It +counts retries rather than attempts, so the default of `3` means one initial +attempt plus up to three retries — four in total — before the `ConnectionException` +of the last attempt is rethrown: + +```php +$client = Client::builder() + ->setMaxRetries(0) // Fail on the first failed attempt + ->build(); +``` + +Between two attempts the transport is closed, so a retry never reuses a +half-established connection: a `StdioTransport` spawns a fresh server process and +an `HttpTransport` discards the session ID of the failed attempt. Each retry is +preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). + +Only the connection handshake is retried. Individual requests such as +`callTool()` are always sent once — retrying them is unsafe as tool calls are not +necessarily idempotent. + +### Client Information + +Set the client's identity reported to servers during initialization: + +```php +$client = Client::builder() + ->setClientInfo( + name: 'AI Assistant Client', + version: '2.1.0', + description: 'Client for automated AI workflows' + ) + ->build(); +``` + +### Protocol Version + +Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the +latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->build(); +``` + +This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as +described in the specification's +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) +section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK +cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed +on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. + +Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no +`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing +else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the +exchange. + +### Capabilities + +Declare client capabilities to enable server features: + +```php +use Mcp\Schema\ClientCapabilities; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, // Enable LLM sampling requests from server + roots: true, // Enable filesystem root listing + )) + ->build(); +``` + +### Notification Handlers + +Register handlers for server-initiated notifications: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + echo "[{$notification->level->value}] {$notification->data}\n"; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Request Handlers + +Register handlers for server-initiated requests (e.g., sampling). The same handlers answer a +[multi round-trip](../handlers/input-required.md) `input_required` result on a modern revision, where the server +returns its ask instead of sending a request: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}; + +$client = Client::builder() + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); +``` + +### Logger + +Configure PSR-3 logging for debugging: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-client'); +$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); + +$client = Client::builder() + ->setLogger($logger) + ->build(); +``` + +## Connecting to Servers + +### Establishing Connection + +```php +$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 +4. Sends InitializedNotification + +On a modern revision it opens the transport and asks `server/discover` for the server's identity instead; a server +that does not answer that optional method still yields a usable connection. + +!!! warning + Always wrap connection in try/catch to handle `ConnectionException` for failed connections. + +### Checking Connection State + +```php +if ($client->isConnected()) { + // Client is connected and initialized +} +``` + +### Disconnecting + +```php +$client->disconnect(); +``` + +Always disconnect when finished to clean up resources: + +```php +try { + $client->connect($transport); + // ... use the client ... +} finally { + $client->disconnect(); +} +``` + +## Server Information + +After successful connection, retrieve server metadata: + +```php +// Get server implementation info +$serverInfo = $client->getServerInfo(); +echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; + +// Get server instructions +$instructions = $client->getInstructions(); +if ($instructions) { + echo "Instructions: {$instructions}\n"; +} +``` diff --git a/docs/client/errors.md b/docs/client/errors.md new file mode 100644 index 0000000..94e27f9 --- /dev/null +++ b/docs/client/errors.md @@ -0,0 +1,155 @@ +# Error Handling + +The client throws exceptions for various error conditions: + +## ConnectionException + +Thrown when connection or initialization fails: + +```php +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + echo "Failed to connect: {$e->getMessage()}\n"; +} +``` + +## RequestException + +Thrown when a request returns an error response: + +```php +use Mcp\Exception\RequestException; + +try { + $result = $client->callTool('unknown_tool', []); +} catch (RequestException $e) { + echo "Request failed: {$e->getMessage()}\n"; + echo "Error code: {$e->getCode()}\n"; +} +``` + +## Complete Example + +Here's a comprehensive example demonstrating client usage: + +```php-file +level->value}] {$notification->data}\n"; + } +); + +// Configure sampling callback +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; + + try { + // Integration with your LLM provider + $response = "This is a mock LLM response for: " . + json_encode($request->messages); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response), + model: 'mock-llm', + stopReason: 'endTurn', + ); + } catch (\Throwable $e) { + throw new SamplingException( + "Sampling failed: {$e->getMessage()}", + 0, + $e + ); + } + } +}; + +// Build client +$client = Client::builder() + ->setClientInfo('Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Create transport +$transport = new StdioTransport( + command: 'php', + args: [__DIR__ . '/server.php'], +); + +// Connect and use server +try { + echo "Connecting to server...\n"; + $client->connect($transport); + + // Get server info + $serverInfo = $client->getServerInfo(); + echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; + + // List capabilities + echo "Available tools:\n"; + $tools = $client->listTools(); + foreach ($tools->tools as $tool) { + echo " - {$tool->name}\n"; + } + + echo "\nAvailable resources:\n"; + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + echo " - {$resource->uri}\n"; + } + + // Set logging level + $client->setLoggingLevel(LoggingLevel::Debug); + + // Call tool with progress + echo "\nCalling tool with progress...\n"; + $result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo " Progress: {$percent}% - {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); + echo "\nDisconnected.\n"; +} +``` diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 0000000..f2a9848 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,41 @@ +# Clients + +The client side is for applications that *use* MCP servers: you connect to a server, +discover what it offers, and call it. The API is synchronous — every method returns a +result or throws. + +```php +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +// Build and configure the client +$client = Client::builder() + ->setClientInfo('My Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); + +// Create a transport +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +// Connect and use the server +$client->connect($transport); +$tools = $client->listTools(); +$client->disconnect(); +``` + +* **[Connecting to a server](connecting.md)** — the builder, the connection lifecycle, + and what the server told you about itself during initialization. +* **[Transports](transports.md)** — launching a local server process (STDIO) or talking + to a remote one (HTTP). +* **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a + server exposes, including progress callbacks and completions. +* **[Server-initiated requests](server-requests.md)** — the other direction: log + messages, sampling requests, and elicitations the server sends *you*. +* **[Error handling](errors.md)** — which exception means what, plus a complete + end-to-end example. +* **[Clients on this revision](../protocol-versions.md)** — the one builder line that speaks + protocol revision `2026-07-28`, and what it changes underneath. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md new file mode 100644 index 0000000..d25cd5c --- /dev/null +++ b/docs/client/server-requests.md @@ -0,0 +1,265 @@ +# Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +## Elicitation (User Input Requests) + +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: + +```php +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // In url mode there is no schema to fill in — the user completes the + // interaction in the browser instead. + if (ElicitationMode::Url === $request->mode) { + echo 'Continue in your browser: '.$request->url.\PHP_EOL; + + return new ElicitResult(ElicitAction::Accept); + } + + // Form mode: present $request->requestedSchema->properties and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->build(); +``` + +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +!!! warning + **Error Handling in Elicitation Callbacks:** + + - **Throw `ElicitationException`** to forward a specific error message to the server + - **Any other exception** is logged but returns a generic error to the server + + ```php + // Good: Server receives "No interactive console available" message + throw new ElicitationException('No interactive console available'); + + // Bad: Server receives generic "Error while processing elicitation" message + throw new \RuntimeException('No interactive console available'); + ``` + +See [`examples/client/stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) for a runnable example against the +elicitation demo server. + +## Sampling (LLM Requests) + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + +Handle server requests for LLM completions: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Exception\SamplingException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; + +class LlmSamplingCallback implements SamplingCallbackInterface +{ + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + try { + // Call your LLM provider + $response = $this->llmClient->complete( + messages: $request->messages, + maxTokens: $request->maxTokens, + temperature: $request->temperature ?? 0.7, + ); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response->text), + model: $response->model, + stopReason: $response->stopReason, + ); + } catch (\Throwable $e) { + // Throw SamplingException to surface error to server + throw new SamplingException( + "LLM sampling failed: {$e->getMessage()}", + (int) $e->getCode(), + $e + ); + } + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) + ->build(); +``` + +### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: 'toolUse', +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + +!!! warning + **Error Handling in Sampling Callbacks:** + + When implementing sampling callbacks, error handling is critical: + + - **Throw `SamplingException`** to forward specific error messages to the server + - **Any other exception** will be logged but return a generic error to the server + + This distinction allows you to control what error information the server receives: + + ```php + // Good: Server receives "Rate limit exceeded" message + throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); + + // Bad: Server receives generic "Error while sampling LLM" message + throw new \RuntimeException('Rate limit exceeded'); + ``` + +## Logging Notifications + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +Receive structured log messages from the server: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); + +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` + +## Roots + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: + +```php +$client->sendRootsListChanged(); +``` + +See [`examples/client/stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 0000000..dd27166 --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,61 @@ +# Transports + +Transports handle the communication layer between client and server. + +## STDIO Transport + +Spawns a server process and communicates via standard input/output: + +```php +use Mcp\Client\Transport\StdioTransport; + +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], + cwd: '/working/directory', // Optional working directory + env: ['KEY' => 'value'], // Optional environment variables +); +``` + +**Parameters:** +- `command` (string): The command to execute +- `args` (array): Command arguments +- `cwd` (string|null): Working directory for the process +- `env` (array|null): Environment variables +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxBufferSize` (int): Maximum buffered bytes per message before the transport gives up + +## HTTP Transport + +Communicates with remote MCP servers over HTTP: + +```php +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + endpoint: 'http://localhost:8000', + headers: ['Authorization' => 'Bearer token'], +); +``` + +**Parameters:** +- `endpoint` (string): The MCP server URL +- `headers` (array): Additional HTTP headers +- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) +- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) +- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxSseBufferBytes` (int): Maximum buffered bytes for a streamed SSE response + +**PSR-18 Auto-Discovery:** + +The transport automatically discovers PSR-18 HTTP clients from: +- `php-http/guzzle7-adapter` +- `php-http/curl-client` +- `symfony/http-client` +- And other PSR-18 compatible implementations + +```bash +# Install any PSR-18 client - discovery works automatically +composer require php-http/guzzle7-adapter +``` diff --git a/docs/examples.md b/docs/examples.md index 14e97fd..4eae5ba 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,520 +1,97 @@ # Examples -The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases -specific features and can be run independently to understand how the SDK works. - -## Table of Contents - -- [Getting Started](#getting-started) -- [Running Examples](#running-examples) -- [Server Examples](#server-examples) -- [Client Examples](#client-examples) - -## Getting Started - -All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples -can be run directly without additional setup. - -### Prerequisites +Every example lives in [`examples/`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples) +and runs against the dependencies installed in the repository root. Each server example is a +`server.php` whose bootstrap picks the transport from the SAPI it runs under: ```bash -# Install dependencies (in project root) -composer install +# STDIO transport +php examples/server/discovery-calculator/server.php + +# Streamable HTTP transport +php -S 127.0.0.1:8000 examples/server/discovery-userprofile/server.php + +# Interactive testing with the MCP Inspector +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` -## Running Examples +## Server examples -The bootstrapping of the example will choose the used transport based on the SAPI you use. +| Example | What it shows | Docs | +| --- | --- | --- | +| [`discovery-calculator`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-calculator) | Attribute discovery of tools, resources and prompts; `ResourceLink` content | [Tools](servers/tools.md) | +| [`discovery-userprofile`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-userprofile) | Resource templates with completion providers; `FileSessionStore` | [Resource templates](servers/resource-templates.md), [Completions](servers/completions.md) | +| [`explicit-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/explicit-registration) | Manual `addTool()`/`addResource()`/`addResourceTemplate()`/`addPrompt()` without discovery | [Registering elements](servers/registration.md) | +| [`combined-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/combined-registration) | Discovery and manual registration combined, and which wins on conflict | [Registering elements](servers/registration.md) | +| [`cached-discovery`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/cached-discovery) | Caching the discovery scan in a PSR-16 cache | [Server builder](run/server-builder.md#discovery-configuration) | +| [`custom-dependencies`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-dependencies) | Handlers pulling services from a PSR-11 container | [Server builder](run/server-builder.md#service-dependencies) | +| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums and defaults | [Schema generation](servers/schemas.md) | +| [`schema-showcase`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/schema-showcase) | `#[Schema]` constraint attributes: formats, patterns, ranges | [Schema generation](servers/schemas.md) | +| [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | +| [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | +| [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `InputRequiredResult` and typed elicitation schemas | [Asking for input](handlers/input-required.md) | +| [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | +| [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | +| [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | +| [`oauth-keycloak`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-keycloak) | OAuth authorization against a Keycloak instance (own README) | [Authorization](run/authorization.md) | +| [`oauth-microsoft`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-microsoft) | OAuth authorization against Microsoft Entra ID (own README) | [Authorization](run/authorization.md) | -### STDIO Transport +## Client examples -The STDIO transport will use standard input/output for communication: +| Example | What it shows | Docs | +| --- | --- | --- | +| [`stdio_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_discovery_calculator.php) | Connecting over STDIO, listing and calling tools, reading resources | [Connecting to a server](client/connecting.md) | +| [`http_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_discovery_calculator.php) | The same conversation over the Streamable HTTP transport | [Transports](client/transports.md) | +| [`stdio_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_client_communication.php) | Answering server-initiated sampling, log and progress messages | [Server-initiated requests](client/server-requests.md) | +| [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over HTTP — see the note on PHP's built-in server below | [Server-initiated requests](client/server-requests.md) | +| [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) | +| [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) | +| [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client pinned to revision `2026-07-28` — see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) | + +Client examples run directly: ```bash -# Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php - -# Or configure the script path in your MCP client -# Path: php examples/discovery-calculator/server.php -``` - -### HTTP Transport - -The Streamable HTTP transport will be chosen if running examples with a web servers: - -```bash -# Start the server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Test with curl -curl -X POST http://localhost:8000 \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' -``` - -## Server Examples - -### Discovery Calculator - -**File**: `examples/discovery-calculator/` - -**What it demonstrates:** -- Attribute-based discovery using `#[McpTool]` and `#[McpResource]` -- Basic arithmetic operations -- Configuration management through resources -- State management between tool calls - -**Key Features:** -```php -#[McpTool(name: 'calculate')] -public function calculate(float $a, float $b, string $operation): float|string - -#[McpResource( - uri: 'config://calculator/settings', - name: 'calculator_config', - mimeType: 'application/json' -)] -public function getConfiguration(): array -``` - -**Usage:** -```bash -# Interactive testing -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Or configure in MCP client: php examples/discovery-calculator/server.php -``` - -### Explicit Registration - -**File**: `examples/explicit-registration/` - -**What it demonstrates:** -- Manual registration of tools, resources, and prompts -- Alternative to attribute-based discovery -- Simple handler functions - -**Key Features:** -```php -$server = Server::builder() - ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') - ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version') - ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') -``` - -### Environment Variables - -**File**: `examples/env-variables/` - -**What it demonstrates:** -- Environment variable integration -- Server configuration from environment -- Environment-based tool behavior - -**Key Features:** -- Reading environment variables within tools -- Conditional behavior based on environment -- Environment validation and defaults - -### Custom Dependencies - -**File**: `examples/custom-dependencies/` - -**What it demonstrates:** -- Dependency injection with PSR-11 containers -- Service layer architecture -- Repository pattern implementation -- Complex business logic integration - -**Key Features:** -```php -$container->set(TaskRepositoryInterface::class, $taskRepo); -$container->set(StatsServiceInterface::class, $statsService); - -$server = Server::builder() - ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) -``` - -### Cached Discovery - -**File**: `examples/cached-discovery/` - -**What it demonstrates:** -- Discovery caching for improved performance -- PSR-16 cache integration -- Cache invalidation strategies - -**Key Features:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) -``` - -### Client Communication - -**File**: `examples/client-communication/` - -**What it demonstrates:** -- Server initiated communication back to the client -- Logging, sampling, progress and notifications -- Using `ClientGateway` in tool method via method argument injection of `RequestContext` - -### Discovery User Profile - -**File**: `examples/discovery-userprofile/` - -**What it demonstrates:** -- HTTP transport with StreamableHttpTransport -- Resource templates with URI parameters -- Completion providers for parameter hints -- User profile management system -- Session persistence with FileSessionStore - -**Key Features:** -```php -#[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - mimeType: 'application/json' -)] -public function getUserProfile( - #[CompletionProvider(values: ['101', '102', '103'])] - string $userId -): array - -#[McpPrompt(name: 'generate_bio_prompt')] -public function generateBio(string $userId, string $tone = 'professional'): array -``` - -**Usage:** -```bash -# Start the HTTP server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Or configure in MCP client: http://localhost:8000 -``` - -### Combined Registration - -**File**: `examples/combined-registration/` - -**What it demonstrates:** -- Mixing attribute discovery with manual registration -- HTTP server with both discovered and manual capabilities -- All three handler styles: discovered, `[Class::class, 'method']`, and a pre-built `[$instance, 'method']` -- Pre-built instance handlers for classes the container cannot auto-wire (e.g. constructor scalars) - -**Key Features:** -```php -// Built here so its constructor dependencies are injected before registration; -// the SDK invokes this very instance instead of constructing one itself. -$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual class-string handler - ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') // Pre-built instance handler - ->addResource([ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority') -``` - -### Complex Tool Schema - -**File**: `examples/complex-tool-schema/` - -**What it demonstrates:** -- Advanced JSON schema definitions -- Complex data structures and validation -- Event scheduling and management -- Enum types and nested objects - -**Key Features:** -```php -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], - 'eventType' => ['type' => 'string', 'enum' => ['meeting', 'deadline', 'reminder']], - 'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high', 'urgent']] - ] -])] -public function scheduleEvent(array $eventData): array -``` - -### Schema Showcase - -**File**: `examples/schema-showcase/` - -**What it demonstrates:** -- Comprehensive JSON schema features -- Parameter-level schema validation -- String constraints (minLength, maxLength, pattern) -- Numeric constraints (minimum, maximum, multipleOf) -- Array and object validation - -**Key Features:** -```php -#[McpTool] -public function formatText( - #[Schema( - type: 'string', - minLength: 5, - maxLength: 100, - pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' - )] - string $text, - - #[Schema(enum: ['uppercase', 'lowercase', 'title', 'sentence'])] - string $format = 'sentence' -): array -``` - -### Elicitation - -**File**: `examples/server/elicitation/` - -**What it demonstrates:** -- Server-to-client elicitation requests -- Interactive user input during tool execution -- Multi-field form schemas with validation -- Boolean confirmation dialogs -- Enum fields with human-readable labels -- Handling accept/decline/cancel responses -- Session persistence requirement for server-initiated requests - -**Key Features:** -```php -// Check client support before eliciting -if (!$context->getClientGateway()->supportsElicitation()) { - return ['status' => 'error', 'message' => 'Client does not support elicitation']; -} - -// Build schema with multiple field types -$schema = new ElicitationSchema( - properties: [ - 'party_size' => new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - minimum: 1, - maximum: 20 - ), - 'date' => new StringSchemaDefinition( - title: 'Reservation Date', - format: 'date' - ), - 'dietary' => new EnumSchemaDefinition( - title: 'Dietary Restrictions', - enum: ['none', 'vegetarian', 'vegan'], - enumNames: ['None', 'Vegetarian', 'Vegan'] - ), - ], - required: ['party_size', 'date'] -); - -// Send elicitation request -$result = $client->elicit( - message: 'Please provide your reservation details', - requestedSchema: $schema -); - -// Handle response -if ($result->isAccepted()) { - $data = $result->content; // User-provided data -} elseif ($result->isDeclined() || $result->isCancelled()) { - // User declined or cancelled -} -``` - -**Important Notes:** -- Elicitation requires a session store (e.g., `FileSessionStore`) -- Check client capabilities with `supportsElicitation()` before sending requests -- Schema supports primitive types: string, number/integer, boolean, enum -- String fields support format validation: date, date-time, email, uri -- Users can accept (providing data), decline, or cancel requests - -**Usage:** -```bash -# Interactive testing with MCP client that supports elicitation -npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php - -# Test with Goose (confirmed working by reviewer) -# Or configure in Claude Desktop or other MCP clients -``` - -**Example Tools:** -1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields -2. **confirm_action** - Simple boolean confirmation dialog -3. **collect_feedback** - Rating and comments form with optional fields - -### MCP Apps - -**File**: `examples/server/mcp-apps/` - -A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` -HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to -the `get_weather` tool. The bundled `weather-app.html` performs the -`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, -and calls back into the server. See the -[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the -TypeScript SDK and richer view-side patterns. - -## Client Examples - -### STDIO Discovery Calculator (Client) - -**File**: `examples/client/stdio_discovery_calculator.php` - -**What it demonstrates:** -- Basic MCP client usage with STDIO transport -- Connecting to a local MCP server process -- Listing and calling tools -- Reading resources - -**Key Features:** -```php -$client = Client::builder() - ->setClientInfo('STDIO Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(60) - ->build(); - -$transport = new StdioTransport( - command: 'php', - args: [__DIR__.'/../server/discovery-calculator/server.php'], -); - -$client->connect($transport); -$tools = $client->listTools(); -$result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); -$resourceContent = $client->readResource('config://calculator/settings'); -``` - -**Usage:** -```bash -# Run the client (automatically starts the server) php examples/client/stdio_discovery_calculator.php ``` -### HTTP Discovery Calculator (Client) +> **Note**: PHP's built-in development server handles one request at a time, so the sampling +> round-trip in `http_client_communication.php` will not complete under a plain `php -S`. +> Start it with worker processes instead: `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. -**File**: `examples/client/http_discovery_calculator.php` +## The 2026-07-28 lifecycle -**What it demonstrates:** -- MCP client with HTTP transport -- Connecting to remote MCP servers -- Listing tools, resources, and prompts +`stateless-lifecycle/server.php` answers both eras on the same URL. On revision `2026-07-28` +every request carries its own protocol version and client capabilities, so a call is a single +POST with no handshake before it: -**Key Features:** -```php -$transport = new HttpTransport('http://localhost:8000'); -$client->connect($transport); - -$tools = $client->listTools(); -$resources = $client->listResources(); -$prompts = $client->listPrompts(); -``` - -**Usage:** ```bash -# Start the server first -php -S localhost:8000 examples/server/http-discovery-calculator/server.php - -# Then run the client -php examples/client/http_discovery_calculator.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php ``` -### STDIO Client Communication - -**File**: `examples/client/stdio_client_communication.php` - -**What it demonstrates:** -- Server-to-client communication (logging, progress, sampling) -- Handling logging notifications from server -- Implementing sampling callbacks for LLM requests -- Progress tracking during tool execution - -**Key Features:** -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\ClientCapabilities; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - } -); - -$samplingHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler($samplingHandler) - ->build(); - -// Call tool with progress tracking -$result = $client->callTool( - name: 'run_dataset_quality_checks', - arguments: ['dataset' => 'customer_orders_2024'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; - echo "[PROGRESS {$percent}%] {$message}\n"; - } -); -``` - -**Usage:** ```bash -# Run the client (automatically starts the communication server) -php examples/client/stdio_client_communication.php +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' ``` -### HTTP Client Communication +The example's header comments document the equivalent handshake-era conversation against the +same endpoint. [Serving both eras](run/protocol-eras.md) explains how the routing works. -**File**: `examples/client/http_client_communication.php` +## Modern-era client -**What it demonstrates:** -- Server-to-client communication over HTTP -- Receiving logging and progress notifications via SSE streaming -- Implementing sampling for HTTP-based servers -- Progress tracking with long-running operations +`stateless_lifecycle_client.php` drives the server above from PHP: it pins +`ProtocolVersion::V2026_07_28`, skips the handshake, discovers the server through +`server/discover` and calls a tool — one process, no session: -**Key Features:** -- Same client-side code as STDIO version -- Uses HttpTransport instead of StdioTransport -- Demonstrates SSE-based real-time notifications -- Shows HTTP session management - -**Usage:** ```bash -# Start the server -php -S 127.0.0.1:8000 examples/server/client-communication/server.php - -# Run the client -php examples/client/http_client_communication.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php & +php examples/client/stateless_lifecycle_client.php ``` -> [!NOTE] -> For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. +See [Clients on the modern revision](client/connecting.md) for the API it uses. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..a280d7f --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/get-started/first-server.md b/docs/get-started/first-server.md new file mode 100644 index 0000000..1fbb8a3 --- /dev/null +++ b/docs/get-started/first-server.md @@ -0,0 +1,84 @@ +# First server + +A server is a plain PHP class plus three lines of wiring. Create `server.php` next to +your `vendor/` directory: + +```php-file title="server.php" +#!/usr/bin/env php + 2]; + } +} + +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); +``` + +Discovery needs `symfony/finder`: + +```bash +composer require symfony/finder +``` + +## What each piece does + +`#[McpTool]` marks a method as an action the *model* can call. Its name defaults to the +method name, its description comes from the docblock (the summary, plus the longer +description if you write one), and its input schema is +generated from the parameter types — `int $a, int $b` becomes a JSON Schema with two +required integers. See [Tools](../servers/tools.md). + +`#[McpResource]` marks a method as read-only data the *application* can read, addressed +by URI. See [Resources](../servers/resources.md). + +`setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor'])` scans those directories for +attributed classes. Scanning is lazy: it happens on the first request that needs the +registry, not when `build()` returns — call `setLazyLoading(false)` if you would rather +pay for it up front. Excluding `vendor` matters because the scan is recursive and would +otherwise read and autoload every file your dependencies ship. If you would rather +register elements explicitly — or mix both — see +[Registering elements](../servers/registration.md). + +`run(new StdioTransport())` speaks JSON-RPC over stdin/stdout and returns an exit code. +That is the transport local MCP hosts launch as a subprocess; for a web-facing server +use the [HTTP transport](../run/http.md) instead. + +!!! warning "Never write to STDOUT" + With the STDIO transport, `STDOUT` carries the protocol. `echo`, `print_r()`, or a + stray `var_dump()` in a handler corrupts the stream. Write to `STDERR`, or use the + [logger](../handlers/logging.md). + +## Run it + +```bash +php server.php +``` + +Nothing happens — the server is waiting for JSON-RPC on stdin, which is exactly right. +Stop it with `Ctrl+C`, and let a real client drive it instead: +[Try it with the Inspector](inspector.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 0000000..eada9d3 --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,27 @@ +# Get started + +New to MCP, or new to this SDK? Start here. These pages take you from nothing to a +server a real MCP host can talk to: [install the SDK](installation.md), build your +[first server](first-server.md), and [open it in the Inspector](inspector.md). + +## Run the code + +Every code block on these pages is a complete, working file — copy it into +`server.php` next to your `vendor/` directory and run it. + +It is worth actually typing (or pasting) and running them: what the SDK does for you +only really shows up in your own editor, where the type hints you write turn into the +schema a model sees. + +## Where to go next + +Once you have a server running, the rest of these docs are a reference, not a course. +Every page stands on its own, so jump straight to what you need: + +* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, an existing framework app) is + **[Running your server](../run/index.md)**. +* What is available inside the functions you register is + **[Inside your handler](../handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](../client/index.md)**. diff --git a/docs/get-started/inspector.md b/docs/get-started/inspector.md new file mode 100644 index 0000000..27e146a --- /dev/null +++ b/docs/get-started/inspector.md @@ -0,0 +1,88 @@ +# Try it with the Inspector + +The [MCP Inspector](https://modelcontextprotocol.io/docs/latest/tools/inspector) is an interactive +UI for poking at a server: it lists what the server exposes and lets you call it by +hand. It is the fastest way to see whether your server does what you think it does. + +It is a Node.js application, so this needs `npx` on your `PATH`. + +## STDIO + +Point the Inspector at the command that starts your server — it launches the process +itself: + +```bash +npx @modelcontextprotocol/inspector php server.php +``` + +Open the URL it prints. Under **Tools**, call `add` with `a=1` and `b=2`; you get `3` +back. The form the Inspector built for you — a required integer field for each argument +— came from the type hints on the method. So will the schema every other MCP host sees. + +Under **Resources**, read `config://calculator/settings` to get the array back as JSON. + +## HTTP + +A server behind a web server speaks the [HTTP transport](../run/http.md) instead, so the +last lines of `server.php` change — `StdioTransport` reads stdin and would just block +under `php -S`: + +```php title="server.php (HTTP variant)" +use Http\Discovery\Psr17Factory; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->build() + ->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); +``` + +That needs a PSR-17 implementation and an emitter +(`composer require nyholm/psr7 laminas/laminas-httphandlerrunner`). Start it, then give +the Inspector its URL: + +```bash +php -S localhost:8000 server.php +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +`curl` works too, if you would rather see the wire format: + +```bash +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' +``` + +## Connect a real host + +Hosts that launch local servers take the same command the Inspector did. For Claude +Desktop, that is an entry in its configuration file: + +```json +{ + "mcpServers": { + "calculator": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` + +Use an absolute path: the host does not run the command from your project directory. + +## Next + +* Add more of what a server can expose: **[Servers](../servers/index.md)**. +* Put it on the web instead of stdin/stdout: **[HTTP transport](../run/http.md)**. +* Drive a server from PHP instead of a UI: **[Clients](../client/index.md)**. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 0000000..4edca46 --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,44 @@ +# Installation + +The SDK ships as a single Composer package: + +```bash +composer require mcp/sdk +``` + +It requires **PHP 8.1+** and the `fileinfo` extension. Most of what it pulls in is PSR +interface packages (`psr/container`, `psr/log`, `psr/http-message`, …); the rest is +`opis/json-schema` for schema validation, `symfony/uid` for session identifiers, +`phpdocumentor/reflection-docblock` for reading descriptions out of your docblocks, and +`php-http/discovery` for finding PSR-17/PSR-18 implementations. + +That is enough for a complete [STDIO server](../run/stdio.md) and for the +[STDIO client](../client/transports.md). + +## Optional packages + +Which extras you need depends on what you build: + +| You want to… | Also install | +| --- | --- | +| discover `#[McpTool]` & friends from a directory | `symfony/finder` | +| serve over [HTTP](../run/http.md) | any PSR-17 implementation, e.g. `nyholm/psr7` | +| emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` | +| [connect a client over HTTP](../client/transports.md) | any PSR-18 client, e.g. `symfony/http-client` | +| [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` | +| store sessions in a PSR-16 cache | `psr/simple-cache` implementation, e.g. `symfony/cache` | + +PSR-17 and PSR-18 implementations are found through +[`php-http/discovery`](https://docs.php-http.org/en/latest/discovery.html), so +installing the package is all that is needed — no wiring: + +```bash +composer require nyholm/psr7 +``` + +If discovery picks the wrong one, or you want to be explicit, pass the factories to the +transport yourself; see [HTTP transport](../run/http.md). + +## Next + +Write your [first server](first-server.md). diff --git a/docs/server-client-communication.md b/docs/handlers/client-communication.md similarity index 68% rename from docs/server-client-communication.md rename to docs/handlers/client-communication.md index 084f51d..578d31e 100644 --- a/docs/server-client-communication.md +++ b/docs/handlers/client-communication.md @@ -1,26 +1,22 @@ -# Client Communication +# Talking back to the client -MCP supports various ways a server can communicate back to a client on top of the main request-response flow. +MCP supports various ways a server can communicate back to a client on top of the main +request-response flow. -> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own -> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are -> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` -> raise a `LogicException` there. Logging and progress still work as described below — they simply travel -> on the request's own response stream, and the client opts into each. See -> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). - -## Table of Contents - -- [ClientGateway](#client-gateway) -- [Sampling](#sampling) -- [Logging](#logging) -- [Notification](#notification) -- [Progress](#progress) +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server +> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, +> elicitation and roots are carried back inside the *result* instead, and +> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. +> Logging and progress still work as described below — they simply travel on the request's own +> response stream, and the client opts into each. See +> [Asking for input](input-required.md). ## ClientGateway Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per -operation. To use the `ClientGateway` in your code, you need to use method argument injection for `RequestContext`. +operation. Reach it through method argument injection for `RequestContext`. (A `ClientGateway`-typed parameter is +injected too, but unlike `RequestContext` it is not excluded from the generated input schema, so it would show up as +an argument of your tool.) Every reference of a MCP element, that translates to an actual method call, can just add an type-hinted argument for the `RequestContext` and the SDK will take care to include the gateway in the arguments of the method call: @@ -37,8 +33,10 @@ class MyService $context->getClientGateway()->log(...); ``` -The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is -only available from a certain revision on: +## Request metadata + +`RequestContext` also carries what the current request said about itself, which is useful when a feature is only +available from a certain revision on: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -48,6 +46,22 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { } ``` +Two more accessors read what the client declared, and both work in either +[protocol era](../protocol-versions.md) — negotiated once during the handshake, or declared per request from +`2026-07-28` on: + +```php +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and the +sub-capability variants — read the same declaration. + +W3C trace context is passed through exactly as it arrived, and echoed onto every notification the request causes, +so a span stays joined across the response stream. Reading it adds no OpenTelemetry dependency — the values are +strings. + ## Sampling > **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. @@ -61,7 +75,7 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper The `sample` method accepts four arguments: -1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` instances. +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `Mcp\Schema\Content\SamplingMessage` instances. 2. `maxTokens`, which defaults to `1000` 3. `timeout` in seconds, which defaults to `120` 4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, @@ -119,9 +133,12 @@ notification a server can update a client while an operation is ongoing: $clientGateway->progress(4.2, 10, 'Downloading needed images.'); ``` +Progress is opt-in by the client in both eras: it sends a `progressToken` with the request, and without one this +call sends nothing. + ## Notification -Lastly, the server can push all kind of notifications, that implement the `Mcp\Schema\JsonRpc\Notification` interface +Lastly, the server can push all kind of notifications, that extend the abstract `Mcp\Schema\JsonRpc\Notification` class to the client to: ```php diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 0000000..bb78814 --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,42 @@ +# Inside your handler + +The methods you register are ordinary PHP methods, but they are not cut off from the +protocol. Type-hint a `Mcp\Server\RequestContext` argument anywhere in the signature and +the SDK passes it in — that object is the way back to the client mid-request. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Server\RequestContext; + +#[McpTool] +public function summarize(string $text, RequestContext $context): string +{ + $context->getClientLogger()->info(\sprintf('Summarizing %d characters', \strlen($text))); + + $result = $context->getClientGateway()->sample("Summarize:\n\n".$text, 500); + + // `content` is TextContent|ImageContent|AudioContent (or ToolUseContent + // blocks when the client samples with tools) + return $result->content instanceof TextContent ? $result->content->text : ''; +} +``` + +Two caveats on this example: clients drop log messages below `warning` unless they raise +the level first (see [Logging](logging.md)), and `sample()` belongs to the features +[deprecated by revision `2026-07-28`](../protocol-versions.md#deprecations) — on that +revision it throws, and [Asking for input](input-required.md) is the way to write it +instead. + +* **[Talking back to the client](client-communication.md)** — the `ClientGateway`: + asking the client's model for a completion (sampling), reporting progress on a long + call, and sending notifications. +* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, + not in your server's log file. +* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a + handler needs elicitation, sampling or roots. Written that way, one handler serves both + [protocol eras](../protocol-versions.md). + +Handlers that need application services (a database connection, an API client) get them +from the container instead; see +[Service dependencies](../run/server-builder.md#service-dependencies). diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md new file mode 100644 index 0000000..9cb3dd5 --- /dev/null +++ b/docs/handlers/input-required.md @@ -0,0 +1,111 @@ +# Asking for input + +Some handlers cannot finish in one go: they need the user to confirm something, fill in a +form, name a directory, or have the client's model draft a paragraph. The way to write that +is to **return** the ask — an `InputRequiredResult` naming what you need — and read the +answer off `RequestContext` when the call comes back. + +Write it that way once and it serves both [protocol eras](../protocol-versions.md). +Revision `2026-07-28` has no server-initiated requests at all, so the client retries the +original call carrying the answers; the specification calls that a multi round-trip request +(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own +channel instead. Your handler does not fork on which — see +[What a handler forks on](#what-a-handler-forks-on). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +## Reading the answers + +`InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean +the same thing to a handler: ask again. `response()` is still there for the raw array. + +## `requestState` + +Whatever the server needs to remember between rounds. It travels through the client, so it +is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and +a state that fails verification never reaches a handler. Configure the key with +`Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random +value works only for a single-process deployment. Nothing secret belongs in the payload — +it is signed, not encrypted. + +## Capabilities + +A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. +Url-mode elicitation needs its own `elicitation.url` declaration; a bare `elicitation` means +form mode only. + +## What not to call + +`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the +handshake era. Calling one under this revision raises a `LogicException` naming +`InputRequiredResult` as the replacement. + +## What a handler forks on + +Nothing. Tools, resources, prompts, structured output, progress and errors do not care +which era called, and neither does the one thing that looks like it should: **asking the +user something**. + +Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the +answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era +connection the SDK's input-required shim fulfils the same ask over that connection's own +channel: each embedded request goes out as the real `elicitation/create` / +`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under +the keys it asked for. It is on by default; +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +and +[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) +are written this way and name no era anywhere. + +Two things to know about it. + +**Re-entry is re-execution.** The handler runs again from the top each round, so it has to +re-derive where it is from what came back rather than from anything it kept. That is already +true of the modern era — the client retries the whole call there — so a portable handler is +written that way regardless. It is only new if you were relying on `ClientGateway::elicit()` +suspending mid-body and keeping your locals; that keeps working untouched, since nothing here +runs unless a handler *returns* an ask. + +**Each round holds the request open.** The shim waits for the client's answer inside the +originating request, which on a process-per-request runtime means it holds a worker for as +long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that +leg, but the shim makes it reachable from handlers that never mention it — so size +`setInputRequiredLimits()` against your pool. + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + // Re-entries per request, and seconds to wait for one answer. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + ->build(); +``` + +`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era +connection instead of being fulfilled behind your back. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md new file mode 100644 index 0000000..1a10e50 --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,39 @@ +# Logging + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +The SDK provides support to send log messages to clients. All standard PSR-3 log levels are supported. +Level **warning** is the default level, so anything below it is dropped until the client raises the level with +`logging/setLevel`. + +!!! note + Under the [2026-07-28 lifecycle](../run/protocol-eras.md#what-a-modern-request-carries) there is no + `logging/setLevel`: the client names its level in each request's + `_meta["io.modelcontextprotocol/logLevel"]`, and a request naming none receives no log + notifications at all. + +!!! note + Only the message is forwarded to the client. A PSR-3 `$context` array is accepted for interface compatibility + but is **not** sent — interpolate anything you need into the message itself. + +## Usage + +The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\RequestContext; + +#[McpTool] +public function processData(string $input, RequestContext $context): array { + $logger = $context->getClientLogger(); + + $logger->info(\sprintf('Processing started for "%s"', $input)); + $logger->warning('Deprecated API used'); + + // ... processing logic ... + + $logger->info('Processing completed'); + return ['result' => 'processed']; +} +``` diff --git a/docs/index.md b/docs/index.md index a1f6f66..cc9c375 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,102 @@ -# MCP PHP SDK Guides +# MCP PHP SDK -- [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. -- [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. -- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. -- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. -- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. -- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. -- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). -- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport. -- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events. -- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a +standardized way, separating the concern of *providing* context from the LLM +interaction itself. + +This is the official PHP SDK for it, a collaboration between +[the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/). With it you can: + +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak both standard transports: STDIO and Streamable HTTP. + +!!! warning "Experimental until 1.0" + This SDK is [experimental](https://symfony.com/doc/current/contributing/code/experimental.html) + until the first major release; see the + [roadmap](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) + for what is planned next. + +## Requirements + +PHP 8.1+. + +## Installation + +```bash +composer require mcp/sdk +``` + +See [Installation](get-started/installation.md) for the optional PSR packages an HTTP +server or client needs. + +## Example + +Create a file `server.php`: + +```php-file title="server.php" + 2]; + } +} + +Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport()); +``` + +That's a complete MCP server. It exposes one **tool**, `add`, and one **resource**, +`config://calculator/settings`. + +Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without +it the server still starts, but discovers nothing and only logs a warning. + +Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — +no request parsing, no serialization, no protocol handling. You wrote a PHP class with +type hints and a docblock; the SDK does the rest. + +[First server](get-started/first-server.md) walks through running it, and +[Try it with the Inspector](get-started/inspector.md) opens it in a UI you can click +around in. + +## Where to go next + +* **[Get started](get-started/index.md)** takes you from `composer require` to a server + a real MCP host can talk to. +* What a server exposes — tools, resources, prompts — is **[Servers](servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, your existing Symfony or Laravel app) is + **[Running your server](run/index.md)**. +* What is available *inside* the functions you register is + **[Inside your handler](handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](client/index.md)**. +* The two protocol eras, and what revision `2026-07-28` changed, are + **[Protocol versions](protocol-versions.md)**. +* Complete, runnable projects are in **[Examples](examples.md)**. +* Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** + is generated from the source. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md deleted file mode 100644 index 3480906..0000000 --- a/docs/mcp-elements.md +++ /dev/null @@ -1,892 +0,0 @@ -# MCP Elements - -MCP elements are the core capabilities of your server: Tools, Resources, Resource Templates, and Prompts. These elements -define what your server can do and how clients can interact with it. The PHP MCP SDK provides both attribute-based -discovery and manual registration methods. - -## Table of Contents - -- [Overview](#overview) -- [Tools](#tools) -- [Resources](#resources) -- [Resource Templates](#resource-templates) -- [Prompts](#prompts) -- [Logging](#logging) -- [Completion Providers](#completion-providers) -- [Schema Generation and Validation](#schema-generation-and-validation) -- [Discovery vs Manual Registration](#discovery-vs-manual-registration) - -## Overview - -MCP defines four types of capabilities: - -- **Tools**: Functions that can be called by clients to perform actions -- **Resources**: Data sources that clients can read (static URIs) -- **Resource Templates**: URI templates for dynamic resources with variables -- **Prompts**: Template generators for AI prompts - -### Registration Methods - -Each capability can be registered using two methods: - -1. **Attribute-Based Discovery**: Use PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) on methods or classes. The - server automatically discovers and registers them. - -2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, `addResource()`, etc.). - -**Priority**: Manual registrations **always override** discovered elements with the same identifier: -- **Tools**: Same `name` -- **Resources**: Same `uri` -- **Resource Templates**: Same `uriTemplate` -- **Prompts**: Same `name` - -For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). - -For runtime, config-driven elements whose shape is not known at compile time, see -[Explicit element registration](server-builder.md#explicit-element-registration) in the Server Builder docs. - -## Tools - -Tools are callable functions that perform actions and return results. - -```php -use Mcp\Capability\Attribute\McpTool; - -class Calculator -{ - /** - * Performs arithmetic operations with validation. - */ - #[McpTool(name: 'calculate')] - public function performCalculation(float $a, float $b, string $operation): float - { - return match($operation) { - 'add' => $a + $b, - 'subtract' => $a - $b, - 'multiply' => $a * $b, - 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), - default => throw new \InvalidArgumentException('Invalid operation') - }; - } -} -``` - -### Parameters - -- **`name`** (optional): Tool identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. -- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Priority for name/description**: Attribute parameters → DocBlock content → Method name - -For tool parameter validation and JSON schema generation, see [Schema Generation and Validation](#schema-generation-and-validation). - -### Tool Return Values - -Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. - -#### Automatic Content Wrapping - -```php -// Primitive types → TextContent -public function getString(): string { return "Hello"; } // TextContent -public function getNumber(): int { return 42; } // TextContent -public function getBool(): bool { return true; } // TextContent -public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) - -// Special cases -public function getNull(): ?string { return null; } // TextContent("(null)") -public function returnVoid(): void { /* no return */ } // Empty content -``` - -#### Explicit Content Types - -For fine control over output formatting: - -```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; - -public function getFormattedCode(): TextContent -{ - return TextContent::code(' 'file://data.json', 'text' => 'File content'] - ); -} - -public function getResourceLink(): ResourceLink -{ - // Reference a resource by URI without embedding its contents, e.g. when - // a tool result would otherwise need to inline many or large resources. - return new ResourceLink( - uri: 'file://data.json', - name: 'data.json', - mimeType: 'application/json' - ); -} -``` - -#### Multiple Content Items - -Return an array of content items: - -```php -public function getMultipleContent(): array -{ - return [ - new TextContent('Here is the analysis:'), - TextContent::code($code, 'php'), - new TextContent('And here is the summary.') - ]; -} -``` - -#### Structured Output - -Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its -shape with `outputSchema`, a JSON Schema of type `object`: - -```php -#[McpTool( - name: 'get_weather', - outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'temperature' => ['type' => 'number'], - 'conditions' => ['type' => 'string'], - ], - 'required' => ['temperature', 'conditions'], - ] -)] -public function getWeather(string $city): array -{ - // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it - return ['temperature' => 22.5, 'conditions' => 'sunny']; -} -``` - -The same schema can be passed to manual registration: - -```php -$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); -``` - -The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it -and lets them validate it. What qualifies depends on the protocol revision the call is served under: - -| Return value | `structuredContent` | -|---|---| -| Associative array (`['temperature' => 22.5]`) | The array | -| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | -| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | -| Array holding `Content` instances | Omitted (already carried in `content`) | -| Scalars, `null`, `Content` instances | Omitted | - -Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON -array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of -revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value -conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the -object shape to produce structured output everywhere. Wrap the list in a key for that: - -```php -// Structured content only from 2026-07-28 on: a bare list is not a JSON object -public function listUsersFlat(): array -{ - return [['id' => 1], ['id' => 2]]; -} - -#[McpTool(outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'items' => ['type' => 'array', 'items' => ['type' => 'object']], - ], - 'required' => ['items'] -])] -public function listUsers(): array -{ - return ['items' => [['id' => 1], ['id' => 2]]]; -} -``` - -Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into -`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as -`structuredContent`, the SDK logs a warning — the value is not silently dropped. - -A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see -[Client Communication](server-client-communication.md#client-gateway). - -[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content - -#### Error Handling - -Tool handlers can throw any exception, but the type determines how it's handled: - -- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\ToolCallException; - -#[McpTool] -public function divideNumbers(float $a, float $b): float -{ - if ($b === 0.0) { - throw new ToolCallException('Division by zero is not allowed'); - } - - return $a / $b; -} - -#[McpTool] -public function processFile(string $filename): string -{ - if (!file_exists($filename)) { - throw new ToolCallException("File not found: {$filename}"); - } - - return file_get_contents($filename); -} -``` - -**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - - -## Resources - -Resources provide access to static data that clients can read. - -```php -use Mcp\Capability\Attribute\McpResource; - -class ConfigProvider -{ - /** - * Provides the current application configuration. - */ - #[McpResource(uri: 'config://app/settings', name: 'app_settings')] - public function getSettings(): array - { - return [ - 'version' => '1.0.0', - 'debug' => false, - 'features' => ['auth', 'logging'] - ]; - } -} -``` - -### Parameters - -- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`size`** (optional): Size in bytes if known. -- **`annotations`** (optional): Additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). -**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. - -### Resource Return Values - -Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. - -#### Supported Return Types - -```php -// String content - converted to text resource -public function getTextFile(): string -{ - return "File content here"; -} - -// Array content - converted to JSON -public function getConfig(): array -{ - return ['debug' => true, 'version' => '1.0']; -} - -// Stream resource - read and converted to blob -public function getImageStream(): resource -{ - return fopen('image.png', 'r'); -} - -// SplFileInfo - file content with MIME type detection -public function getFileInfo(): \SplFileInfo -{ - return new \SplFileInfo('document.pdf'); -} -``` - -**Explicit resource content types** - -```php -use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; - -public function getExplicitText(): TextResourceContents -{ - return new TextResourceContents( - uri: 'config://app/settings', - mimeType: 'application/json', - text: json_encode(['setting' => 'value']) - ); -} - -public function getExplicitBlob(): BlobResourceContents -{ - return new BlobResourceContents( - uri: 'file://image.png', - mimeType: 'image/png', - blob: base64_encode(file_get_contents('image.png')) - ); -} -``` - -**Special Array Formats** - -```php -// Array with 'text' key - used as text content -public function getTextArray(): array -{ - return ['text' => 'Content here', 'mimeType' => 'text/plain']; -} - -// Array with 'blob' key - used as blob content -public function getBlobArray(): array -{ - return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; -} - -// Multiple resource contents -public function getMultipleResources(): array -{ - return [ - new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), - new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') - ]; -} -``` - -#### Error Handling - -Resource handlers can throw any exception, but the type determines how it's handled: - -- **`ResourceReadException`**: 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 - -```php -use Mcp\Exception\ResourceReadException; - -#[McpResource(uri: 'file://{path}')] -public function getFile(string $path): string -{ - if (!file_exists($path)) { - throw new ResourceReadException("File not found: {$path}"); - } - - if (!is_readable($path)) { - throw new ResourceReadException("File not readable: {$path}"); - } - - return file_get_contents($path); -} -``` - -**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Resource Templates - -Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules -as static resources (URI schemas, return values, MIME types, etc.) but accept variables using [RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). - -```php -use Mcp\Capability\Attribute\McpResourceTemplate; - -class UserProvider -{ - /** - * Retrieves user profile information by ID. - */ - #[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile/{section}', - name: 'user_profile', - description: 'User profile data by section', - mimeType: 'application/json' - )] - public function getUserProfile(string $userId, string $section): array - { - return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); - } -} -``` - -### Parameters - -- **`uriTemplate`** (required): URI template with `{variables}` using RFC 6570 syntax. Must comply with RFC 3986. -- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Template description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`annotations`** (optional): Additional metadata. - -### Variable Rules - -1. **Variable names must match exactly** between URI template and method parameters -2. **Parameter order matters** - variables are passed in the order they appear in the URI template -3. **All variables are required** - no optional parameters supported -4. **Type hints work normally** - parameters can be typed (string, int, etc.) - -**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` - -## Prompts - -Prompts generate templates for AI interactions. - -```php -use Mcp\Capability\Attribute\McpPrompt; - -class PromptGenerator -{ - /** - * Generates a code review request prompt. - */ - #[McpPrompt(name: 'code_review')] - public function reviewCode(string $language, string $code, string $focus = 'general'): array - { - return [ - ['role' => 'system', 'content' => 'You are an expert code reviewer.'], - ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] - ]; - } -} -``` - -### Parameters - -- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -### Prompt Return Values - -Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. - -#### Supported Return Formats - -```php -// Array of message objects with role and content -public function basicPrompt(): array -{ - return [ - ['role' => 'assistant', 'content' => 'You are a helpful assistant'], - ['role' => 'user', 'content' => 'Hello, how are you?'] - ]; -} - -// Single message (automatically wrapped in array) -public function singleMessage(): array -{ - return [ - ['role' => 'user', 'content' => 'Write a poem about PHP'] - ]; -} - -// Associative array with user/assistant keys -public function userAssistantFormat(): array -{ - return [ - 'user' => 'Explain how arrays work in PHP', - 'assistant' => 'Arrays in PHP are ordered maps...' - ]; -} - -// Mixed content types in messages -use Mcp\Schema\Content\{TextContent, ImageContent}; - -public function mixedContent(): array -{ - return [ - [ - 'role' => 'user', - 'content' => [ - new TextContent('Analyze this image:'), - new ImageContent(data: $imageData, mimeType: 'image/png') - ] - ] - ]; -} - -// Using explicit PromptMessage objects -use Mcp\Schema\PromptMessage; -use Mcp\Schema\Enum\Role; - -public function explicitMessages(): array -{ - return [ - new PromptMessage(Role::Assistant, [new TextContent('System instructions')]), - new PromptMessage(Role::User, [new TextContent('User question')]) - ]; -} -``` - -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. - -#### Valid Message Roles - -- **`user`**: User input or questions -- **`assistant`**: Assistant responses/system - -#### 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 - -```php -use Mcp\Exception\PromptGetException; - -#[McpPrompt] -public function generatePrompt(string $topic, string $style): array -{ - $validStyles = ['casual', 'formal', 'technical']; - - if (!in_array($style, $validStyles)) { - throw new PromptGetException( - "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) - ); - } - - return [ - ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] - ]; -} -``` - -**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Logging - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -The SDK provides support to send structured log messages to clients. All standard PSR-3 log levels are supported. -Level **warning** as the default level. - -### Usage - -The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. - -```php -use Mcp\Capability\Logger\ClientLogger; -use Mcp\Server\RequestContext; - -#[McpTool] -public function processData(string $input, RequestContext $context): array { - $logger = $context->getClientLogger(); - - $logger->info('Processing started', ['input' => $input]); - $logger->warning('Deprecated API used'); - - // ... processing logic ... - - $logger->info('Processing completed'); - return ['result' => 'processed']; -} -``` - -## Completion Providers - -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. - -### Completion Provider Types - -#### 1. Value Lists - -Provide a static list of possible values: - -```php -use Mcp\Capability\Attribute\CompletionProvider; - -#[McpPrompt] -public function generateContent( - #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] - string $contentType, - - #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] - string $difficulty -): array -{ - return [ - ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] - ]; -} -``` - -#### 2. Enum Classes - -Use enum values for completion: - -```php -enum Priority: string -{ - case LOW = 'low'; - case MEDIUM = 'medium'; - case HIGH = 'high'; -} - -enum Status // Unit enum -{ - case DRAFT; - case PUBLISHED; - case ARCHIVED; -} - -#[McpResourceTemplate(uriTemplate: 'tasks/{taskId}')] -public function getTask( - string $taskId, - - #[CompletionProvider(enum: Priority::class)] // Uses backing values - string $priority, - - #[CompletionProvider(enum: Status::class)] // Uses case names - string $status -): array -{ - // Implementation -} -``` - -#### 3. Custom Provider Classes - -For dynamic completion logic: - -```php -use Mcp\Capability\Prompt\Completion\ProviderInterface; - -class UserIdCompletionProvider implements ProviderInterface -{ - public function __construct(private DatabaseService $db) {} - - public function getCompletions(string $currentValue): array - { - // Return dynamic completions based on current input - return $this->db->searchUserIds($currentValue); - } -} - -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] -public function getUserProfile( - #[CompletionProvider(provider: UserIdCompletionProvider::class)] - string $userId -): array -{ - // Implementation -} -``` - -**Provider Resolution:** -- **Class strings** (`Provider::class`) → Resolved from PSR-11 container -- **Instances** (`new Provider()`) → Used directly -- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` -- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` - -> **Important** -> -> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate -> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. - -## Schema Generation and Validation - -The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema -generation applies to both attribute-discovered and manually registered tools. - -### Schema Generation Priority - -The server follows this order of precedence: - -1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) -2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements -3. **Method-level `#[Schema]` attribute** - Method-wide configuration -4. **PHP type hints + docblocks** - Automatic inference (lowest priority) - -### Automatic Schema from PHP Types - -```php -#[McpTool] -public function processUser( - string $email, // Required string - int $age, // Required integer - ?string $name = null, // Optional string - bool $active = true // Boolean with default -): array -{ - // Schema auto-generated from method signature -} -``` - -### Parameter-Level Schema Enhancement - -Add validation rules to specific parameters: - -```php -use Mcp\Capability\Attribute\Schema; - -#[McpTool] -public function validateUser( - #[Schema(format: 'email')] - string $email, - - #[Schema(minimum: 18, maximum: 120)] - int $age, - - #[Schema( - pattern: '^[A-Z][a-z]+$', - description: 'Capitalized first name' - )] - string $firstName -): bool -{ - // PHP types provide base validation - // Schema attributes add constraints -} -``` - -### Method-Level Schema - -Add validation for complex object structures: - -```php -#[McpTool] -#[Schema( - properties: [ - 'userData' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - 'age' => ['type' => 'integer', 'minimum' => 18] - ], - 'required' => ['name', 'email'] - ] - ], - required: ['userData'] -)] -public function createUser(array $userData): array -{ - // Method-level schema adds object structure validation - // PHP array type provides base type -} -``` - -### Complete Schema Override - -**Use sparingly** - bypasses all automatic inference: - -```php -#[McpTool] -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'endpoint' => ['type' => 'string', 'format' => 'uri'], - 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], - 'headers' => [ - 'type' => 'object', - 'patternProperties' => [ - '^[A-Za-z0-9-]+$' => ['type' => 'string'] - ] - ] - ], - 'required' => ['endpoint', 'method'] -])] -public function makeApiRequest(string $endpoint, string $method, array $headers): array -{ - // Complete definition override - PHP types ignored -} -``` - -**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex -validation requirements that cannot be achieved through the priority system. - -## Discovery vs Manual Registration - -### Attribute-Based Discovery - -**Advantages:** -- Declarative and readable -- Automatic parameter inference -- DocBlock integration -- Type-safe by default -- Caching support - -**Example:** -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->build(); -``` - -### Manual Registration - -**Advantages:** -- Fine-grained control -- Runtime configuration -- Conditional registration -- External handler support - -**Example:** -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->addPrompt([Prompts::class, 'email'], 'write_email') - ->build(); -``` - -For detailed information on manual registration, see [Server Builder](server-builder.md#manual-capability-registration). - -### Hybrid Approach - -Combine both methods for maximum flexibility: - -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Discover most capabilities - ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones - ->build(); -``` - -Manual registrations always take precedence over discovered elements with the same identifier. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000..09081ba --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,178 @@ +# Protocol versions + +MCP has two eras. Everything up to `2025-11-25` opens with an `initialize` handshake and +keeps the negotiated revision on a session. Protocol revision `2026-07-28` removed both: +everything a server needs to answer a request travels *in* that request, so any process can +answer any request and none of them need to share state. + +The SDK serves both, and a server built the ordinary way answers either. This page is the +map; the mechanics live with the task they belong to. + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. + +## Negotiating in the handshake era + +Revisions up to `2025-11-25` agree on one during `initialize`. The client names the revision it wants to speak, and +the server answers with the revision the connection will actually use. Both sides disconnect if they cannot agree. +This follows the +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) +section of the specification. The modern era negotiates nothing — each request names its own revision. + +The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` +ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first +ProtocolVersion::modernVersions(); // every revision served without a handshake +ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true +``` + +Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, +but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. + +### How the server answers + +| Client requests | Server responds with | +| --- | --- | +| A revision the server supports | That same revision | +| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | +| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | + +A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the +connection. The negotiated revision is stored on the session under `protocol_version`. + +The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through +this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions +would leave a connection neither side could use. A client speaking it never gets here: it sends the envelope instead +of an `initialize` request, and the transport routes it to the modern dispatcher before any negotiation is attempted. +See [Serving both eras](run/protocol-eras.md). + + +### Pinning a revision + +`Builder::setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported +set. The pin wins over the client's request, so a client asking for anything else receives the pinned revision as a +counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other +revisions. + +It pins the handshake era only. `setModernVersions()` narrows what the modern leg answers for, and +`withoutModernEra()` removes that leg altogether — see +[Serving one era only](run/protocol-eras.md#serving-one-era-only). + +!!! note + On the Streamable HTTP transport, every handshake-era request after the handshake also carries an + `MCP-Protocol-Version` header, which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach + that check: the transport builds the middleware without access to the server configuration, so the header keeps + being accepted for every revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the + middleware yourself with the same revision — see + [Protocol Version Validation](run/http.md#protocol-version-validation). + +## What changes, and where it is written down + +Tools, resources, prompts and their handlers are unaffected — the same registrations serve +either lifecycle. What changes: + +* **[Asking for input](handlers/input-required.md)** — a handler that needs elicitation, + sampling or roots *returns* the ask instead of calling out. Write handlers this way and + they serve both eras. +* **[Serving both eras](run/protocol-eras.md)** — what a modern request carries, how one + endpoint classifies and routes each request, and how to serve one era only. +* **[Caching](run/caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must + carry. +* **[Subscriptions](run/subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Sessions](run/sessions.md)** — handshake-era only; the modern era has none. +* Progress and logging become per-request opt-ins — see + [Talking back to the client](handlers/client-communication.md#progress) and + [Logging](handlers/logging.md). + +## Speaking it from a client + +One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the + server's identity, and a server that does not answer it still yields a usable connection — + the method is optional. If discovery *does* report `supportedVersions` and the configured + revision is not among them, the client moves to a modern revision the server lists, or + refuses the connection outright rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the + client identity. The capabilities are what let a server decide, per request, whether it may + ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the + method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into + `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what + populates that knowledge. A tool whose annotations are malformed is dropped from the listing + and refused if called, since the client cannot produce the headers it demands. +- **[Multi round-trip calls](handlers/input-required.md) are answered by the client.** A result + of `resultType: "input_required"` is resolved through the same + [request handlers](client/server-requests.md) that served server-initiated requests in the + handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` + echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing +`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry +them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies +to both. + +See +[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) +for a runnable version, described in [Examples](examples.md#modern-era-client). + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` + filter of `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). +A broken response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be +emitted by a server of this revision. The SDK picks the code from the revision serving the +request, so a handshake-era client still gets `-32002`. + +## Deprecations + +Roots, sampling and logging are all **deprecated** as of this revision +([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest +removal `2027-07-28`. They remain functional until then; new servers should pass directories +through tool arguments or resource URIs instead of roots, integrate with an LLM provider +directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/authorization.md b/docs/run/authorization.md similarity index 91% rename from docs/authorization.md rename to docs/run/authorization.md index 184eb7e..0985656 100644 --- a/docs/authorization.md +++ b/docs/run/authorization.md @@ -3,18 +3,6 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). -## Table of Contents - -- [Scope: what this SDK does and does not do](#scope-what-this-sdk-does-and-does-not-do) -- [Overview](#overview) -- [Quick Start](#quick-start) -- [Components](#components) -- [JWT Token Validation](#jwt-token-validation) -- [Protected Resource Metadata](#protected-resource-metadata) -- [Custom Token Validators](#custom-token-validators) -- [Scope-Based Access Control](#scope-based-access-control) -- [Examples](#examples) - ## Scope: what this SDK does and does not do The MCP server is an OAuth 2.1 **Resource Server**. It validates the tokens it receives and may @@ -30,7 +18,7 @@ and it does not issue tokens.** To issue tokens, front the MCP server with an external IdP (Keycloak, Auth0, Microsoft Entra ID, Okta) or run `league/oauth2-server` in your own application, and let the MCP server validate those tokens as a Resource Server. See -[adr/0001-oauth-authorization-server-out-of-scope.md](../adr/0001-oauth-authorization-server-out-of-scope.md). +[adr/0001-oauth-authorization-server-out-of-scope.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/adr/0001-oauth-authorization-server-out-of-scope.md). ## Overview @@ -62,6 +50,7 @@ Authorization in MCP is implemented at the transport level using PSR-15 middlewa ```php use Mcp\Server; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; @@ -99,13 +88,20 @@ $metadataMiddleware = new ProtectedResourceMetadataMiddleware( // 5. Create transport with middleware $transport = new StreamableHttpTransport( $request, - middlewares: [$metadataMiddleware, $authMiddleware], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $metadataMiddleware, + $authMiddleware, + // Bridges the OAuth attributes onto the JSON-RPC request meta, which is + // what makes them reachable from a handler (see Scope-Based Access Control). + new OAuthRequestMetaMiddleware(), + ], ); // 6. Run server $server = Server::builder() ->setServerInfo('Protected MCP Server', '1.0.0') - ->setDiscovery(__DIR__) + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) ->build(); $response = $server->run($transport); @@ -155,7 +151,7 @@ $validator = new JwtTokenValidator( audience: 'mcp-server', // Expected audience (string or array) jwksProvider: $jwksProvider, // JwksProviderInterface jwksUri: null, // Explicit JWKS URI (auto-discovered) - algorithms: ['RS256', 'RS384'], // Allowed algorithms + algorithms: ['RS256', 'RS384', 'RS512'], // Allowed algorithms (this is the default) scopeClaim: 'scope', // Claim name for scopes ); ``` @@ -363,7 +359,10 @@ AuthorizationResult::badRequest('invalid_request', 'Malformed header'); #[McpTool(name: 'admin_action')] public function adminAction(RequestContext $context): array { - $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + // The OAuth attributes arrive on the request meta, under the `oauth` key. + // This requires OAuthRequestMetaMiddleware in the transport's middleware stack. + $meta = $context->getRequest()->getMeta() ?? []; + $scopes = $meta['oauth']['oauth.scopes'] ?? []; if (!in_array('mcp:admin', $scopes, true)) { throw new \RuntimeException('Admin scope required'); @@ -403,7 +402,7 @@ docker-compose up -d # Test credentials: demo / demo123 ``` -See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) +See [oauth-keycloak/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-keycloak/README.md) ### Microsoft Entra ID Example @@ -414,7 +413,7 @@ cp env.example .env docker-compose up -d ``` -See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) +See [oauth-microsoft/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-microsoft/README.md) ## Security Considerations diff --git a/docs/run/caching.md b/docs/run/caching.md new file mode 100644 index 0000000..777b66c --- /dev/null +++ b/docs/run/caching.md @@ -0,0 +1,28 @@ +# Caching + +> Caching hints belong to protocol revision `2026-07-28`; see [Protocol versions](../protocol-versions.md). +> A handshake-era client is served by the same server and simply never sees them. + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and +`cacheScope`. The default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat +refusal to let anything be cached. Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for +results that do not vary by caller. Only the operator can make that call, which is why the +conservative default stands until you change it. + +A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. + +Results produced by an [MRTR retry](../handlers/input-required.md) are never given hints: their inputs +are not part of any cache key. diff --git a/docs/run/framework-integration.md b/docs/run/framework-integration.md new file mode 100644 index 0000000..f461773 --- /dev/null +++ b/docs/run/framework-integration.md @@ -0,0 +1,202 @@ +# Framework integration + +The HTTP transport is a PSR-7 request handler, not a web server. This page +covers how it fits into an application you already have. + +## Architecture + +The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that +your application can handle however it needs to: + +``` +Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App +``` + +This design allows integration with any PHP framework or application that supports PSR-7. + +## Basic Usage (Standalone) + +Here's a simplified example using PSR-17 discovery and Laminas emitter: + +```php +use Http\Discovery\Psr17Factory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Session\FileSessionStore; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); +``` + +## Framework Integration + +### Symfony Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that uses Symfony's PSR-7 bridge: + +> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. + +```php +// In a Symfony controller +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response + { + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); + $httpFoundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrHttpFactory->createRequest($request); + + // Process with MCP (factories auto-discovered) + $transport = new StreamableHttpTransport($psrRequest); + $psrResponse = $server->run($transport); + + // Convert PSR-7 response back to Symfony + return $httpFoundationFactory->createResponse($psrResponse); + } +} +``` + +### Laravel Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that type-hints `ServerRequestInterface`: + +> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. + +```php +// In a Laravel controller +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface + { + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $server->run($transport); + } +} + +// Route registration +Route::any('/mcp', [McpController::class, 'handle']); +``` + +### Slim Framework Integration + +Slim Framework works natively with PSR-7. + +Create a route handler using Slim's built-in factories and container: + +```php +use Slim\Factory\AppFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); + +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); +}); +``` + +## HTTP Method Handling + +The transport handles all HTTP methods automatically: + +- **POST**: Send MCP requests +- **GET**: Not implemented (returns 405) +- **DELETE**: End session +- **OPTIONS**: CORS preflight + +You should route **all methods** to your MCP endpoint, not just POST. + +## Session Management + +HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport +where in-memory sessions work fine, HTTP transport needs a persistent session store: + +```php +use Mcp\Server\Session\FileSessionStore; + +// ✅ Good for HTTP +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// ❌ Not recommended for HTTP (sessions lost between requests) +$server = Server::builder() + ->setSession(new InMemorySessionStore()) + ->build(); +``` + +## Recommended Route + +It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: + +```php +// Recommended +Route::any('/mcp', [McpController::class, 'handle']); + +// Also valid +Route::any('/', [McpController::class, 'handle']); +Route::any('/api/mcp', [McpController::class, 'handle']); +``` + +## Testing HTTP Transport + +Use the MCP Inspector to test HTTP servers: + +```bash +# Start your PHP server +php -S localhost:8000 server.php + +# Connect with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 +``` diff --git a/docs/transports.md b/docs/run/http.md similarity index 50% rename from docs/transports.md rename to docs/run/http.md index ffa1496..a57a8db 100644 --- a/docs/transports.md +++ b/docs/run/http.md @@ -1,94 +1,4 @@ -# Transports - -Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport -implementations: STDIO for command-line integration and HTTP for web-based communication. - -## Table of Contents - -- [Transport Overview](#transport-overview) -- [STDIO Transport](#stdio-transport) -- [HTTP Transport](#http-transport) -- [Choosing a Transport](#choosing-a-transport) - -## Transport Overview - -All transports implement the `TransportInterface` and follow the same basic pattern: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new SomeTransport(); - -$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP -``` - -## STDIO Transport - -The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. - -```php -$transport = new StdioTransport( - input: STDIN, // Input stream (default: STDIN) - output: STDOUT, // Output stream (default: STDOUT) - logger: $logger // Optional PSR-3 logger -); -``` - -### Parameters - -- **`input`** (optional): Input stream resource. Defaults to `STDIN`. -- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. -- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. - -> [!IMPORTANT] -> When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. -> Use `STDERR` for debugging instead. - -### Example Server Script - -```php -#!/usr/bin/env php -setServerInfo('STDIO Calculator', '1.0.0') - ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') - ->addTool(InvokableCalculator::class) - ->build(); - -$transport = new StdioTransport(); - -$status = $server->run($transport); - -exit($status); // 0 on clean shutdown, non-zero if STDIN errored -``` - -### Client Configuration - -For MCP clients like Claude Desktop: - -```json -{ - "mcpServers": { - "my-php-server": { - "command": "php", - "args": ["/absolute/path/to/server.php"] - } - } -} -``` - -## HTTP Transport +# HTTP Transport The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application. @@ -105,7 +15,7 @@ $transport = new StreamableHttpTransport( ); ``` -### Parameters +## Parameters - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. @@ -114,7 +24,7 @@ $transport = new StreamableHttpTransport( - **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. - **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). -### PSR-17 Auto-Discovery +## PSR-17 Auto-Discovery The transport automatically discovers PSR-17 factory implementations from these popular packages: @@ -138,7 +48,7 @@ $psr17Factory = new Psr17Factory(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); ``` -### Default Middleware +## Default Middleware When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: @@ -163,9 +73,9 @@ both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-V to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, and the modern leg answers for its own revisions. It is available as `StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. -See [Serving both eras](stateless-lifecycle.md#serving-both-eras). +See [Serving both eras](protocol-eras.md). -### CORS Configuration +## CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it in place of (or alongside) the defaults: @@ -173,7 +83,6 @@ in place of (or alongside) the defaults: ```php 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; // Reflect a specific origin @@ -182,7 +91,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['https://myapp.com']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); @@ -192,7 +100,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['*']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); ``` @@ -203,13 +110,13 @@ so shared caches/CDNs do not serve a response generated for one origin to a requ Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds defaults when they are absent. -> [!IMPORTANT] -> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying -> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves -> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. -> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. +!!! warning + `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying + `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves + a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. + The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. -### DNS Rebinding Protection +## DNS Rebinding Protection `DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` when `Origin` is absent). The default allowlist is localhost-only: @@ -223,12 +130,16 @@ new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain or supply a permissive allowlist. -### Protocol Version Validation +## Protocol Version Validation `ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some legacy clients do not send it. +It is applied by the transport itself, to handshake-era traffic only — do not add it to a custom `middleware` +list, where it would run before the era is classified and reject every modern-era request. Construct it +yourself only to narrow the supported set on a server that also pins its handshake: + ```php use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; @@ -243,12 +154,16 @@ The default set is `ProtocolVersion::handshakeVersions()` — every revision the header itself, so a header-less request cannot be newer than that. This header check is separate from, and happens after, the handshake itself. See -[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the +[Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how the revision is agreed in the first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against the set it was constructed with, not against the revision a given session negotiated, so a server that pins the handshake has to pass that revision here as well. -### Request Body Size Limit +Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` rather than in a +header, and is routed away from this check entirely — see +[Serving both eras](protocol-eras.md). + +## Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches @@ -265,24 +180,19 @@ When the request stream advertises a size, the transport rejects it up-front. Ot unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot exhaust memory. A value below `1` throws `InvalidArgumentException`. -### JSON-RPC Batch Size Limit +## JSON-RPC Batch Size Limit -A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any -message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on -`MessageFactory`: - -```php -use Mcp\JsonRpc\MessageFactory; - -$factory = MessageFactory::make(maxBatchSize: 50); -``` +A JSON-RPC batch (top-level array) is capped at 100 messages. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap +lives on `Mcp\JsonRpc\MessageFactory` and is not currently configurable through the builder — a server built +with `Server::builder()` always uses the default of 100. Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws `InvalidArgumentException`. -### Custom PSR-15 Middleware +## Custom PSR-15 Middleware `StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append your own middleware — the defaults stay outermost so CORS headers are applied to every response, including @@ -348,207 +258,3 @@ $transport = new StreamableHttpTransport( middleware: [new AuthMiddleware($responseFactory)], ); ``` - -### Architecture - -The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that -your application can handle however it needs to: - -``` -Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App -``` - -This design allows integration with any PHP framework or application that supports PSR-7. - -### Basic Usage (Standalone) - -Here's a simplified example using PSR-17 discovery and Laminas emitter: - -```php -use Http\Discovery\Psr17Factory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; -use Mcp\Server\Session\FileSessionStore; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); - -$server = Server::builder() - ->setServerInfo('HTTP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions - ->build(); - -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); -``` - -### Framework Integration - -#### Symfony Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that uses Symfony's PSR-7 bridge: - -> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. - -```php -// In a Symfony controller -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - #[Route('/mcp', name: 'mcp_endpoint')] - public function handle(Request $request, Server $server): Response - { - // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) - $psrHttpFactory = new PsrHttpFactory(); - $httpFoundationFactory = new HttpFoundationFactory(); - $psrRequest = $psrHttpFactory->createRequest($request); - - // Process with MCP (factories auto-discovered) - $transport = new StreamableHttpTransport($psrRequest); - $psrResponse = $server->run($transport); - - // Convert PSR-7 response back to Symfony - return $httpFoundationFactory->createResponse($psrResponse); - } -} -``` - -#### Laravel Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that type-hints `ServerRequestInterface`: - -> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. - -```php -// In a Laravel controller -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ResponseInterface; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - public function handle(ServerRequestInterface $request, Server $server): ResponseInterface - { - // Create the MCP HTTP transport - $transport = new StreamableHttpTransport($request); - - // Process MCP request and return PSR-7 response - // Laravel automatically handles PSR-7 responses - return $server->run($transport); - } -} - -// Route registration -Route::any('/mcp', [McpController::class, 'handle']); -``` - -#### Slim Framework Integration - -Slim Framework works natively with PSR-7. - -Create a route handler using Slim's built-in factories and container: - -```php -use Slim\Factory\AppFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$app = AppFactory::create(); - -$app->any('/mcp', function ($request, $response) { - $server = Server::builder() - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - - $transport = new StreamableHttpTransport($request); - - return $server->run($transport); -}); -``` - -### HTTP Method Handling - -The transport handles all HTTP methods automatically: - -- **POST**: Send MCP requests -- **GET**: Not implemented (returns 405) -- **DELETE**: End session -- **OPTIONS**: CORS preflight - -You should route **all methods** to your MCP endpoint, not just POST. - -### Session Management - -HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport -where in-memory sessions work fine, HTTP transport needs a persistent session store: - -```php -use Mcp\Server\Session\FileSessionStore; - -// ✅ Good for HTTP -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// ❌ Not recommended for HTTP (sessions lost between requests) -$server = Server::builder() - ->setSession(new InMemorySessionStore()) - ->build(); -``` - -### Recommended Route - -It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: - -```php -// Recommended -Route::any('/mcp', [McpController::class, 'handle']); - -// Also valid -Route::any('/', [McpController::class, 'handle']); -Route::any('/api/mcp', [McpController::class, 'handle']); -``` - -### Testing HTTP Transport - -Use the MCP Inspector to test HTTP servers: - -```bash -# Start your PHP server -php -S localhost:8000 server.php - -# Connect with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 -``` - -## Choosing a Transport - -The choice between STDIO and HTTP transport depends on the client you want to integrate with. -If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. -If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 0000000..208f22a --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,41 @@ +# Running your server + +`Server::builder()` configures a server; `run()` puts it on a transport and starts +answering. Every transport implements `TransportInterface` and is used the same way: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + +$transport = new SomeTransport(); + +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP +``` + +## Choosing a transport + +The choice depends on the client you want to integrate with: + +* The client runs **locally** and launches your server as a subprocess (Claude Desktop, + most editors) → **[STDIO](stdio.md)**. +* The client is **remote**, or your server lives in a web application → + **[HTTP](http.md)**, the Streamable HTTP transport. + +The rest of this section: + +* **[Server builder](server-builder.md)** — every configuration knob: server info, + discovery, dependency injection, logging, pagination. +* **[Framework integration](framework-integration.md)** — mounting the HTTP transport in + a Symfony, Laravel, or Slim application, or running it standalone. +* **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as + you serve HTTP from more than one process. +* **[Serving both eras](protocol-eras.md)** — the same endpoint also answers protocol + revision `2026-07-28`, which has no handshake and no sessions; what a modern request + carries, and how each one is routed. +* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a modern-era result carries. +* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of + the HTTP transport. diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md new file mode 100644 index 0000000..9c6cb28 --- /dev/null +++ b/docs/run/protocol-eras.md @@ -0,0 +1,136 @@ +# Serving both eras + +`Server::builder()->build()` produces a server carrying a dispatcher for each +[protocol era](../protocol-versions.md), and `StreamableHttpTransport` decides per request +which of them answers. There is nothing to configure: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. Modern-era requests +accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is routed as +one. + +A full example lives in +[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), +described in [Examples](../examples.md#the-2026-07-28-lifecycle). + +## What a modern request carries + +There is no handshake to remember anything, so every request carries what the server needs to +answer it. Two members in `params._meta` are **required**, and the HTTP layer mirrors some of +them into headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and +`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing +required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the +supported set for the client to retry from. + +What a handler can read off all this is +[Talking back to the client](../handlers/client-communication.md#request-metadata). + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the +client, and the server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a +`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` +refuses a definition that breaks any of those rather than letting it fail later as a header +mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written +`inputSchema` fits. + +## How a request is routed + +Every request is classified once, before anything else looks at it. The decision is +**body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a +request whose header contradicts its `_meta` is refused with `-32020` before either leg sees +it — the check has to happen at the edge, because a body claiming a handshake revision routes +to a leg that has no such check of its own. A modern header on a request carrying no envelope +is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, +the modern leg answers, naming the modern revisions it serves; named only in a header, the +handshake leg answers, naming the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler +instances, one session manager. A tool registered once is reachable from both, and a change +made through one is visible to the other. + +## Middleware + +The [default middleware stack](http.md#default-middleware) runs at the edge, before the +request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` +is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so +the transport applies it only to requests it classified as handshake-era traffic, and the +modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the +edge stack. + +## Serving one era only + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does +serve. `setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the +dispatcher on its own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md new file mode 100644 index 0000000..52d27e7 --- /dev/null +++ b/docs/run/server-builder.md @@ -0,0 +1,336 @@ +# Server builder + +The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. +It provides methods for setting server information, configuring discovery, registering capabilities, and customizing +various aspects of the server behavior. + +## Basic Usage + +There are two ways to obtain a server builder instance: + +### Method 1: Static Builder Method (Recommended) + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); +``` + +### Method 2: Direct Instantiation + +```php +use Mcp\Server\Builder; + +$server = (new Builder()) + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); +``` + +Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the +final `Server` instance ready for use. + +## Server Configuration + +### Server Information + +Set the server's identity with name, version, and optional description: + +```php +use Mcp\Schema\Icon; +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo( + name: 'Calculator Server', + version: '1.2.0', + description: 'Advanced mathematical calculations', + icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], + websiteUrl: 'https://example.com', + ); +``` + +**Parameters:** +- `$name` (string): The server name +- `$version` (string): Version string (semantic versioning recommended) +- `$description` (string|null): Optional description +- `$icons` (Icon[]|null): Optional array of server icons +- `$websiteUrl` (string|null): Optional server website URL +- `$title` (string|null): Optional human-readable display title, distinct from `$name` + +### Pagination Limit + +Configure the maximum number of items returned in paginated responses: + +```php +$server = Server::builder() + ->setPaginationLimit(100); // Default: 50 +``` + +### Instructions + +Provide hints to help AI models understand how to use your server: + +```php +$server = Server::builder() + ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); +``` + +### Protocol Version + +By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do +not need to configure anything. `setProtocolVersion()` pins that handshake to exactly one revision instead: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$server = Server::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18); +``` + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how negotiation resolves and +what pinning changes. It only pins the handshake era; to narrow or remove what the modern leg answers for, use +`setModernVersions()` / `withoutModernEra()` — see +[Serving both eras](protocol-eras.md#serving-one-era-only). + +## Modern-Era Options + +`build()` returns a server that answers both [protocol eras](../protocol-versions.md), and none of the following is +required to serve either one. Each knob is covered in depth elsewhere: +[Asking for input](../handlers/input-required.md) for the first two, +[Caching](caching.md), [Subscriptions](subscriptions.md), and +[Serving both eras](protocol-eras.md) for the last. + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Wire\CachePolicy; + +$server = Server::builder() + // Signs the state a multi round-trip request carries through the client. + // The same key must reach every process that might serve the retry. + ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) + + // Bounds the input-required shim, which fulfils an `InputRequiredResult` + // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + + // Caching hints stamped on cacheable results. Defaults to `ttlMs: 0, private`. + ->setCachePolicy(CachePolicy::default(30_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)) + + // Delivery for `subscriptions/listen`, and how long such a stream is held. + ->setNotificationBus(new Psr16NotificationBus($cache)) + ->setSubscriptionLifetime(0) + + // Narrow the modern leg, or drop it entirely. + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); +``` + +`buildStateless()` returns the modern dispatcher alone, for an endpoint that serves no handshake-era traffic at all. +Its requests are checked against the SEP-2243 standard headers — that `Mcp-Method`, `Mcp-Name` and `Mcp-Param-*` agree +with the body they travel with. `setHeaderValidator(false)` turns that check off, which is needed only when the +dispatcher is served by a transport that has no header layer to check. + +## Discovery Configuration + +**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. + +```php +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance, // Optional: cache discovered elements + namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match + ); +``` + +**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 +- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance +- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) + +**Basic Discovery (scans current directory and `src/`):** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) // Scans '.' and 'src' + ->build(); +``` + +!!! warning + Always exclude `vendor/` when the scanned directories contain it. The recursive scan + tries to load every class it finds, and a single file that cannot be loaded makes the + scan abort — the server then reports an **empty** element list instead of an error. + +**Production Setup with Caching:** +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// Cache discovered elements to avoid filesystem scanning on every server start +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['src', 'lib'], // Scan these directories recursively + excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs + cache: $cache // Cache for performance + ) + ->build(); +``` + +**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`) + +> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making +> subsequent server startups nearly instantaneous. + +## Service Dependencies + +### Container + +The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. +The SDK includes a basic container with simple auto-wiring capabilities. + +```php +use Mcp\Capability\Registry\Container; + +// Use the default basic container +$container = new Container(); +$container->set(DatabaseService::class, new DatabaseService($pdo)); +$container->set(\PDO::class, $pdo); + +$server = Server::builder() + ->setContainer($container) + ->build(); +``` + +**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 +- Does NOT support scalar/built-in type injection without defaults +- Detects circular dependencies + +You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). + +### Logger + +Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$server = Server::builder() + ->setLogger($logger); +``` + +### Event Dispatcher + +Configure event dispatching: + +```php +$server = Server::builder() + ->setEventDispatcher($eventDispatcher); +``` + +## Complete Example + +Here's a comprehensive example showing all major configuration options: + +```php +use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Capability\Registry\Container; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +// Setup dependencies +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); +$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); + +// Setup container with dependencies +$container = new Container(); +$container->set(\PDO::class, new \PDO('sqlite::memory:')); +$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); + +// Build server +$server = Server::builder() + // Server identity + ->setServerInfo('Advanced Calculator', '2.1.0') + + // Performance and behavior + ->setPaginationLimit(100) + ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') + + // Discovery with caching + ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) + + // Session management + ->setSession($sessionStore) + + // Services + ->setLogger($logger) + ->setContainer($container) + + // Manual capability registration + ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') + ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') + + // Build the server + ->build(); +``` + +## Method Reference + +| Method | Parameters | Description | +|--------|------------|-------------| +| `setServerInfo()` | name, version, description? | Set server identity | +| `setPaginationLimit()` | limit | Set max items per page | +| `setInstructions()` | instructions | Set usage instructions | +| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | +| `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | +| `withoutModernEra()` | - | Serve the handshake era only | +| `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | +| `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | +| `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | +| `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | +| `setNotificationBus()` | bus | Delivery for `subscriptions/listen` streams | +| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open (`0` = unbounded) | +| `setHeaderValidator()` | enabled | Toggle the SEP-2243 standard-header check on `buildStateless()` | +| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | +| `setLogger()` | logger | Set PSR-3 logger | +| `setContainer()` | container | Set PSR-11 container | +| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | +| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | +| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | +| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | +| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | +| `build()` | - | Create the server instance | +| `buildStateless()` | supportedVersions? | Create the modern dispatcher alone, for `StatelessHttpTransport` | diff --git a/docs/run/sessions.md b/docs/run/sessions.md new file mode 100644 index 0000000..abdac60 --- /dev/null +++ b/docs/run/sessions.md @@ -0,0 +1,127 @@ +# Session Management + +> Sessions belong to the handshake era. Protocol revision `2026-07-28` removed them, along with +> `Mcp-Session-Id`, because every request carries what is needed to answer it — see +> [The 2026-07-28 lifecycle](../protocol-versions.md). One server serves both, so the configuration below still +> applies to the handshake-era clients it answers. + +Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: + +```php +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16SessionStore; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; + +// Override with file-based storage +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Override with in-memory storage and custom TTL +$server = Server::builder() + ->setSession(new InMemorySessionStore(3600)) + ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16SessionStore( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); +``` + +## Garbage Collection Configuration + +The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native +`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given +request is `gcProbability / gcDivisor`. + +```php +// Default: 1/100 (1% chance per request) +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Higher frequency: 1/10 (10% chance per request) +$server = Server::builder() + ->setSession( + new FileSessionStore(__DIR__ . '/sessions'), + gcProbability: 1, + gcDivisor: 10, + ) + ->build(); + +// Run GC on every request +$server = Server::builder() + ->setSession(gcProbability: 1, gcDivisor: 1) + ->build(); + +// Disable GC entirely (e.g. when using an external cleanup process) +$server = Server::builder() + ->setSession(gcProbability: 0) + ->build(); +``` + +**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. + +> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, +> 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 + +**Custom Session Stores:** + +Implement `SessionStoreInterface` to create custom session storage: + +```php +use Mcp\Server\Session\SessionStoreInterface; +use Symfony\Component\Uid\Uuid; + +class RedisSessionStore implements SessionStoreInterface +{ + public function __construct(private $redis, private int $ttl = 3600) {} + + public function exists(Uuid $id): bool + { + return $this->redis->exists($id->toRfc4122()); + } + + public function read(Uuid $sessionId): string|false + { + $data = $this->redis->get($sessionId->toRfc4122()); + return $data !== false ? $data : false; + } + + public function write(Uuid $sessionId, string $data): bool + { + return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); + } + + public function destroy(Uuid $sessionId): bool + { + return $this->redis->del($sessionId->toRfc4122()) > 0; + } + + public function gc(): array + { + // Redis handles TTL automatically + return []; + } +} +``` diff --git a/docs/run/stdio.md b/docs/run/stdio.md new file mode 100644 index 0000000..2f6ccca --- /dev/null +++ b/docs/run/stdio.md @@ -0,0 +1,64 @@ +# STDIO Transport + +The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. + +```php +$transport = new StdioTransport( + input: STDIN, // Input stream (default: STDIN) + output: STDOUT, // Output stream (default: STDOUT) + logger: $logger // Optional PSR-3 logger +); +``` + +## Parameters + +- **`input`** (optional): Input stream resource. Defaults to `STDIN`. +- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`runnerControl`** (optional): `RunnerControlInterface` - controls the read loop; the default runs until the input stream closes. +- **`maxLineBytes`** (optional): Maximum accepted line length in bytes. Oversized lines are rejected as invalid messages. + +!!! warning + When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. + Use `STDERR` for debugging instead. + +## Example Server Script + +```php-file +#!/usr/bin/env php +setServerInfo('STDIO Calculator', '1.0.0') + ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') + ->addTool(InvokableCalculator::class) + ->build(); + +$transport = new StdioTransport(); + +$status = $server->run($transport); + +exit($status); // listen() returns 0 when the input stream closes +``` + +## Client Configuration + +For MCP clients like Claude Desktop: + +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` diff --git a/docs/run/subscriptions.md b/docs/run/subscriptions.md new file mode 100644 index 0000000..4c3e989 --- /dev/null +++ b/docs/run/subscriptions.md @@ -0,0 +1,40 @@ +# Subscriptions + +> `subscriptions/listen` belongs to protocol revision `2026-07-28`; see +> [Protocol versions](../protocol-versions.md). Handshake-era clients keep using the HTTP `GET` stream and +> `resources/subscribe`, which the same server still answers. + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client +opens a long-lived POST whose response stream carries the notification types it asked for; +the server acknowledges first with `notifications/subscriptions/acknowledged`, reporting the +subset it agreed to honour. + +## The notification bus + +Delivery needs a bus, because the process that publishes and the process holding the stream +open are often not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. +Anything else — `notifications/resources/updated` above all — is published by the +application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +## How long a stream lives + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server +closes it gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot +outlive `max_execution_time`. Pass `0` for "until the client or the runtime ends it". diff --git a/docs/server-builder.md b/docs/server-builder.md deleted file mode 100644 index 6e1cee7..0000000 --- a/docs/server-builder.md +++ /dev/null @@ -1,747 +0,0 @@ -# Server Builder - -The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. -It provides methods for setting server information, configuring discovery, registering capabilities, and customizing -various aspects of the server behavior. - -## Table of Contents - -- [Basic Usage](#basic-usage) -- [Server Configuration](#server-configuration) -- [Protocol Version Negotiation](#protocol-version-negotiation) -- [Discovery Configuration](#discovery-configuration) -- [Session Management](#session-management) -- [Manual Capability Registration](#manual-capability-registration) -- [Service Dependencies](#service-dependencies) -- [Custom Message Handlers](#custom-message-handlers) -- [Complete Example](#complete-example) -- [Method Reference](#method-reference) - -## Basic Usage - -There are two ways to obtain a server builder instance: - -### Method 1: Static Builder Method (Recommended) - -```php -use Mcp\Server; - -$server = Server::builder() - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); -``` - -### Method 2: Direct Instantiation - -```php -use Mcp\Server\Builder; - -$server = (new Builder()) - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); -``` - -Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the -final `Server` instance ready for use. - -## Server Configuration - -### Server Information - -Set the server's identity with name, version, and optional description: - -```php -use Mcp\Schema\Icon; -use Mcp\Server; - -$server = Server::builder() - ->setServerInfo( - name: 'Calculator Server', - version: '1.2.0', - description: 'Advanced mathematical calculations', - icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], - websiteUrl: 'https://example.com' - '); -``` - -**Parameters:** -- `$name` (string): The server name -- `$version` (string): Version string (semantic versioning recommended) -- `$description` (string|null): Optional description -- `$icons` (Icon[]|null): Optional array of server icons -- `$websiteUrl` (string|null): Optional server website URL - -### Pagination Limit - -Configure the maximum number of items returned in paginated responses: - -```php -$server = Server::builder() - ->setPaginationLimit(100); // Default: 50 -``` - -### Instructions - -Provide hints to help AI models understand how to use your server: - -```php -$server = Server::builder() - ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); -``` - -### Protocol Version - -By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do -not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that -negotiation resolves, and for what `setProtocolVersion()` changes: - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$server = Server::builder() - ->setProtocolVersion(ProtocolVersion::V2025_06_18); -``` - -## Protocol Version Negotiation - -MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in -its `initialize` request, and the server answers with the revision the connection will actually use. Both sides -disconnect if they cannot agree. This follows the -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section of the specification. - -The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` -ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first -ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true -``` - -Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, -but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. - -### How the server answers - -| Client requests | Server responds with | -| --- | --- | -| A revision the server supports | That same revision | -| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | -| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | - -A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the -connection. The negotiated revision is stored on the session under `protocol_version`. - -The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through -this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions -would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to -mis-negotiate it. - -This table is mirrored by the `provideNegotiationTable()` data provider in -`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so -a newly declared revision is covered automatically. - -### Pinning a revision - -`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The -pin wins over the client's request, so a client asking for anything else receives the pinned revision as a -counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions. - -> [!NOTE] -> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header, -> which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport -> builds the middleware without access to the server configuration, so the header keeps being accepted for every -> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the -> same revision — see [Protocol Version Validation](transports.md#protocol-version-validation). - -## Discovery Configuration - -**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. - -```php -$server = Server::builder() - ->setDiscovery( - basePath: __DIR__, - scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes - excludeDirs: ['vendor', 'tests'], // Where NOT to look - cache: $cacheInstance, // Optional: cache discovered elements - namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match - ); -``` - -**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 -- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance -- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) - -**Basic Discovery (scans current directory and `src/`):** -```php -$server = Server::builder() - ->setDiscovery(__DIR__) // Minimal setup - ->build(); -``` - -**Production Setup with Caching:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -// Cache discovered elements to avoid filesystem scanning on every server start -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery( - basePath: __DIR__, - scanDirs: ['src', 'lib'], // Scan these directories recursively - excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs - cache: $cache // Cache for performance - ) - ->build(); -``` - -**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`) - -> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making -> subsequent server startups nearly instantaneous. - -## Session Management - -Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: - -```php -use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Session\InMemorySessionStore; -use Mcp\Server\Session\Psr16SessionStore; -use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Adapter\RedisAdapter; - -// Override with file-based storage -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// Override with in-memory storage and custom TTL -$server = Server::builder() - ->setSession(new InMemorySessionStore(3600)) - ->build(); - -// Override with PSR-16 cache-based storage -// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) -// composer require psr/simple-cache symfony/cache -$redisAdapter = new RedisAdapter( - RedisAdapter::createConnection('redis://localhost:6379'), - 'mcp_sessions' -); - -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); -``` - -### Garbage Collection Configuration - -The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native -`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given -request is `gcProbability / gcDivisor`. - -```php -// Default: 1/100 (1% chance per request) -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// Higher frequency: 1/10 (10% chance per request) -$server = Server::builder() - ->setSession( - new FileSessionStore(__DIR__ . '/sessions'), - gcProbability: 1, - gcDivisor: 10, - ) - ->build(); - -// Run GC on every request -$server = Server::builder() - ->setSession(gcProbability: 1, gcDivisor: 1) - ->build(); - -// Disable GC entirely (e.g. when using an external cleanup process) -$server = Server::builder() - ->setSession(gcProbability: 0) - ->build(); -``` - -**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. - -> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, -> 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 -- `Psr16StoreSession`: PSR-16 compliant cache-based storage - -**Custom Session Stores:** - -Implement `SessionStoreInterface` to create custom session storage: - -```php -use Mcp\Server\Session\SessionStoreInterface; -use Symfony\Component\Uid\Uuid; - -class RedisSessionStore implements SessionStoreInterface -{ - public function __construct(private $redis, private int $ttl = 3600) {} - - public function exists(Uuid $id): bool - { - return $this->redis->exists($id->toRfc4122()); - } - - public function read(Uuid $sessionId): string|false - { - $data = $this->redis->get($sessionId->toRfc4122()); - return $data !== false ? $data : false; - } - - public function write(Uuid $sessionId, string $data): bool - { - return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); - } - - public function destroy(Uuid $sessionId): bool - { - return $this->redis->del($sessionId->toRfc4122()) > 0; - } - - public function gc(): array - { - // Redis handles TTL automatically - return []; - } -} -``` - -## Manual Capability Registration - -Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be any PHP callable. - -### Handler Types - -**Handler** can be any PHP callable: - -1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` -2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) -3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime -4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method - -### Manual Tool Registration - -```php -$server = Server::builder() - // Using closure - ->addTool( - handler: function(int $a, int $b): int { return $a + $b; }, - name: 'add_numbers', - description: 'Adds two numbers together' - ) - - // Using class method pair - ->addTool( - handler: [Calculator::class, 'multiply'], - name: 'multiply_numbers' - // name and description are optional - derived from method name and docblock - ) - - // Using instance method - ->addTool( - handler: [$calculatorInstance, 'divide'] - ) - - // Using invokable class - ->addTool( - handler: InvokableCalculator::class - ); -``` - -#### Parameters - -- `handler` (callable|string): The tool handler -- `name` (string|null): Optional tool name -- `title` (string|null): Optional human-readable title for display in UI -- `description` (string|null): Optional tool description -- `annotations` (ToolAnnotations|null): Optional annotations for the tool -- `inputSchema` (array|null): Optional input schema for the tool -- `icons` (Icon[]|null): Optional array of icons for the tool -- `meta` (array|null): Optional metadata for the tool - -### Manual Resource Registration - -Register static resources: - -```php -$server = Server::builder() - ->addResource( - handler: [Config::class, 'getSettings'], - uri: 'config://app/settings', - name: 'app_config', - description: 'Application configuration', - mimeType: 'application/json' - ); -``` - -#### Parameters - -- `handler` (callable|string): The resource handler -- `uri` (string): The resource URI -- `name` (string|null): Optional resource name -- `description` (string|null): Optional resource description -- `mimeType` (string|null): Optional MIME type of the resource -- `size` (int|null): Optional size of the resource in bytes -- `annotations` (Annotations|null): Optional annotations for the resource -- `icons` (Icon[]|null): Optional array of icons for the resource -- `meta` (array|null): Optional metadata for the resource - -### Manual Resource Template Registration - -Register dynamic resources with URI templates: - -```php -$server = Server::builder() - ->addResourceTemplate( - handler: [UserService::class, 'getUserProfile'], - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - description: 'User profile by ID', - mimeType: 'application/json' - ); -``` - -#### Parameters - -- `handler` (callable|string): The resource template handler -- `uriTemplate` (string): The resource URI template -- `name` (string|null): Optional resource template name -- `description` (string|null): Optional resource template description -- `mimeType` (string|null): Optional MIME type of the resource -- `annotations` (Annotations|null): Optional annotations for the resource template - -### Manual Prompt Registration - -Register prompt generators: - -```php -$server = Server::builder() - ->addPrompt( - handler: [PromptService::class, 'generatePrompt'], - name: 'custom_prompt', - description: 'A custom prompt generator' - ); -``` - -#### Parameters - -- `handler` (callable|string): The prompt handler -- `name` (string|null): Optional prompt name -- `title` (string|null): Optional human-readable title for display in UI -- `description` (string|null): Optional prompt description -- `icons` (Icon[]|null): Optional array of icons for the prompt - -**Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from -the handler's method name and docblock. - -For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). - -### Explicit element registration - -When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of -the four handler interfaces below and register it through `Builder::add()`. - -| Element kind | Handler interface | -|-------------------|-------------------------------------------------------| -| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | -| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | -| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | -| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | - -Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a -`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed -template variables. - -```php -use Mcp\Schema\Tool; -use Mcp\Server; -use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\ToolHandlerInterface; - -final class WeatherHandler implements ToolHandlerInterface -{ - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['temperature' => 21, 'unit' => 'C']; - } -} - -$tool = new Tool( - name: 'get_weather', - title: null, - inputSchema: [ - 'type' => 'object', - 'properties' => ['city' => ['type' => 'string']], - 'required' => ['city'], - ], - description: 'Returns the current weather for a city.', - annotations: null, -); - -$server = Server::builder() - ->add($tool, new WeatherHandler()) - ->build(); -``` - -`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a -`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own -inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns. - -Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, -prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and -docblock. - -## Service Dependencies - -### Container - -The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. -The SDK includes a basic container with simple auto-wiring capabilities. - -```php -use Mcp\Capability\Registry\Container; - -// Use the default basic container -$container = new Container(); -$container->set(DatabaseService::class, new DatabaseService($pdo)); -$container->set(\PDO::class, $pdo); - -$server = Server::builder() - ->setContainer($container) - ->build(); -``` - -**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 -- Does NOT support scalar/built-in type injection without defaults -- Detects circular dependencies - -You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). - -### Logger - -Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -$logger = new Logger('mcp-server'); -$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); - -$server = Server::builder() - ->setLogger($logger); -``` - -### Event Dispatcher - -Configure event dispatching: - -```php -$server = Server::builder() - ->setEventDispatcher($eventDispatcher); -``` - -## Custom Message Handlers - -**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over -individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass -those dependencies in yourself. - -> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless -> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable -> taking on the additional plumbing. - -### Request Handlers - -Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a -`Response` or an `Error` object. - -Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these -methods as many times as needed; each call prepends the handlers so they execute before the defaults: - -```php -$server = Server::builder() - ->addRequestHandler(new CustomListToolsHandler()) - ->addRequestHandlers([ - new CustomCallToolHandler(), - new CustomGetPromptHandler(), - ]) - ->build(); -``` - -Request handlers implement `RequestHandlerInterface`: - -```php -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; -use Mcp\Schema\JsonRpc\Response; -use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Session\SessionInterface; - -interface RequestHandlerInterface -{ - public function supports(Request $request): bool; - - public function handle(Request $request, SessionInterface $session): Response|Error; -} -``` - -- `supports()` decides if the handler should process the incoming request -- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) - -### Notification Handlers - -Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** -return anything - they perform side effects only. - -Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): - -```php -$server = Server::builder() - ->addNotificationHandler(new LoggingNotificationHandler()) - ->addNotificationHandlers([ - new InitializedNotificationHandler(), - new ProgressNotificationHandler(), - ]) - ->build(); -``` - -Notification handlers implement `NotificationHandlerInterface`: - -```php -use Mcp\Schema\JsonRpc\Notification; -use Mcp\Server\Handler\Notification\NotificationHandlerInterface; -use Mcp\Server\Session\SessionInterface; - -interface NotificationHandlerInterface -{ - public function supports(Notification $notification): bool; - - public function handle(Notification $notification, SessionInterface $session): void; -} -``` - -- `supports()` decides if the handler should process the incoming notification -- `handle()` performs side effects but **does not** return a value (notifications have no response) - -### Key Differences - -| Handler Type | Interface | Returns | Use Case | -|-------------|-----------|---------|----------| -| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | -| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | - -### Example - -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement -custom `tools/list` and `tools/call` request handlers independently of the registry. - -## Complete Example - -Here's a comprehensive example showing all major configuration options: - -```php -use Mcp\Server; -use Mcp\Server\Session\FileSessionStore; -use Mcp\Capability\Registry\Container; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -// Setup dependencies -$logger = new Logger('mcp-server'); -$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); -$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); - -// Setup container with dependencies -$container = new Container(); -$container->set(\PDO::class, new \PDO('sqlite::memory:')); -$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); - -// Build server -$server = Server::builder() - // Server identity - ->setServerInfo('Advanced Calculator', '2.1.0') - - // Performance and behavior - ->setPaginationLimit(100) - ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') - - // Discovery with caching - ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) - - // Session management - ->setSession($sessionStore) - - // Services - ->setLogger($logger) - ->setContainer($container) - - // Manual capability registration - ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') - ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') - - // Build the server - ->build(); -``` - -## Method Reference - -| Method | Parameters | Description | -|--------|------------|-------------| -| `setServerInfo()` | name, version, description? | Set server identity | -| `setPaginationLimit()` | limit | Set max items per page | -| `setInstructions()` | instructions | Set usage instructions | -| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | -| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | -| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | -| `setLogger()` | logger | Set PSR-3 logger | -| `setContainer()` | container | Set PSR-11 container | -| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `addRequestHandler()` | handler | Prepend a single custom request handler | -| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | -| `addNotificationHandler()` | handler | Prepend a single custom notification handler | -| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | -| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | -| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | -| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | -| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | -| `add()` | definition, handler | Register an element from a schema VO + handler pair | -| `build()` | - | Create the server instance | diff --git a/docs/servers/completions.md b/docs/servers/completions.md new file mode 100644 index 0000000..c422620 --- /dev/null +++ b/docs/servers/completions.md @@ -0,0 +1,98 @@ +# Completion Providers + +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. + +## Completion Provider Types + +### 1. Value Lists + +Provide a static list of possible values: + +```php +use Mcp\Capability\Attribute\CompletionProvider; + +#[McpPrompt] +public function generateContent( + #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] + string $contentType, + + #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] + string $difficulty +): array +{ + return [ + ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] + ]; +} +``` + +### 2. Enum Classes + +Use enum values for completion: + +```php +enum Priority: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; +} + +enum Status // Unit enum +{ + case DRAFT; + case PUBLISHED; + case ARCHIVED; +} + +#[McpResourceTemplate(uriTemplate: 'tasks://{priority}/{status}')] +public function getTask( + #[CompletionProvider(enum: Priority::class)] // Uses backing values + string $priority, + + #[CompletionProvider(enum: Status::class)] // Uses case names + string $status +): array +{ + // Implementation +} +``` + +### 3. Custom Provider Classes + +For dynamic completion logic: + +```php +use Mcp\Capability\Completion\ProviderInterface; + +class UserIdCompletionProvider implements ProviderInterface +{ + public function __construct(private DatabaseService $db) {} + + public function getCompletions(string $currentValue): array + { + // Return dynamic completions based on current input + return $this->db->searchUserIds($currentValue); + } +} + +#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] +public function getUserProfile( + #[CompletionProvider(provider: UserIdCompletionProvider::class)] + string $userId +): array +{ + // Implementation +} +``` + +**Provider Resolution:** +- **Class strings** (`Provider::class`) → Resolved from PSR-11 container +- **Instances** (`new Provider()`) → Used directly +- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` +- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` + +> **Important** +> +> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate +> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. diff --git a/docs/servers/index.md b/docs/servers/index.md new file mode 100644 index 0000000..616acac --- /dev/null +++ b/docs/servers/index.md @@ -0,0 +1,30 @@ +# Servers + +An MCP server exposes four kinds of elements to a connected client. They differ by who +decides to use them: + +* A **[tool](tools.md)** is an action the *model* picks and calls. This is the page most + people want first. +* A **[resource](resources.md)** is read-only data the *application* chooses to read, + addressed by a fixed URI. **[Resource templates](resource-templates.md)** are the same + thing with variables in the URI, for data that is generated per request. +* A **[prompt](prompts.md)** is a message template a *person* invokes by name, from a + menu or a slash command. + +Around those, the rest of what a server declares: + +* **[Completions](completions.md)** is server-side autocomplete for prompt and + resource-template arguments. +* **[Schema generation](schemas.md)** explains how your PHP types and docblocks become + the JSON Schema a model sees, and how to override it where the types are not enough. +* **[Registering elements](registration.md)** covers the three ways an element reaches + the registry: attribute discovery, explicit registration, or both at once. + +Every page here stands on its own; jump straight to the one you need. If you have not +built a server yet, start with **[First server](../get-started/first-server.md)** +instead. + +What happens *inside* the functions you register — logging, progress, asking the client +for an LLM completion — is the next section, +**[Inside your handler](../handlers/index.md)**. Getting the server in front of a client +is **[Running your server](../run/index.md)**. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md new file mode 100644 index 0000000..33da3c7 --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,126 @@ +# Prompts + +Prompts generate templates for AI interactions. + +```php +use Mcp\Capability\Attribute\McpPrompt; + +class PromptGenerator +{ + /** + * Generates a code review request prompt. + */ + #[McpPrompt(name: 'code_review')] + public function reviewCode(string $language, string $code, string $focus = 'general'): array + { + return [ + ['role' => 'assistant', 'content' => 'You are an expert code reviewer.'], + ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + ]; + } +} +``` + +## Parameters + +- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Prompt description. Falls back to the docblock (summary plus long description). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Prompt Return Values + +Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. + +### Supported Return Formats + +```php +// Array of message objects with role and content +public function basicPrompt(): array +{ + return [ + ['role' => 'assistant', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello, how are you?'] + ]; +} + +// Single message (automatically wrapped in array) +public function singleMessage(): array +{ + return [ + ['role' => 'user', 'content' => 'Write a poem about PHP'] + ]; +} + +// Associative array with user/assistant keys +public function userAssistantFormat(): array +{ + return [ + 'user' => 'Explain how arrays work in PHP', + 'assistant' => 'Arrays in PHP are ordered maps...' + ]; +} + +// Non-text content — each message carries exactly one content block, +// so an image goes into its own message +use Mcp\Schema\Content\{TextContent, ImageContent}; + +public function mixedContent(): array +{ + return [ + ['role' => 'user', 'content' => new TextContent('Analyze this image:')], + ['role' => 'user', 'content' => new ImageContent(data: $imageData, mimeType: 'image/png')] + ]; +} + +// Using explicit PromptMessage objects +use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Enum\Role; + +public function explicitMessages(): array +{ + return [ + new PromptMessage(Role::Assistant, new TextContent('System instructions')), + new PromptMessage(Role::User, new TextContent('User question')) + ]; +} +``` + +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + +### Valid Message Roles + +- **`user`**: User input or questions +- **`assistant`**: Assistant responses, including system-style instructions + +Those two are the only valid roles — MCP has no `system` role, and any other value +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 + +```php +use Mcp\Exception\PromptGetException; + +#[McpPrompt] +public function generatePrompt(string $topic, string $style): array +{ + $validStyles = ['casual', 'formal', 'technical']; + + if (!in_array($style, $validStyles)) { + throw new PromptGetException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/registration.md b/docs/servers/registration.md new file mode 100644 index 0000000..e2dc563 --- /dev/null +++ b/docs/servers/registration.md @@ -0,0 +1,247 @@ +# Registering elements + +Every tool, resource, resource template, and prompt has to reach the server's +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 +- Type-safe by default +- Caching support + +**Example:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Automatic discovery + ->build(); +``` + +## Manual Registration + +Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be +any PHP callable. + +**Advantages:** +- Fine-grained control +- Runtime configuration +- Conditional registration +- External handler support + +**Example:** +```php +$server = Server::builder() + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') + ->addPrompt([Prompts::class, 'email'], 'write_email') + ->build(); +``` + + +### Handler Types + +**Handler** can be any PHP callable: + +1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` +2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) +3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime +4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method + +### Manual Tool Registration + +```php +$server = Server::builder() + // Using closure + ->addTool( + handler: function(int $a, int $b): int { return $a + $b; }, + name: 'add_numbers', + description: 'Adds two numbers together' + ) + + // Using class method pair + ->addTool( + handler: [Calculator::class, 'multiply'], + name: 'multiply_numbers' + // name and description are optional - derived from method name and docblock + ) + + // Using instance method + ->addTool( + handler: [$calculatorInstance, 'divide'] + ) + + // Using invokable class + ->addTool( + handler: InvokableCalculator::class + ); +``` + +#### Parameters + +- `handler` (callable|string): The tool handler +- `name` (string|null): Optional tool name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional tool description +- `annotations` (ToolAnnotations|null): Optional annotations for the tool +- `inputSchema` (array|null): Optional input schema for the tool +- `icons` (Icon[]|null): Optional array of icons for the tool +- `meta` (array|null): Optional metadata for the tool +- `outputSchema` (array|null): Optional JSON schema describing the tool's `structuredContent` + +### Manual Resource Registration + +Register static resources: + +```php +$server = Server::builder() + ->addResource( + handler: [Config::class, 'getSettings'], + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource handler +- `uri` (string): The resource URI +- `name` (string|null): Optional resource name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional resource description +- `mimeType` (string|null): Optional MIME type of the resource +- `size` (int|null): Optional size of the resource in bytes +- `annotations` (Annotations|null): Optional annotations for the resource +- `icons` (Icon[]|null): Optional array of icons for the resource +- `meta` (array|null): Optional metadata for the resource + +### Manual Resource Template Registration + +Register dynamic resources with URI templates: + +```php +$server = Server::builder() + ->addResourceTemplate( + handler: [UserService::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource template handler +- `uriTemplate` (string): The resource URI template +- `name` (string|null): Optional resource template name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional resource template description +- `mimeType` (string|null): Optional MIME type of the resource +- `annotations` (Annotations|null): Optional annotations for the resource template +- `meta` (array|null): Optional metadata for the resource template + +### Manual Prompt Registration + +Register prompt generators: + +```php +$server = Server::builder() + ->addPrompt( + handler: [PromptService::class, 'generatePrompt'], + name: 'custom_prompt', + description: 'A custom prompt generator' + ); +``` + +#### Parameters + +- `handler` (callable|string): The prompt handler +- `name` (string|null): Optional prompt name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional prompt description +- `icons` (Icon[]|null): Optional array of icons for the prompt +- `meta` (array|null): Optional metadata for the prompt + +**Note:** `name` and `description` are optional when the handler is a method or an invokable class — they are then +derived from the method name and its docblock. A **closure** handler has neither, so it gets a generated name +(`closure_tool_`) and no description; name your closures explicitly. + +For more details on the elements themselves, see [Tools](tools.md), [Resources](resources.md), [Resource templates](resource-templates.md), and [Prompts](prompts.md). + +### Explicit element registration + +When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of +the four handler interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value objects validate some of +their own input as well — `Tool` requires an object-typed input schema, `ResourceDefinition` and `ResourceTemplate` +check the name pattern and URI — but an invalid tool or prompt *name* is not rejected, it is only logged as a warning +when the element is registered. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + +## Hybrid Approach + +Combine both methods for maximum flexibility: + +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Discover most capabilities + ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones + ->build(); +``` + +Manual registrations always take precedence over discovered elements with the same identifier — same `name` for tools +and prompts, same `uri` for resources, same `uriTemplate` for resource templates. + +For runtime, config-driven elements whose shape is not known at compile time, see +[Explicit element registration](#explicit-element-registration). diff --git a/docs/servers/resource-templates.md b/docs/servers/resource-templates.md new file mode 100644 index 0000000..2cff71e --- /dev/null +++ b/docs/servers/resource-templates.md @@ -0,0 +1,49 @@ +# Resource Templates + +Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules +as static resources (URI schemas, return values, MIME types, etc.) but accept `{variable}` placeholders in the URI. + +Only simple [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) variable expansion +is supported — `{var}`, one path segment each. Operators such as `{+var}`, `{#var}`, +`{/path}`, `{?query}` and explode (`{list*}`) are not parsed, and a variable's value +cannot contain `/`. + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; + +class UserProvider +{ + /** + * Retrieves user profile information by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile/{section}', + name: 'user_profile', + description: 'User profile data by section', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId, string $section): array + { + return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); + } +} +``` + +## Parameters + +- **`uriTemplate`** (required): URI with `{variables}`. Must start with a scheme (`file://`, `user://`, …) and contain at least one variable. +- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Template description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`annotations`** (optional): Additional metadata. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Variable Rules + +1. **Variable names must match exactly** between URI template and method parameters — + they are bound by name, so the parameter order is free +2. **All variables are required** - no optional parameters supported +3. **Type hints work normally** - parameters can be typed (string, int, etc.) + +**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` diff --git a/docs/servers/resources.md b/docs/servers/resources.md new file mode 100644 index 0000000..10736c4 --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,152 @@ +# Resources + +Resources provide access to static data that clients can read. + +```php +use Mcp\Capability\Attribute\McpResource; + +class ConfigProvider +{ + /** + * Provides the current application configuration. + */ + #[McpResource(uri: 'config://app/settings', name: 'app_settings')] + public function getSettings(): array + { + return [ + 'version' => '1.0.0', + 'debug' => false, + 'features' => ['auth', 'logging'] + ]; + } +} +``` + +## Parameters + +- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Resource description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). +**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. + +## Resource Return Values + +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. + +### Supported Return Types + +```php +// String content - converted to text resource +public function getTextFile(): string +{ + return "File content here"; +} + +// Array content - converted to JSON +public function getConfig(): array +{ + return ['debug' => true, 'version' => '1.0']; +} + +// Stream resource - read and converted to blob. +// `resource` is not a PHP type declaration, so the return type is left off. +/** @return resource */ +public function getImageStream() +{ + return fopen('image.png', 'r'); +} + +// SplFileInfo - file content with MIME type detection +public function getFileInfo(): \SplFileInfo +{ + return new \SplFileInfo('document.pdf'); +} +``` + +**Explicit resource content types** + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +public function getExplicitText(): TextResourceContents +{ + return new TextResourceContents( + uri: 'config://app/settings', + mimeType: 'application/json', + text: json_encode(['setting' => 'value']) + ); +} + +public function getExplicitBlob(): BlobResourceContents +{ + return new BlobResourceContents( + uri: 'file://image.png', + mimeType: 'image/png', + blob: base64_encode(file_get_contents('image.png')) + ); +} +``` + +**Special Array Formats** + +```php +// Array with 'text' key - used as text content +public function getTextArray(): array +{ + return ['text' => 'Content here', 'mimeType' => 'text/plain']; +} + +// Array with 'blob' key - used as blob content +public function getBlobArray(): array +{ + return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; +} + +// Multiple resource contents +public function getMultipleResources(): array +{ + return [ + new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), + new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') + ]; +} +``` + +### Error Handling + +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: 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 + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Exception\ResourceReadException; + +// A URI with variables is a resource *template*; `#[McpResource]` registers a +// fixed URI and would never receive `$path`. Note a variable matches a single +// segment, so `$path` here cannot contain `/`. +#[McpResourceTemplate(uriTemplate: 'file://{path}')] +public function getFile(string $path): string +{ + if (!file_exists($path)) { + throw new ResourceReadException("File not found: {$path}"); + } + + if (!is_readable($path)) { + throw new ResourceReadException("File not readable: {$path}"); + } + + return file_get_contents($path); +} +``` + +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/schemas.md b/docs/servers/schemas.md new file mode 100644 index 0000000..d6d11d7 --- /dev/null +++ b/docs/servers/schemas.md @@ -0,0 +1,111 @@ +# Schema Generation and Validation + +The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema +generation applies to both attribute-discovered and manually registered tools. + +## Schema Generation Priority + +The server follows this order of precedence: + +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide configuration +4. **PHP type hints + docblocks** - Automatic inference (lowest priority) + +## Automatic Schema from PHP Types + +```php +#[McpTool] +public function processUser( + string $email, // Required string + int $age, // Required integer + ?string $name = null, // Optional string + bool $active = true // Boolean with default +): array +{ + // Schema auto-generated from method signature +} +``` + +## Parameter-Level Schema Enhancement + +Add validation rules to specific parameters: + +```php +use Mcp\Capability\Attribute\Schema; + +#[McpTool] +public function validateUser( + #[Schema(format: 'email')] + string $email, + + #[Schema(minimum: 18, maximum: 120)] + int $age, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized first name' + )] + string $firstName +): bool +{ + // PHP types provide base validation + // Schema attributes add constraints +} +``` + +## Method-Level Schema + +Add validation for complex object structures: + +```php +#[McpTool] +#[Schema( + properties: [ + 'userData' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['userData'] +)] +public function createUser(array $userData): array +{ + // Method-level schema adds object structure validation + // PHP array type provides base type +} +``` + +## Complete Schema Override + +**Use sparingly** - bypasses all automatic inference: + +```php +#[McpTool] +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => ['type' => 'string', 'format' => 'uri'], + 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], + 'headers' => [ + 'type' => 'object', + 'patternProperties' => [ + '^[A-Za-z0-9-]+$' => ['type' => 'string'] + ] + ] + ], + 'required' => ['endpoint', 'method'] +])] +public function makeApiRequest(string $endpoint, string $method, array $headers): array +{ + // Complete definition override - PHP types ignored +} +``` + +**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex +validation requirements that cannot be achieved through the priority system. diff --git a/docs/servers/tools.md b/docs/servers/tools.md new file mode 100644 index 0000000..a919b40 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,236 @@ +# Tools + +Tools are callable functions that perform actions and return results. + +```php +use Mcp\Capability\Attribute\McpTool; + +class Calculator +{ + /** + * Performs arithmetic operations with validation. + */ + #[McpTool(name: 'calculate')] + public function performCalculation(float $a, float $b, string $operation): float + { + return match($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), + default => throw new \InvalidArgumentException('Invalid operation') + }; + } +} +``` + +## Parameters + +- **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Tool description. Falls back to the docblock (summary plus long description); stays unset if there is no docblock. +- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`outputSchema`** (optional): JSON schema describing `structuredContent`; see [Structured Output](#structured-output). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Priority**: `name` is the attribute parameter, else the method name. `description` is the attribute parameter, else the docblock — the method name is never used as a description. + +For tool parameter validation and JSON schema generation, see [Schema generation](schemas.md). + +## Tool Return Values + +Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. + +### Automatic Content Wrapping + +```php +// Primitive types → TextContent +public function getString(): string { return "Hello"; } // TextContent +public function getNumber(): int { return 42; } // TextContent +public function getBool(): bool { return true; } // TextContent +public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) + +// Special cases +public function getNull(): ?string { return null; } // TextContent("(null)") +public function returnVoid(): void { /* no return */ } // TextContent("(null)") +``` + +### Explicit Content Types + +For fine control over output formatting: + +```php +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: + +```php +// Structured content only from 2026-07-28 on: a bare list is not a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Talking back to the client](../handlers/client-communication.md#clientgateway). + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + +### Error Handling + +Tool handlers can throw any exception, but the type determines how it's handled: + +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\ToolCallException; + +#[McpTool] +public function divideNumbers(float $a, float $b): float +{ + if ($b === 0.0) { + throw new ToolCallException('Division by zero is not allowed'); + } + + return $a / $b; +} + +#[McpTool] +public function processFile(string $filename): string +{ + if (!file_exists($filename)) { + throw new ToolCallException("File not found: {$filename}"); + } + + return file_get_contents($filename); +} +``` + +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md deleted file mode 100644 index 45e3cb2..0000000 --- a/docs/stateless-lifecycle.md +++ /dev/null @@ -1,391 +0,0 @@ -# The 2026-07-28 lifecycle - -Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a -server needs to answer a request now travels *in* that request, which means any process can answer any -request and none of them need to share state. - -This guide covers what changes for a server author. Tools, resources, prompts and their handlers are -unaffected — the same registrations serve either lifecycle. - -- [The two eras](#the-two-eras) -- [Building a stateless server](#building-a-stateless-server) -- [Per-request metadata](#per-request-metadata) -- [Multi round-trip requests](#multi-round-trip-requests) -- [Progress and logging](#progress-and-logging) -- [Caching](#caching) -- [Subscriptions](#subscriptions) -- [Serving both eras](#serving-both-eras) -- [What was removed](#what-was-removed) - -## The two eras - -| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | -| --- | --- | --- | -| Opening | `initialize` / `notifications/initialized` | none | -| Version | negotiated once, kept on the session | declared on every request | -| Capabilities | exchanged once | declared on every request | -| Discovery | `initialize` result | `server/discover` | -| Sessions | `Mcp-Session-Id` | removed | -| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | -| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | -| Dispatcher | `Protocol` | `StatelessProtocol` | -| HTTP entry | `StreamableHttpTransport` — the same one, for both | - -`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` -is where the boundary sits. - -## Building a stateless server - -There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for -each era, and `StreamableHttpTransport` decides per request which of them answers: - -```php -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') - ->build(); - -(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); -``` - -That one endpoint answers `initialize` and `server/discover` alike. See -[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it. - -Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is -routed as one. - -A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php). - -## Per-request metadata - -Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into -headers so an intermediary can route without parsing the body: - -| `_meta` key | Required | Header | -| --- | --- | --- | -| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | -| `io.modelcontextprotocol/clientCapabilities` | yes | — | -| `io.modelcontextprotocol/clientInfo` | no | — | -| `io.modelcontextprotocol/logLevel` | no | — | -| `progressToken` | no | — | -| `traceparent`, `tracestate`, `baggage` | no | — | - -Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`. -A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with -`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from. - -Handlers read the metadata through `RequestContext`: - -```php -$context->getProtocolVersion(); // the revision serving this request -$context->getClientCapabilities(); // what this client declared, or null in the handshake era -$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim -``` - -`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and -the sub-capability variants — read the same declaration, so they work in both eras. - -### Mirroring a tool argument into a header - -A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the -server checks that the two agree: - -```php -->addTool( - static fn (string $region, string $query): string => …, - name: 'execute_sql', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'query' => ['type' => 'string'], - ], - 'required' => ['region', 'query'], - ], -) -``` - -The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer` -or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks -any of those rather than letting it fail later as a header mismatch. - -## Multi round-trip requests - -There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots -**returns** the ask, and the client retries the original call carrying the answers. - -This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the -same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on). - -```php -use Mcp\Schema\Result\CallToolResult; -use Mcp\Schema\Result\InputRequiredResult; - -static function (RequestContext $context): CallToolResult|InputRequiredResult { - $answer = $context->getInputContext()?->elicitResult('who'); - - if (null === $answer) { - return new InputRequiredResult( - ['who' => new ElicitRequest('Your name?', $schema)], - requestState: $context->mintRequestState(['asked' => 'who']), - ); - } - - return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); -} -``` - -`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. - -**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, -`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing -to a handler: ask again. `response()` is still there for the raw array. - -**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so -it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that -fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`: - -```php -->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) -``` - -The **same key must reach every process that might serve the retry**. A per-process random value works only -for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. - -**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against -the request's declared capabilities and answers `-32021` — with the missing set in -`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation -needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only. - -**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the -handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the -replacement. - -## Progress and logging - -Both travel on the request's own response stream, and both are opt-in by the client: - -- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing. -- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must - not** emit `notifications/message` at all, and does not. - -```php -static function (RequestContext $context): string { - $client = $context->getClientGateway(); - $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); - $client->progress(1, 3, 'Shard 1 of 3'); - - return 'done'; -} -``` - -The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it -does — so an error that has to carry a specific status still gets one, and a handler that talks gets a -stream. Trace context from the request is echoed onto every notification it causes. - -## Caching - -`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The -default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. -Say what you actually mean: - -```php -use Mcp\Schema\Enum\CacheScope; -use Mcp\Server\Wire\CachePolicy; - -->setCachePolicy( - CachePolicy::default(30_000) - ->withMethod('tools/list', 3_600_000, CacheScope::Public) - ->withMethod('server/discover', 3_600_000, CacheScope::Public), -) -``` - -`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not -vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. -Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key. - -## Subscriptions - -`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a -long-lived POST whose response stream carries the notification types it asked for; the server acknowledges -first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour. - -Delivery needs a bus, because the process that publishes and the process holding the stream open are often -not the same one: - -```php -use Mcp\Server\Subscription\InMemoryNotificationBus; -use Mcp\Server\Subscription\Psr16NotificationBus; - -// stdio, or a persistent runtime where the whole server is one process -->setNotificationBus(new InMemoryNotificationBus()) - -// PHP-FPM: the publisher and the stream are different workers -->setNotificationBus(new Psr16NotificationBus($cache)) -``` - -Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else — -`notifications/resources/updated` above all — is published by the application: - -```php -$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); -``` - -`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it -gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`. -Pass `0` for "until the client or the runtime ends it". - -## Serving both eras - -One endpoint serves both, and the client picks nothing. Every request is classified once, before anything -else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**: - -| Evidence | Routed to | -| --- | --- | -| `params._meta` names a modern revision | modern era | -| `params._meta` names a handshake revision | handshake era | -| no such member | handshake era — `initialize` included | -| a notification with no member, under a modern header | modern era | -| `GET` / `DELETE` | handshake era | - -The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose -header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen -at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its -own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants. - -An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg -answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming -the handshake ones. - -Both legs come from **one** builder configuration — one registry, one set of handler instances, one session -manager. A tool registered once is reachable from both, and a change made through one is visible to the -other. - -To serve the handshake era alone, say so: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->withoutModernEra() - ->build(); -``` - -That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve. -`setModernVersions()` narrows the modern leg instead of removing it. - -For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its -own and mount it on `StatelessHttpTransport`: - -```php -$protocol = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->buildStateless([ProtocolVersion::V2026_07_28]); - -(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); -``` - -### What a handler forks on - -Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, -and neither does the one thing that looks like it should: **asking the user something**. - -Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off -`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's -input-required shim fulfils the same ask over that connection's own channel: each embedded request goes -out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is -re-entered with the answers under the keys it asked for. It is on by default; -[`examples/server/elicitation`](../examples/server/elicitation) and -[`examples/server/client-communication`](../examples/server/client-communication) are written this way and -name no era anywhere. - -Two things to know about it. - -**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where -it is from what came back rather than from anything it kept. That is already true of the modern era — the -client retries the whole call there — so a portable handler is written that way regardless. It is only new -if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps -working untouched, since nothing here runs unless a handler *returns* an ask. - -**Each round holds the request open.** The shim waits for the client's answer inside the originating -request, which on a process-per-request runtime means it holds a worker for as long as the user takes. -That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable -from handlers that never mention it — so size `setInputRequiredLimits()` against your pool. - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - // Re-entries per request, and seconds to wait for one answer. - ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) - ->build(); -``` - -`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of -being fulfilled behind your back. - -## Writing a client for this revision - -One line selects the lifecycle; nothing else about the API changes. - -```php -$client = Client::builder() - ->setClientInfo('my-client', '1.0.0') - ->setProtocolVersion(ProtocolVersion::V2026_07_28) - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($myElicitationHandler) - ->build(); - -$client->connect(new HttpTransport('https://example.com/mcp')); - -$client->callTool('greet', []); -``` - -What that changes underneath: - -- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's - identity, and a server that does not answer it still yields a usable connection — the method is - optional. If discovery *does* report `supportedVersions` and the configured revision is not among - them, the client moves to a modern revision the server lists, or refuses the connection outright - rather than talking past it. -- **An envelope on every request**, carrying the revision, the declared capabilities and the client - identity. The capabilities are what let a server decide, per request, whether it may ask for input. -- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method - addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which - requires the client to have listed the tool first; `tools/list` is what populates that knowledge. - A tool whose annotations are malformed is dropped from the listing and refused if called, since the - client cannot produce the headers it demands. -- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is - resolved through the same request handlers that served server-initiated requests in the handshake era, - and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for - byte, under a new JSON-RPC id. The caller sees one call and one result. - -Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; -`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the -skipped handshake, the round-trip loop — applies to both. - -See `examples/client/stateless_lifecycle_client.php` for a runnable version. - -## What was removed - -Answered with `404` and `-32601` by a modern server: - -- `initialize`, `notifications/initialized` -- `ping` -- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` -- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of - `subscriptions/listen` -- `notifications/roots/list_changed` - -Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken -response stream loses the request; the client re-issues it with a new id. - -Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a -server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era -client still gets `-32002`. - -Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least -twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots, -integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of -`notifications/message`. diff --git a/examples/client/README.md b/examples/client/README.md index c212171..0a6dd8f 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -35,6 +35,25 @@ php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php php examples/client/stateless_lifecycle_client.php ``` +## Server-initiated requests + +The remaining examples answer requests the server sends back during a call: + +```bash +# Sampling, log and progress messages (also available over HTTP) +php examples/client/stdio_client_communication.php + +# Elicitation: answering the server's questions from an interactive prompt +php examples/client/stdio_elicitation.php + +# Roots: exposing workspace folders and signalling roots/list_changed +php examples/client/stdio_roots.php +``` + +> **Note**: `http_client_communication.php` needs a server that can answer a second request +> mid-call; PHP's built-in web server only does that with worker processes, e.g. +> `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index ffcfa67..06de879 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -18,7 +18,7 @@ * Usage: php examples/client/http_discovery_calculator.php * * Before running, start an HTTP MCP server: - * php -S localhost:8080 examples/server/http-discovery-calculator/server.php + * php -S localhost:8000 examples/server/discovery-calculator/server.php */ require_once __DIR__.'/../../vendor/autoload.php'; diff --git a/examples/server/README.md b/examples/server/README.md index 779d8a4..8d60b4d 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -24,9 +24,9 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser ## The 2026-07-28 lifecycle -`stateless-lifecycle/server.php` speaks protocol revision `2026-07-28`, which removed the `initialize` -handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which -opens with `initialize`: +`stateless-lifecycle/server.php` demonstrates protocol revision `2026-07-28`, which removed the +`initialize` handshake and protocol-level sessions. Like every example it answers both eras — the +Inspector's `initialize` still works — but the interesting part is the modern one: ```bash php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..491441b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,169 @@ +site_name: MCP PHP SDK +site_description: The official PHP SDK for the Model Context Protocol +site_url: https://php.sdk.modelcontextprotocol.io/ +repo_name: modelcontextprotocol/php-sdk +repo_url: https://github.com/modelcontextprotocol/php-sdk +edit_uri: edit/main/docs/ + +# The class-level API reference is still generated by phpDocumentor and copied +# into `site/api/` by `make docs`; Zensical owns everything under `docs/`. +# A relative nav entry would have to resolve to a page in this build, so the +# reference is linked by its absolute URL. +nav: + - MCP PHP SDK: index.md + - Get started: + - get-started/index.md + - Installation: get-started/installation.md + - First server: get-started/first-server.md + - Try it with the Inspector: get-started/inspector.md + - Servers: + - servers/index.md + - Tools: servers/tools.md + - Resources: servers/resources.md + - Resource templates: servers/resource-templates.md + - Prompts: servers/prompts.md + - Completions: servers/completions.md + - Schema generation: servers/schemas.md + - Registering elements: servers/registration.md + - Inside your handler: + - handlers/index.md + - Talking back to the client: handlers/client-communication.md + - Asking for input: handlers/input-required.md + - Logging: handlers/logging.md + - Running your server: + - run/index.md + - Server builder: run/server-builder.md + - STDIO transport: run/stdio.md + - HTTP transport: run/http.md + - Framework integration: run/framework-integration.md + - Sessions: run/sessions.md + - Serving both eras: run/protocol-eras.md + - Caching: run/caching.md + - Subscriptions: run/subscriptions.md + - Authorization: run/authorization.md + - Clients: + - client/index.md + - Connecting to a server: client/connecting.md + - Transports: client/transports.md + - "Tools, resources & prompts": client/capabilities.md + - Server-initiated requests: client/server-requests.md + - Error handling: client/errors.md + - Protocol versions: protocol-versions.md + - Advanced: + - advanced/index.md + - Events: advanced/events.md + - Protocol extensions: advanced/extensions.md + - Custom message handlers: advanced/custom-handlers.md + - Examples: examples.md + - API Reference: https://php.sdk.modelcontextprotocol.io/api/ + +# Look and feel is kept in sync with the Python SDK's documentation +# (https://py.sdk.modelcontextprotocol.io/) so the language SDKs read as one +# set of docs: same MCP mark, same black/slate palette, same navigation. +theme: + name: material + # Zensical's own look, and its default; `classic` renders the older + # Material for MkDocs styling instead. + variant: modern + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: white + toggle: + icon: material/lightbulb-auto-outline + name: "Switch to system preference" + features: + - search.suggest + - search.highlight + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune + - navigation.sections + - navigation.top + - navigation.tracking + - toc.follow + + +# Zensical natively re-implements `search`; it does not run arbitrary MkDocs +# plugins or hooks. +plugins: + - search + +markdown_extensions: + - tables + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - sane_lists # this means you can start a list from any number + - pymdownx.betterem + - pymdownx.caret + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.mark + - pymdownx.tilde + - pymdownx.highlight: + pygments_lang_class: true + # Pygments' PHP lexer only starts highlighting after a ` MCP PHP SDK + - .phpdoc/build + .phpdoc/build/api - latest src - api vendor/**/* tests/**/* @@ -31,14 +34,8 @@ implements - - - docs - - / - - + diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..ffc1be8 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +# Toolchain for the documentation site under `docs/`, built by `make docs`. +# +zensical==0.0.50 diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php index 0b0cf7f..1d0d2a1 100644 --- a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php @@ -27,7 +27,7 @@ use Psr\Http\Server\RequestHandlerInterface; * - inside the MCP transport, wrapped by {@see \Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware}; * - as a bare PSR-7 handler in a hand-rolled front controller; * - as a framework callable controller (Symfony/Laravel), by converting the framework - * request to PSR-7 and the returned PSR-7 response back — see docs/authorization.md. + * request to PSR-7 and the returned PSR-7 response back — see docs/run/authorization.md. * * It performs no path or method matching: routing is the caller's responsibility (the * middleware's guard, or the framework router). From e0b9c1a26f7973e349531b1616f1e306d5327471 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 20 Aug 2026 01:25:59 +0200 Subject: [PATCH 118/143] [Server] Serve ClientGateway::elicit() under the modern lifecycle (#466) One handler asks the user something on any revision now. Where the client can be asked mid-request it still is; where it cannot, the ask becomes the input_required result that revision carries and the same call returns the answer once the client re-sends it. Costs one handler entry per ask, so side effects belong after the last question. Answers from earlier rounds travel in the requestState, which is why asking more than once needs Builder::setRequestState(). sample() and listRoots() still raise a LogicException there: that revision removed them outright. --- CHANGELOG.md | 1 + docs/examples.md | 2 +- docs/handlers/client-communication.md | 13 +- docs/handlers/index.md | 6 +- docs/handlers/input-required.md | 106 ++++----- .../elicitation/ElicitationHandlers.php | 47 ++-- src/Server/ClientGateway.php | 46 +++- src/Server/Stateless/ElicitationReplay.php | 169 ++++++++++++++ src/Server/Stateless/StatelessProtocol.php | 67 +++++- .../Stateless/ElicitationReplayTest.php | 151 +++++++++++++ .../Stateless/StatelessProtocolTest.php | 209 +++++++++++++++++- 11 files changed, 700 insertions(+), 117 deletions(-) create mode 100644 src/Server/Stateless/ElicitationReplay.php create mode 100644 tests/Unit/Server/Stateless/ElicitationReplayTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3cb3e..598fc73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Serve `ClientGateway::elicit()`/`elicitUrl()` under the 2026-07-28 lifecycle, so one handler asks the user something on any revision. Where the client can be asked mid-request it still is; where it cannot, `Mcp\Server\Stateless\ElicitationReplay` turns the ask into the `input_required` result that revision carries and returns from the same call once the client re-sends it with the answer — at the cost of entering the handler once per ask. Both methods take an optional `$key` naming an ask across those rounds, defaulting to its position in the handler. Answers from earlier rounds travel in the `requestState`, so a handler asking more than once needs `Builder::setRequestState()`. `sample()` and `listRoots()` still raise a `LogicException` there: that revision removed them outright. * Speak the 2026-07-28 lifecycle from the client: `Client` opens with `server/discover` instead of `initialize` on that revision, stamps each request's `_meta` with the protocol version, its own capabilities and client info, and sends the standard `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers an intermediary routes on — the last from the new `Mcp\Client\Stateless\ToolCatalog`, which knows from the tool list which arguments a call must mirror. An `input_required` result is answered automatically by `InputRequestResolver`, which asks the host's elicitation, sampling and roots handlers and retries the same request with `inputResponses` and the `requestState` the server sent. `Mcp\Schema\Wire\McpHeader` holds the header names and the `=?base64?…?=` sentinel both sides share. * Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. * Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. diff --git a/docs/examples.md b/docs/examples.md index 4eae5ba..901b567 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser | [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | | [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | | [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | -| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `InputRequiredResult` and typed elicitation schemas | [Asking for input](handlers/input-required.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `ClientGateway::elicit()` and typed elicitation schemas, on either protocol era | [Asking for input](handlers/input-required.md) | | [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | | [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | | [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | diff --git a/docs/handlers/client-communication.md b/docs/handlers/client-communication.md index 578d31e..1b84409 100644 --- a/docs/handlers/client-communication.md +++ b/docs/handlers/client-communication.md @@ -3,13 +3,10 @@ MCP supports various ways a server can communicate back to a client on top of the main request-response flow. -> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server -> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, -> elicitation and roots are carried back inside the *result* instead, and -> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. -> Logging and progress still work as described below — they simply travel on the request's own -> response stream, and the client opts into each. See -> [Asking for input](input-required.md). +> **Protocol revision `2026-07-28`.** Logging, progress and notifications work as described +> below on every revision; under the modern lifecycle they travel on the request's own +> response stream and the client opts into each. Sampling is the exception — see its section. +> Asking the user something has a page of its own: [Asking for input](input-required.md). ## ClientGateway @@ -64,7 +61,7 @@ strings. ## Sampling -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. It keeps working on a handshake-era connection until then, but that revision removed server-initiated requests outright, so `sample()` — like `listRoots()` — raises a `LogicException` when a modern-era client made the call. New integrations should call an LLM provider's API directly instead. With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: diff --git a/docs/handlers/index.md b/docs/handlers/index.md index bb78814..e0db0a4 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -33,9 +33,9 @@ instead. call, and sending notifications. * **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, not in your server's log file. -* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a - handler needs elicitation, sampling or roots. Written that way, one handler serves both - [protocol eras](../protocol-versions.md). +* **[Asking for input](input-required.md)** — `ClientGateway::elicit()`, or returning an + `InputRequiredResult` when a handler needs several answers at once. Either way, one + handler serves both [protocol eras](../protocol-versions.md). Handlers that need application services (a database connection, an API client) get them from the container instead; see diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md index 9cb3dd5..39ebe11 100644 --- a/docs/handlers/input-required.md +++ b/docs/handlers/input-required.md @@ -1,16 +1,51 @@ # Asking for input Some handlers cannot finish in one go: they need the user to confirm something, fill in a -form, name a directory, or have the client's model draft a paragraph. The way to write that -is to **return** the ask — an `InputRequiredResult` naming what you need — and read the -answer off `RequestContext` when the call comes back. +form, name a directory, or have the client's model draft a paragraph. There are two ways to +write that: ask for it, or return the ask. -Write it that way once and it serves both [protocol eras](../protocol-versions.md). -Revision `2026-07-28` has no server-initiated requests at all, so the client retries the -original call carrying the answers; the specification calls that a multi round-trip request -(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own -channel instead. Your handler does not fork on which — see -[What a handler forks on](#what-a-handler-forks-on). +## Just asking + +For elicitation, ask and use the answer: + +```php +static function (RequestContext $context): string { + $answer = $context->getClientGateway()->elicit('Your name?', $schema, key: 'who'); + + return "Hello, {$answer->content['name']}!"; +} +``` + +`key` names an ask, so its answer keeps finding the question it belongs to. Leave it out and +asks are keyed by position — `elicitation_1`, `elicitation_2`, … — which holds as long as the +handler reaches them in the same order every time. + +**Write the handler so it can run more than once.** Some clients answer inside the open +request; others answer by re-sending the whole call, which enters your handler again from the +top, once per question. Everything above an ask therefore has to be safe to repeat — put side +effects after the last one, and re-derive where you are from the answers rather than from +anything you kept. + +Answers given in an earlier round travel in the [`requestState`](#requeststate), so a handler +asking more than once needs `Builder::setRequestState()` configured. +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +is written this way. + +## Returning the ask + +The explicit form: **return** an `InputRequiredResult` naming what you need, and read the +answer off `RequestContext` when the call comes back. It is more to write, and it is the only +way to ask several things in **one** round trip, to carry your own state, or to ask for +anything other than elicitation. + +> **Revision `2026-07-28`.** Multi round-trip requests (MRTR) are that revision's feature, and +> only there does the protocol itself carry this shape. Over a handshake-era connection the SDK +> emulates it: the input-required shim sends each ask as the real `elicitation/create` / +> `sampling/createMessage` / `roots/list` and re-enters your handler with the answers. That is +> on by default and bounded by `setInputRequiredLimits()` — each round holds the originating +> request open, so it holds a worker for as long as the user takes — and +> `withoutInputRequiredShim()` turns it off, after which such a handler fails there. See +> [Server builder](../run/server-builder.md). ```php use Mcp\Schema\Result\CallToolResult; @@ -63,49 +98,14 @@ form mode only. ## What not to call -`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the -handshake era. Calling one under this revision raises a `LogicException` naming -`InputRequiredResult` as the replacement. +`ClientGateway::sample()` and `listRoots()` belong to the handshake era — revision +`2026-07-28` removed both outright, so calling one there raises a `LogicException`. Take what +they gave you from tool arguments, resource URIs or server configuration instead. `elicit()` +and `elicitUrl()` are unaffected: elicitation survived that revision, as an ask carried in the +result. -## What a handler forks on +## Which revision called -Nothing. Tools, resources, prompts, structured output, progress and errors do not care -which era called, and neither does the one thing that looks like it should: **asking the -user something**. - -Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the -answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era -connection the SDK's input-required shim fulfils the same ask over that connection's own -channel: each embedded request goes out as the real `elicitation/create` / -`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under -the keys it asked for. It is on by default; -[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) -and -[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) -are written this way and name no era anywhere. - -Two things to know about it. - -**Re-entry is re-execution.** The handler runs again from the top each round, so it has to -re-derive where it is from what came back rather than from anything it kept. That is already -true of the modern era — the client retries the whole call there — so a portable handler is -written that way regardless. It is only new if you were relying on `ClientGateway::elicit()` -suspending mid-body and keeping your locals; that keeps working untouched, since nothing here -runs unless a handler *returns* an ask. - -**Each round holds the request open.** The shim waits for the client's answer inside the -originating request, which on a process-per-request runtime means it holds a worker for as -long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that -leg, but the shim makes it reachable from handlers that never mention it — so size -`setInputRequiredLimits()` against your pool. - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - // Re-entries per request, and seconds to wait for one answer. - ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) - ->build(); -``` - -`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era -connection instead of being fulfilled behind your back. +Nothing above forks on it. `elicit()` works the same on every revision — only the mechanics +underneath differ, and the SDK picks them. The one thing to keep in mind is the rule already +stated: a handler that asks may be entered again from the top, so let it repeat safely. diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index 95a19d7..9ae87f9 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,9 +17,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; -use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\ElicitResult; -use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -46,10 +44,10 @@ final class ElicitationHandlers * - String field with date format for reservation date * - Enum field for dietary restrictions with human-readable labels * - * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} */ #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] - public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult + public function bookRestaurant(RequestContext $context, string $restaurantName): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -93,12 +91,6 @@ final class ElicitationHandlers $schema, ); - // Modern era, first round: the ask travels back as the result and the - // client retries this whole call carrying the answer. - if ($result instanceof InputRequiredResult) { - return $result; - } - if ($result->isDeclined()) { $this->logger->info('User declined to provide reservation details.'); @@ -162,10 +154,10 @@ final class ElicitationHandlers * * Demonstrates the simplest elicitation pattern - a yes/no confirmation. * - * @return array{status: string, message: string}|InputRequiredResult + * @return array{status: string, message: string} */ #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] - public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult + public function confirmAction(RequestContext $context, string $actionDescription): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -192,10 +184,6 @@ final class ElicitationHandlers $schema, ); - if ($result instanceof InputRequiredResult) { - return $result; - } - if (!$result->isAccepted()) { return [ 'status' => 'not_confirmed', @@ -234,10 +222,10 @@ final class ElicitationHandlers * * Demonstrates elicitation with optional fields and enum with labels. * - * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} */ #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] - public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult + public function collectFeedback(RequestContext $context, string $topic): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -270,10 +258,6 @@ final class ElicitationHandlers $schema, ); - if ($result instanceof InputRequiredResult) { - return $result; - } - if (!$result->isAccepted()) { return [ 'status' => 'skipped', @@ -308,22 +292,19 @@ final class ElicitationHandlers /** * Ask the user one question. * - * Written the way revision 2026-07-28 asks: the question is *returned*, the - * client answers it and retries the whole call, and the answer comes back - * through the input context under the same key. Nothing here names an era — - * on a handshake-era connection the SDK fulfils the same ask over that - * connection's own channel and re-enters the tool with the answer. - * - * The caller gets an {@see ElicitResult} once there is one, or an - * {@see InputRequiredResult} to hand straight back to its own caller. + * One call, every revision. Where the client can be interrupted mid-tool it + * is, and this returns the answer to that; from 2026-07-28 on there is no + * interrupting, so the SDK ends the call with the ask and returns here when + * the client re-sends it with the answer — which means everything above this + * line runs once per question. The `$key` is what ties an answer to the + * question it belongs to across those rounds. */ private function ask( RequestContext $context, string $key, string $message, ElicitationSchema $schema, - ): ElicitResult|InputRequiredResult { - return $context->getInputContext()?->elicitResult($key) - ?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]); + ): ElicitResult { + return $context->getClientGateway()->elicit($message, $schema, key: $key); } } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 12756fc..a60e539 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -192,17 +192,29 @@ class ClientGateway * Requests additional information from the user via the client. The user can * accept (providing the requested data), decline, or cancel the request. * + * Serves every revision, by two different mechanics. Where the client can be + * asked while the request is open, it is: an `elicitation/create` goes out and + * this call returns its answer. From 2026-07-28 on there are no + * server-initiated requests, so the ask ends the request instead and the call + * returns once the client re-sends it with the answer — see + * {@see Stateless\ElicitationReplay} for what that costs, namely + * that the handler is entered once per ask. + * * @param string $message A human-readable message describing what information is needed * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user - * @param int $timeout The timeout in seconds + * @param int $timeout The timeout in seconds; unused where the answer arrives on a later request + * @param string|null $key Names this ask, so it keeps resolving to the same answer across the rounds + * a revision without server-initiated requests needs. Defaults to the ask's + * position in the handler, which only a handler asking in a different order + * every time needs to override. * * @return ElicitResult The elicitation response containing the user's action and any provided content * * @throws ClientException if the client request results in an error message */ - public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult + public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120, ?string $key = null): ElicitResult { - return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout); + return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout, $key); } /** @@ -213,17 +225,21 @@ class ClientGateway * the user's action; unlike form mode there is no content to read back, so * whatever the user did there has to be picked up through the URL's own channel. * + * Portable across revisions on the same terms as {@see self::elicit()}. + * + * @param string|null $key names this ask across the rounds of a multi round-trip call + * * @throws ClientException if the client request results in an error message * @throws InvalidArgumentException if the client did not declare url-mode elicitation */ - public function elicitUrl(string $message, string $url, int $timeout = 120): ElicitResult + public function elicitUrl(string $message, string $url, int $timeout = 120, ?string $key = null): ElicitResult { // URL mode only exists from 2025-11-25 on, and only for clients declaring it if (!$this->supportsElicitationUrl()) { throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); } - return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout); + return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout, $key); } /** @@ -386,9 +402,9 @@ class ClientGateway /** * @throws ClientException if the client request results in an error message */ - private function sendElicitation(ElicitRequest $request, int $timeout): ElicitResult + private function sendElicitation(ElicitRequest $request, int $timeout, ?string $key = null): ElicitResult { - $response = $this->request($request, $timeout); + $response = $this->suspend($request, $timeout, $key); if ($response instanceof Error) { throw new ClientException($response); @@ -417,12 +433,28 @@ class ClientGateway * @internal */ public function request(Request $request, int $timeout = 120): Response|Error + { + return $this->suspend($request, $timeout); + } + + /** + * Hands the request to whatever is driving this fiber and waits for its answer. + * + * @param string|null $key the name an elicitation's answer is filed under when + * the revision serving this call answers by asking + * ({@see Stateless\ElicitationReplay}); + * ignored by every leg that has a live client to ask + * + * @return Response>|Error the peer's answer + */ + private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error { $response = \Fiber::suspend([ 'type' => 'request', 'request' => $request, 'session_id' => $this->session->getId()->toRfc4122(), 'timeout' => $timeout, + 'input_key' => $key, ]); if (!$response instanceof Response && !$response instanceof Error) { diff --git a/src/Server/Stateless/ElicitationReplay.php b/src/Server/Stateless/ElicitationReplay.php new file mode 100644 index 0000000..fe93627 --- /dev/null +++ b/src/Server/Stateless/ElicitationReplay.php @@ -0,0 +1,169 @@ + + */ +final class ElicitationReplay +{ + /** + * The `requestState` member carrying answers from earlier rounds. Reserved, + * like every `_mcp.` key: a handler's own payload travels beside it. + */ + public const CARRIED_ANSWERS = '_mcp.answers'; + + /** @var array> raw answers, keyed as they were asked */ + private array $answers = []; + + /** @var array the verified state this round arrived with */ + private array $payload; + + /** @var array keys whose answer this run could not read */ + private array $rejected = []; + + private int $asked = 0; + + public function __construct(?InputContext $input, private readonly ?RequestStateCodec $codec = null) + { + $this->payload = $input?->requestState() ?? []; + + $carried = $this->payload[self::CARRIED_ANSWERS] ?? []; + + if (\is_array($carried)) { + $this->answers = array_filter($carried, \is_array(...)); + } + + // Lifted out, so what is sealed again is what this run could still use + // rather than whatever the last round happened to carry. + unset($this->payload[self::CARRIED_ANSWERS]); + + // What this round answered wins over what an earlier one did: a key is + // only re-asked because its old answer was not usable. + foreach ($input?->all() ?? [] as $key => $answer) { + if (\is_array($answer)) { + $this->answers[$key] = $answer; + } + } + } + + /** + * The key an ask is filed under: the one the handler named, or its position + * among this run's asks. + * + * Positional keys hold across rounds only because the handler reaches its + * asks in the same order every time — which it does whenever it is + * re-enterable at all. A handler whose asks depend on a coin flip should + * name them. + */ + public function key(?string $key): string + { + ++$this->asked; + + return $key ?? 'elicitation_'.$this->asked; + } + + /** + * The answer to `$key`, or null when there is none to give the handler. + * + * Null covers an answer that does not parse as well as one that never + * arrived: the specification says a server SHOULD ask again for what it + * still needs, and a malformed answer left the server still needing it. + * + * @return array|null + */ + public function answer(string $key, ElicitationMode $mode = ElicitationMode::Form): ?array + { + $answer = $this->answers[$key] ?? null; + + if (null === $answer) { + return null; + } + + try { + ElicitResult::fromArray($answer, $mode); + } catch (\Throwable) { + // Noted rather than dropped: `answer()` only reads, and what makes + // this one unreadable is the mode it was read under. Marking it + // keeps it out of the state, since it is about to be asked again. + $this->rejected[$key] = true; + + return null; + } + + unset($this->rejected[$key]); + + return $answer; + } + + /** + * The result that ends this request by asking, carrying everything already + * answered so the next round does not have to ask for it again. + * + * @throws LogicException when there is state to carry and no key to sign it with + */ + public function ask(string $key, ElicitRequest $request): InputRequiredResult + { + return new InputRequiredResult([$key => $request], $this->state()); + } + + private function state(): ?string + { + $payload = $this->payload; + $answers = array_diff_key($this->answers, $this->rejected); + + if ([] !== $answers) { + $payload[self::CARRIED_ANSWERS] = $answers; + } + + if ([] === $payload) { + return null; + } + + if (null === $this->codec) { + throw new LogicException('Carrying an answer to the next round of a multi round-trip call needs a signing key; call Builder::setRequestState() to configure one.'); + } + + return $this->codec->mint($payload); + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index b746957..721c9a7 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -24,6 +24,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\DiscoverResult; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Configuration; @@ -549,9 +550,19 @@ final class StatelessProtocol * * The fiber is what makes a handler's `$gateway->progress(...)` look * synchronous while the caller decides where the notification goes. Server - * -to-client *requests* are refused rather than forwarded: this revision - * carries those in the result (MRTR), and putting one on a response stream - * is something the transport binding forbids outright. + * -to-client *requests* are never forwarded: this revision carries what it + * needs in the result (MRTR), and putting a request on a response stream is + * something the transport binding forbids outright. + * + * An elicitation is answered here rather than refused. Already answered, it + * resumes the fiber and the handler runs on; not yet, and the ask becomes + * the result — abandoning the fiber, since this request has nothing left to + * say and the client will re-send it. Abandoning unwinds it, so a handler's + * `finally` still runs; what does not run is everything after the ask. + * + * That is what lets one handler serve both eras through + * {@see \Mcp\Server\ClientGateway::elicit()}; see {@see ElicitationReplay} + * for what it costs. * * @param RequestHandlerInterface $handler * @@ -560,10 +571,31 @@ final class StatelessProtocol private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator { $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); + $input = $session->get(InputContext::class); + $replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $this->requestStateCodec); $suspended = $fiber->start(); while (!$fiber->isTerminated()) { + if (null !== $elicitation = self::readElicitation($suspended)) { + [$named, $elicit] = $elicitation; + $key = $replay->key($named); + + if (null === $answer = $replay->answer($key, $elicit->mode)) { + try { + return new Response($request->getId(), $replay->ask($key, $elicit)); + } catch (LogicException $e) { + $this->logger->error('A handler asked for input across rounds on a server with no requestState signing key.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + } + + $suspended = $fiber->resume(new Response($request->getId(), $answer)); + + continue; + } + $notification = $this->readNotification($suspended, $meta); if (null !== $notification) { @@ -588,7 +620,10 @@ final class StatelessProtocol { if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) { if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) { - throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.'); + // Elicitation never reaches here — it is answered in run(). What + // is left are the kinds this revision removed outright, and no + // multi round-trip shape brings them back. + throw new LogicException('This protocol revision has no server-initiated requests: sampling and roots were removed with it, so take what you need through tool arguments, resource URIs or server configuration instead. Elicitation is the one ask that survived, as a multi round-trip request.'); } return null; @@ -612,6 +647,30 @@ final class StatelessProtocol return $notification; } + /** + * One fiber suspension read as an elicitation, or null when it is not one. + * + * @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with + * + * @return array{0: string|null, 1: ElicitRequest}|null the name the handler gave the ask, and the ask + */ + private static function readElicitation(mixed $suspended): ?array + { + if (!\is_array($suspended) || 'request' !== ($suspended['type'] ?? null)) { + return null; + } + + $request = $suspended['request'] ?? null; + + if (!$request instanceof ElicitRequest) { + return null; + } + + $key = $suspended['input_key'] ?? null; + + return [\is_string($key) ? $key : null, $request]; + } + /** * The frames of a request-scoped response stream: the notifications the * handler emits, then the response that ends it. diff --git a/tests/Unit/Server/Stateless/ElicitationReplayTest.php b/tests/Unit/Server/Stateless/ElicitationReplayTest.php new file mode 100644 index 0000000..83cc710 --- /dev/null +++ b/tests/Unit/Server/Stateless/ElicitationReplayTest.php @@ -0,0 +1,151 @@ + 'accept', 'content' => ['n' => 'ada']]; + + private static function codec(): RequestStateCodec + { + return new RequestStateCodec(str_repeat('k', 32)); + } + + private static function request(): ElicitRequest + { + return ElicitRequest::forForm('Who?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + } + + #[TestDox('an unnamed ask is filed under its position, a named one under its name')] + public function testKeysFallBackToPosition(): void + { + $replay = new ElicitationReplay(null); + + $this->assertSame('elicitation_1', $replay->key(null)); + $this->assertSame('seat', $replay->key('seat')); + // Counted even when named, so a position always means the same ask. + $this->assertSame('elicitation_3', $replay->key(null)); + } + + #[TestDox('an answer from this round is handed to the handler')] + public function testAnswerFromThisRound(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + $this->assertNull($replay->answer('other')); + } + + #[TestDox('an answer from an earlier round is read back out of the state')] + public function testAnswerCarriedInState(): void + { + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + } + + #[TestDox('what this round answered wins over what an earlier one did')] + public function testThisRoundOverridesTheCarriedAnswer(): void + { + $fresh = ['action' => 'accept', 'content' => ['n' => 'grace']]; + + $replay = new ElicitationReplay(new InputContext(['who' => $fresh], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame($fresh, $replay->answer('who')); + } + + #[TestDox('an answer that does not parse counts as no answer, and is not carried on')] + public function testMalformedAnswerIsDropped(): void + { + // Accepted, but a form-mode acceptance has to carry content. + $replay = new ElicitationReplay(new InputContext(['who' => ['action' => 'accept']]), self::codec()); + + $this->assertNull($replay->answer('who')); + + $ask = $replay->ask('who', self::request()); + + $this->assertNull($ask->requestState); + } + + #[TestDox('an answer rejected this round is not carried into the next one')] + public function testRejectedAnswerIsNotCarriedOn(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => ['action' => 'accept']], + ]), $codec); + + $this->assertNull($replay->answer('who')); + $this->assertNull($replay->ask('who', self::request())->requestState); + } + + #[TestDox('a url-mode answer is read against its own mode')] + public function testUrlModeAnswer(): void + { + $replay = new ElicitationReplay(new InputContext(['consent' => ['action' => 'accept']])); + + // Content is required in form mode and forbidden in url mode, so the + // same answer only parses under the mode it was asked in. + $this->assertNull($replay->answer('consent')); + $this->assertSame(['action' => 'accept'], $replay->answer('consent', ElicitationMode::Url)); + } + + #[TestDox('a first ask carries no state, since nothing has been answered yet')] + public function testFirstAskCarriesNoState(): void + { + $ask = (new ElicitationReplay(null, self::codec()))->ask('who', self::request()); + + $this->assertSame(['who'], array_keys($ask->inputRequests)); + $this->assertNull($ask->requestState); + } + + #[TestDox('a later ask seals what is already answered, beside the handler\'s own payload')] + public function testLaterAskSealsTheAnswers(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED], ['booking' => 42]), $codec); + + $ask = $replay->ask('seat', self::request()); + + $this->assertIsString($ask->requestState); + $this->assertSame([ + 'booking' => 42, + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ], $codec->verify($ask->requestState)); + } + + #[TestDox('there is nowhere to keep an answer without a signing key')] + public function testSealingNeedsASigningKey(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/setRequestState/'); + + $replay->ask('seat', self::request()); + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 13265a7..ba933d7 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -24,6 +24,7 @@ use Mcp\Schema\Notification\PromptListChangedNotification; use Mcp\Schema\Notification\ResourceUpdatedNotification; use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Schema\ServerCapabilities; @@ -97,14 +98,35 @@ class StatelessProtocolTest extends TestCase ) ->addTool( static function (RequestContext $context): string { - // The pattern this revision replaced: kept as a fixture so - // the refusal has something to refuse. - $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + $answer = $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'hello '.($answer->content['n'] ?? '?'); + }, + name: 'elicits_directly', + description: 'Asks through the gateway, the way a handshake-era handler does', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $gateway->progress(0, 100, 'starting'); + + $answer = $gateway->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'hello '.($answer->content['n'] ?? '?'); + }, + name: 'elicits_after_progress', + description: 'Reports progress, then asks through the gateway', + ) + ->addTool( + static function (RequestContext $context): string { + // A kind this revision removed rather than reshaped: kept as + // a fixture so the refusal has something to refuse. + $context->getClientGateway()->request(new ListRootsRequest()); return 'unreachable'; }, - name: 'elicits_directly', - description: 'Asks the client directly, which this revision forbids', + name: 'asks_for_roots', + description: 'Asks the client directly for something this revision removed', ) ->addTool( static fn (): InputRequiredResult => new InputRequiredResult([ @@ -414,15 +436,186 @@ class StatelessProtocolTest extends TestCase $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']); } - #[TestDox('a server-initiated request is refused with the pattern that replaced it')] + #[TestDox('a server-initiated request this revision removed is refused')] public function testServerInitiatedRequestIsRefused(): void { - $result = self::callStreaming(self::protocol(), 'elicits_directly'); + $result = self::callStreaming(self::protocol(), 'asks_for_roots'); $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); $this->assertSame(500, $result->httpStatus); - $this->assertStringContainsString('InputRequiredResult', $body['error']['message']); + $this->assertStringContainsString('no server-initiated requests', $body['error']['message']); + } + + #[TestDox('a gateway elicitation becomes the ask this revision carries in the result')] + public function testGatewayElicitationBecomesAnAsk(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + // Unnamed, so filed under its position among the handler's asks. + $this->assertSame('elicitation/create', $answer['body']['result']['inputRequests']['elicitation_1']['method']); + // Nothing is answered yet, so there is nothing to carry. + $this->assertArrayNotHasKey('requestState', $answer['body']['result']); + } + + #[TestDox('the retry resumes the gateway call the ask came from')] + public function testGatewayElicitationResumesOnRetry(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + 'inputResponses' => ['elicitation_1' => ['action' => 'accept', 'content' => ['n' => 'ada']]], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('hello ada', $answer['body']['result']['content'][0]['text']); + } + + #[TestDox('a gateway ask that follows a notification ends the stream it opened')] + public function testGatewayElicitationEndsAnOpenStream(): void + { + $result = self::callStreaming(self::protocol(), 'elicits_after_progress', [ + 'progressToken' => 'p1', + RequestMeta::CLIENT_CAPABILITIES => ['elicitation' => new \stdClass()], + ]); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + $this->assertSame('notifications/progress', $frames[0]['method']); + $this->assertSame('input_required', $frames[1]['result']['resultType']); + } + + #[TestDox('an ask the client did not declare it can answer is refused before it is minted')] + public function testUndeclaredGatewayElicitationIsRefused(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + } + + /** + * A handler asking twice: the second round has to remember the first + * round's answer, which is what the `requestState` is for. + */ + private static function twoAskProtocol(bool $signed = true): StatelessProtocol + { + $builder = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('V')], ['v']); + + $when = $gateway->elicit('When?', $schema, key: 'when'); + $seat = $gateway->elicit('Seat?', $schema, key: 'seat'); + + return $when->content['v'].'/'.$seat->content['v']; + }, + name: 'books_flight', + description: 'Asks twice before it answers', + ); + + if ($signed) { + $builder->setRequestState(str_repeat('k', 32)); + } + + return $builder->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * + * @return array + */ + private static function book(StatelessProtocol $protocol, array $params = []): array + { + return self::call( + $protocol, + 'tools/call', + ['name' => 'books_flight', 'arguments' => [], ...$params], + ['Mcp-Name' => 'books_flight'], + ['elicitation' => new \stdClass()], + ); + } + + #[TestDox('a handler asking twice is served one ask per round, remembering the first')] + public function testNamedAsksCarryAcrossRounds(): void + { + $protocol = self::twoAskProtocol(); + + $first = self::book($protocol); + $this->assertSame('input_required', $first['body']['result']['resultType']); + $this->assertSame(['when'], array_keys($first['body']['result']['inputRequests'])); + // Nothing answered yet, so nothing to carry. + $this->assertArrayNotHasKey('requestState', $first['body']['result']); + + $second = self::book($protocol, [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + $this->assertSame('input_required', $second['body']['result']['resultType']); + $this->assertSame(['seat'], array_keys($second['body']['result']['inputRequests'])); + $this->assertIsString($second['body']['result']['requestState']); + + // The client echoes the state and answers only what it was just asked. + $third = self::book($protocol, [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $second['body']['result']['requestState'], + ]); + $this->assertSame(200, $third['status']); + $this->assertSame('morning/aisle', $third['body']['result']['content'][0]['text']); + } + + #[TestDox('carrying an answer to the next round without a signing key fails loudly')] + public function testCarryingAnAnswerNeedsASigningKey(): void + { + $answer = self::book(self::twoAskProtocol(signed: false), [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INTERNAL_ERROR, $answer['body']['error']['code']); + } + + #[TestDox('an answer that does not parse is asked for again')] + public function testMalformedAnswerIsAskedAgain(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + // Accepted, but a form-mode acceptance carries content. + 'inputResponses' => ['elicitation_1' => ['action' => 'accept']], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('elicitation_1', $answer['body']['result']['inputRequests']); } #[TestDox('a request\'s trace context reaches the handler')] From b1c7272761622c9248247ac850801177efcb1109 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Sat, 22 Aug 2026 01:46:29 +0200 Subject: [PATCH 119/143] [Server] Lower expected tool failure logging (#433) Expected tool failures are part of normal tool execution, so log them at debug severity while preserving error logging for unexpected exceptions. --- CHANGELOG.md | 1 + .../Handler/Request/CallToolHandler.php | 2 +- .../Handler/Request/CallToolHandlerTest.php | 90 ++++++++++++++----- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598fc73..b8e2be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. * [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. +* Log expected tool execution failures (`ToolCallException`) at debug level instead of error level; unexpected exceptions remain errors. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 616db3a..72f3c21 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -152,7 +152,7 @@ final class CallToolHandler implements RequestHandlerInterface // needs to retry declaring the capability. Rendered as -32021. throw $e; } catch (ToolCallException $e) { - $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ + $this->logger->debug(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, 'exception' => $e, diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 47351dc..308c980 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -26,6 +26,7 @@ use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class CallToolHandlerTest extends TestCase @@ -207,16 +208,8 @@ class CallToolHandlerTest extends TestCase ->willThrowException($exception); $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "failing_tool": "Tool execution failed".', - [ - 'tool' => 'failing_tool', - 'arguments' => ['param' => 'value', '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); + ->expects($this->atLeastOnce()) + ->method('debug'); $response = $this->handler->handle($request, $this->session); @@ -231,6 +224,61 @@ class CallToolHandlerTest extends TestCase $this->assertEquals('Tool execution failed', $result->content[0]->text); } + public function testHandleToolCallExceptionLogsAtDebugLevel(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new ToolCallException('Expected tool failure'); + $logger = new class extends AbstractLogger { + public array $records = []; + + // @phpstan-ignore missingType.parameter (compatible with psr/log 1.x) + public function log($level, $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + }; + $handler = new CallToolHandler($this->registry, $this->referenceHandler, $logger); + $toolReference = $this->createToolReference('failing_tool', static function () { + return 'unused'; + }); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $arguments = ['param' => 'value', '_session' => $this->session, '_request' => $request]; + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willThrowException($exception); + + $handler->handle($request, $this->session); + + $failureRecords = array_values(array_filter( + $logger->records, + static fn (array $record): bool => str_contains($record['message'], 'Expected tool failure'), + )); + + $this->assertSame([ + [ + 'level' => 'debug', + 'message' => 'Error while executing tool "failing_tool": "Expected tool failure".', + 'context' => [ + 'tool' => 'failing_tool', + 'arguments' => $arguments, + 'exception' => $exception, + ], + ], + ], $failureRecords); + $this->assertSame([], array_values(array_filter( + $logger->records, + static fn (array $record): bool => \in_array($record['level'], ['error', 'critical'], true), + ))); + } + public function testHandleWithNullResult(): void { $request = $this->createCallToolRequest('null_tool', []); @@ -270,7 +318,7 @@ class CallToolHandlerTest extends TestCase $this->assertInstanceOf(CallToolHandler::class, $handler); } - public function testHandleLogsErrorWithCorrectParameters(): void + public function testHandleLogsToolCallException(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException('Custom error message'); @@ -291,16 +339,8 @@ class CallToolHandlerTest extends TestCase ->willThrowException($exception); $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "test_tool": "Custom error message".', - [ - 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); + ->expects($this->atLeastOnce()) + ->method('debug'); $response = $this->handler->handle($request, $this->session); @@ -336,6 +376,14 @@ class CallToolHandlerTest extends TestCase ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Unhandled error during tool execution', [ + 'name' => 'failing_tool', + 'exception' => $exception, + ]); + $response = $this->handler->handle($request, $this->session); // Generic exceptions should return Error, not Response From c882401ee7a3518d187fbbb3c3df0108de953db5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 22 Aug 2026 13:08:17 +0200 Subject: [PATCH 120/143] [Server] Log failures in CompletionCompleteHandler (#471) --- src/Server/Builder.php | 2 +- .../Request/CompletionCompleteHandler.php | 7 ++++ .../Request/CompletionCompleteHandlerTest.php | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index e38e683..90272ef 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -1079,7 +1079,7 @@ final class Builder $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), - new Handler\Request\CompletionCompleteHandler($registry, $container), + new Handler\Request\CompletionCompleteHandler($registry, $container, $logger), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), new Handler\Request\InitializeHandler($configuration), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index a1eab00..704116d 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -25,6 +25,8 @@ use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Handles completion/complete requests. @@ -38,6 +40,7 @@ final class CompletionCompleteHandler implements RequestHandlerInterface public function __construct( private readonly RegistryInterface $registry, private readonly ?ContainerInterface $container = null, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -87,8 +90,12 @@ final class CompletionCompleteHandler implements RequestHandlerInterface } catch (PromptNotFoundException|ResourceNotFoundException $e) { // The reference names something the server does not have, which is // a bad parameter rather than a missing resource. + $this->logger->warning(\sprintf('Completion requested for unknown reference: %s', $e->getMessage()), ['exception' => $e]); + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while handling completion request: %s', $e->getMessage()), ['exception' => $e]); + return Error::forInternalError('Error while handling completion request', $request->getId()); } } diff --git a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php index 63eefba..b35347a 100644 --- a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php @@ -11,9 +11,11 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; use Mcp\Schema\ResourceTemplate; @@ -23,6 +25,7 @@ use Mcp\Server\Session\SessionInterface; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class CompletionCompleteHandlerTest extends TestCase { @@ -79,6 +82,42 @@ class CompletionCompleteHandlerTest extends TestCase $this->assertEquals(new CompletionCompleteResult(['alpha'], 1, false), $response->result); } + public function testLogsProviderFailureBeforeReturningInternalError(): void + { + $uri = 'file://users/alice'; + $request = $this->createCompletionRequest($uri, ['name' => 'id', 'value' => 'al']); + + $exception = new \RuntimeException('Provider blew up'); + $provider = $this->createMock(ProviderInterface::class); + $provider->method('getCompletions')->willThrowException($exception); + + $templateReference = new ResourceTemplateReference( + new ResourceTemplate('file://users/{id}', 'user'), + static fn () => null, + ['id' => $provider], + ); + + $this->registry + ->method('getResource') + ->with($uri) + ->willReturn($templateReference); + + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while handling completion request: Provider blew up', + ['exception' => $exception], + ); + + $handler = new CompletionCompleteHandler($this->registry, null, $logger); + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('Error while handling completion request', $response->message); + } + /** * @param array{ name: string, value: string } $argument */ From 32464ec32e53f3d6aca22a3b213b72dfa6e5fe8c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 01:16:10 +0200 Subject: [PATCH 121/143] [Changelog] Restructure 0.8.0 section (#478) --- CHANGELOG.md | 59 +++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e2be6..f085872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,34 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- -* Serve `ClientGateway::elicit()`/`elicitUrl()` under the 2026-07-28 lifecycle, so one handler asks the user something on any revision. Where the client can be asked mid-request it still is; where it cannot, `Mcp\Server\Stateless\ElicitationReplay` turns the ask into the `input_required` result that revision carries and returns from the same call once the client re-sends it with the answer — at the cost of entering the handler once per ask. Both methods take an optional `$key` naming an ask across those rounds, defaulting to its position in the handler. Answers from earlier rounds travel in the `requestState`, so a handler asking more than once needs `Builder::setRequestState()`. `sample()` and `listRoots()` still raise a `LogicException` there: that revision removed them outright. -* Speak the 2026-07-28 lifecycle from the client: `Client` opens with `server/discover` instead of `initialize` on that revision, stamps each request's `_meta` with the protocol version, its own capabilities and client info, and sends the standard `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers an intermediary routes on — the last from the new `Mcp\Client\Stateless\ToolCatalog`, which knows from the tool list which arguments a call must mirror. An `input_required` result is answered automatically by `InputRequestResolver`, which asks the host's elicitation, sampling and roots handlers and retries the same request with `inputResponses` and the `requestState` the server sent. `Mcp\Schema\Wire\McpHeader` holds the header names and the `=?base64?…?=` sentinel both sides share. -* Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. -* Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. -* Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". -* Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. -* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. -* Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. -* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. -* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. -* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestState()`. -* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. -* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. -* Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. -* [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. -* Log expected tool execution failures (`ToolCallException`) at debug level instead of error level; unexpected exceptions remain errors. -* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. -* Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. -* Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. -* [BC Break] Gate `structuredContent` on the negotiated protocol revision: a tool result that's a PHP list (or an object serializing to a JSON array) is now only sent as `structuredContent` on protocol revisions `2026-07-28`+; older revisions omit it and keep the JSON-encoded value in `content`. -* Add protocol revision negotiation during `initialize`: the server counter-offers a revision it supports, and the client now fails the handshake instead of continuing on an unagreed revision. Adds `Client::getProtocolVersion()` and the `2026-07-28` revision. -* Add sampling-with-tools support: sampling requests can include tools and tool-choice preferences, messages support tool-use/tool-result content blocks, and clients advertise `sampling.context`/`sampling.tools` via `ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`. A request that violates the spec's tool-flow rules is now rejected with a proper JSON-RPC error instead of being left unanswered. -* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now be a list of content blocks instead of just one — use the new `getContentBlocks()` to always get a list. -* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, per spec. -* Close the remaining schema gaps for `2025-06-18`/`2025-11-25` and add the `2026-07-28` surface (SEP-2106): url-mode elicitation (`ClientGateway::elicitUrl()`/`supportsElicitationUrl()`), `Implementation::title`, and `outputSchema`/`structuredContent` accepting any JSON value rather than only objects. -* Add `Mcp\Schema\Content\ResourceLink` — reference a resource by URI/name in tool results and prompt messages without embedding its contents. -* Add client-side Roots support: `RootsCallbackInterface`, `Client::sendRootsListChanged()`, and server-side `ClientGateway::listRoots()`/`supportsRoots()`. -* Add `ClientGateway::supportsSampling()` to check the client's advertised capabilities before sending a sampling request, matching `supportsRoots()`/`supportsElicitation()`. -* Fix empty tool/resource schemas serializing as `[]` instead of `{}` in `inputSchema`/`outputSchema`. -* Fix `PromptResultFormatter` dropping `annotations`, `_meta`, and `mimeType` when a prompt generator returns content as a plain array. -* Add `annotations` support to `ImageContent`, matching `TextContent`/`AudioContent`. +* Support for MCP spec version 2026-07-28 incl. stateless HTTP transport + * Negotiate the protocol revision during `initialize`: the server counter-offers a revision it supports and the client fails the handshake instead of continuing on an unagreed one. Adds `Client::getProtocolVersion()`. + * Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request (`InboundClassifier`) and routes it to the matching dispatcher, so one URL answers modern and handshake-era clients alike. `Builder::withoutModernEra()` opts out, `Builder::setModernVersions()` narrows the modern leg. + * Speak the lifecycle from the client: `Client` opens with `server/discover`, stamps `_meta` with version, capabilities and client info, and sends the `Mcp-Method`/`Mcp-Name`/`Mcp-Param-*` headers (via `Client\Stateless\ToolCatalog`). `Schema\Wire\McpHeader` holds the shared header names. + * Add multi round-trip requests (SEP-2322): a handler returning `InputRequiredResult` yields `resultType: "input_required"` with an opaque, signed `requestState` (`RequestStateCodec`, key via `Builder::setRequestState()`); the client retries with `inputResponses`, read through `RequestContext::getInputContext()` and its typed `elicitResult()`/`samplingResult()`/`rootsResult()`. On the client, `InputRequestResolver` answers such results automatically from the host's elicitation, sampling and roots handlers. + * Serve `ClientGateway::elicit()`/`elicitUrl()` on every revision: where the client cannot be asked mid-request, `Server\Stateless\ElicitationReplay` turns the ask into `input_required` and resumes once re-sent — the handler is entered once per ask. `InputRequiredShim` does the reverse for handshake-era clients. `sample()`/`listRoots()` raise a `LogicException` on `2026-07-28`, which removed them. + * Run stateless handlers in a fiber so `$gateway->progress()`/`log()` stream over SSE when the handler emits something and the client accepts `text/event-stream`; honour `io.modelcontextprotocol/logLevel` (SEP-2575). Adds `LoggingLevel::severity()`/`isAtLeast()`. + * Deliver notifications on `subscriptions/listen` (SEP-2575) via `NotificationBusInterface` — `InMemoryNotificationBus` for persistent runtimes, `Psr16NotificationBus` for PHP-FPM — set with `Builder::setNotificationBus()`; `Builder::setSubscriptionLifetime()` replaces the hard-coded 30s ceiling. + * Validate the standard request headers (SEP-2243) with `StandardHeaderValidator` (`Builder::setHeaderValidator()`), answering `-32020` when they contradict the body. + * Add `Wire\CachePolicy` (`Builder::setCachePolicy()`) for SEP-2549 caching hints; defaults to `ttlMs: 0, cacheScope: private`. A `ReadResourceResult` may override with its own values. + * Carry W3C trace context (SEP-414): `traceparent`/`tracestate`/`baggage` from `_meta` are exposed via `RequestContext::getTraceContext()` and echoed onto the request's notifications. + * Close the schema gaps for `2025-06-18`/`2025-11-25` and add the `2026-07-28` surface (SEP-2106): url-mode elicitation (`ClientGateway::elicitUrl()`/`supportsElicitationUrl()`), `Implementation::title`, and `outputSchema`/`structuredContent` accepting any JSON value. + * Deprecate Roots, Sampling and Logging (SEP-2577, earliest removal `2027-07-28`); they keep working but trigger a deprecation notice. + * [BC Break] Answer a not-found subject with `-32602` instead of `-32002` (SEP-2164): `resources/read` picks the code by revision (`-32602` from `2026-07-28` on), `prompts/get`, `completion/complete` and `tools/call` switch on every revision. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. + * [BC Break] A list-shaped tool result is only sent as `structuredContent` on `2026-07-28`+; older revisions keep the JSON-encoded value in `content`. +* [BC Break] Add the extensions framework (SEP-2133) MCP Apps sits on: `ExtensionInterface::getId()` returns an `ExtensionIdentifier` value object, and the interface gains `getMessages()`/`getRequestHandlers()` (extend `AbstractExtension` to skip both). `MessageFactory::make()` takes an `$additional` message list; `RequestHandlerInterface`'s result template is covariant. `ServerExtensionInterface` is replaced by the side-agnostic `Schema\Extension\ExtensionInterface`. +* Add client-side extension negotiation: `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, `ClientCapabilities::withExtensions()`. +* Add sampling-with-tools: sampling requests can carry tools and tool-choice preferences, messages support tool-use/tool-result blocks, and clients advertise `sampling.context`/`sampling.tools` (`ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`). Requests violating the tool-flow rules are rejected with a JSON-RPC error. +* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may be a list of content blocks — use `getContentBlocks()`. `CreateSamplingMessageResult` rejects roles other than `assistant` and empty content. +* Add client-side Roots support (`RootsCallbackInterface`, `Client::sendRootsListChanged()`) and server-side `ClientGateway::listRoots()`/`supportsRoots()`/`supportsSampling()`. +* Add `Schema\Content\ResourceLink` to reference a resource by URI in tool results and prompt messages. +* [BC Break] `Schema\JsonRpc\Error` accepts `null` as `$id`; an unreadable id now omits the member instead of sending `"id": ""`. `MessageFactory` decodes a missing or null id as an id-less error. +* Preserve the request `id` on an invalid-but-parseable message (`-32600`) via `InvalidInputMessageException::getRequestId()`. +* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name`; the spec allows any string. +* Log expected tool failures (`ToolCallException`) at debug level instead of error. +* Add `annotations` to `ImageContent`. +* Fix empty tool/resource schemas serializing as `[]` instead of `{}`. +* Fix `PromptResultFormatter` dropping `annotations`, `_meta` and `mimeType` for plain-array content. 0.7.0 ----- From d09fe3019e37492f50f9b4f7e530909d1e456e53 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 01:17:09 +0200 Subject: [PATCH 122/143] [Server] Make resource subscribe/unsubscribe errors protocol-version-aware (SEP-2164) (#474) * [Server] Make resource subscribe/unsubscribe errors protocol-version-aware (SEP-2164) ReadResourceHandler already picked -32602 vs -32002 based on the negotiated revision; ResourceSubscribeHandler and ResourceUnsubscribeHandler still hard-coded the retired -32002 for every client. Bring them in line. * Remove unnecessary comments Co-authored-by: Christopher Hertel --- .../Request/ResourceSubscribeHandler.php | 5 +- .../Request/ResourceUnsubscribeHandler.php | 5 +- .../Request/ResourceSubscribeHandlerTest.php | 127 ++++++++++++++++++ .../ResourceUnsubscribeHandlerTest.php | 127 ++++++++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php create mode 100644 tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php index b5421f8..1e8147b 100644 --- a/src/Server/Handler/Request/ResourceSubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; use Mcp\Schema\Result\EmptyResult; +use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -57,7 +58,9 @@ final class ResourceSubscribeHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } $this->logger->debug('Subscribing to resource', ['uri' => $uri]); diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php index 50ab8bc..522c512 100644 --- a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; use Mcp\Schema\Result\EmptyResult; +use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -57,7 +58,9 @@ final class ResourceUnsubscribeHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } $this->logger->debug('Unsubscribing from resource', ['uri' => $uri]); diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php new file mode 100644 index 0000000..d16519e --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php @@ -0,0 +1,127 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); + } + + public function testSupportsResourceSubscribeRequest(): void + { + $request = $this->createRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulSubscribe(): void + { + $uri = 'file://test.txt'; + $request = $this->createRequest($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($this->createMock(\Mcp\Capability\Registry\ResourceReference::class)); + + $this->subscriptionManager + ->expects($this->once()) + ->method('subscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + /** + * @dataProvider provideResourceNotFoundRevisions + */ + public function testHandleResourceNotFoundIsProtocolVersionAware(?string $negotiated, int $expectedCode): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->subscriptionManager + ->expects($this->never()) + ->method('subscribe'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals($expectedCode, $response->code); + } + + /** + * @return iterable + */ + public static function provideResourceNotFoundRevisions(): iterable + { + yield 'no negotiated revision' => [null, Error::RESOURCE_NOT_FOUND]; + yield '2025-11-25' => ['2025-11-25', Error::RESOURCE_NOT_FOUND]; + yield '2026-07-28' => ['2026-07-28', Error::INVALID_PARAMS]; + } + + private function createRequest(string $uri): ResourceSubscribeRequest + { + return ResourceSubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceSubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php new file mode 100644 index 0000000..daa82f5 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php @@ -0,0 +1,127 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); + } + + public function testSupportsResourceUnsubscribeRequest(): void + { + $request = $this->createRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulUnsubscribe(): void + { + $uri = 'file://test.txt'; + $request = $this->createRequest($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($this->createMock(\Mcp\Capability\Registry\ResourceReference::class)); + + $this->subscriptionManager + ->expects($this->once()) + ->method('unsubscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + /** + * @dataProvider provideResourceNotFoundRevisions + */ + public function testHandleResourceNotFoundIsProtocolVersionAware(?string $negotiated, int $expectedCode): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->subscriptionManager + ->expects($this->never()) + ->method('unsubscribe'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals($expectedCode, $response->code); + } + + /** + * @return iterable + */ + public static function provideResourceNotFoundRevisions(): iterable + { + yield 'no negotiated revision' => [null, Error::RESOURCE_NOT_FOUND]; + yield '2025-11-25' => ['2025-11-25', Error::RESOURCE_NOT_FOUND]; + yield '2026-07-28' => ['2026-07-28', Error::INVALID_PARAMS]; + } + + private function createRequest(string $uri): ResourceUnsubscribeRequest + { + return ResourceUnsubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceUnsubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} From 1e9520e731fa682166c27226969ffbfed8ab30fd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 01:23:52 +0200 Subject: [PATCH 123/143] [Docs] Deprecation policy, SDK tier target, and CONTRIBUTING.md (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Docs] Add deprecation policy doc (SEP-2596) Formalizes the Active -> Deprecated -> Removed lifecycle and the twelve-month deprecation window the SDK already applies via SEP-2577, and states the SDK's own BC promise ahead of 1.0. * [Docs] Add SDK Tier 2 gap analysis (SEP-1730) * [Docs] Wire deprecation policy and SDK tier docs into the guide nav Adds both pages under a new Project section in mkdocs.yml, links them from index.md's "Where to go next", and points their repo-root references at GitHub URLs instead of paths escaping docs/ (dead under Zensical's --strict build). * [Docs] Add CONTRIBUTING.md Root-level contribution guide: dev setup, the make ci checks to run before a PR, coding standards, testing expectations, the docs-guide link-resolution rule Zensical enforces, and licensing terms. Linked from README's existing Contributing section. * [Docs] Trim deprecation policy per review Drop the worked SEP-2577 example and the "relationship to the protocol lifecycle" section; both were redundant restating of detail that lives in the code/CHANGELOG and protocol-versions.md already. * [Docs] Cite the Tier 3 audit and add a versioning policy (SEP-1730) Replace our own hand-rolled conformance numbers with the authoritative audit in modelcontextprotocol/modelcontextprotocol#3274: SDK is currently Tier 3, blocked from Tier 2 by client OAuth conformance (20%) and no stable 1.0.0+ release. Add the Versioning section the audit found missing — SemVer commitment pre-1.0, Symfony's BC promise after — and point CONTRIBUTING.md at it. * [Docs] Link Symfony's BC promise from the deprecation policy Was plain text; sdk-tier.md's Versioning section already links it. * [Docs] Drop the "process gaps addressed" aside from sdk-tier.md * Apply suggestion from @chr-hertel * Apply suggestion from @chr-hertel --- CONTRIBUTING.md | 84 ++++++++++++++++++++++++++++++++++++++ README.md | 4 +- docs/deprecation-policy.md | 28 +++++++++++++ docs/index.md | 3 ++ docs/sdk-tier.md | 68 ++++++++++++++++++++++++++++++ mkdocs.yml | 3 ++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/deprecation-policy.md create mode 100644 docs/sdk-tier.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c02ee47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +Thanks for considering a contribution to the PHP SDK for the Model Context Protocol. This is a +collaboration between [the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/), and it follows Symfony's conventions throughout. + +## Ways to contribute + +- **Report a bug** or **propose a feature** by [opening an issue](https://github.com/modelcontextprotocol/php-sdk/issues). + Check existing issues first — many spec-driven changes are already tracked under the relevant + `2026-07-28`-style release label. +- **Send a pull request** for a fix, a new capability, or a docs improvement. +- **Improve the guides** under `docs/` — see [Documentation](#documentation) below for how they're built. +- **Help close a conformance gap** — `make conformance-tests` runs the official MCP conformance + suite against this SDK; a failing scenario there is a concrete, well-scoped contribution. + +## Development setup + +Requires PHP 8.1+. + +```bash +composer install +``` + +## Before opening a pull request + +Run the full CI suite locally: + +```bash +make ci +``` + +This runs, in order: `make cs` (PHP CS Fixer, auto-fixes style), `make phpstan` (static analysis), +and `make tests` (unit + inspector tests). All three must pass. If your change touches +protocol-observable behavior, also run: + +```bash +make conformance-tests # requires Docker +``` + +## Coding standards + +This project follows [Symfony's coding standards](https://symfony.com/doc/current/contributing/code/standards.html) +and [backward compatibility promise](https://symfony.com/doc/current/contributing/code/bc.html). In short: + +See [CLAUDE.md](CLAUDE.md) for a fuller tour of the codebase's architecture and layout. + +## Tests + +New capabilities need unit tests (`tests/Unit/`) covering the core logic, and — for anything +reachable over the wire — inspector tests (`tests/Inspector/`) for end-to-end coverage. If you're +adding a documented pattern, consider adding or updating an example under `examples/`. + +## Documentation + +The guides under `docs/` are built with [Zensical](https://zensical.org/) in `--strict` mode, +which fails the build on a broken internal link: + +```bash +make docs-guides +``` + +Links between guide pages must be relative paths that resolve within `docs/` (e.g. +`protocol-versions.md`, `../CLAUDE.md` will *not* resolve — Zensical only follows the `docs/` tree). +For anything at the repo root (`CLAUDE.md`, `ROADMAP.md`, `CHANGELOG.md`), link to it by its GitHub +URL instead, matching the pattern already used across `docs/`. The class-level API reference is +generated separately by phpDocumentor (`make docs-api`) and isn't hand-written. + +## Versioning + +The SDK follows [Semantic Versioning](https://semver.org/) — see +[SDK tier target](docs/sdk-tier.md#versioning) for what that means pre- and post-1.0, and how it +lines up with Symfony's backward compatibility promise. + +## Licensing + +New contributions are licensed under Apache License, Version 2.0. Existing code predating this +policy remains under the MIT License — see [LICENSE](LICENSE) for the details. By opening a pull +request, you agree your contribution is provided under those terms. + +## Getting help + +If something is unclear or you want early feedback on an approach before writing code, open an +issue or a draft pull request — that's the right place to ask, rather than guessing at scope. diff --git a/README.md b/README.md index 194230a..04965f8 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,9 @@ Building something on top of the SDK? Open a pull request to add it to this list We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or -[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). See +[CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and what to run before +opening a PR. ## Credits diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md new file mode 100644 index 0000000..4f512dd --- /dev/null +++ b/docs/deprecation-policy.md @@ -0,0 +1,28 @@ +# Deprecation policy + +The MCP specification (SEP-2596) defines a formal feature lifecycle: **Active → Deprecated → Removed**, +with a minimum of twelve months between a feature being marked deprecated and its earliest possible removal. +Each stage transition gets its own SEP — deprecating a feature and removing it are separate proposals, never +bundled into one. + +The PHP SDK mirrors that window for anything it deprecates, whether the deprecation originates in the spec +or is SDK-internal (an API shape the SDK wants to retire independently of the protocol). + +## What a deprecation looks like here + +A deprecated element carries a PHP `@deprecated` tag naming the protocol revision (or SDK version, for an +SDK-internal deprecation) that introduced the deprecation and the earliest removal date, twelve months out: + +```php +/** + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + */ +``` + +## The SDK's own BC promise + +The SDK is pre-1.0 and experimental, and the public +API can still change without a deprecation cycle where the spec itself hasn't moved. Once past 1.0, the SDK +follows [Symfony's backward-compatibility promise](https://symfony.com/doc/current/contributing/code/bc.html), +and the twelve-month deprecation window above becomes the +floor for any BC break the SDK introduces on its own, not just ones the spec forces. diff --git a/docs/index.md b/docs/index.md index cc9c375..4b31a92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,6 +97,9 @@ around in. **[Clients](client/index.md)**. * The two protocol eras, and what revision `2026-07-28` changed, are **[Protocol versions](protocol-versions.md)**. +* What gets deprecated, for how long, and where the SDK stands on spec-tier + support are under **Project**: **[Deprecation policy](deprecation-policy.md)** + and **[SDK tier target](sdk-tier.md)**. * Complete, runnable projects are in **[Examples](examples.md)**. * Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** is generated from the source. diff --git a/docs/sdk-tier.md b/docs/sdk-tier.md new file mode 100644 index 0000000..8fe7e8d --- /dev/null +++ b/docs/sdk-tier.md @@ -0,0 +1,68 @@ +# SDK Tier Target (SEP-1730) + +The MCP spec defines three SDK tiers: Tier 1 (Fully Supported), Tier 2 +(Commitment to Full Support), and Tier 3 (Experimental). This document tracks +where the PHP SDK stands and what's still missing to move up a tier. + +## Current standing + +**Tier 3**, per the official audit in +[modelcontextprotocol/modelcontextprotocol#3274](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/3274) +(2026-08-19, superseding the earlier Tier 3 assessment in #2305). Two things +block Tier 2: + +- **Client conformance is 20% (10/50)**, against the ≥80% bar. Almost + entirely OAuth: 38 of 39 scored auth scenarios fail. Every one of those + failures is pre-declared in the SDK's own + [`tests/Conformance/conformance-baseline-*.yml`](https://github.com/modelcontextprotocol/php-sdk/tree/main/tests/Conformance) + files and tracked in `ROADMAP.md` — a known, scoped gap, not silent + breakage. Server conformance is 100% (67/67). +- **No stable release ≥ 1.0.0 has ever shipped** (latest: v0.7.1). Tier 2 + requires at least one. + +Tier 1 needs both of those plus full (not ≥80%) client conformance and +closing 10 documentation gaps the audit lists by name (mostly small +per-feature additions — legacy SSE transport and the elicitation +complete-notification need either an implementation or an explicit +"intentionally not implemented" note). + +## Target + +**Tier 2 next, Tier 1 after the 1.0 release.** Tier 1's "stable release" +requirement is structurally out of reach until 1.0 ships regardless of how +complete every other Tier 1 criterion is. + +## Path to Tier 2 + +Roughly in priority order, per the audit's own recommendation: + +1. **OAuth client conformance.** The single highest-leverage fix — it + accounts for 38 of 40 client failures and blocks both tiers on its own. + Already scoped: token endpoint auth methods, scope handling (step-up, + retry-limit, from-`WWW-Authenticate`, from `scopes_supported`), dynamic + client registration, issuer validation, `offline_access`, + authorization-server migration — see `ROADMAP.md` and the + `2026-07-28`-labeled auth issues. +2. **Fix the two non-auth client failures** (`sse-retry`, + `elicitation-sep1034-client-defaults`, both scored at 2025-11-25). +3. **Ship a stable 1.0.0+ release.** + +## Versioning + +The SDK follows [Semantic Versioning](https://semver.org/): +`MAJOR.MINOR.PATCH`. Pre-1.0, that means any digit can carry a breaking +change per SemVer §4 — every one is logged with a `[BC Break]` marker in +[`CHANGELOG.md`](https://github.com/modelcontextprotocol/php-sdk/blob/main/CHANGELOG.md). + +Once at 1.0, the SDK adopts Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html): +PATCH releases never break BC, MINOR releases only add functionality (gated +by the deprecation window in [Deprecation policy](deprecation-policy.md)), +and a breaking change ships only in a MAJOR release. + +## Roadmap + +See [ROADMAP.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) +for the feature-level plan toward 1.0. This document only tracks the +process/tier-classification gap, which is narrower and more mechanical than +the feature roadmap. diff --git a/mkdocs.yml b/mkdocs.yml index 491441b..d5e4543 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,9 @@ nav: - Events: advanced/events.md - Protocol extensions: advanced/extensions.md - Custom message handlers: advanced/custom-handlers.md + - Project: + - Deprecation policy: deprecation-policy.md + - SDK tier target: sdk-tier.md - Examples: examples.md - API Reference: https://php.sdk.modelcontextprotocol.io/api/ From d59950f1acdd6d198369b4bf2a800a9ffba8fc8e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 01:26:22 +0200 Subject: [PATCH 124/143] [Server][Client] Bridge W3C trace context between HTTP headers and _meta (SEP-414) (#473) RequestMeta::parseTraceContext() now falls back to the native traceparent/ tracestate/baggage HTTP headers when _meta carries none, and HeaderFactory mirrors an application-set _meta trace context onto those same headers on outbound requests. _meta wins when both are present. --- src/Client/Stateless/HeaderFactory.php | 35 ++++++++++++-- src/Server/Stateless/RequestMeta.php | 21 +++++++-- src/Server/Stateless/StatelessProtocol.php | 2 +- .../Client/Stateless/HeaderFactoryTest.php | 39 +++++++++++++++ .../Stateless/StatelessProtocolTest.php | 47 +++++++++++++++++++ 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php index 3640cf9..428e1c7 100644 --- a/src/Client/Stateless/HeaderFactory.php +++ b/src/Client/Stateless/HeaderFactory.php @@ -13,6 +13,7 @@ namespace Mcp\Client\Stateless; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Wire\McpHeader; +use Mcp\Server\Stateless\RequestMeta; /** * The HTTP headers a modern-era client puts on every POST (SEP-2243, SEP-2575). @@ -41,18 +42,21 @@ final class HeaderFactory public function forMessage(array $payload, ProtocolVersion $protocolVersion): array { $method = $payload['method'] ?? null; + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; // A response to a server-initiated request carries no method to mirror; - // the version header is unconditional and still applies. + // the version header is unconditional and still applies. Trace context + // still rides along if the application put it in `_meta` — the party + // that started the trace is not necessarily the one with a method to + // report. if (!\is_string($method)) { - return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value]; + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value, ...$this->traceHeaders($params)]; } - $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; - $headers = [ McpHeader::PROTOCOL_VERSION => $protocolVersion->value, McpHeader::METHOD => $method, + ...$this->traceHeaders($params), ]; if (null !== $name = McpHeader::nameFor($method, $params)) { @@ -72,4 +76,27 @@ final class HeaderFactory return $headers; } + + /** + * Mirrors W3C trace context from `_meta` onto its native HTTP headers, so + * an application that only knows how to set `_meta` — the transport-agnostic + * surface — still gets header-based propagation for free on HTTP. + * + * @param array|null $params + * + * @return array + */ + private function traceHeaders(?array $params): array + { + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $headers = []; + foreach (RequestMeta::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $headers[$key] = $meta[$key]; + } + } + + return $headers; + } } diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php index f418db5..3744d92 100644 --- a/src/Server/Stateless/RequestMeta.php +++ b/src/Server/Stateless/RequestMeta.php @@ -15,6 +15,7 @@ use Mcp\Exception\MissingRequestMetaException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Implementation; +use Mcp\Server\Wire\InboundClassifier; /** * The per-request metadata that replaces the `initialize` handshake in the @@ -56,11 +57,13 @@ final class RequestMeta } /** - * @param array|null $params the request's `params` member, if any + * @param array|null $params the request's `params` member, if any + * @param array $headers request headers, case-insensitively matched; a + * transport without a header layer (stdio) passes none * * @throws MissingRequestMetaException when a structurally required member is absent or malformed */ - public static function fromParams(?array $params): self + public static function fromParams(?array $params, array $headers = []): self { $meta = $params['_meta'] ?? null; @@ -86,7 +89,7 @@ final class RequestMeta ClientCapabilities::fromArray((array) $capabilities), \is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null, self::parseLogLevel($meta[self::LOG_LEVEL] ?? null), - self::parseTraceContext($meta), + self::parseTraceContext($meta, $headers), ); } @@ -103,17 +106,25 @@ final class RequestMeta * Carried through opaquely: the values are the tracing ecosystem's to * interpret, and a malformed one is not this server's to reject. * - * @param array $meta + * `_meta` wins over the native W3C header of the same name when both are + * present — it is the more specific of the two, scoped to this one call + * rather than the whole HTTP request, and a caller that put it there did + * so deliberately. + * + * @param array $meta + * @param array $headers * * @return array */ - private static function parseTraceContext(array $meta): array + private static function parseTraceContext(array $meta, array $headers): array { $context = []; foreach (self::TRACE_KEYS as $key) { if (\is_string($meta[$key] ?? null)) { $context[$key] = $meta[$key]; + } elseif (null !== $header = InboundClassifier::header($headers, $key)) { + $context[$key] = $header; } } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 721c9a7..a179a21 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -197,7 +197,7 @@ final class StatelessProtocol } try { - $meta = RequestMeta::fromParams($params); + $meta = RequestMeta::fromParams($params, $headers); } catch (MissingRequestMetaException $e) { return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); } diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php index ea4a3a4..5f28431 100644 --- a/tests/Unit/Client/Stateless/HeaderFactoryTest.php +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -81,6 +81,45 @@ final class HeaderFactoryTest extends TestCase $this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers); } + #[TestDox('mirrors trace context an application put in `_meta` onto its native headers')] + public function testTraceContextMirrorsOntoHeaders(): void + { + $payload = [ + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', $headers['traceparent']); + $this->assertSame('acme=1', $headers['tracestate']); + } + + #[TestDox('a response with no method still mirrors its trace context')] + public function testTraceContextMirrorsOntoAResponse(): void + { + $payload = ['id' => 1, 'result' => [], 'params' => ['_meta' => ['traceparent' => 'tp-1']]]; + + $this->assertSame( + ['MCP-Protocol-Version' => '2026-07-28', 'traceparent' => 'tp-1'], + $this->headersFor($payload), + ); + } + + #[TestDox('an untraced request mirrors nothing')] + public function testNoTraceContextMirrorsNothing(): void + { + $headers = $this->headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertArrayNotHasKey('traceparent', $headers); + $this->assertArrayNotHasKey('tracestate', $headers); + } + /** * @param array $payload * diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index ba933d7..62c7eb7 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -643,6 +643,53 @@ class StatelessProtocolTest extends TestCase ); } + #[TestDox('a native traceparent/tracestate header reaches the handler when `_meta` carries none')] + public function testHttpTraceHeadersReachTheHandler(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_trace', 'arguments' => []], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ); + + $this->assertSame( + 'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;tracestate=acme=1', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('`_meta` trace context wins over a conflicting native header')] + public function testMetaTraceContextWinsOverHeader(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'probe_trace', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], + ], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + ], + ); + + $this->assertSame( + 'traceparent=00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + $answer['body']['result']['content'][0]['text'], + ); + } + #[TestDox('notifications caused by a traced request carry its trace context')] public function testNotificationsCarryTheTraceContext(): void { From c36e80508648748bdffe0222830705942ca39071 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 25 Aug 2026 00:43:08 +0200 Subject: [PATCH 125/143] [Server] Restrict FileSessionStore::gc() to owned session files (#479) --- src/Server/Session/FileSessionStore.php | 11 +++++----- .../Server/Session/FileSessionStoreTest.php | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 9716e3d..e218e75 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -128,6 +128,11 @@ class FileSessionStore implements SessionStoreInterface continue; } + // Only delete files this store owns: sessions are named by their RFC 4122 UUID + if (!Uuid::isValid($entry)) { + continue; + } + $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; if (!is_file($path)) { continue; @@ -136,11 +141,7 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($now - $mtime) > $this->ttl) { @unlink($path); - try { - $deleted[] = Uuid::fromString($entry); - } catch (\Throwable) { - // ignore non-UUID file names - } + $deleted[] = Uuid::fromString($entry); } } diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php index 1e0caa7..a2b8729 100644 --- a/tests/Unit/Server/Session/FileSessionStoreTest.php +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -62,6 +62,28 @@ class FileSessionStoreTest extends TestCase $this->assertSame('payload', $store->read($id)); } + #[TestDox('gc() only deletes expired session files, never foreign files in the directory')] + public function testGcLeavesForeignFilesAlone(): void + { + $store = new FileSessionStore($this->directory, ttl: 60); + $id = new UuidV4(); + $store->write($id, 'payload'); + + $foreign = $this->directory.'/important.lock'; + file_put_contents($foreign, 'not a session'); + + // Make everything stale + $expired = time() - 120; + touch($this->directory.'/'.$id->toRfc4122(), $expired); + touch($foreign, $expired); + + $deleted = $store->gc(); + + $this->assertEquals([$id], $deleted); + $this->assertFileDoesNotExist($this->directory.'/'.$id->toRfc4122()); + $this->assertFileExists($foreign); + } + #[TestDox('rejects an unwritable directory with the SDK\'s own exception')] public function testUnwritableDirectoryThrowsPackageException(): void { From 312c621417a1905bb67c9d0ea13604a6e30cb1b0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 25 Aug 2026 00:45:00 +0200 Subject: [PATCH 126/143] [Capability] Suppress list-changed events during the initial registry load (#480) * [Capability] Suppress list-changed events during the initial registry load * Shorten dispatch() comment to a one-liner * Use multi-line docblock format for dispatch() comment --- src/Capability/Registry.php | 28 +++++++++++----- tests/Unit/Capability/RegistryTest.php | 46 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f90a4ca..794193f 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -108,7 +108,7 @@ final class Registry implements RegistryInterface $reference = new ToolReference($tool, $handler); $this->tools[$tool->name] = $reference; - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); return $reference; } @@ -118,7 +118,7 @@ final class Registry implements RegistryInterface $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); return $reference; } @@ -131,7 +131,7 @@ final class Registry implements RegistryInterface $reference = new ResourceTemplateReference($template, $handler, $completionProviders); $this->resourceTemplates[$template->uriTemplate] = $reference; - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); return $reference; } @@ -144,7 +144,7 @@ final class Registry implements RegistryInterface $reference = new PromptReference($prompt, $handler, $completionProviders); $this->prompts[$prompt->name] = $reference; - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); return $reference; } @@ -157,7 +157,7 @@ final class Registry implements RegistryInterface unset($this->tools[$name]); - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); } public function unregisterResource(string $uri): void @@ -168,7 +168,7 @@ final class Registry implements RegistryInterface unset($this->resources[$uri]); - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); } public function unregisterResourceTemplate(string $uriTemplate): void @@ -179,7 +179,7 @@ final class Registry implements RegistryInterface unset($this->resourceTemplates[$uriTemplate]); - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); } public function unregisterPrompt(string $name): void @@ -190,7 +190,7 @@ final class Registry implements RegistryInterface unset($this->prompts[$name]); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); } public function hasTool(string $name): bool @@ -390,6 +390,18 @@ final class Registry implements RegistryInterface return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } + /** + * Suppressed while the deferred loader runs, since it only sets up initial state. + */ + private function dispatch(object $event): void + { + if ($this->loading) { + return; + } + + $this->eventDispatcher?->dispatch($event); + } + /** * Calculate next cursor for pagination. * diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 0b796db..cb32556 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -19,6 +19,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; @@ -31,6 +32,7 @@ use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; class RegistryTest extends TestCase @@ -722,6 +724,50 @@ class RegistryTest extends TestCase $this->assertArrayHasKey('loaded', $registry->getTools()->references); } + public function testListChangedEventsAreSuppressedDuringTheDeferredLoad(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->never())->method('dispatch'); + + $loader = new class($this->createValidTool('loaded'), $this->createValidResource('file:///loaded'), $this->createValidResourceTemplate('file:///loaded/{id}'), $this->createValidPrompt('loaded_prompt')) implements LoaderInterface { + public function __construct( + private readonly Tool $tool, + private readonly ResourceDefinition $resource, + private readonly ResourceTemplate $template, + private readonly Prompt $prompt, + ) { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, 'handler'); + $registry->registerResource($this->resource, 'handler'); + $registry->registerResourceTemplate($this->template, 'handler'); + $registry->registerPrompt($this->prompt, 'handler'); + } + }; + + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + + $this->assertTrue($registry->hasTools()); + $this->assertTrue($registry->hasPrompts()); + } + + public function testListChangedEventsAreStillDispatchedForRuntimeRegistrations(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)) + ->willReturnArgument(0); + + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + $registry->load(); + + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + } + public function testLoadRunsTheConfiguredLoaderEagerly(): void { $loader = $this->createMock(LoaderInterface::class); From ff0b5fadc8c323df9bbcf8b7438d8c2ffcd4f6ac Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 25 Aug 2026 00:46:21 +0200 Subject: [PATCH 127/143] [Capability] Extract shared element name/description resolution (#481) --- src/Capability/Discovery/Discoverer.php | 20 ++++----- .../Discovery/ElementMetadataResolver.php | 44 +++++++++++++++++++ .../Loader/ReflectedElementLoader.php | 33 ++++---------- 3 files changed, 61 insertions(+), 36 deletions(-) create mode 100644 src/Capability/Discovery/ElementMetadataResolver.php diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index dab6590..5b7e476 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -217,7 +217,6 @@ final class Discoverer implements DiscovererInterface private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $className = $method->getDeclaringClass()->getName(); - $classShortName = $method->getDeclaringClass()->getShortName(); $methodName = $method->getName(); $attributeClassName = $attribute->getName(); @@ -226,9 +225,8 @@ final class Discoverer implements DiscovererInterface switch ($attributeClassName) { case McpTool::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $inputSchema = $this->schemaGenerator->generate($method); $outputSchema = $this->schemaGenerator->generateOutputSchema($method); $tool = new Tool( @@ -246,9 +244,8 @@ final class Discoverer implements DiscovererInterface break; case McpResource::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $resource = new ResourceDefinition( $instance->uri, $name, @@ -267,8 +264,8 @@ final class Discoverer implements DiscovererInterface case McpPrompt::class: $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $arguments = []; $paramTags = $this->docBlockParser->getParamTags($docBlock); foreach ($method->getParameters() as $param) { @@ -286,9 +283,8 @@ final class Discoverer implements DiscovererInterface break; case McpResourceTemplate::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $mimeType = $instance->mimeType; $annotations = $instance->annotations; $meta = $instance->meta ?? null; diff --git a/src/Capability/Discovery/ElementMetadataResolver.php b/src/Capability/Discovery/ElementMetadataResolver.php new file mode 100644 index 0000000..3c755dd --- /dev/null +++ b/src/Capability/Discovery/ElementMetadataResolver.php @@ -0,0 +1,44 @@ + + */ +final class ElementMetadataResolver +{ + private function __construct() + { + } + + /** + * Explicit names win; otherwise invokable classes are named after the class, + * regular handlers after the method. + */ + public static function resolveName(\ReflectionMethod $method, ?string $name): string + { + $methodName = $method->getName(); + + return $name ?? ('__invoke' === $methodName ? $method->getDeclaringClass()->getShortName() : $methodName); + } + + /** + * Explicit descriptions win; otherwise the summary of the method's doc block is used. + */ + public static function resolveDescription(\ReflectionMethod $method, ?string $description, DocBlockParser $docBlockParser): ?string + { + return $description ?? $docBlockParser->getDescription($docBlockParser->parseDocBlock($method->getDocComment() ?? null)); + } +} diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index a87dd58..d1e849d 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -16,6 +16,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\ElementMetadataResolver; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; @@ -106,12 +107,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); @@ -148,12 +145,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $resource = new ResourceDefinition( @@ -189,12 +182,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $template = new ResourceTemplate( @@ -229,12 +218,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $arguments = []; From e173367c5f506af39345f7a18e88764786edbdfa Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 25 Aug 2026 01:12:29 +0200 Subject: [PATCH 128/143] [Server] Keep injectable parameters out of the published inputSchema (#482) --- src/Capability/Discovery/SchemaGenerator.php | 12 ++-- .../Registry/InjectableParameters.php | 63 +++++++++++++++++++ src/Capability/Registry/ReferenceHandler.php | 14 +---- tests/Integration/Fixture/sampling.php | 16 +++++ tests/Integration/SamplingTest.php | 23 +++++++ .../Discovery/SchemaGeneratorFixture.php | 9 +++ .../Discovery/SchemaGeneratorTest.php | 11 ++++ .../Registry/ReferenceHandlerTest.php | 26 ++++++++ 8 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 src/Capability/Registry/InjectableParameters.php diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 673477d..357c9e9 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -13,9 +13,9 @@ namespace Mcp\Capability\Discovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Capability\Registry\InjectableParameters; use Mcp\Exception\BadMethodCallException; use Mcp\Exception\InvalidArgumentException; -use Mcp\Server\RequestContext; use phpDocumentor\Reflection\DocBlock\Tags\Param; /** @@ -528,12 +528,10 @@ final class SchemaGenerator implements SchemaGeneratorInterface foreach ($reflection->getParameters() as $rp) { $reflectionType = $rp->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - $typeName = $reflectionType->getName(); - - if (is_a($typeName, RequestContext::class, true)) { - continue; - } + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin() + && InjectableParameters::supports($reflectionType->getName()) + ) { + continue; } $paramName = $rp->getName(); diff --git a/src/Capability/Registry/InjectableParameters.php b/src/Capability/Registry/InjectableParameters.php new file mode 100644 index 0000000..4e8eba2 --- /dev/null +++ b/src/Capability/Registry/InjectableParameters.php @@ -0,0 +1,63 @@ + $arguments the raw argument bag including the internal "_session" and "_request" entries + */ + public static function resolve(string $typeName, array $arguments): ?object + { + if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { + return new RequestContext($arguments['_session'], $arguments['_request']); + } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + return new ClientGateway($arguments['_session']); + } + + return null; + } +} diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 99e5844..ba5a18b 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,8 +13,6 @@ namespace Mcp\Capability\Registry; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; -use Mcp\Server\ClientGateway; -use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -106,15 +104,9 @@ final class ReferenceHandler implements ReferenceHandlerInterface // Check if parameter is a special injectable type $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); - - if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { - $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); - continue; - } - - if (ClientGateway::class === $typeName && isset($arguments['_session'])) { - $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + $injected = InjectableParameters::resolve($type->getName(), $arguments); + if (null !== $injected) { + $finalArgs[$paramPosition] = $injected; continue; } } diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php index bb37bbf..5ea62c8 100644 --- a/tests/Integration/Fixture/sampling.php +++ b/tests/Integration/Fixture/sampling.php @@ -16,6 +16,7 @@ use Mcp\Exception\ClientException; use Mcp\Schema\Content\TextContent; use Mcp\Server; +use Mcp\Server\ClientGateway; use Mcp\Server\RequestContext; use Mcp\Server\Transport\StdioTransport; @@ -38,5 +39,20 @@ Server::builder() name: 'summarize', description: 'Summarizes text by asking the client to sample.', ) + ->addTool( + static function (ClientGateway $client, string $text): string { + try { + $result = $client->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize_via_gateway', + description: 'Summarizes text through a directly injected gateway.', + ) ->build() ->run(new StdioTransport()); diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index c581cf8..0f45286 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -56,6 +56,29 @@ final class SamplingTest extends IntegrationTestCase $this->assertSame(64, $seen[0]->maxTokens); } + #[TestDox('a gateway parameter is injected, not published in the schema')] + public function testGatewayParameterIsInjectedNotPublished(): void + { + $client = $this->connect('sampling', $this->clientSampling()); + + $tool = null; + foreach ($client->listTools()->tools as $candidate) { + if ('summarize_via_gateway' === $candidate->name) { + $tool = $candidate; + } + } + + $this->assertNotNull($tool); + $this->assertArrayNotHasKey('client', $tool->inputSchema['properties']); + $this->assertArrayHasKey('text', $tool->inputSchema['properties']); + $this->assertSame(['text'], $tool->inputSchema['required']); + + $result = $client->callTool('summarize_via_gateway', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + #[TestDox('a client that cannot sample refuses instead of stalling the tool')] public function testClientWithoutSamplingRefuses(): void { diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 14f2d8f..613369b 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -13,6 +13,8 @@ namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Server\ClientGateway; +use Mcp\Server\RequestContext; use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; @@ -519,6 +521,13 @@ class SchemaGeneratorFixture { } + /** + * @param string $query The search query + */ + public function withInjectableParameters(string $query, ClientGateway $gateway, RequestContext $context, int $limit = 10): void + { + } + // ===== OUTPUT SCHEMA FIXTURES ===== #[McpTool( outputSchema: [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 51d73d2..32817f6 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -413,6 +413,17 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['inferredParam'], $schema['required']); } + public function testExcludesInjectableParameterTypesFromSchema(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withInjectableParameters'); + $schema = $this->schemaGenerator->generate($method); + $this->assertArrayNotHasKey('gateway', $schema['properties']); + $this->assertArrayNotHasKey('context', $schema['properties']); + $this->assertEquals(['type' => 'string', 'description' => 'The search query'], $schema['properties']['query']); + $this->assertEquals(['type' => 'integer', 'default' => 10], $schema['properties']['limit']); + $this->assertEquals(['query'], $schema['required']); + } + public static function methodsWithForbiddenParameter(): array { return [ diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index dadca9f..f18196d 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -108,6 +108,32 @@ final class ReferenceHandlerTest extends TestCase $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); } + public function testHandleInjectsClientGatewayIntoReflectedHandler(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $handler = new class { + public ?ClientGateway $receivedGateway = null; + + public function search(string $query, ClientGateway $gateway): string + { + $this->receivedGateway = $gateway; + + return 'found: '.$query; + } + }; + + $result = (new ReferenceHandler())->handle(new ElementReference([$handler, 'search']), [ + '_session' => $session, + '_request' => new \stdClass(), + 'query' => 'foo', + ]); + + $this->assertSame('found: foo', $result); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void { $session = $this->createMock(SessionInterface::class); From e473c3c1158fa63c1a1d17324d3163bfde82a0e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:45:13 +0200 Subject: [PATCH 129/143] chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#483) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v9.0.0...v10.0.1) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 20b6e08..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@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true From 46628fbe6ad5468eee2d60b07dd5d4af9a15eded Mon Sep 17 00:00:00 2001 From: Sviatoslav Date: Sat, 29 Aug 2026 16:46:40 +0300 Subject: [PATCH 130/143] [Capability] Cast variadic tool parameters element-by-element (#465) * Cast variadic tool parameters element-by-element SchemaGenerator::buildVariadicParameterSchema() already advertises a variadic parameter (e.g. `int ...$scores`) as a JSON "array" schema, so a spec-conformant client sends an array for it. But ReferenceHandler::prepareArguments() never special-cased ReflectionParameter::isVariadic(): it fell through to the regular single-value branch and passed the whole array to castArgumentType(), which tried to cast it as one scalar and failed - e.g. "Cannot cast value to integer" for `int ...$scores` given [1, 2, 3]. Any tool handler declaring a variadic parameter was therefore unreachable through the advertised schema, confirmed by direct reproduction against ReferenceHandler::handle() before this change. Casts each array element individually via the existing castArgumentType() (unchanged - it already inspects the variadic parameter's element type, not "array", per PHP reflection semantics) and appends to the result in order; variadic is always the last parameter, so this preserves correct positional ordering. A non-array value for a variadic argument now fails clearly with an invalid-params error instead of the confusing cast message. * Address chr-hertel's review: wrap scalars, reject objects, index cast errors - Non-array variadic argument is now wrapped as a single-element list instead of being rejected: MCP prompt arguments are always Record per protocol, so a variadic prompt parameter legitimately receives a plain string, not an array. Rejecting it regressed behaviour that worked on main. - A JSON object (non-list array) is now explicitly rejected instead of being silently flattened into positional args by array_values(). - Cast-failure errors now include the parameter name and element index, so a failure inside a large array is actually actionable. - Trimmed the inline rationale comment; the PR description already covers it. Tests updated: replaced the "throws on non-array" test (no longer correct) with tests for the wrap-as-single-element, reject-object, and indexed-error-message behaviours. * Pin argument order for an injectable before a variadic --------- Co-authored-by: Christopher Hertel --- src/Capability/Registry/ReferenceHandler.php | 24 ++++- .../Registry/ReferenceHandlerTest.php | 96 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index ba5a18b..c0084cb 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -97,10 +97,32 @@ final class ReferenceHandler implements ReferenceHandlerInterface $finalArgs = []; foreach ($reflection->getParameters() as $parameter) { - // TODO: Handle variadic parameters. $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); + if ($parameter->isVariadic()) { + // Each element is cast individually below; a non-array value is + // wrapped as a single element, since prompt arguments arrive as + // plain strings (Record per the protocol) even + // though tool arguments follow the advertised "array" schema. + $values = $arguments[$paramName] ?? []; + if (!\is_array($values)) { + $values = [$values]; + } elseif (!array_is_list($values)) { + throw RegistryException::invalidParams(\sprintf('Parameter `%s` must be a list of values, not an object.', $paramName)); + } + foreach ($values as $index => $value) { + try { + $finalArgs[] = $this->castArgumentType($value, $parameter); + } catch (InvalidArgumentException $e) { + throw RegistryException::invalidParams(\sprintf('Parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e); + } catch (\Throwable $e) { + throw RegistryException::internalError(\sprintf('Error processing parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e); + } + } + continue; + } + // Check if parameter is a special injectable type $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index f18196d..2952c81 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -14,6 +14,7 @@ namespace Mcp\Tests\Unit\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\RegistryException; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\ResourceHandlerInterface; use Mcp\Server\Handler\ToolHandlerInterface; @@ -167,4 +168,99 @@ final class ReferenceHandlerTest extends TestCase (new ReferenceHandler())->handle($reference, ['_session' => $session]); } + + public function testHandleCastsEachElementOfAnArrayArgumentForAVariadicParameter(): void + { + // SchemaGenerator advertises variadic parameters as a JSON "array" schema, + // so the array arrives here as a single named argument (not spread across + // multiple keys) and must be cast element-by-element to the variadic's type. + $closure = static fn (string $name, int ...$scores): string => \sprintf('%s:%d', $name, array_sum($scores)); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'total', + 'scores' => ['1', '2', '3'], + ]); + + $this->assertSame('total:6', $result); + } + + public function testHandleTreatsOmittedVariadicArgumentAsZeroElements(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'empty', + ]); + + $this->assertSame(0, $result); + } + + public function testHandleWrapsANonArrayVariadicArgumentAsASingleElement(): void + { + // Tool arguments follow the advertised "array" schema, but MCP prompt + // arguments are always Record per the protocol - a + // client sends a plain string for a variadic prompt parameter, and + // that must still work rather than being rejected. + $closure = static fn (string $name, string ...$topics): array => $topics; + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'single', + 'topics' => 'php', + ]); + + $this->assertSame(['php'], $result); + } + + public function testHandleThrowsRegistryExceptionWhenVariadicArgumentIsAnObjectNotAList(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Parameter `scores` must be a list of values, not an object.'); + + (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'bad', + 'scores' => ['a' => 1, 'b' => 2], + ]); + } + + public function testHandleKeepsArgumentOrderWhenAnInjectableParameterPrecedesAVariadic(): void + { + // Injectable and regular parameters are assigned by position while variadic + // elements are appended, so the final order only holds because a variadic is + // always the last parameter. + $closure = static fn (ClientGateway $gateway, string $sep = ',', string ...$parts): string => implode($sep, $parts); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + '_request' => new \stdClass(), + 'parts' => ['a', 'b'], + ]); + + $this->assertSame('a,b', $result); + } + + public function testHandleIncludesParameterNameAndIndexWhenAVariadicElementFailsToCast(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Parameter `scores[1]`: Cannot cast value to integer. Expected integer representation.'); + + (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'bad', + 'scores' => ['1', 'not-a-number', '3'], + ]); + } } From c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 30 Aug 2026 01:10:56 +0200 Subject: [PATCH 131/143] [Server][Capability] Do not announce an externally loaded registry as changed (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Capability] Do not announce an externally loaded registry as changed `Registry` suppresses its `*ListChangedEvent`s while it is loading — `dispatch()` returns early on the `loading` guard, so the elements a loader registers are the registry's initial contents rather than a change to them. `testListChangedEventsAreSuppressedDuringTheDeferredLoad` pins that. The guard only covers `Registry::load()`, which needs the loader the constructor took. A registry the caller built cannot be given one that way, so `Builder::resolve()` loads it from the outside instead — by calling `$chainLoader->load($registry)` directly, which never sets the guard. Every element then dispatches on the way in. With a notification bus configured that is not quiet. `PublishingEventDispatcher` turns each event into a published notification, so every `build()` puts one `list_changed` per element on the bus for a registry that did not change. Under PHP-FPM, where the server is built per request and `Psr16NotificationBus` is shared and persistent, every request broadcasts its whole element list to every open `subscriptions/listen` stream and consumes the 256-entry backlog — after which a reader that fell behind silently skips real notifications. Split the guarded body of `load()` into `loadFrom(LoaderInterface $loader)` and route the custom-registry branch through it. `loadFrom()` takes only the `loading` guard, not the `loaded` bookkeeping, which stays with `load()`. The loader it runs belongs to the caller, so it cannot stand in for the one the registry was constructed with: marking the registry loaded would retire a constructor loader that never ran, and would make a second `loadFrom()` — one registry handed to two builders — a silent no-op. Keeping `loaded` out of it also keeps `load()`'s promise that a transient failure is retried on the next read, including when the failing loader reads the registry during its own run. `RegistryInterface` declares no `load()`, so this stays on the concrete `Registry`, and a third-party implementation keeps the path it has today. * Apply batched suggestions from code review Co-authored-by: Christopher Hertel --- src/Capability/Registry.php | 21 +++++++- src/Server/Builder.php | 10 +++- tests/Unit/Capability/RegistryTest.php | 68 ++++++++++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 39 +++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 794193f..9784043 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -88,10 +88,27 @@ final class Registry implements RegistryInterface return; } + $this->loadFrom($this->loader); + + $this->loaded = true; + } + + /** + * Runs $loader with the change events its registrations would dispatch suppressed, since they + * describe the registry filling up rather than changing. + * + * Re-entrant-safe, and failure propagates. Does not mark the registry loaded: $loader is the + * caller's, and the one the constructor took is still owed its run. + */ + public function loadFrom(LoaderInterface $loader): void + { + if ($this->loading) { + return; + } + $this->loading = true; try { - $this->loader->load($this); - $this->loaded = true; + $loader->load($this); } finally { $this->loading = false; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 90272ef..1a5dd73 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -522,7 +522,8 @@ final class Builder * * Lazy (the default) defers loading to the first registry read so a persistent runtime does not * freeze the registry to a source not yet ready at build time. Disable to load eagerly at build. - * A registry supplied via setRegistry() is always loaded eagerly. + * A registry supplied via setRegistry() is always loaded eagerly; its own constructor loader, + * if it has one, still runs on the first read. */ public function setLazyLoading(bool $lazyLoading = true): self { @@ -1045,8 +1046,13 @@ final class Builder if ($this->hasCustomRegistry) { // Builder can't inject the loader into an already-constructed instance, so load it eagerly. + // Via loadFrom(), which suppresses the change events the load would otherwise dispatch. $registry = $this->registry; - $chainLoader->load($registry); + if ($registry instanceof Registry) { + $registry->loadFrom($chainLoader); + } else { + $chainLoader->load($registry); + } $eagerlyLoaded = true; } else { $registry = new Registry($eventDispatcher, $logger, loader: $chainLoader); diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index cb32556..92782ab 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -753,6 +753,74 @@ class RegistryTest extends TestCase $this->assertTrue($registry->hasPrompts()); } + public function testListChangedEventsAreSuppressedWhenTheLoaderIsSuppliedFromOutside(): void + { + // As quiet as the deferred load above: these are the initial contents, not a change. + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->never())->method('dispatch'); + + $registry = new Registry($eventDispatcher, $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('loaded'))); + + $this->assertTrue($registry->hasTool('loaded')); + } + + public function testARuntimeRegistrationAfterAnExternalLoadIsStillDispatched(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)) + ->willReturnArgument(0); + + $registry = new Registry($eventDispatcher, $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('loaded'))); + + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + } + + public function testAnExternalLoadDoesNotConsumeTheRegistrysOwnLoader(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger, loader: $this->toolLoader($this->createValidTool('own'))); + $registry->loadFrom($this->toolLoader($this->createValidTool('external'))); + + $this->assertTrue($registry->hasTool('external')); + $this->assertTrue($registry->hasTool('own')); + } + + public function testAnExternalLoadCanRunMoreThanOnce(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('first'))); + $registry->loadFrom($this->toolLoader($this->createValidTool('second'))); + + $this->assertTrue($registry->hasTool('first')); + $this->assertTrue($registry->hasTool('second')); + } + + public function testAFailedExternalLoadLeavesTheRegistryRetryable(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger); + $failing = new class implements LoaderInterface { + public function load(RegistryInterface $registry): void + { + // Reads during its own run, as discovery's identity check does. + $registry->hasTool('anything'); + + throw new \RuntimeException('data source not ready'); + } + }; + + foreach ([1, 2] as $attempt) { + try { + $registry->loadFrom($failing); + $this->fail('The loader was expected to fail.'); + } catch (\RuntimeException $e) { + $this->assertSame('data source not ready', $e->getMessage()); + } + } + } + public function testListChangedEventsAreStillDispatchedForRuntimeRegistrations(): void { $eventDispatcher = $this->createMock(EventDispatcherInterface::class); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 9cdf329..790af5f 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\RegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; @@ -32,6 +33,8 @@ use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\ThingListHandler; use Mcp\Tests\Unit\Server\Extension\ThingListRequest; @@ -404,6 +407,42 @@ final class BuilderTest extends TestCase $this->fail('CallToolHandler not found in request handlers'); } + + public function testBuildingWithASuppliedRegistryDoesNotAnnounceTheLoadAsAChange(): void + { + // Otherwise every build publishes one list_changed per element, for no change. + $bus = new InMemoryNotificationBus(); + $registry = new Registry(new PublishingEventDispatcher($bus)); + + Server::builder() + ->setRegistry($registry) + ->setNotificationBus($bus) + ->addTool(static fn (): string => 'ok', 'alpha') + ->addTool(static fn (): string => 'ok', 'beta') + ->build(); + + $this->assertSame(0, $bus->cursor()); + $this->assertTrue($registry->hasTool('alpha')); + $this->assertTrue($registry->hasTool('beta')); + + // A real change after the build is still published. + $registry->unregisterTool('alpha'); + + $this->assertSame(1, $bus->cursor()); + } + + public function testAThirdPartyRegistryIsStillLoadedThroughThePlainLoader(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with($this->callback(static fn (Tool $tool): bool => 'alpha' === $tool->name)); + + Server::builder() + ->setRegistry($registry) + ->addTool(static fn (): string => 'ok', 'alpha') + ->build(); + } } /** From 418034f2b07bd719b440e2f2f3073f5c5d53824e Mon Sep 17 00:00:00 2001 From: Rob Loach Date: Fri, 4 Sep 2026 17:55:20 -0400 Subject: [PATCH 132/143] [Client] Expose session ID from HttpTransport (#497) * Expose session ID from HttpTransport * [Client] State what getSessionId() returns on 2026-07-28 That revision removed protocol-level sessions (SEP-2567), so the getter is always null there. Adds the changelog entry. --------- Co-authored-by: Christopher Hertel --- CHANGELOG.md | 5 ++ src/Client/Transport/HttpTransport.php | 16 +++++++ .../Client/Transport/HttpTransportTest.php | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f085872..96f6000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.9.0 +----- + +* 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. + 0.8.0 ----- diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index b5499e8..3c12ea7 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -121,6 +121,22 @@ 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/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 6c6119e..83a77e0 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -94,6 +94,53 @@ 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 { From 4630d7424e2ba3941f477990e613ab21a4451fb6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 4 Sep 2026 23:55:48 +0200 Subject: [PATCH 133/143] [Server] Stop Session::forget() from creating missing segments (#484) --- src/Server/Session/Session.php | 7 ++----- tests/Unit/Server/Session/SessionTest.php | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 6eee72d..4db11f7 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -110,15 +110,12 @@ class Session implements SessionInterface while (\count($segments) > 1) { $segment = array_shift($segments); if (!isset($data[$segment]) || !\is_array($data[$segment])) { - $data[$segment] = []; + return; } $data = &$data[$segment]; } - $lastKey = array_shift($segments); - if (isset($data[$lastKey])) { - unset($data[$lastKey]); - } + unset($data[array_shift($segments)]); } public function clear(): void diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index 40483d3..a6b1697 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -245,6 +245,21 @@ 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'); From ebe067f6b380606c97cfe79371d048f948074728 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 5 Sep 2026 01:11:20 +0200 Subject: [PATCH 134/143] [Server] Log filesystem failures in FileSessionStore (#488) * [Server] Log filesystem failures in FileSessionStore * [Server] Warn on failed unlink during session garbage collection --- src/Server/Session/FileSessionStore.php | 53 +++++++- .../Server/Session/FileSessionStoreTest.php | 119 ++++++++++++++++++ 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index e218e75..8d08bae 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -14,6 +14,8 @@ 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; /** @@ -26,9 +28,13 @@ 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); + 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) || !is_writable($this->directory)) { @@ -59,13 +65,23 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { - @unlink($path); + if (!@unlink($path) && is_file($path)) { + $this->logger->warning('Failed to delete expired session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + } 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; } @@ -78,6 +94,11 @@ 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; } @@ -85,6 +106,10 @@ 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; @@ -101,8 +126,11 @@ class FileSessionStore implements SessionStoreInterface { $path = $this->pathFor($id); - if (is_file($path)) { - @unlink($path); + 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', + ]); } return true; @@ -119,6 +147,11 @@ 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; } @@ -140,7 +173,15 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($now - $mtime) > $this->ttl) { - @unlink($path); + if (!@unlink($path) && is_file($path)) { + $this->logger->warning('Failed to delete expired session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + + continue; + } + $deleted[] = Uuid::fromString($entry); } } diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php index a2b8729..ed22e86 100644 --- a/tests/Unit/Server/Session/FileSessionStoreTest.php +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -16,6 +16,8 @@ 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 @@ -109,4 +111,121 @@ 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]; + } + } } From b458008f07d6dd055dbd7e38889f277676b7e9ac Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 5 Sep 2026 01:13:09 +0200 Subject: [PATCH 135/143] [Server] Fix StreamableHttpTransport mutable state and clock (#487) --- .../Transport/StreamableHttpTransport.php | 30 ++++--- .../Transport/StreamableHttpTransportTest.php | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 2bf088b..c01503e 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -15,6 +15,7 @@ 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; @@ -22,6 +23,7 @@ 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; @@ -79,12 +81,13 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware */ public function __construct( - private ServerRequestInterface $request, + private readonly 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); @@ -185,12 +188,17 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra { $this->handleMessage($body, $this->sessionId); - if (null !== $this->immediateResponse) { - $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($this->immediateResponse)); + // 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; - return $response; + if (null !== $immediateResponse) { + return $this->responseFactory->createResponse($immediateStatusCode ?? 200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($immediateResponse)); } if (null !== $this->sessionFiber) { @@ -268,7 +276,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra break; } - if (time() - $timestamp >= $timeout) { + if ($this->clock->now()->getTimestamp() - $timestamp >= $timeout) { $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); @@ -376,8 +384,6 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra private function handleRequest(ServerRequestInterface $request): ResponseInterface { - $this->request = $request; - if ('OPTIONS' === $request->getMethod()) { return $this->handleOptionsRequest(); } @@ -405,7 +411,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra } if ($classification->modern) { - return $this->handleModernRequest($body ?? '', $classification->claimedVersion ?? ''); + return $this->handleModernRequest($request, $body ?? '', $classification->claimedVersion ?? ''); } // The version-header rule only reaches the traffic it is about. Running @@ -443,7 +449,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra /** * Answers a request that claimed the modern era's per-request envelope. */ - private function handleModernRequest(string $body, string $claimedVersion): ResponseInterface + private function handleModernRequest(ServerRequestInterface $request, string $body, string $claimedVersion): ResponseInterface { if (null === $this->stateless) { return $this->responder->error( @@ -452,7 +458,7 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra ); } - return $this->responder->respond($this->stateless->handle($body, self::headers($this->request))); + return $this->responder->respond($this->stateless->handle($body, self::headers($request))); } /** diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 932f5c7..186432f 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -12,19 +12,23 @@ 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 { @@ -347,6 +351,92 @@ 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 { From 1bd7895b5ff0517f9de26c7bfb8a6de7db163b05 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 5 Sep 2026 01:18:54 +0200 Subject: [PATCH 136/143] [Schema] Fix _meta guard key in Request and Notification jsonSerialize() (#485) --- src/Schema/JsonRpc/Notification.php | 2 +- src/Schema/JsonRpc/Request.php | 2 +- .../Unit/Schema/JsonRpc/NotificationTest.php | 40 +++++++++++++++++++ tests/Unit/Schema/JsonRpc/RequestTest.php | 37 +++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php index bfd37f1..3ec0883 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 cb8ed83..afdf9e9 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/tests/Unit/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php index 0d7d7c4..b2580de 100644 --- a/tests/Unit/Schema/JsonRpc/NotificationTest.php +++ b/tests/Unit/Schema/JsonRpc/NotificationTest.php @@ -53,4 +53,44 @@ 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 f400a90..d23b6a9 100644 --- a/tests/Unit/Schema/JsonRpc/RequestTest.php +++ b/tests/Unit/Schema/JsonRpc/RequestTest.php @@ -55,4 +55,41 @@ 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()); + } } From 749c0353efeda29dcc1c0809855e313731084439 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 5 Sep 2026 01:36:39 +0200 Subject: [PATCH 137/143] [Server] Keep inbound request intact when queueing an outbound one (#486) --- src/Server/Protocol.php | 6 ++- tests/Unit/Server/ProtocolTest.php | 59 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 4d6e5f5..f460c3c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -303,9 +303,11 @@ class Protocol $notification = $result['notification']; $this->sendNotification($notification, $session); } elseif ('request' === $result['type']) { - $request = $result['request']; + // Keep $request untouched: it is the inbound request the catch + // blocks below answer under, not this outbound one. + $outboundRequest = $result['request']; $timeout = $result['timeout'] ?? 120; - $this->sendRequest($request, $timeout, $session); + $this->sendRequest($outboundRequest, $timeout, $session); } } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 997c39d..175db74 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -25,7 +25,9 @@ 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; @@ -793,6 +795,63 @@ 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 { From f39625b257017457ee17fddea3f7f671816a133a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 7 Sep 2026 23:27:53 +0200 Subject: [PATCH 138/143] [Capability] Remove CompletionProvider's dead providerClass argument (#499) * [Capability] Remove CompletionProvider's dead providerClass argument It always threw on construction, so it never worked in any release. `provider:` takes the same class-string and is now the first positional argument. * Reword Changelog --- CHANGELOG.md | 1 + .../Attribute/CompletionProvider.php | 2 +- src/Capability/Discovery/Discoverer.php | 2 - .../Loader/ReflectedElementLoader.php | 4 +- tests/Integration/CompletionTest.php | 57 +++++++++++++++++++ .../Fixture/Completion/BookingElements.php | 38 +++++++++++++ .../Completion/SeatCompletionProvider.php | 42 ++++++++++++++ tests/Integration/Fixture/completion.php | 31 ++++++++++ .../Attribute/CompletionProviderTest.php | 9 +++ .../Capability/Discovery/DiscoveryTest.php | 14 ++++- .../Fixtures/EnhancedCompletionHandler.php | 7 ++- 11 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 tests/Integration/CompletionTest.php create mode 100644 tests/Integration/Fixture/Completion/BookingElements.php create mode 100644 tests/Integration/Fixture/Completion/SeatCompletionProvider.php create mode 100644 tests/Integration/Fixture/completion.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f6000..22ad2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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. 0.8.0 diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index 9e8dc80..a21963c 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 5b7e476..c70559f 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -325,8 +325,6 @@ 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 d1e849d..209644c 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 + * @return array|ProviderInterface> */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { @@ -307,8 +307,6 @@ 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/tests/Integration/CompletionTest.php b/tests/Integration/CompletionTest.php new file mode 100644 index 0000000..0da021a --- /dev/null +++ b/tests/Integration/CompletionTest.php @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..2a64e5d --- /dev/null +++ b/tests/Integration/Fixture/Completion/BookingElements.php @@ -0,0 +1,38 @@ + + */ +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 new file mode 100644 index 0000000..a749fcf --- /dev/null +++ b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php @@ -0,0 +1,42 @@ + + */ +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 new file mode 100644 index 0000000..f33e951 --- /dev/null +++ b/tests/Integration/Fixture/completion.php @@ -0,0 +1,31 @@ +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 19a7875..4b5269d 100644 --- a/tests/Unit/Capability/Attribute/CompletionProviderTest.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderTest.php @@ -26,6 +26,15 @@ 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 5c716d7..628aaa6 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(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $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(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $prompts['content_creator']->completionProviders); $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); @@ -182,4 +182,14 @@ 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 5ac67dd..9975f9a 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -14,13 +14,14 @@ 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 and enum completion providers. + * Create content with list, enum and positional provider completion providers. */ #[McpPrompt(name: 'content_creator')] public function createContent( @@ -30,9 +31,11 @@ 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}"], + ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority} for {$author}"], ]; } From 70470190304b128e659cb1dcb748648262476bb4 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 8 Sep 2026 00:49:47 +0200 Subject: [PATCH 139/143] [README] Show conformance badges in a table (#501) --- README.md | 20 +++++++++++--------- tests/Conformance/score.php | 13 +++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 04965f8..231c4fe 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,10 @@ # 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). @@ -25,6 +16,17 @@ 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/tests/Conformance/score.php b/tests/Conformance/score.php index 06c3409..37ebcab 100644 --- a/tests/Conformance/score.php +++ b/tests/Conformance/score.php @@ -16,11 +16,12 @@ * php score.php [results-dir] * * The conformance CLI writes one `checks.json` per scenario into - * `[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 + * `[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 * `-conformance-.json`, so each revision gets its own - * badge. + * 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. */ use Symfony\Component\Console\Command\Command; @@ -92,7 +93,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $badge = [ 'schemaVersion' => 1, - 'label' => sprintf('%s %s', $suite, $specVersion), + 'label' => 'checks', 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', 'color' => match (true) { 0 === $total => 'lightgrey', @@ -116,7 +117,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $io->listing($failures); } - $io->success(sprintf('%s: %s', $badge['label'], $badge['message'])); + $io->success(sprintf('%s %s: %s', $suite, $specVersion, $badge['message'])); return Command::SUCCESS; }) From e7aa2d77de02637565c895842516d7e2f5aef53e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 8 Sep 2026 01:14:59 +0200 Subject: [PATCH 140/143] [Docs] Add blank line before lists so they render (#503) --- docs/advanced/events.md | 4 ++++ docs/client/connecting.md | 1 + docs/client/transports.md | 3 +++ docs/run/server-builder.md | 4 ++++ docs/run/sessions.md | 2 ++ docs/servers/completions.md | 1 + docs/servers/prompts.md | 1 + docs/servers/registration.md | 2 ++ 8 files changed, 18 insertions(+) diff --git a/docs/advanced/events.md b/docs/advanced/events.md index 590ad6e..d6d7b44 100644 --- a/docs/advanced/events.md +++ b/docs/advanced/events.md @@ -35,6 +35,7 @@ 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 @@ -45,6 +46,7 @@ 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 @@ -56,6 +58,7 @@ 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. @@ -67,6 +70,7 @@ 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 b060916..1fe01e1 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -169,6 +169,7 @@ $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 dd27166..84e1500 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -18,6 +18,7 @@ $transport = new StdioTransport( ``` **Parameters:** + - `command` (string): The command to execute - `args` (array): Command arguments - `cwd` (string|null): Working directory for the process @@ -39,6 +40,7 @@ $transport = new HttpTransport( ``` **Parameters:** + - `endpoint` (string): The MCP server URL - `headers` (array): Additional HTTP headers - `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) @@ -50,6 +52,7 @@ $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 52d27e7..017d4a8 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -54,6 +54,7 @@ $server = Server::builder() ``` **Parameters:** + - `$name` (string): The server name - `$version` (string): Version string (semantic versioning recommended) - `$description` (string|null): Optional description @@ -152,6 +153,7 @@ $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 @@ -189,6 +191,7 @@ $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`) @@ -217,6 +220,7 @@ $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 abdac60..93f01c7 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -74,6 +74,7 @@ $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. @@ -81,6 +82,7 @@ $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 c422620..e1f6086 100644 --- a/docs/servers/completions.md +++ b/docs/servers/completions.md @@ -87,6 +87,7 @@ 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 33da3c7..bb46ea5 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -100,6 +100,7 @@ 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 e2dc563..896333b 100644 --- a/docs/servers/registration.md +++ b/docs/servers/registration.md @@ -6,6 +6,7 @@ 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 @@ -25,6 +26,7 @@ Register MCP elements programmatically without using attributes. The handler is any PHP callable. **Advantages:** + - Fine-grained control - Runtime configuration - Conditional registration From 5a64bab1c707074f28dcd69c2abb420d7155ac51 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 8 Sep 2026 01:15:25 +0200 Subject: [PATCH 141/143] [CI] Open conformance issue on manual runs too (#502) --- .github/workflows/conformance-weekly.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index d301237..9b255b3 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -125,10 +125,11 @@ jobs: name: Open issue on failure runs-on: ubuntu-latest needs: [server, client] - if: failure() && github.event_name == 'schedule' + if: failure() 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 @@ -140,12 +141,13 @@ jobs: gh issue create \ --title '[conformance] Weekly conformance run failed' \ --label conformance-weekly \ - --body "Weekly conformance against \`@modelcontextprotocol/conformance@latest\` failed. + --body "Conformance against the newest published \`@modelcontextprotocol/conformance\` failed. - Run: $RUN_URL - - Triggered: $(date -u +%FT%TZ) + - Trigger: $EVENT_NAME + - Date: $(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 \`tests/Conformance/conformance-baseline-2025-11-25.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 the matching \`tests/Conformance/conformance-baseline-.yml\`." fi publish: From ef6bee55317d591fe1ac2ab7ae09907001d85864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=92=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D1=8B=D0=BD=D1=86=D0=B5=D0=B2?= <89937736+karedum@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:14:48 +0300 Subject: [PATCH 142/143] [Server] fix OIDC discovery for issuers with trailing slash (#506) * fix OIDC discovery for issuers with trailing slash * add trailing-slash issuer tests and changelog entry --------- Co-authored-by: Dmitry Vorotyntsev Co-authored-by: Christopher Hertel --- CHANGELOG.md | 1 + .../Transport/Http/OAuth/OidcDiscovery.php | 8 +- .../Http/OAuth/OidcDiscoveryTest.php | 82 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ad2b3..e77f062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [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/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php index 2f24685..a0c0cd6 100644 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -146,8 +146,10 @@ class OidcDiscovery implements OidcDiscoveryInterface */ private function fetchMetadata(string $issuer): array { - $issuer = rtrim($issuer, '/'); - $parsed = parse_url($issuer); + // 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); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); @@ -170,7 +172,7 @@ class OidcDiscovery implements OidcDiscoveryInterface // 2. OIDC path insertion $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; + $discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 29c86d6..fd86d02 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -293,6 +293,88 @@ 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)) { From 16836d4e9a0f96831789ac6e64d5ec5238d2c833 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:57:24 +0200 Subject: [PATCH 143/143] chore(deps): bump astral-sh/setup-uv from 10.0.1 to 10.1.0 (#512) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.0.1 to 10.1.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v10.0.1...v10.1.0) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a3ef63c..7a399f1 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.0.1 + uses: astral-sh/setup-uv@v10.1.0 with: enable-cache: true