From 15e496ccafd3577c97f30e1d77825a0541263748 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Nov 2026 10:04:42 +0100 Subject: [PATCH] Make ext-fileinfo optional and drop composer suggest --- AGENTS.md | 2 ++ CHANGELOG.md | 1 + composer.json | 7 ---- docs/get-started/installation.md | 6 ++-- src/Schema/Content/AudioContent.php | 2 +- src/Schema/Content/BlobResourceContents.php | 2 +- src/Schema/Content/EmbeddedResource.php | 4 +-- src/Schema/Content/ImageContent.php | 2 +- src/Schema/Content/MimeType.php | 36 +++++++++++++++++++++ tests/Unit/Schema/Content/MimeTypeTest.php | 25 ++++++++++++++ 10 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/Schema/Content/MimeType.php create mode 100644 tests/Unit/Schema/Content/MimeTypeTest.php diff --git a/AGENTS.md b/AGENTS.md index 4f04b3257..302b32e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,8 @@ reference concrete extension types. ## Conventions - Throw `Mcp\Exception\*` (all implement `ExceptionInterface`), never native SPL exceptions. +- No `suggest` in `composer.json`: code that needs an optional package or extension checks for it and throws an + exception that names what to install. - Add an `@author` tag to newly introduced classes. - Define array shapes for parameters and return types. - Prefer classic `if` statements over short-circuit evaluation when possible. diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf2c89f4..74c5b69e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] An `x-mcp-header` annotation that is not on a property reached through `properties` alone, e.g. under `items`, `anyOf` or `$defs`, makes the tool definition invalid instead of being ignored: `Tool` throws an `InvalidArgumentException` for it, the client drops such a tool from `tools/list` and refuses a sampling request that carries one. A null annotation is invalid too, wherever it sits. * [BC Break] `CallToolResult` preserves explicit `structuredContent: null` in construction and decoding. Calls that used null for omission must omit the argument (use `meta: $meta` for metadata) or pass `StructuredContent::Absent`. An explicit null that violates the output schema becomes a tool error, and strict handshake-era clients can reject it. `hasStructuredContent()` distinguishes absent content from null; text-only `success()` and `error()` results remain absent. +* `ext-fileinfo` is optional: without it, creating content from a file without a MIME type throws a `LogicException`. 0.9.0 ----- diff --git a/composer.json b/composer.json index bdabb142a..fbeed9b94 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,6 @@ ], "require": { "php": "^8.2", - "ext-fileinfo": "*", "opis/json-schema": "^2.4", "php-http/discovery": "^1.20", "phpdocumentor/reflection-docblock": "^6.0", @@ -35,12 +34,6 @@ "symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, - "suggest": { - "firebase/php-jwt": "Required for the JwtTokenValidator, and for private_key_jwt client authentication in the OAuth client.", - "psr/cache-implementation": "Required for JwtTokenValidator::fromIssuer() to cache metadata and keys.", - "symfony/finder": "Required for file-based discovery.", - "symfony/lock": "Required for the SymfonyLockSessionLock." - }, "require-dev": { "ext-openssl": "*", "amphp/amp": "^3.0", diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 616f873d7..2f904c87d 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -6,12 +6,14 @@ The SDK ships as a single Composer package: composer require mcp/sdk ``` -It requires **PHP 8.2+** and the `fileinfo` extension. Most of what it pulls in is PSR +It requires **PHP 8.2+**. 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, `php-http/discovery` for finding PSR-17/PSR-18 implementations, and -`symfony/deprecation-contracts` for deprecation notices. +`symfony/deprecation-contracts` for deprecation notices. Without the `fileinfo` extension, +pass a MIME type to `ImageContent::fromFile()` and the other content factories that read +a file, or they throw an exception. That is enough for a complete [STDIO server](../run/stdio.md) and for the [STDIO client](../client/transports.md). diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php index 517be814f..8baa357b3 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -78,7 +78,7 @@ public static function fromFile(string $path, ?string $mimeType = null, ?Annotat throw new RuntimeException(\sprintf('Could not read audio file: "%s".', $path)); } $data = base64_encode($content); - $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + $detectedMime = $mimeType ?? MimeType::guess($path) ?? 'application/octet-stream'; return new self($data, $detectedMime, $annotations); } diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index 24c342cae..d7ffa3fbd 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -83,7 +83,7 @@ public static function fromStream(string $uri, $stream, string $mimeType, ?array * */ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?array $meta = null): self { - $mimeType = $explicitMimeType ?? (mime_content_type($file->getPathname()) ?: null); + $mimeType = $explicitMimeType ?? MimeType::guess($file->getPathname()); $blob = file_get_contents($file->getPathname()); if (false === $blob) { throw new InvalidArgumentException(\sprintf('Could not read file: "%s".', $file->getPathname())); diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php index eb772c039..9eeefde85 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -90,7 +90,7 @@ public static function fromFile(string $uri, string $path, ?string $explicitMime throw new RuntimeException(\sprintf('Could not read file: "%s".', $path)); } - $guessedMimeType = $explicitMimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + $guessedMimeType = $explicitMimeType ?? MimeType::guess($path) ?? 'application/octet-stream'; if (self::isTextMimeTypeHeuristic($guessedMimeType) && mb_check_encoding($content, 'UTF-8')) { $resourceContent = new TextResourceContents($uri, $guessedMimeType, $content); @@ -121,7 +121,7 @@ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string throw new RuntimeException(\sprintf('Could not read file: "%s".', $file->getPathname())); } - return new self(new BlobResourceContents($uri, $explicitMimeType ?? (mime_content_type($file->getPathname()) ?: null), base64_encode($content)), $annotations); + return new self(new BlobResourceContents($uri, $explicitMimeType ?? MimeType::guess($file->getPathname()), base64_encode($content)), $annotations); } /** diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php index 3f7ada583..dc5dce1e7 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -83,7 +83,7 @@ public static function fromFile(string $path, ?string $mimeType = null, ?Annotat } $data = base64_encode($contents); - $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png'; + $detectedMime = $mimeType ?? MimeType::guess($path) ?? 'image/png'; return new self($data, $detectedMime, $annotations); } diff --git a/src/Schema/Content/MimeType.php b/src/Schema/Content/MimeType.php new file mode 100644 index 000000000..9eb83e619 --- /dev/null +++ b/src/Schema/Content/MimeType.php @@ -0,0 +1,36 @@ + + */ +final class MimeType +{ + /** + * @throws LogicException if the fileinfo extension is missing + */ + public static function guess(string $path): ?string + { + if (!\function_exists('mime_content_type')) { + throw new LogicException(\sprintf('Detecting the MIME type of "%s" requires the "fileinfo" PHP extension. Install it or pass the MIME type explicitly.', $path)); + } + + $mimeType = mime_content_type($path); + + return false === $mimeType ? null : $mimeType; + } +} diff --git a/tests/Unit/Schema/Content/MimeTypeTest.php b/tests/Unit/Schema/Content/MimeTypeTest.php new file mode 100644 index 000000000..4e7885f6f --- /dev/null +++ b/tests/Unit/Schema/Content/MimeTypeTest.php @@ -0,0 +1,25 @@ +assertSame('text/plain', MimeType::guess(__DIR__.'/../../../../LICENSE')); + } +}