This is page 2 of 5. Use http://codebase.md/php-mcp/server?page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── changelog.yml
│ └── tests.yml
├── .gitignore
├── .php-cs-fixer.php
├── CHANGELOG.md
├── composer.json
├── CONTRIBUTING.md
├── examples
│ ├── 01-discovery-stdio-calculator
│ │ ├── McpElements.php
│ │ └── server.php
│ ├── 02-discovery-http-userprofile
│ │ ├── McpElements.php
│ │ ├── server.php
│ │ └── UserIdCompletionProvider.php
│ ├── 03-manual-registration-stdio
│ │ ├── server.php
│ │ └── SimpleHandlers.php
│ ├── 04-combined-registration-http
│ │ ├── DiscoveredElements.php
│ │ ├── ManualHandlers.php
│ │ └── server.php
│ ├── 05-stdio-env-variables
│ │ ├── EnvToolHandler.php
│ │ └── server.php
│ ├── 06-custom-dependencies-stdio
│ │ ├── McpTaskHandlers.php
│ │ ├── server.php
│ │ └── Services.php
│ ├── 07-complex-tool-schema-http
│ │ ├── EventTypes.php
│ │ ├── McpEventScheduler.php
│ │ └── server.php
│ └── 08-schema-showcase-streamable
│ ├── SchemaShowcaseElements.php
│ └── server.php
├── LICENSE
├── phpunit.xml
├── README.md
├── src
│ ├── Attributes
│ │ ├── CompletionProvider.php
│ │ ├── McpPrompt.php
│ │ ├── McpResource.php
│ │ ├── McpResourceTemplate.php
│ │ ├── McpTool.php
│ │ └── Schema.php
│ ├── Configuration.php
│ ├── Context.php
│ ├── Contracts
│ │ ├── CompletionProviderInterface.php
│ │ ├── EventStoreInterface.php
│ │ ├── LoggerAwareInterface.php
│ │ ├── LoopAwareInterface.php
│ │ ├── ServerTransportInterface.php
│ │ ├── SessionHandlerInterface.php
│ │ └── SessionInterface.php
│ ├── Defaults
│ │ ├── ArrayCache.php
│ │ ├── BasicContainer.php
│ │ ├── DefaultUuidSessionIdGenerator.php
│ │ ├── EnumCompletionProvider.php
│ │ ├── FileCache.php
│ │ ├── InMemoryEventStore.php
│ │ ├── ListCompletionProvider.php
│ │ └── SystemClock.php
│ ├── Dispatcher.php
│ ├── Elements
│ │ ├── RegisteredElement.php
│ │ ├── RegisteredPrompt.php
│ │ ├── RegisteredResource.php
│ │ ├── RegisteredResourceTemplate.php
│ │ └── RegisteredTool.php
│ ├── Exception
│ │ ├── ConfigurationException.php
│ │ ├── DiscoveryException.php
│ │ ├── McpServerException.php
│ │ ├── ProtocolException.php
│ │ └── TransportException.php
│ ├── Protocol.php
│ ├── Registry.php
│ ├── Server.php
│ ├── ServerBuilder.php
│ ├── Session
│ │ ├── ArraySessionHandler.php
│ │ ├── CacheSessionHandler.php
│ │ ├── Session.php
│ │ ├── SessionManager.php
│ │ └── SubscriptionManager.php
│ ├── Transports
│ │ ├── HttpServerTransport.php
│ │ ├── StdioServerTransport.php
│ │ └── StreamableHttpServerTransport.php
│ └── Utils
│ ├── Discoverer.php
│ ├── DocBlockParser.php
│ ├── HandlerResolver.php
│ ├── SchemaGenerator.php
│ └── SchemaValidator.php
└── tests
├── Fixtures
│ ├── Discovery
│ │ ├── DiscoverablePromptHandler.php
│ │ ├── DiscoverableResourceHandler.php
│ │ ├── DiscoverableTemplateHandler.php
│ │ ├── DiscoverableToolHandler.php
│ │ ├── EnhancedCompletionHandler.php
│ │ ├── InvocablePromptFixture.php
│ │ ├── InvocableResourceFixture.php
│ │ ├── InvocableResourceTemplateFixture.php
│ │ ├── InvocableToolFixture.php
│ │ ├── NonDiscoverableClass.php
│ │ └── SubDir
│ │ └── HiddenTool.php
│ ├── Enums
│ │ ├── BackedIntEnum.php
│ │ ├── BackedStringEnum.php
│ │ ├── PriorityEnum.php
│ │ ├── StatusEnum.php
│ │ └── UnitEnum.php
│ ├── General
│ │ ├── CompletionProviderFixture.php
│ │ ├── DocBlockTestFixture.php
│ │ ├── InvokableHandlerFixture.php
│ │ ├── PromptHandlerFixture.php
│ │ ├── RequestAttributeChecker.php
│ │ ├── ResourceHandlerFixture.php
│ │ ├── ToolHandlerFixture.php
│ │ └── VariousTypesHandler.php
│ ├── Middlewares
│ │ ├── ErrorMiddleware.php
│ │ ├── FirstMiddleware.php
│ │ ├── HeaderMiddleware.php
│ │ ├── RequestAttributeMiddleware.php
│ │ ├── SecondMiddleware.php
│ │ ├── ShortCircuitMiddleware.php
│ │ └── ThirdMiddleware.php
│ ├── Schema
│ │ └── SchemaGenerationTarget.php
│ ├── ServerScripts
│ │ ├── HttpTestServer.php
│ │ ├── StdioTestServer.php
│ │ └── StreamableHttpTestServer.php
│ └── Utils
│ ├── AttributeFixtures.php
│ ├── DockBlockParserFixture.php
│ └── SchemaGeneratorFixture.php
├── Integration
│ ├── DiscoveryTest.php
│ ├── HttpServerTransportTest.php
│ ├── SchemaGenerationTest.php
│ ├── StdioServerTransportTest.php
│ └── StreamableHttpServerTransportTest.php
├── Mocks
│ ├── Clients
│ │ ├── MockJsonHttpClient.php
│ │ ├── MockSseClient.php
│ │ └── MockStreamHttpClient.php
│ └── Clock
│ └── FixedClock.php
├── Pest.php
├── TestCase.php
└── Unit
├── Attributes
│ ├── CompletionProviderTest.php
│ ├── McpPromptTest.php
│ ├── McpResourceTemplateTest.php
│ ├── McpResourceTest.php
│ └── McpToolTest.php
├── ConfigurationTest.php
├── Defaults
│ ├── EnumCompletionProviderTest.php
│ └── ListCompletionProviderTest.php
├── DispatcherTest.php
├── Elements
│ ├── RegisteredElementTest.php
│ ├── RegisteredPromptTest.php
│ ├── RegisteredResourceTemplateTest.php
│ ├── RegisteredResourceTest.php
│ └── RegisteredToolTest.php
├── ProtocolTest.php
├── RegistryTest.php
├── ServerBuilderTest.php
├── ServerTest.php
├── Session
│ ├── ArraySessionHandlerTest.php
│ ├── CacheSessionHandlerTest.php
│ ├── SessionManagerTest.php
│ └── SessionTest.php
└── Utils
├── DocBlockParserTest.php
├── HandlerResolverTest.php
└── SchemaValidatorTest.php
```
# Files
--------------------------------------------------------------------------------
/tests/Fixtures/Schema/SchemaGenerationTarget.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Fixtures\Schema;
use PhpMcp\Server\Attributes\Schema;
use PhpMcp\Server\Attributes\Schema\Format;
use PhpMcp\Server\Attributes\Schema\ArrayItems;
use PhpMcp\Server\Attributes\Schema\Property;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum;
use stdClass;
class SchemaGenerationTarget
{
public function noParamsMethod(): void
{
}
/**
* Method with simple required types.
* @param string $pString String param
* @param int $pInt Int param
* @param bool $pBool Bool param
* @param float $pFloat Float param
* @param array $pArray Array param
* @param stdClass $pObject Object param
*/
public function simpleRequiredTypes(string $pString, int $pInt, bool $pBool, float $pFloat, array $pArray, stdClass $pObject): void
{
}
/**
* Method with simple optional types with default values.
* @param string $pStringOpt String param with default
* @param int $pIntOpt Int param with default
* @param bool $pBoolOpt Bool param with default
* @param ?float $pFloatOptNullable Float param with default, also nullable
* @param array $pArrayOpt Array param with default
* @param ?stdClass $pObjectOptNullable Object param with default null
*/
public function optionalTypesWithDefaults(
string $pStringOpt = "hello",
int $pIntOpt = 123,
bool $pBoolOpt = true,
?float $pFloatOptNullable = 1.23,
array $pArrayOpt = ['a', 'b'],
?stdClass $pObjectOptNullable = null
): void {
}
/**
* Nullable types without explicit defaults.
* @param ?string $pNullableString Nullable string
* @param int|null $pUnionNullableInt Union nullable int
*/
public function nullableTypes(?string $pNullableString, ?int $pUnionNullableInt, ?BackedStringEnum $pNullableEnum): void
{
}
/**
* Union types.
* @param string|int $pStringOrInt String or Int
* @param bool|float|null $pBoolOrFloatOrNull Bool, Float or Null
*/
public function unionTypes(string|int $pStringOrInt, $pBoolOrFloatOrNull): void
{
} // PHP 7.x style union in docblock usually
/**
* Various array type hints.
* @param string[] $pStringArray Array of strings (docblock style)
* @param array<int> $pIntArrayGeneric Array of integers (generic style)
* @param array<string, mixed> $pAssocArray Associative array
* @param BackedIntEnum[] $pEnumArray Array of enums
* @param array{name: string, age: int} $pShapeArray Typed array shape
* @param array<array{id:int, value:string}> $pArrayOfShapes Array of shapes
*/
public function arrayTypes(
array $pStringArray,
array $pIntArrayGeneric,
array $pAssocArray,
array $pEnumArray,
array $pShapeArray,
array $pArrayOfShapes
): void {
}
/**
* Enum types.
* @param BackedStringEnum $pBackedStringEnum Backed string enum
* @param BackedIntEnum $pBackedIntEnum Backed int enum
* @param UnitEnum $pUnitEnum Unit enum
*/
public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void
{
}
/**
* Variadic parameters.
* @param string ...$pVariadicStrings Variadic strings
*/
public function variadicParams(string ...$pVariadicStrings): void
{
}
/**
* Mixed type.
* @param mixed $pMixed Mixed type
*/
public function mixedType(mixed $pMixed): void
{
}
/**
* With #[Schema] attributes for enhanced validation.
* @param string $email With email format.
* @param int $quantity With numeric constraints.
* @param string[] $tags With array constraints.
* @param array $userProfile With object property constraints.
*/
public function withSchemaAttributes(
#[Schema(format: Format::EMAIL)]
string $email,
#[Schema(minimum: 1, maximum: 100, multipleOf: 5)]
int $quantity,
#[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))]
array $tags,
#[Schema(
properties: [
new Property(name: 'id', minimum: 1),
new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'),
],
required: ['id', 'username'],
additionalProperties: false
)]
array $userProfile
): void {
}
}
```
--------------------------------------------------------------------------------
/examples/02-discovery-http-userprofile/McpElements.php:
--------------------------------------------------------------------------------
```php
<?php
namespace Mcp\HttpUserProfileExample;
use PhpMcp\Server\Attributes\CompletionProvider;
use PhpMcp\Server\Attributes\McpPrompt;
use PhpMcp\Server\Attributes\McpResource;
use PhpMcp\Server\Attributes\McpResourceTemplate;
use PhpMcp\Server\Attributes\McpTool;
use PhpMcp\Server\Exception\McpServerException;
use Psr\Log\LoggerInterface;
class McpElements
{
// Simulate a simple user database
private array $users = [
'101' => ['name' => 'Alice', 'email' => '[email protected]', 'role' => 'admin'],
'102' => ['name' => 'Bob', 'email' => '[email protected]', 'role' => 'user'],
'103' => ['name' => 'Charlie', 'email' => '[email protected]', 'role' => 'user'],
];
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
$this->logger->debug('HttpUserProfileExample McpElements instantiated.');
}
/**
* Retrieves the profile data for a specific user.
*
* @param string $userId The ID of the user (from URI).
* @return array User profile data.
*
* @throws McpServerException If the user is not found.
*/
#[McpResourceTemplate(
uriTemplate: 'user://{userId}/profile',
name: 'user_profile',
description: 'Get profile information for a specific user ID.',
mimeType: 'application/json'
)]
public function getUserProfile(
#[CompletionProvider(values: ['101', '102', '103'])]
string $userId
): array {
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
if (! isset($this->users[$userId])) {
// Throwing an exception that Processor can turn into an error response
throw McpServerException::invalidParams("User profile not found for ID: {$userId}");
}
return $this->users[$userId];
}
/**
* Retrieves a list of all known user IDs.
*
* @return array List of user IDs.
*/
#[McpResource(
uri: 'user://list/ids',
name: 'user_id_list',
description: 'Provides a list of all available user IDs.',
mimeType: 'application/json'
)]
public function listUserIds(): array
{
$this->logger->info('Reading resource: user ID list');
return array_keys($this->users);
}
/**
* Sends a welcome message to a user.
* (This is a placeholder - in a real app, it might queue an email)
*
* @param string $userId The ID of the user to message.
* @param string|null $customMessage An optional custom message part.
* @return array Status of the operation.
*/
#[McpTool(name: 'send_welcome')]
public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array
{
$this->logger->info('Executing tool: send_welcome', ['userId' => $userId]);
if (! isset($this->users[$userId])) {
return ['success' => false, 'error' => "User ID {$userId} not found."];
}
$user = $this->users[$userId];
$message = "Welcome, {$user['name']}!";
if ($customMessage) {
$message .= ' ' . $customMessage;
}
// Simulate sending
$this->logger->info("Simulated sending message to {$user['email']}: {$message}");
return ['success' => true, 'message_sent' => $message];
}
#[McpTool(name: 'test_tool_without_params')]
public function testToolWithoutParams()
{
return ['success' => true, 'message' => 'Test tool without params'];
}
/**
* Generates a prompt to write a bio for a user.
*
* @param string $userId The user ID to generate the bio for.
* @param string $tone Desired tone (e.g., 'formal', 'casual').
* @return array Prompt messages.
*
* @throws McpServerException If user not found.
*/
#[McpPrompt(name: 'generate_bio_prompt')]
public function generateBio(
#[CompletionProvider(provider: UserIdCompletionProvider::class)]
string $userId,
string $tone = 'professional'
): array {
$this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
if (! isset($this->users[$userId])) {
throw McpServerException::invalidParams("User not found for bio prompt: {$userId}");
}
$user = $this->users[$userId];
return [
['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."],
];
}
}
```
--------------------------------------------------------------------------------
/examples/01-discovery-stdio-calculator/McpElements.php:
--------------------------------------------------------------------------------
```php
<?php
namespace Mcp\StdioCalculatorExample;
use PhpMcp\Server\Attributes\McpResource;
use PhpMcp\Server\Attributes\McpTool;
class McpElements
{
private array $config = [
'precision' => 2,
'allow_negative' => true,
];
/**
* Performs a calculation based on the operation.
*
* Supports 'add', 'subtract', 'multiply', 'divide'.
* Obeys the 'precision' and 'allow_negative' settings from the config resource.
*
* @param float $a The first operand.
* @param float $b The second operand.
* @param string $operation The operation ('add', 'subtract', 'multiply', 'divide').
* @return float|string The result of the calculation, or an error message string.
*/
#[McpTool(name: 'calculate')]
public function calculate(float $a, float $b, string $operation): float|string
{
// Use STDERR for logs
fwrite(STDERR, "Calculate tool called: a=$a, b=$b, op=$operation\n");
$op = strtolower($operation);
$result = null;
switch ($op) {
case 'add':
$result = $a + $b;
break;
case 'subtract':
$result = $a - $b;
break;
case 'multiply':
$result = $a * $b;
break;
case 'divide':
if ($b == 0) {
return 'Error: Division by zero.';
}
$result = $a / $b;
break;
default:
return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.";
}
if (! $this->config['allow_negative'] && $result < 0) {
return 'Error: Negative results are disabled.';
}
return round($result, $this->config['precision']);
}
/**
* Provides the current calculator configuration.
* Can be read by clients to understand precision etc.
*
* @return array The configuration array.
*/
#[McpResource(
uri: 'config://calculator/settings',
name: 'calculator_config',
description: 'Current settings for the calculator tool (precision, allow_negative).',
mimeType: 'application/json' // Return as JSON
)]
public function getConfiguration(): array
{
fwrite(STDERR, "Resource config://calculator/settings read.\n");
return $this->config;
}
/**
* Updates a specific configuration setting.
* Note: This requires more robust validation in a real app.
*
* @param string $setting The setting key ('precision' or 'allow_negative').
* @param mixed $value The new value (int for precision, bool for allow_negative).
* @return array Success message or error.
*/
#[McpTool(name: 'update_setting')]
public function updateSetting(string $setting, mixed $value): array
{
fwrite(STDERR, "Update Setting tool called: setting=$setting, value=".var_export($value, true)."\n");
if (! array_key_exists($setting, $this->config)) {
return ['success' => false, 'error' => "Unknown setting '{$setting}'."];
}
if ($setting === 'precision') {
if (! is_int($value) || $value < 0 || $value > 10) {
return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.'];
}
$this->config['precision'] = $value;
// In real app, notify subscribers of config://calculator/settings change
// $registry->notifyResourceChanged('config://calculator/settings');
return ['success' => true, 'message' => "Precision updated to {$value}."];
}
if ($setting === 'allow_negative') {
if (! is_bool($value)) {
// Attempt basic cast for flexibility
if (in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) {
$value = true;
} elseif (in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) {
$value = false;
} else {
return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).'];
}
}
$this->config['allow_negative'] = $value;
// $registry->notifyResourceChanged('config://calculator/settings');
return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.'];
}
return ['success' => false, 'error' => 'Internal error handling setting.']; // Should not happen
}
}
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/ResourceHandlerFixture.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Fixtures\General;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Content\BlobResourceContents;
use Psr\Log\LoggerInterface;
use SplFileInfo;
class ResourceHandlerFixture
{
public static string $staticTextContent = "Default static text content.";
public array $dynamicContentStore = [];
public static ?string $unlinkableSplFile = null;
public function __construct()
{
$this->dynamicContentStore['dynamic://data/item1'] = "Content for item 1";
}
public function returnStringText(string $uri): string
{
return "Plain string content for {$uri}";
}
public function returnStringJson(string $uri): string
{
return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']);
}
public function returnStringHtml(string $uri): string
{
return "<html><title>{$uri}</title><body>Content</body></html>";
}
public function returnArrayJson(string $uri): array
{
return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()];
}
public function returnEmptyArray(string $uri): array
{
return [];
}
public function returnStream(string $uri) // Returns a stream resource
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, "Streamed content for {$uri}");
rewind($stream);
return $stream;
}
public function returnSplFileInfo(string $uri): SplFileInfo
{
self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_');
file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}");
return new SplFileInfo(self::$unlinkableSplFile);
}
public function returnEmbeddedResource(string $uri): EmbeddedResource
{
return EmbeddedResource::make(
TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content')
);
}
public function returnTextResourceContents(string $uri): TextResourceContents
{
return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents');
}
public function returnBlobResourceContents(string $uri): BlobResourceContents
{
return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents'));
}
public function returnArrayForBlobSchema(string $uri): array
{
return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array'];
}
public function returnArrayForTextSchema(string $uri): array
{
return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text'];
}
public function returnArrayOfResourceContents(string $uri): array
{
return [
TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'),
BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')),
];
}
public function returnArrayOfEmbeddedResources(string $uri): array
{
return [
EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '<doc1/>')),
EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))),
];
}
public function returnMixedArrayWithResourceTypes(string $uri): array
{
return [
"A raw string piece", // Will be formatted
TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is
['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON)
EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted
];
}
public function handlerThrowsException(string $uri): void
{
throw new \DomainException("Cannot read resource {$uri} - handler error.");
}
public function returnUnformattableType(string $uri)
{
return new \DateTimeImmutable();
}
public function resourceHandlerNeedsUri(string $uri): string
{
return "Handler received URI: " . $uri;
}
public function resourceHandlerDoesNotNeedUri(): string
{
return "Handler did not need or receive URI parameter.";
}
public function getTemplatedContent(
string $category,
string $itemId,
string $format,
): array {
return [
'message' => "Content for item {$itemId} in category {$category}, format {$format}.",
'category_received' => $category,
'itemId_received' => $itemId,
'format_received' => $format,
];
}
public function getStaticText(): string
{
return self::$staticTextContent;
}
}
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/VariousTypesHandler.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Fixtures\General;
use PhpMcp\Server\Context;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum;
use stdClass;
class VariousTypesHandler
{
public function noArgsMethod(): array
{
return compact([]);
}
public function simpleRequiredArgs(string $pString, int $pInt, bool $pBool): array
{
return compact('pString', 'pInt', 'pBool');
}
public function optionalArgsWithDefaults(
string $pString = 'default_string',
int $pInt = 100,
?bool $pNullableBool = true,
float $pFloat = 3.14
): array {
return compact('pString', 'pInt', 'pNullableBool', 'pFloat');
}
public function nullableArgsWithoutDefaults(?string $pString, ?int $pInt, ?array $pArray): array
{
return compact('pString', 'pInt', 'pArray');
}
public function mixedTypeArg(mixed $pMixed): array
{
return compact('pMixed');
}
public function backedEnumArgs(
BackedStringEnum $pBackedString,
BackedIntEnum $pBackedInt,
?BackedStringEnum $pNullableBackedString = null,
BackedIntEnum $pOptionalBackedInt = BackedIntEnum::First
): array {
return compact('pBackedString', 'pBackedInt', 'pNullableBackedString', 'pOptionalBackedInt');
}
public function unitEnumArg(UnitEnum $pUnitEnum): array
{
return compact('pUnitEnum');
}
public function arrayArg(array $pArray): array
{
return compact('pArray');
}
public function objectArg(stdClass $pObject): array
{
return compact('pObject');
}
public function variadicArgs(string ...$items): array
{
return compact('items');
}
/**
* A comprehensive method for testing various argument types and casting.
* @param string $strParam A string.
* @param int $intParam An integer.
* @param bool $boolProp A boolean.
* @param float $floatParam A float.
* @param array $arrayParam An array.
* @param BackedStringEnum $backedStringEnumParam A backed string enum.
* @param BackedIntEnum $backedIntEnumParam A backed int enum.
* @param UnitEnum $unitEnumParam A unit enum (passed as instance).
* @param string|null $nullableStringParam A nullable string.
* @param int $optionalIntWithDefaultParam An optional int with default.
* @param mixed $mixedParam A mixed type.
* @param stdClass $objectParam An object.
* @param string $stringForIntCast String that should be cast to int.
* @param string $stringForFloatCast String that should be cast to float.
* @param string $stringForBoolTrueCast String that should be cast to bool true.
* @param string $stringForBoolFalseCast String that should be cast to bool false.
* @param int $intForStringCast Int that should be cast to string.
* @param int $intForFloatCast Int that should be cast to float.
* @param bool $boolForStringCast Bool that should be cast to string.
* @param string $valueForBackedStringEnum String value for backed string enum.
* @param int $valueForBackedIntEnum Int value for backed int enum.
*/
public function comprehensiveArgumentTest(
string $strParam,
int $intParam,
bool $boolProp,
float $floatParam,
array $arrayParam,
BackedStringEnum $backedStringEnumParam,
BackedIntEnum $backedIntEnumParam,
UnitEnum $unitEnumParam,
?string $nullableStringParam,
mixed $mixedParam,
stdClass $objectParam,
string $stringForIntCast,
string $stringForFloatCast,
string $stringForBoolTrueCast,
string $stringForBoolFalseCast,
int $intForStringCast,
int $intForFloatCast,
bool $boolForStringCast,
string $valueForBackedStringEnum,
int $valueForBackedIntEnum,
int $optionalIntWithDefaultParam = 999,
): array {
return compact(
'strParam',
'intParam',
'boolProp',
'floatParam',
'arrayParam',
'backedStringEnumParam',
'backedIntEnumParam',
'unitEnumParam',
'nullableStringParam',
'optionalIntWithDefaultParam',
'mixedParam',
'objectParam',
'stringForIntCast',
'stringForFloatCast',
'stringForBoolTrueCast',
'stringForBoolFalseCast',
'intForStringCast',
'intForFloatCast',
'boolForStringCast',
'valueForBackedStringEnum',
'valueForBackedIntEnum'
);
}
public function methodCausesTypeError(int $mustBeInt): void
{
}
public function contextArg(Context $context): array {
return [
'session' => $context->session->get('testKey'),
'request' => $context->request->getHeaderLine('testHeader'),
];
}
}
```
--------------------------------------------------------------------------------
/examples/02-discovery-http-userprofile/server.php:
--------------------------------------------------------------------------------
```php
#!/usr/bin/env php
<?php
/*
|--------------------------------------------------------------------------
| MCP HTTP User Profile Server (Attribute Discovery)
|--------------------------------------------------------------------------
|
| This server demonstrates attribute-based discovery for MCP elements
| (ResourceTemplates, Resources, Tools, Prompts) defined in 'McpElements.php'.
| It runs via the HTTP transport, listening for SSE and POST requests.
|
| To Use:
| 1. Ensure 'McpElements.php' defines classes with MCP attributes.
| 2. Run this script from your CLI: `php server.php`
| The server will listen on http://127.0.0.1:8080 by default.
| 3. Configure your MCP Client (e.g., Cursor) for this server:
|
| {
| "mcpServers": {
| "php-http-userprofile": {
| "url": "http://127.0.0.1:8080/mcp/sse" // Use the SSE endpoint
| // Ensure your client can reach this address
| }
| }
| }
|
| The ServerBuilder builds the server, $server->discover() scans for elements,
| and then $server->listen() starts the ReactPHP HTTP server.
|
| If you provided a `CacheInterface` implementation to the ServerBuilder,
| the discovery process will be cached, so you can comment out the
| discovery call after the first run to speed up subsequent runs.
|
*/
declare(strict_types=1);
chdir(__DIR__);
require_once '../../vendor/autoload.php';
require_once 'McpElements.php';
require_once 'UserIdCompletionProvider.php';
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Server\Defaults\BasicContainer;
use PhpMcp\Server\Server;
use PhpMcp\Server\Transports\HttpServerTransport;
use PhpMcp\Server\Transports\StreamableHttpServerTransport;
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;
class StderrLogger extends AbstractLogger
{
public function log($level, \Stringable|string $message, array $context = []): void
{
fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context)));
}
}
try {
$logger = new StderrLogger();
$logger->info('Starting MCP HTTP User Profile Server...');
// --- Setup DI Container for DI in McpElements class ---
$container = new BasicContainer();
$container->set(LoggerInterface::class, $logger);
$server = Server::make()
->withServerInfo('HTTP User Profiles', '1.0.0')
->withCapabilities(ServerCapabilities::make(completions: true, logging: true))
->withLogger($logger)
->withContainer($container)
->withTool(
function (float $a, float $b, string $operation = 'add'): array {
$result = match ($operation) {
'add' => $a + $b,
'subtract' => $a - $b,
'multiply' => $a * $b,
'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'),
default => throw new \InvalidArgumentException("Unknown operation: {$operation}")
};
return [
'operation' => $operation,
'operands' => [$a, $b],
'result' => $result
];
},
name: 'calculator',
description: 'Perform basic math operations (add, subtract, multiply, divide)'
)
->withResource(
function (): array {
$memoryUsage = memory_get_usage(true);
$memoryPeak = memory_get_peak_usage(true);
$uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time();
$serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI';
return [
'server_time' => date('Y-m-d H:i:s'),
'uptime_seconds' => $uptime,
'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2),
'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2),
'php_version' => PHP_VERSION,
'server_software' => $serverSoftware,
'operating_system' => PHP_OS_FAMILY,
'status' => 'healthy'
];
},
uri: 'system://status',
name: 'system_status',
description: 'Current system status and runtime information',
mimeType: 'application/json'
)
->build();
$server->discover(__DIR__, ['.']);
// $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp');
$transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp');
$server->listen($transport);
$logger->info('Server listener stopped gracefully.');
exit(0);
} catch (\Throwable $e) {
fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n");
fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n");
fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n");
fwrite(STDERR, $e->getTraceAsString() . "\n");
exit(1);
}
```
--------------------------------------------------------------------------------
/src/Session/Session.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Session;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;
class Session implements SessionInterface
{
/**
* @var array<string, mixed> Stores all session data.
* Keys are snake_case by convention for MCP-specific data.
*
* Official keys are:
* - initialized: bool
* - client_info: array|null
* - protocol_version: string|null
* - subscriptions: array<string, bool>
* - message_queue: array<string>
* - log_level: string|null
*/
protected array $data = [];
public function __construct(
protected SessionHandlerInterface $handler,
protected string $id = '',
?array $data = null
) {
if (empty($this->id)) {
$this->id = $this->generateId();
}
if ($data !== null) {
$this->hydrate($data);
} elseif ($sessionData = $this->handler->read($this->id)) {
$this->data = json_decode($sessionData, true) ?? [];
}
}
/**
* Retrieve an existing session instance from handler or return null if session doesn't exist
*/
public static function retrieve(string $id, SessionHandlerInterface $handler): ?SessionInterface
{
$sessionData = $handler->read($id);
if (!$sessionData) {
return null;
}
$data = json_decode($sessionData, true);
if ($data === null) {
return null;
}
return new static($handler, $id, $data);
}
public function getId(): string
{
return $this->id;
}
public function getHandler(): SessionHandlerInterface
{
return $this->handler;
}
public function generateId(): string
{
return bin2hex(random_bytes(16));
}
public function save(): void
{
$this->handler->write($this->id, json_encode($this->data));
}
public function get(string $key, mixed $default = null): mixed
{
$key = explode('.', $key);
$data = $this->data;
foreach ($key as $segment) {
if (is_array($data) && array_key_exists($segment, $data)) {
$data = $data[$segment];
} else {
return $default;
}
}
return $data;
}
public function set(string $key, mixed $value, bool $overwrite = true): void
{
$segments = explode('.', $key);
$data = &$this->data;
while (count($segments) > 1) {
$segment = array_shift($segments);
if (!isset($data[$segment]) || !is_array($data[$segment])) {
$data[$segment] = [];
}
$data = &$data[$segment];
}
$lastKey = array_shift($segments);
if ($overwrite || !isset($data[$lastKey])) {
$data[$lastKey] = $value;
}
}
public function has(string $key): bool
{
$key = explode('.', $key);
$data = $this->data;
foreach ($key as $segment) {
if (is_array($data) && array_key_exists($segment, $data)) {
$data = $data[$segment];
} elseif (is_object($data) && isset($data->{$segment})) {
$data = $data->{$segment};
} else {
return false;
}
}
return true;
}
public function forget(string $key): void
{
$segments = explode('.', $key);
$data = &$this->data;
while (count($segments) > 1) {
$segment = array_shift($segments);
if (!isset($data[$segment]) || !is_array($data[$segment])) {
$data[$segment] = [];
}
$data = &$data[$segment];
}
$lastKey = array_shift($segments);
if (isset($data[$lastKey])) {
unset($data[$lastKey]);
}
}
public function clear(): void
{
$this->data = [];
}
public function pull(string $key, mixed $default = null): mixed
{
$value = $this->get($key, $default);
$this->forget($key);
return $value;
}
public function all(): array
{
return $this->data;
}
public function hydrate(array $attributes): void
{
$this->data = array_merge(
[
'initialized' => false,
'client_info' => null,
'protocol_version' => null,
'message_queue' => [],
'log_level' => null,
],
$attributes
);
unset($this->data['id']);
}
public function queueMessage(string $rawFramedMessage): void
{
$this->data['message_queue'][] = $rawFramedMessage;
}
public function dequeueMessages(): array
{
$messages = $this->data['message_queue'] ?? [];
$this->data['message_queue'] = [];
return $messages;
}
public function hasQueuedMessages(): bool
{
return !empty($this->data['message_queue']);
}
public function jsonSerialize(): array
{
return $this->all();
}
}
```
--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockJsonHttpClient.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Tests\Mocks\Clients;
use Psr\Http\Message\ResponseInterface;
use React\Http\Browser;
use React\Promise\PromiseInterface;
class MockJsonHttpClient
{
public Browser $browser;
public string $baseUrl;
public ?string $sessionId = null;
public array $lastResponseHeaders = []; // Store last response headers for testing
public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2)
{
$this->browser = (new Browser())->withTimeout($timeout);
$this->baseUrl = "http://{$host}:{$port}/{$mcpPath}";
}
public function sendRequest(string $method, array $params = [], ?string $id = null, array $additionalHeaders = []): PromiseInterface
{
$payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params];
if ($id !== null) {
$payload['id'] = $id;
}
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream'];
if ($this->sessionId && $method !== 'initialize') {
$headers['Mcp-Session-Id'] = $this->sessionId;
}
$headers += $additionalHeaders;
$body = json_encode($payload);
return $this->browser->post($this->baseUrl, $headers, $body)
->then(function (ResponseInterface $response) use ($method) {
// Store response headers for testing
$this->lastResponseHeaders = [];
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
$this->lastResponseHeaders[] = "{$name}: {$value}";
}
}
$bodyContent = (string) $response->getBody()->getContents();
$statusCode = $response->getStatusCode();
if ($method === 'initialize' && $statusCode === 200) {
$this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
}
if ($statusCode === 202) {
if ($bodyContent !== '') {
throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}");
}
return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()];
}
try {
$decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR);
return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()];
} catch (\JsonException $e) {
throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e);
}
});
}
public function sendBatchRequest(array $batchRequestObjects): PromiseInterface
{
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json'];
if ($this->sessionId) {
$headers['Mcp-Session-Id'] = $this->sessionId;
}
$body = json_encode($batchRequestObjects);
return $this->browser->post($this->baseUrl, $headers, $body)
->then(function (ResponseInterface $response) {
$bodyContent = (string) $response->getBody()->getContents();
$statusCode = $response->getStatusCode();
if ($statusCode === 202) {
if ($bodyContent !== '') {
throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}");
}
return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()];
}
try {
$decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR);
return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()];
} catch (\JsonException $e) {
throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e);
}
});
}
public function sendDeleteRequest(): PromiseInterface
{
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json'];
if ($this->sessionId) {
$headers['Mcp-Session-Id'] = $this->sessionId;
}
return $this->browser->delete($this->baseUrl, $headers)
->then(function (ResponseInterface $response) {
$bodyContent = (string) $response->getBody()->getContents();
$statusCode = $response->getStatusCode();
return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()];
});
}
public function sendNotification(string $method, array $params = []): PromiseInterface
{
return $this->sendRequest($method, $params, null);
}
public function connectSseForNotifications(): PromiseInterface
{
return resolve(null);
}
}
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/PromptHandlerFixture.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Fixtures\General;
use PhpMcp\Schema\Content\PromptMessage;
use PhpMcp\Schema\Enum\Role;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Content\ImageContent;
use PhpMcp\Schema\Content\AudioContent;
use PhpMcp\Server\Attributes\CompletionProvider;
use Psr\Log\LoggerInterface;
class PromptHandlerFixture
{
public function generateSimpleGreeting(string $name, string $style = "friendly"): array
{
return [
['role' => 'user', 'content' => "Craft a {$style} greeting for {$name}."]
];
}
public function returnSinglePromptMessageObject(): PromptMessage
{
return PromptMessage::make(Role::User, TextContent::make("Single PromptMessage object."));
}
public function returnArrayOfPromptMessageObjects(): array
{
return [
PromptMessage::make(Role::User, TextContent::make("First message object.")),
PromptMessage::make(Role::Assistant, ImageContent::make("img_data", "image/png")),
];
}
public function returnEmptyArrayForPrompt(): array
{
return [];
}
public function returnSimpleUserAssistantMap(): array
{
return [
'user' => "This is the user's turn.",
'assistant' => "And this is the assistant's reply."
];
}
public function returnUserAssistantMapWithContentObjects(): array
{
return [
'user' => TextContent::make("User text content object."),
'assistant' => ImageContent::make("asst_img_data", "image/gif"),
];
}
public function returnUserAssistantMapWithMixedContent(): array
{
return [
'user' => "Plain user string.",
'assistant' => AudioContent::make("aud_data", "audio/mp3"),
];
}
public function returnUserAssistantMapWithArrayContent(): array
{
return [
'user' => ['type' => 'text', 'text' => 'User array content'],
'assistant' => ['type' => 'image', 'data' => 'asst_arr_img_data', 'mimeType' => 'image/jpeg'],
];
}
public function returnListOfRawMessageArrays(): array
{
return [
['role' => 'user', 'content' => "First raw message string."],
['role' => 'assistant', 'content' => TextContent::make("Second raw message with Content obj.")],
['role' => 'user', 'content' => ['type' => 'image', 'data' => 'raw_img_data', 'mimeType' => 'image/webp']],
['role' => 'assistant', 'content' => ['type' => 'audio', 'data' => 'raw_aud_data', 'mimeType' => 'audio/ogg']],
['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'file://doc.pdf', 'blob' => base64_encode('pdf-data'), 'mimeType' => 'application/pdf']]],
['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'config://settings.json', 'text' => '{"theme":"dark"}']]],
];
}
public function returnListOfRawMessageArraysWithScalars(): array
{
return [
['role' => 'user', 'content' => 123], // int
['role' => 'assistant', 'content' => true], // bool
['role' => 'user', 'content' => null], // null
['role' => 'assistant', 'content' => 3.14], // float
['role' => 'user', 'content' => ['key' => 'value']], // array that becomes JSON
];
}
public function returnMixedArrayOfPromptMessagesAndRaw(): array
{
return [
PromptMessage::make(Role::User, TextContent::make("This is a PromptMessage object.")),
['role' => 'assistant', 'content' => "This is a raw message array."],
PromptMessage::make(Role::User, ImageContent::make("pm_img", "image/bmp")),
['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Raw message with typed content.']],
];
}
public function promptWithArgumentCompletion(
#[CompletionProvider(provider: CompletionProviderFixture::class)]
string $entityName,
string $action = "describe"
): array {
return [
['role' => 'user', 'content' => "Please {$action} the entity: {$entityName}."]
];
}
public function promptReturnsNonArray(): string
{
return "This is not a valid prompt return type.";
}
public function promptReturnsArrayWithInvalidRole(): array
{
return [['role' => 'system', 'content' => 'System messages are not directly supported.']];
}
public function promptReturnsInvalidRole(): array
{
return [['role' => 'system', 'content' => 'System messages are not directly supported.']];
}
public function promptReturnsArrayWithInvalidContentStructure(): array
{
return [['role' => 'user', 'content' => ['text_only_no_type' => 'invalid']]];
}
public function promptReturnsArrayWithInvalidTypedContent(): array
{
return [['role' => 'user', 'content' => ['type' => 'image', 'source' => 'url.jpg']]]; // 'image' needs 'data' and 'mimeType'
}
public function promptReturnsArrayWithInvalidResourceContent(): array
{
return [
[
'role' => 'user',
'content' => ['type' => 'resource', 'resource' => ['uri' => 'uri://uri']]
]
];
}
public function promptHandlerThrows(): void
{
throw new \LogicException("Prompt generation failed inside handler.");
}
}
```
--------------------------------------------------------------------------------
/tests/Unit/Utils/HandlerResolverTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Utils;
use PhpMcp\Server\Utils\HandlerResolver;
use ReflectionMethod;
use ReflectionFunction;
use InvalidArgumentException;
class ValidHandlerClass
{
public function publicMethod() {}
protected function protectedMethod() {}
private function privateMethod() {}
public static function staticMethod() {}
public function __construct() {}
public function __destruct() {}
}
class ValidInvokableClass
{
public function __invoke() {}
}
class NonInvokableClass {}
abstract class AbstractHandlerClass
{
abstract public function abstractMethod();
}
// Test closure support
it('resolves closures to ReflectionFunction', function () {
$closure = function (string $input): string {
return "processed: $input";
};
$resolved = HandlerResolver::resolve($closure);
expect($resolved)->toBeInstanceOf(ReflectionFunction::class);
expect($resolved->getNumberOfParameters())->toBe(1);
expect($resolved->getReturnType()->getName())->toBe('string');
});
it('resolves valid array handler', function () {
$handler = [ValidHandlerClass::class, 'publicMethod'];
$resolved = HandlerResolver::resolve($handler);
expect($resolved)->toBeInstanceOf(ReflectionMethod::class);
expect($resolved->getName())->toBe('publicMethod');
expect($resolved->getDeclaringClass()->getName())->toBe(ValidHandlerClass::class);
});
it('resolves valid invokable class string handler', function () {
$handler = ValidInvokableClass::class;
$resolved = HandlerResolver::resolve($handler);
expect($resolved)->toBeInstanceOf(ReflectionMethod::class);
expect($resolved->getName())->toBe('__invoke');
expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class);
});
it('resolves static methods for manual registration', function () {
$handler = [ValidHandlerClass::class, 'staticMethod'];
$resolved = HandlerResolver::resolve($handler);
expect($resolved)->toBeInstanceOf(ReflectionMethod::class);
expect($resolved->getName())->toBe('staticMethod');
expect($resolved->isStatic())->toBeTrue();
});
it('throws for invalid array handler format (count)', function () {
HandlerResolver::resolve([ValidHandlerClass::class]);
})->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
it('throws for invalid array handler format (types)', function () {
HandlerResolver::resolve([ValidHandlerClass::class, 123]);
})->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
it('throws for non-existent class in array handler', function () {
HandlerResolver::resolve(['NonExistentClass', 'method']);
})->throws(InvalidArgumentException::class, "Handler class 'NonExistentClass' not found");
it('throws for non-existent method in array handler', function () {
HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']);
})->throws(InvalidArgumentException::class, "Handler method 'nonExistentMethod' not found in class");
it('throws for non-existent class in string handler', function () {
HandlerResolver::resolve('NonExistentInvokableClass');
})->throws(InvalidArgumentException::class, 'Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
it('throws for non-invokable class string handler', function () {
HandlerResolver::resolve(NonInvokableClass::class);
})->throws(InvalidArgumentException::class, "Invokable handler class '" . NonInvokableClass::class . "' must have a public '__invoke' method.");
it('throws for protected method handler', function () {
HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']);
})->throws(InvalidArgumentException::class, 'must be public');
it('throws for private method handler', function () {
HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']);
})->throws(InvalidArgumentException::class, 'must be public');
it('throws for constructor as handler', function () {
HandlerResolver::resolve([ValidHandlerClass::class, '__construct']);
})->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor');
it('throws for destructor as handler', function () {
HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']);
})->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor');
it('throws for abstract method handler', function () {
HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']);
})->throws(InvalidArgumentException::class, 'cannot be abstract');
// Test different closure types
it('resolves closures with different signatures', function () {
$noParams = function () {
return 'test';
};
$withParams = function (int $a, string $b = 'default') {
return $a . $b;
};
$variadic = function (...$args) {
return $args;
};
expect(HandlerResolver::resolve($noParams))->toBeInstanceOf(ReflectionFunction::class);
expect(HandlerResolver::resolve($withParams))->toBeInstanceOf(ReflectionFunction::class);
expect(HandlerResolver::resolve($variadic))->toBeInstanceOf(ReflectionFunction::class);
expect(HandlerResolver::resolve($noParams)->getNumberOfParameters())->toBe(0);
expect(HandlerResolver::resolve($withParams)->getNumberOfParameters())->toBe(2);
expect(HandlerResolver::resolve($variadic)->isVariadic())->toBeTrue();
});
// Test that we can distinguish between closures and callable arrays
it('distinguishes between closures and callable arrays', function () {
$closure = function () {
return 'closure';
};
$array = [ValidHandlerClass::class, 'publicMethod'];
$string = ValidInvokableClass::class;
expect(HandlerResolver::resolve($closure))->toBeInstanceOf(ReflectionFunction::class);
expect(HandlerResolver::resolve($array))->toBeInstanceOf(ReflectionMethod::class);
expect(HandlerResolver::resolve($string))->toBeInstanceOf(ReflectionMethod::class);
});
```
--------------------------------------------------------------------------------
/tests/Unit/Utils/DocBlockParserTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Utils;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlock\Tags\Return_;
use phpDocumentor\Reflection\DocBlock\Tags\See;
use phpDocumentor\Reflection\DocBlock\Tags\Throws;
use PhpMcp\Server\Utils\DocBlockParser;
use PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture;
use ReflectionMethod;
beforeEach(function () {
$this->parser = new DocBlockParser();
});
test('getSummary returns correct summary', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
expect($this->parser->getSummary($docBlock))->toBe('Simple summary line.');
$method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
$docComment2 = $method2->getDocComment() ?: null;
$docBlock2 = $this->parser->parseDocBlock($docComment2);
expect($this->parser->getSummary($docBlock2))->toBe('Summary line here.');
});
test('getDescription returns correct description', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`.";
expect($this->parser->getDescription($docBlock))->toBe($expectedDesc);
$method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
$docComment2 = $method2->getDocComment() ?: null;
$docBlock2 = $this->parser->parseDocBlock($docComment2);
expect($this->parser->getDescription($docBlock2))->toBe('Simple summary line.');
});
test('getParamTags returns structured param info', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$params = $this->parser->getParamTags($docBlock);
expect($params)->toBeArray()->toHaveCount(6);
expect($params)->toHaveKeys(['$param1', '$param2', '$param3', '$param4', '$param5', '$param6']);
expect($params['$param1'])->toBeInstanceOf(Param::class);
expect($params['$param1']->getVariableName())->toBe('param1');
expect($this->parser->getParamTypeString($params['$param1']))->toBe('string');
expect($this->parser->getParamDescription($params['$param1']))->toBe('Description for string param.');
expect($params['$param2'])->toBeInstanceOf(Param::class);
expect($params['$param2']->getVariableName())->toBe('param2');
expect($this->parser->getParamTypeString($params['$param2']))->toBe('int|null');
expect($this->parser->getParamDescription($params['$param2']))->toBe('Description for nullable int param.');
expect($params['$param3'])->toBeInstanceOf(Param::class);
expect($params['$param3']->getVariableName())->toBe('param3');
expect($this->parser->getParamTypeString($params['$param3']))->toBe('bool');
expect($this->parser->getParamDescription($params['$param3']))->toBeNull();
expect($params['$param4'])->toBeInstanceOf(Param::class);
expect($params['$param4']->getVariableName())->toBe('param4');
expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed');
expect($this->parser->getParamDescription($params['$param4']))->toBe('Missing type.');
expect($params['$param5'])->toBeInstanceOf(Param::class);
expect($params['$param5']->getVariableName())->toBe('param5');
expect($this->parser->getParamTypeString($params['$param5']))->toBe('array<string,mixed>');
expect($this->parser->getParamDescription($params['$param5']))->toBe('Array description.');
expect($params['$param6'])->toBeInstanceOf(Param::class);
expect($params['$param6']->getVariableName())->toBe('param6');
expect($this->parser->getParamTypeString($params['$param6']))->toBe('stdClass');
expect($this->parser->getParamDescription($params['$param6']))->toBe('Object param.');
});
test('getReturnTag returns structured return info', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithReturn');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$returnTag = $this->parser->getReturnTag($docBlock);
expect($returnTag)->toBeInstanceOf(Return_::class);
expect($this->parser->getReturnTypeString($returnTag))->toBe('string');
expect($this->parser->getReturnDescription($returnTag))->toBe('The result of the operation.');
$method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
$docComment2 = $method2->getDocComment() ?: null;
$docBlock2 = $this->parser->parseDocBlock($docComment2);
expect($this->parser->getReturnTag($docBlock2))->toBeNull();
});
test('getTagsByName returns specific tags', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
expect($docBlock)->toBeInstanceOf(DocBlock::class);
$throwsTags = $docBlock->getTagsByName('throws');
expect($throwsTags)->toBeArray()->toHaveCount(1);
expect($throwsTags[0])->toBeInstanceOf(Throws::class);
expect((string) $throwsTags[0]->getType())->toBe('\\RuntimeException');
expect($throwsTags[0]->getDescription()->render())->toBe('If processing fails.');
$deprecatedTags = $docBlock->getTagsByName('deprecated');
expect($deprecatedTags)->toBeArray()->toHaveCount(1);
expect($deprecatedTags[0])->toBeInstanceOf(Deprecated::class);
expect($deprecatedTags[0]->getDescription()->render())->toBe('Use newMethod() instead.');
$seeTags = $docBlock->getTagsByName('see');
expect($seeTags)->toBeArray()->toHaveCount(1);
expect($seeTags[0])->toBeInstanceOf(See::class);
expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestFixture::newMethod()');
$nonExistentTags = $docBlock->getTagsByName('nosuchtag');
expect($nonExistentTags)->toBeArray()->toBeEmpty();
});
test('handles method with no docblock gracefully', function () {
$method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
expect($docBlock)->toBeNull();
expect($this->parser->getSummary($docBlock))->toBeNull();
expect($this->parser->getDescription($docBlock))->toBeNull();
expect($this->parser->getParamTags($docBlock))->toBeArray()->toBeEmpty();
expect($this->parser->getReturnTag($docBlock))->toBeNull();
});
```
--------------------------------------------------------------------------------
/src/Attributes/Schema.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Attributes;
use Attribute;
/**
* Defines a JSON Schema for a method's input or an individual parameter.
*
* When used at the method level, it describes an object schema where properties
* correspond to the method's parameters.
*
* When used at the parameter level, it describes the schema for that specific parameter.
* If 'type' is omitted at the parameter level, it will be inferred.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)]
class Schema
{
/**
* The complete JSON schema array.
* If provided, it takes precedence over individual properties like $type, $properties, etc.
*/
public ?array $definition = null;
/**
* Alternatively, provide individual top-level schema keywords.
* These are used if $definition is null.
*/
public ?string $type = null;
public ?string $description = null;
public mixed $default = null;
public ?array $enum = null; // list of allowed values
public ?string $format = null; // e.g., 'email', 'date-time'
// Constraints for string
public ?int $minLength = null;
public ?int $maxLength = null;
public ?string $pattern = null;
// Constraints for number/integer
public int|float|null $minimum = null;
public int|float|null $maximum = null;
public ?bool $exclusiveMinimum = null;
public ?bool $exclusiveMaximum = null;
public int|float|null $multipleOf = null;
// Constraints for array
public ?array $items = null; // JSON schema for array items
public ?int $minItems = null;
public ?int $maxItems = null;
public ?bool $uniqueItems = null;
// Constraints for object (primarily used when Schema is on a method or an object-typed parameter)
public ?array $properties = null; // [propertyName => [schema array], ...]
public ?array $required = null; // [propertyName, ...]
public bool|array|null $additionalProperties = null; // true, false, or a schema array
/**
* @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored.
* @param Type|null $type The JSON schema type.
* @param string|null $description Description of the element.
* @param array|null $enum Allowed enum values.
* @param string|null $format String format (e.g., 'date-time', 'email').
* @param int|null $minLength Minimum length for strings.
* @param int|null $maxLength Maximum length for strings.
* @param string|null $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|null $exclusiveMinimum Exclusive minimum.
* @param bool|null $exclusiveMaximum Exclusive maximum.
* @param int|float|null $multipleOf Must be a multiple of this value.
* @param array|null $items JSON Schema for items if type is 'array'.
* @param int|null $minItems Minimum items for an array.
* @param int|null $maxItems Maximum items for an array.
* @param bool|null $uniqueItems Whether array items must be unique.
* @param array|null $properties Property definitions if type is 'object'. [name => schema_array].
* @param array|null $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,
?string $type = null,
?string $description = null,
?array $enum = null,
?string $format = null,
?int $minLength = null,
?int $maxLength = null,
?string $pattern = null,
int|float|null $minimum = null,
int|float|null $maximum = null,
?bool $exclusiveMinimum = null,
?bool $exclusiveMaximum = null,
int|float|null $multipleOf = null,
?array $items = null,
?int $minItems = null,
?int $maxItems = null,
?bool $uniqueItems = null,
?array $properties = null,
?array $required = null,
bool|array|null $additionalProperties = null
) {
if ($definition !== null) {
$this->definition = $definition;
} else {
$this->type = $type;
$this->description = $description;
$this->enum = $enum;
$this->format = $format;
$this->minLength = $minLength;
$this->maxLength = $maxLength;
$this->pattern = $pattern;
$this->minimum = $minimum;
$this->maximum = $maximum;
$this->exclusiveMinimum = $exclusiveMinimum;
$this->exclusiveMaximum = $exclusiveMaximum;
$this->multipleOf = $multipleOf;
$this->items = $items;
$this->minItems = $minItems;
$this->maxItems = $maxItems;
$this->uniqueItems = $uniqueItems;
$this->properties = $properties;
$this->required = $required;
$this->additionalProperties = $additionalProperties;
}
}
/**
* Converts the attribute's definition to a JSON schema array.
*/
public function toArray(): array
{
if ($this->definition !== null) {
return [
'definition' => $this->definition,
];
}
$schema = [];
if ($this->type !== null) {
$schema['type'] = $this->type;
}
if ($this->description !== null) {
$schema['description'] = $this->description;
}
if ($this->enum !== null) {
$schema['enum'] = $this->enum;
}
if ($this->format !== null) {
$schema['format'] = $this->format;
}
// String
if ($this->minLength !== null) {
$schema['minLength'] = $this->minLength;
}
if ($this->maxLength !== null) {
$schema['maxLength'] = $this->maxLength;
}
if ($this->pattern !== null) {
$schema['pattern'] = $this->pattern;
}
// Numeric
if ($this->minimum !== null) {
$schema['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$schema['maximum'] = $this->maximum;
}
if ($this->exclusiveMinimum !== null) {
$schema['exclusiveMinimum'] = $this->exclusiveMinimum;
}
if ($this->exclusiveMaximum !== null) {
$schema['exclusiveMaximum'] = $this->exclusiveMaximum;
}
if ($this->multipleOf !== null) {
$schema['multipleOf'] = $this->multipleOf;
}
// Array
if ($this->items !== null) {
$schema['items'] = $this->items;
}
if ($this->minItems !== null) {
$schema['minItems'] = $this->minItems;
}
if ($this->maxItems !== null) {
$schema['maxItems'] = $this->maxItems;
}
if ($this->uniqueItems !== null) {
$schema['uniqueItems'] = $this->uniqueItems;
}
// Object
if ($this->properties !== null) {
$schema['properties'] = $this->properties;
}
if ($this->required !== null) {
$schema['required'] = $this->required;
}
if ($this->additionalProperties !== null) {
$schema['additionalProperties'] = $this->additionalProperties;
}
return $schema;
}
}
```
--------------------------------------------------------------------------------
/src/Server.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server;
use LogicException;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\ConfigurationException;
use PhpMcp\Server\Exception\DiscoveryException;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Utils\Discoverer;
use Throwable;
/**
* Core MCP Server instance.
*
* Holds the configured MCP logic (Configuration, Registry, Protocol)
* but is transport-agnostic. It relies on a ServerTransportInterface implementation,
* provided via the listen() method, to handle network communication.
*
* Instances should be created via the ServerBuilder.
*/
class Server
{
protected bool $discoveryRan = false;
protected bool $isListening = false;
/**
* @internal Use ServerBuilder::make()->...->build().
*
* @param Configuration $configuration Core configuration and dependencies.
* @param Registry $registry Holds registered MCP element definitions.
* @param Protocol $protocol Handles MCP requests and responses.
*/
public function __construct(
protected readonly Configuration $configuration,
protected readonly Registry $registry,
protected readonly Protocol $protocol,
protected readonly SessionManager $sessionManager,
) {
}
public static function make(): ServerBuilder
{
return new ServerBuilder();
}
/**
* Runs the attribute discovery process based on the configuration
* provided during build time. Caches results if cache is available.
* Can be called explicitly, but is also called by ServerBuilder::build()
* if discovery paths are configured.
*
* @param bool $force Re-run discovery even if already run.
* @param bool $useCache Attempt to load from/save to cache. Defaults to true if cache is available.
*
* @throws DiscoveryException If discovery process encounters errors.
* @throws ConfigurationException If discovery paths were not configured.
*/
public function discover(
string $basePath,
array $scanDirs = ['.', 'src'],
array $excludeDirs = [],
bool $force = false,
bool $saveToCache = true,
?Discoverer $discoverer = null
): void {
$realBasePath = realpath($basePath);
if ($realBasePath === false || ! is_dir($realBasePath)) {
throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}");
}
$excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']);
if ($this->discoveryRan && ! $force) {
$this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.');
return;
}
$cacheAvailable = $this->configuration->cache !== null;
$shouldSaveCache = $saveToCache && $cacheAvailable;
$this->configuration->logger->info('Starting MCP element discovery...', [
'basePath' => $realBasePath,
'force' => $force,
'saveToCache' => $shouldSaveCache,
]);
$this->registry->clear();
try {
$discoverer ??= new Discoverer($this->registry, $this->configuration->logger);
$discoverer->discover($realBasePath, $scanDirs, $excludeDirs);
$this->discoveryRan = true;
if ($shouldSaveCache) {
$this->registry->save();
}
} catch (Throwable $e) {
$this->discoveryRan = false;
$this->configuration->logger->critical('MCP element discovery failed.', ['exception' => $e]);
throw new DiscoveryException("Element discovery failed: {$e->getMessage()}", $e->getCode(), $e);
}
}
/**
* Binds the server's MCP logic to the provided transport and starts the transport's listener,
* then runs the event loop, making this a BLOCKING call suitable for standalone servers.
*
* For framework integration where the loop is managed externally, use `getProtocol()`
* and bind it to your framework's transport mechanism manually.
*
* @param ServerTransportInterface $transport The transport to listen with.
*
* @throws LogicException If called after already listening.
* @throws Throwable If transport->listen() fails immediately.
*/
public function listen(ServerTransportInterface $transport, bool $runLoop = true): void
{
if ($this->isListening) {
throw new LogicException('Server is already listening via a transport.');
}
$this->warnIfNoElements();
if ($transport instanceof LoggerAwareInterface) {
$transport->setLogger($this->configuration->logger);
}
if ($transport instanceof LoopAwareInterface) {
$transport->setLoop($this->configuration->loop);
}
$protocol = $this->getProtocol();
$closeHandlerCallback = function (?string $reason = null) use ($protocol) {
$this->isListening = false;
$this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']);
$protocol->unbindTransport();
$this->configuration->loop->stop();
};
$transport->once('close', $closeHandlerCallback);
$protocol->bindTransport($transport);
try {
$transport->listen();
$this->isListening = true;
if ($runLoop) {
$this->sessionManager->startGcTimer();
$this->configuration->loop->run();
$this->endListen($transport);
}
} catch (Throwable $e) {
$this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]);
$this->endListen($transport);
throw $e;
}
}
public function endListen(ServerTransportInterface $transport): void
{
$protocol = $this->getProtocol();
$protocol->unbindTransport();
$this->sessionManager->stopGcTimer();
$transport->removeAllListeners('close');
$transport->close();
$this->isListening = false;
$this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down.");
}
/**
* Warns if no MCP elements are registered and discovery has not been run.
*/
protected function warnIfNoElements(): void
{
if (! $this->registry->hasElements() && ! $this->discoveryRan) {
$this->configuration->logger->warning(
'Starting listener, but no MCP elements are registered and discovery has not been run. ' .
'Call $server->discover(...) at least once to find and cache elements before listen().'
);
} elseif (! $this->registry->hasElements() && $this->discoveryRan) {
$this->configuration->logger->warning(
'Starting listener, but no MCP elements were found after discovery/cache load.'
);
}
}
/**
* Gets the Configuration instance associated with this server.
*/
public function getConfiguration(): Configuration
{
return $this->configuration;
}
/**
* Gets the Registry instance associated with this server.
*/
public function getRegistry(): Registry
{
return $this->registry;
}
/**
* Gets the Protocol instance associated with this server.
*/
public function getProtocol(): Protocol
{
return $this->protocol;
}
public function getSessionManager(): SessionManager
{
return $this->sessionManager;
}
}
```
--------------------------------------------------------------------------------
/tests/Unit/Session/ArraySessionHandlerTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Session;
use PhpMcp\Server\Session\ArraySessionHandler;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Defaults\SystemClock;
use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
const SESSION_ID_ARRAY_1 = 'array-session-id-1';
const SESSION_ID_ARRAY_2 = 'array-session-id-2';
const SESSION_ID_ARRAY_3 = 'array-session-id-3';
const SESSION_DATA_1 = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":2},{"id":"prod_B","qty":1}],"total":150.75},"theme":"dark"}';
const SESSION_DATA_2 = '{"user_id":102,"preferences":{"notifications":true,"language":"en"},"last_login":"2024-07-15T10:00:00Z"}';
const SESSION_DATA_3 = '{"guest":true,"viewed_products":["prod_C","prod_D"]}';
const DEFAULT_TTL_ARRAY = 3600;
beforeEach(function () {
$this->fixedClock = new FixedClock();
$this->handler = new ArraySessionHandler(DEFAULT_TTL_ARRAY, $this->fixedClock);
});
it('implements SessionHandlerInterface', function () {
expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class);
});
it('constructs with a default TTL and SystemClock if no clock provided', function () {
$handler = new ArraySessionHandler();
expect($handler->ttl)->toBe(DEFAULT_TTL_ARRAY);
$reflection = new \ReflectionClass($handler);
$clockProp = $reflection->getProperty('clock');
$clockProp->setAccessible(true);
expect($clockProp->getValue($handler))->toBeInstanceOf(SystemClock::class);
});
it('constructs with a custom TTL and injected clock', function () {
$customTtl = 1800;
$clock = new FixedClock();
$handler = new ArraySessionHandler($customTtl, $clock);
expect($handler->ttl)->toBe($customTtl);
$reflection = new \ReflectionClass($handler);
$clockProp = $reflection->getProperty('clock');
$clockProp->setAccessible(true);
expect($clockProp->getValue($handler))->toBe($clock);
});
it('writes session data and reads it back correctly', function () {
$writeResult = $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
expect($writeResult)->toBeTrue();
$readData = $this->handler->read(SESSION_ID_ARRAY_1);
expect($readData)->toBe(SESSION_DATA_1);
});
it('returns false when reading a non-existent session', function () {
$readData = $this->handler->read('non-existent-session-id');
expect($readData)->toBeFalse();
});
it('overwrites existing session data on subsequent write', function () {
$this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$updatedData = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":3}],"total":175.25},"theme":"light"}';
$this->handler->write(SESSION_ID_ARRAY_1, $updatedData);
$readData = $this->handler->read(SESSION_ID_ARRAY_1);
expect($readData)->toBe($updatedData);
});
it('returns false and removes data when reading an expired session due to handler TTL', function () {
$ttl = 60;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler($ttl, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds($ttl + 1);
$readData = $handler->read(SESSION_ID_ARRAY_1);
expect($readData)->toBeFalse();
$reflection = new \ReflectionClass($handler);
$storeProp = $reflection->getProperty('store');
$storeProp->setAccessible(true);
$internalStore = $storeProp->getValue($handler);
expect($internalStore)->not->toHaveKey(SESSION_ID_ARRAY_1);
});
it('does not return data if read exactly at TTL expiration time', function () {
$shortTtl = 60;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler($shortTtl, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds($shortTtl);
$readData = $handler->read(SESSION_ID_ARRAY_1);
expect($readData)->toBe(SESSION_DATA_1);
$fixedClock->addSecond();
$readDataExpired = $handler->read(SESSION_ID_ARRAY_1);
expect($readDataExpired)->toBeFalse();
});
it('updates timestamp on write, effectively extending session life', function () {
$veryShortTtl = 5;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler($veryShortTtl, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds(3);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_2);
$fixedClock->addSeconds(3);
$readData = $handler->read(SESSION_ID_ARRAY_1);
expect($readData)->toBe(SESSION_DATA_2);
});
it('destroys an existing session and it cannot be read', function () {
$this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1);
$destroyResult = $this->handler->destroy(SESSION_ID_ARRAY_1);
expect($destroyResult)->toBeTrue();
expect($this->handler->read(SESSION_ID_ARRAY_1))->toBeFalse();
$reflection = new \ReflectionClass($this->handler);
$storeProp = $reflection->getProperty('store');
$storeProp->setAccessible(true);
expect($storeProp->getValue($this->handler))->not->toHaveKey(SESSION_ID_ARRAY_1);
});
it('destroy returns true and does nothing for a non-existent session', function () {
$destroyResult = $this->handler->destroy('non-existent-id');
expect($destroyResult)->toBeTrue();
});
it('garbage collects only sessions older than maxLifetime', function () {
$gcMaxLifetime = 100;
$handlerTtl = 300;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler($handlerTtl, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds(50);
$handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2);
$fixedClock->addSeconds(80);
$deletedSessions = $handler->gc($gcMaxLifetime);
expect($deletedSessions)->toBeArray()->toEqual([SESSION_ID_ARRAY_1]);
expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse();
expect($handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2);
});
it('garbage collection respects maxLifetime precisely', function () {
$maxLifetime = 60;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler(300, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds($maxLifetime);
$deleted = $handler->gc($maxLifetime);
expect($deleted)->toBeEmpty();
expect($handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1);
$fixedClock->addSecond();
$deleted2 = $handler->gc($maxLifetime);
expect($deleted2)->toEqual([SESSION_ID_ARRAY_1]);
expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse();
});
it('garbage collection returns empty array if no sessions meet criteria', function () {
$this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$this->handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2);
$this->fixedClock->addSeconds(DEFAULT_TTL_ARRAY / 2);
$deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY);
expect($deletedSessions)->toBeArray()->toBeEmpty();
expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1);
expect($this->handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2);
});
it('garbage collection correctly handles an empty store', function () {
$deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY);
expect($deletedSessions)->toBeArray()->toBeEmpty();
});
it('garbage collection removes multiple expired sessions', function () {
$maxLifetime = 30;
$fixedClock = new FixedClock();
$handler = new ArraySessionHandler(300, $fixedClock);
$handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1);
$fixedClock->addSeconds(20);
$handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2);
$fixedClock->addSeconds(20);
$handler->write(SESSION_ID_ARRAY_3, SESSION_DATA_3);
$fixedClock->addSeconds(20);
$deleted = $handler->gc($maxLifetime);
expect($deleted)->toHaveCount(2)->toContain(SESSION_ID_ARRAY_1)->toContain(SESSION_ID_ARRAY_2);
expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse();
expect($handler->read(SESSION_ID_ARRAY_2))->toBeFalse();
expect($handler->read(SESSION_ID_ARRAY_3))->toBe(SESSION_DATA_3);
});
```
--------------------------------------------------------------------------------
/src/Defaults/BasicContainer.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1); // Added missing strict_types
namespace PhpMcp\Server\Defaults;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionParameter;
use Throwable; // Changed from \Throwable to Throwable
/**
* A basic PSR-11 container implementation with simple constructor auto-wiring.
*
* Supports instantiating classes with parameterless constructors or constructors
* where all parameters are type-hinted classes/interfaces known to the container,
* or have default values. Does NOT support scalar/built-in type injection without defaults.
*/
class BasicContainer implements ContainerInterface
{
/** @var array<string, object> Cache for already created instances (shared singletons) */
private array $instances = [];
/** @var array<string, bool> Track classes currently being resolved to detect circular dependencies */
private array $resolving = [];
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for (usually a FQCN).
* @return mixed Entry.
*
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency).
*/
public function get(string $id): mixed
{
// 1. Check instance cache
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
// 2. Check if class exists
if (! class_exists($id) && ! interface_exists($id)) { // Also check interface for bindings
throw new NotFoundException("Class, interface, or entry '{$id}' not found.");
}
// 7. Circular Dependency Check
if (isset($this->resolving[$id])) {
throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}");
}
$this->resolving[$id] = true; // Mark as currently resolving
try {
// 3. Reflect on the class
$reflector = new ReflectionClass($id);
// Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated)
if (! $reflector->isInstantiable()) {
// We might have an interface bound to a concrete class via set()
// This check is slightly redundant due to class_exists but good practice
throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding).");
}
// 4. Get the constructor
$constructor = $reflector->getConstructor();
// 5. If no constructor or constructor has no parameters, instantiate directly
if ($constructor === null || $constructor->getNumberOfParameters() === 0) {
$instance = $reflector->newInstance();
} else {
// 6. Constructor has parameters, attempt to resolve them
$parameters = $constructor->getParameters();
$resolvedArgs = [];
foreach ($parameters as $parameter) {
$resolvedArgs[] = $this->resolveParameter($parameter, $id);
}
// Instantiate with resolved arguments
$instance = $reflector->newInstanceArgs($resolvedArgs);
}
// Cache the instance
$this->instances[$id] = $instance;
return $instance;
} catch (ReflectionException $e) {
throw new ContainerException("Reflection failed for '{$id}'.", 0, $e);
} catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly
throw $e;
} catch (Throwable $e) { // Catch other instantiation errors
throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e);
} finally {
// 7. Remove from resolving stack once done (success or failure)
unset($this->resolving[$id]);
}
}
/**
* Attempts to resolve a single constructor parameter.
*
* @throws ContainerExceptionInterface If a required dependency cannot be resolved.
*/
private function resolveParameter(ReflectionParameter $parameter, string $consumerClassId): mixed
{
// Check for type hint
$type = $parameter->getType();
if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
// Type hint is a class or interface name
$typeName = $type->getName();
try {
// Recursively get the dependency
return $this->get($typeName);
} catch (NotFoundExceptionInterface $e) {
// Dependency class not found, fail ONLY if required
if (! $parameter->isOptional() && ! $parameter->allowsNull()) {
throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e);
}
// If optional or nullable, proceed (will check allowsNull/Default below)
} catch (ContainerExceptionInterface $e) {
// Dependency itself failed to resolve (e.g., its own deps, circular)
throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e);
}
}
// Check if parameter has a default value
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
// Check if parameter allows null (and wasn't resolved above)
if ($parameter->allowsNull()) {
return null;
}
// Check if it was a built-in type without a default (unresolvable by this basic container)
if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
}
// Check if it was a union/intersection type without a default (also unresolvable)
if ($type !== null && ! $type instanceof ReflectionNamedType) {
throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
}
// If we reach here, it's an untyped, required parameter without a default.
// Or potentially an unresolvable optional class dependency where null is not allowed (edge case).
throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type).");
}
/**
* Returns true if the container can return an entry for the given identifier.
* Checks explicitly set instances and if the class/interface exists.
* Does not guarantee `get()` will succeed if auto-wiring fails.
*/
public function has(string $id): bool
{
return isset($this->instances[$id]) || class_exists($id) || interface_exists($id);
}
/**
* Adds a pre-built instance or a factory/binding to the container.
* This basic version only supports pre-built instances (singletons).
*/
public function set(string $id, object $instance): void
{
// Could add support for closures/factories later if needed
$this->instances[$id] = $instance;
}
}
// Keep custom exception classes as they are PSR-11 compliant placeholders
class ContainerException extends \Exception implements ContainerExceptionInterface
{
}
class NotFoundException extends \Exception implements NotFoundExceptionInterface
{
}
```
--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredToolTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Elements;
use InvalidArgumentException;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PhpMcp\Schema\Tool;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Elements\RegisteredTool;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Content\ImageContent;
use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture;
use Psr\Container\ContainerInterface;
use JsonException;
use PhpMcp\Server\Exception\McpServerException;
uses(MockeryPHPUnitIntegration::class);
beforeEach(function () {
$this->container = Mockery::mock(ContainerInterface::class);
$this->handlerInstance = new ToolHandlerFixture();
$this->container->shouldReceive('get')->with(ToolHandlerFixture::class)
->andReturn($this->handlerInstance)->byDefault();
$this->toolSchema = Tool::make(
name: 'test-tool',
inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]
);
$this->registeredTool = RegisteredTool::make(
$this->toolSchema,
[ToolHandlerFixture::class, 'greet']
);
$this->context = new Context(Mockery::mock(SessionInterface::class));
});
it('constructs correctly and exposes schema', function () {
expect($this->registeredTool->schema)->toBe($this->toolSchema);
expect($this->registeredTool->handler)->toBe([ToolHandlerFixture::class, 'greet']);
expect($this->registeredTool->isManual)->toBeFalse();
});
it('can be made as a manual registration', function () {
$manualTool = RegisteredTool::make($this->toolSchema, [ToolHandlerFixture::class, 'greet'], true);
expect($manualTool->isManual)->toBeTrue();
});
it('calls the handler with prepared arguments', function () {
$tool = RegisteredTool::make(
Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]),
[ToolHandlerFixture::class, 'sum']
);
$mockHandler = Mockery::mock(ToolHandlerFixture::class);
$mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15);
$this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
$resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10'], $this->context); // '10' will be cast to int by prepareArguments
expect($resultContents)->toBeArray()->toHaveCount(1);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15');
});
it('calls handler with no arguments if tool takes none and none provided', function () {
$tool = RegisteredTool::make(
Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'noParamsTool']
);
$mockHandler = Mockery::mock(ToolHandlerFixture::class);
$mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']);
$this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
});
dataset('tool_handler_return_values', [
'string' => ['returnString', "This is a string result."],
'integer' => ['returnInteger', "12345"],
'float' => ['returnFloat', "67.89"],
'boolean_true' => ['returnBooleanTrue', "true"],
'boolean_false' => ['returnBooleanFalse', "false"],
'null' => ['returnNull', "(null)"],
'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
]);
it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) {
$tool = RegisteredTool::make(
Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, $handlerMethod]
);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents)->toBeArray()->toHaveCount(1);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText);
})->with('tool_handler_return_values');
it('returns single Content object from handler as array with one Content object', function () {
$tool = RegisteredTool::make(
Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'returnTextContent']
);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents)->toBeArray()->toHaveCount(1);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent.");
});
it('returns array of Content objects from handler as is', function () {
$tool = RegisteredTool::make(
Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'returnArrayOfContent']
);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents)->toBeArray()->toHaveCount(2);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1");
expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata");
});
it('formats mixed array from handler into array of Content objects', function () {
$tool = RegisteredTool::make(
Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'returnMixedArray']
);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents)->toBeArray()->toHaveCount(8);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string");
expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved
expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123");
expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true");
expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)");
expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved
expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
});
it('formats empty array from handler into TextContent with "[]"', function () {
$tool = RegisteredTool::make(
Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'returnEmptyArray']
);
$resultContents = $tool->call($this->container, [], $this->context);
expect($resultContents)->toBeArray()->toHaveCount(1);
expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]');
});
it('throws JsonException during formatResult if handler returns unencodable value', function () {
$tool = RegisteredTool::make(
Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'toolUnencodableResult']
);
$tool->call($this->container, [], $this->context);
})->throws(JsonException::class);
it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () {
$tool = RegisteredTool::make(
Tool::make('exception-tool', ['type' => 'object', 'properties' => []]),
[ToolHandlerFixture::class, 'toolThatThrows']
);
$this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture());
$tool->call($this->container, [], $this->context);
})->throws(InvalidArgumentException::class, "Something went wrong in the tool.");
```
--------------------------------------------------------------------------------
/src/Transports/StdioServerTransport.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Transports;
use Evenement\EventEmitterTrait;
use PhpMcp\Schema\JsonRpc\Parser;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Message;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use React\ChildProcess\Process;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Stream\ReadableResourceStream;
use React\Stream\ReadableStreamInterface;
use React\Stream\WritableResourceStream;
use React\Stream\WritableStreamInterface;
use Throwable;
use function React\Promise\reject;
/**
* Implementation of the STDIO server transport using ReactPHP Process and Streams.
* Listens on STDIN, writes to STDOUT, and emits events for the Protocol.
*/
class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
{
use EventEmitterTrait;
protected LoggerInterface $logger;
protected LoopInterface $loop;
protected ?Process $process = null;
protected ?ReadableStreamInterface $stdin = null;
protected ?WritableStreamInterface $stdout = null;
protected string $buffer = '';
protected bool $closing = false;
protected bool $listening = false;
private const CLIENT_ID = 'stdio';
/**
* Constructor takes optional stream resources.
* Defaults to STDIN and STDOUT if not provided.
* Dependencies like Logger and Loop are injected via setters.
*
* @param resource|null $inputStreamResource The readable resource (e.g., STDIN).
* @param resource|null $outputStreamResource The writable resource (e.g., STDOUT).
*
* @throws TransportException If provided resources are invalid.
*/
public function __construct(
protected $inputStreamResource = STDIN,
protected $outputStreamResource = STDOUT
) {
if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) {
$message = 'STDIN and STDOUT are not supported as input and output stream resources' .
'on Windows due to PHP\'s limitations with non blocking pipes.' .
'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.';
throw new TransportException($message);
}
// if (str_contains(PHP_OS, 'WIN')) {
// $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r'));
// $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w'));
// }
if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') {
throw new TransportException('Invalid input stream resource provided.');
}
if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') {
throw new TransportException('Invalid output stream resource provided.');
}
$this->logger = new NullLogger();
$this->loop = Loop::get();
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function setLoop(LoopInterface $loop): void
{
$this->loop = $loop;
}
/**
* Starts listening on STDIN.
*
* @throws TransportException If already listening or streams cannot be opened.
*/
public function listen(): void
{
if ($this->listening) {
throw new TransportException('Stdio transport is already listening.');
}
if ($this->closing) {
throw new TransportException('Cannot listen, transport is closing/closed.');
}
try {
$this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop);
$this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop);
} catch (Throwable $e) {
$this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]);
throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e);
}
$this->stdin->on('data', function ($chunk) {
$this->buffer .= $chunk;
$this->processBuffer();
});
$this->stdin->on('error', function (Throwable $error) {
$this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]);
$this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
$this->close();
});
$this->stdin->on('close', function () {
$this->logger->info('STDIN stream closed.');
$this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']);
$this->close();
});
$this->stdout->on('error', function (Throwable $error) {
$this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]);
$this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
$this->close();
});
try {
$signalHandler = function (int $signal) {
$this->logger->info("Received signal {$signal}, shutting down.");
$this->close();
};
$this->loop->addSignal(SIGTERM, $signalHandler);
$this->loop->addSignal(SIGINT, $signalHandler);
} catch (Throwable $e) {
$this->logger->debug('Signal handling not supported by current event loop.');
}
$this->logger->info('Server is up and listening on STDIN 🚀');
$this->listening = true;
$this->closing = false;
$this->emit('ready');
$this->emit('client_connected', [self::CLIENT_ID]);
}
/** Processes the internal buffer to find complete lines/frames. */
private function processBuffer(): void
{
while (str_contains($this->buffer, "\n")) {
$pos = strpos($this->buffer, "\n");
$line = substr($this->buffer, 0, $pos);
$this->buffer = substr($this->buffer, $pos + 1);
$trimmedLine = trim($line);
if (empty($trimmedLine)) {
continue;
}
try {
$message = Parser::parse($trimmedLine);
} catch (Throwable $e) {
$this->logger->error('Error parsing message', ['exception' => $e]);
$error = Error::forParseError("Invalid JSON: " . $e->getMessage());
$this->sendMessage($error, self::CLIENT_ID);
continue;
}
$this->emit('message', [$message, self::CLIENT_ID]);
}
}
/**
* Sends a raw, framed message to STDOUT.
*/
public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
{
if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) {
return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.'));
}
$deferred = new Deferred();
$json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$written = $this->stdout->write($json . "\n");
if ($written) {
$deferred->resolve(null);
} else {
$this->logger->debug('STDOUT buffer full, waiting for drain.');
$this->stdout->once('drain', function () use ($deferred) {
$this->logger->debug('STDOUT drained.');
$deferred->resolve(null);
});
}
return $deferred->promise();
}
/**
* Stops listening and cleans up resources.
*/
public function close(): void
{
if ($this->closing) {
return;
}
$this->closing = true;
$this->listening = false;
$this->logger->info('Closing Stdio transport...');
$this->stdin?->close();
$this->stdout?->close();
$this->stdin = null;
$this->stdout = null;
$this->emit('close', ['StdioTransport closed.']);
$this->removeAllListeners();
}
}
```
--------------------------------------------------------------------------------
/src/Elements/RegisteredResource.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Elements;
use PhpMcp\Schema\Content\BlobResourceContents;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Schema\Content\ResourceContents;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Resource;
use PhpMcp\Server\Context;
use Psr\Container\ContainerInterface;
use Throwable;
class RegisteredResource extends RegisteredElement
{
public function __construct(
public readonly Resource $schema,
callable|array|string $handler,
bool $isManual = false,
) {
parent::__construct($handler, $isManual);
}
public static function make(Resource $schema, callable|array|string $handler, bool $isManual = false): self
{
return new self($schema, $handler, $isManual);
}
/**
* Reads the resource content.
*
* @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
*/
public function read(ContainerInterface $container, string $uri, Context $context): array
{
$result = $this->handle($container, ['uri' => $uri], $context);
return $this->formatResult($result, $uri, $this->schema->mimeType);
}
/**
* Formats the raw result of a resource read operation into MCP ResourceContent items.
*
* @param mixed $readResult The raw result from the resource handler method.
* @param string $uri The URI of the resource that was read.
* @param ?string $mimeType The MIME type from the ResourceDefinition.
* @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
*
* @throws \RuntimeException If the result cannot be formatted.
*
* Supported result types:
* - ResourceContent: Used as-is
* - EmbeddedResource: Resource is extracted from the EmbeddedResource
* - string: Converted to text content with guessed or provided MIME type
* - stream resource: Read and converted to blob with provided MIME type
* - array with 'blob' key: Used as blob content
* - array with 'text' key: Used as text content
* - SplFileInfo: Read and converted to blob
* - array: Converted to JSON if MIME type is application/json or contains 'json'
* For other MIME types, will try to convert to JSON with a warning
*/
protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
{
if ($readResult instanceof ResourceContents) {
return [$readResult];
}
if ($readResult instanceof EmbeddedResource) {
return [$readResult->resource];
}
if (is_array($readResult)) {
if (empty($readResult)) {
return [TextResourceContents::make($uri, 'application/json', '[]')];
}
$allAreResourceContents = true;
$hasResourceContents = false;
$allAreEmbeddedResource = true;
$hasEmbeddedResource = false;
foreach ($readResult as $item) {
if ($item instanceof ResourceContents) {
$hasResourceContents = true;
$allAreEmbeddedResource = false;
} elseif ($item instanceof EmbeddedResource) {
$hasEmbeddedResource = true;
$allAreResourceContents = false;
} else {
$allAreResourceContents = false;
$allAreEmbeddedResource = false;
}
}
if ($allAreResourceContents && $hasResourceContents) {
return $readResult;
}
if ($allAreEmbeddedResource && $hasEmbeddedResource) {
return array_map(fn($item) => $item->resource, $readResult);
}
if ($hasResourceContents || $hasEmbeddedResource) {
$result = [];
foreach ($readResult as $item) {
if ($item instanceof ResourceContents) {
$result[] = $item;
} elseif ($item instanceof EmbeddedResource) {
$result[] = $item->resource;
} else {
$result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
}
}
return $result;
}
}
if (is_string($readResult)) {
$mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
return [TextResourceContents::make($uri, $mimeType, $readResult)];
}
if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
$result = BlobResourceContents::fromStream(
$uri,
$readResult,
$mimeType ?? 'application/octet-stream'
);
@fclose($readResult);
return [$result];
}
if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
$mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
}
if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
$mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
}
if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
}
return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
}
if (is_array($readResult)) {
if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
$mimeType === 'application/json')) {
try {
$jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
return [TextResourceContents::make($uri, $mimeType, $jsonString)];
} catch (\JsonException $e) {
throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
}
}
try {
$jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
$mimeType = $mimeType ?? 'application/json';
return [TextResourceContents::make($uri, $mimeType, $jsonString)];
} catch (\JsonException $e) {
throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
}
}
throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
}
/** Guesses MIME type from string content (very basic) */
private function guessMimeTypeFromString(string $content): string
{
$trimmed = ltrim($content);
if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
if (str_contains($trimmed, '<html')) {
return 'text/html';
}
if (str_contains($trimmed, '<?xml')) {
return 'application/xml';
}
return 'text/plain';
}
if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
return 'application/json';
}
if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
return 'application/json';
}
return 'text/plain';
}
public function toArray(): array
{
return [
'schema' => $this->schema->toArray(),
...parent::toArray(),
];
}
public static function fromArray(array $data): self|false
{
try {
if (! isset($data['schema']) || ! isset($data['handler'])) {
return false;
}
return new self(
Resource::fromArray($data['schema']),
$data['handler'],
$data['isManual'] ?? false,
);
} catch (Throwable $e) {
return false;
}
}
}
```
--------------------------------------------------------------------------------
/src/Defaults/FileCache.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Defaults;
use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;
use Psr\SimpleCache\CacheInterface;
use Throwable;
/**
* Basic PSR-16 file cache implementation.
*
* Stores cache entries serialized in a JSON file.
* Uses file locking for basic concurrency control during writes.
* Not recommended for high-concurrency environments.
*/
class FileCache implements CacheInterface
{
/**
* @param string $cacheFile Absolute path to the cache file.
* The directory will be created if it doesn't exist.
* @param int $filePermission Optional file mode (octal) for the cache file (default: 0664).
* @param int $dirPermission Optional directory mode (octal) for the cache directory (default: 0775).
*/
public function __construct(
private readonly string $cacheFile,
private readonly int $filePermission = 0664,
private readonly int $dirPermission = 0775
) {
$this->ensureDirectoryExists(dirname($this->cacheFile));
}
// ---------------------------------------------------------------------
// PSR-16 Methods
// ---------------------------------------------------------------------
public function get(string $key, mixed $default = null): mixed
{
$data = $this->readCacheFile();
$key = $this->sanitizeKey($key);
if (! isset($data[$key])) {
return $default;
}
if ($this->isExpired($data[$key]['expiry'])) {
$this->delete($key); // Clean up expired entry
return $default;
}
return $data[$key]['value'] ?? $default;
}
public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
{
$data = $this->readCacheFile();
$key = $this->sanitizeKey($key);
$data[$key] = [
'value' => $value,
'expiry' => $this->calculateExpiry($ttl),
];
return $this->writeCacheFile($data);
}
public function delete(string $key): bool
{
$data = $this->readCacheFile();
$key = $this->sanitizeKey($key);
if (isset($data[$key])) {
unset($data[$key]);
return $this->writeCacheFile($data);
}
return true; // Key didn't exist, considered successful delete
}
public function clear(): bool
{
// Write an empty array to the file
return $this->writeCacheFile([]);
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$keys = $this->iterableToArray($keys);
$this->validateKeys($keys);
$data = $this->readCacheFile();
$results = [];
$needsWrite = false;
foreach ($keys as $key) {
$sanitizedKey = $this->sanitizeKey($key);
if (! isset($data[$sanitizedKey])) {
$results[$key] = $default;
continue;
}
if ($this->isExpired($data[$sanitizedKey]['expiry'])) {
unset($data[$sanitizedKey]); // Clean up expired entry
$needsWrite = true;
$results[$key] = $default;
continue;
}
$results[$key] = $data[$sanitizedKey]['value'] ?? $default;
}
if ($needsWrite) {
$this->writeCacheFile($data);
}
return $results;
}
public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
{
$values = $this->iterableToArray($values);
$this->validateKeys(array_keys($values));
$data = $this->readCacheFile();
$expiry = $this->calculateExpiry($ttl);
foreach ($values as $key => $value) {
$sanitizedKey = $this->sanitizeKey((string) $key);
$data[$sanitizedKey] = [
'value' => $value,
'expiry' => $expiry,
];
}
return $this->writeCacheFile($data);
}
public function deleteMultiple(iterable $keys): bool
{
$keys = $this->iterableToArray($keys);
$this->validateKeys($keys);
$data = $this->readCacheFile();
$deleted = false;
foreach ($keys as $key) {
$sanitizedKey = $this->sanitizeKey($key);
if (isset($data[$sanitizedKey])) {
unset($data[$sanitizedKey]);
$deleted = true;
}
}
if ($deleted) {
return $this->writeCacheFile($data);
}
return true; // No keys existed or no changes made
}
public function has(string $key): bool
{
$data = $this->readCacheFile();
$key = $this->sanitizeKey($key);
if (! isset($data[$key])) {
return false;
}
if ($this->isExpired($data[$key]['expiry'])) {
$this->delete($key); // Clean up expired
return false;
}
return true;
}
// ---------------------------------------------------------------------
// Internal Methods
// ---------------------------------------------------------------------
private function readCacheFile(): array
{
if (! file_exists($this->cacheFile) || filesize($this->cacheFile) === 0) {
return [];
}
$handle = @fopen($this->cacheFile, 'rb');
if ($handle === false) {
return [];
}
try {
if (! flock($handle, LOCK_SH)) {
return [];
}
$content = stream_get_contents($handle);
flock($handle, LOCK_UN);
if ($content === false || $content === '') {
return [];
}
$data = unserialize($content);
if ($data === false) {
return [];
}
return $data;
} finally {
if (is_resource($handle)) {
fclose($handle);
}
}
}
private function writeCacheFile(array $data): bool
{
$jsonData = serialize($data);
if ($jsonData === false) {
return false;
}
$handle = @fopen($this->cacheFile, 'cb');
if ($handle === false) {
return false;
}
try {
if (! flock($handle, LOCK_EX)) {
return false;
}
if (! ftruncate($handle, 0)) {
return false;
}
if (fwrite($handle, $jsonData) === false) {
return false;
}
fflush($handle);
flock($handle, LOCK_UN);
@chmod($this->cacheFile, $this->filePermission);
return true;
} catch (Throwable $e) {
flock($handle, LOCK_UN); // Ensure lock release on error
return false;
} finally {
if (is_resource($handle)) {
fclose($handle);
}
}
}
private function ensureDirectoryExists(string $directory): void
{
if (! is_dir($directory)) {
if (! @mkdir($directory, $this->dirPermission, true)) {
throw new InvalidArgumentException("Cache directory does not exist and could not be created: {$directory}");
}
@chmod($directory, $this->dirPermission);
}
}
private function calculateExpiry(DateInterval|int|null $ttl): ?int
{
if ($ttl === null) {
return null;
}
$now = time();
if (is_int($ttl)) {
return $ttl <= 0 ? $now - 1 : $now + $ttl;
}
if ($ttl instanceof DateInterval) {
try {
return (new DateTimeImmutable())->add($ttl)->getTimestamp();
} catch (Throwable $e) {
return null;
}
}
throw new InvalidArgumentException('Invalid TTL type provided. Must be null, int, or DateInterval.');
}
private function isExpired(?int $expiry): bool
{
return $expiry !== null && time() >= $expiry;
}
private function sanitizeKey(string $key): string
{
if ($key === '') {
throw new InvalidArgumentException('Cache key cannot be empty.');
}
// PSR-16 validation (optional stricter check)
// if (preg_match('/[{}()\/@:]/', $key)) {
// throw new InvalidArgumentException("Cache key \"{$key}\" contains reserved characters.");
// }
return $key;
}
private function validateKeys(array $keys): void
{
foreach ($keys as $key) {
if (! is_string($key)) {
throw new InvalidArgumentException('Cache key must be a string, got ' . gettype($key));
}
$this->sanitizeKey($key);
}
}
private function iterableToArray(iterable $iterable): array
{
if (is_array($iterable)) {
return $iterable;
}
return iterator_to_array($iterable);
}
}
```
--------------------------------------------------------------------------------
/tests/Integration/DiscoveryTest.php:
--------------------------------------------------------------------------------
```php
<?php
use PhpMcp\Server\Defaults\EnumCompletionProvider;
use PhpMcp\Server\Defaults\ListCompletionProvider;
use PhpMcp\Server\Elements\RegisteredPrompt;
use PhpMcp\Server\Elements\RegisteredResource;
use PhpMcp\Server\Elements\RegisteredResourceTemplate;
use PhpMcp\Server\Elements\RegisteredTool;
use PhpMcp\Server\Registry;
use PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler;
use PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture;
use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture;
use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture;
use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture;
use PhpMcp\Server\Utils\Discoverer;
use PhpMcp\Server\Utils\DocBlockParser;
use PhpMcp\Server\Utils\SchemaGenerator;
use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
use Psr\Log\NullLogger;
beforeEach(function () {
$logger = new NullLogger();
$this->registry = new Registry($logger);
$docBlockParser = new DocBlockParser($logger);
$schemaGenerator = new SchemaGenerator($docBlockParser);
$this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator);
$this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures');
});
it('discovers all element types correctly from fixture files', function () {
$scanDir = 'Discovery';
$this->discoverer->discover($this->fixtureBasePath, [$scanDir]);
$tools = $this->registry->getTools();
expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool
$greetUserTool = $this->registry->getTool('greet_user');
expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class)
->and($greetUserTool->isManual)->toBeFalse()
->and($greetUserTool->schema->name)->toBe('greet_user')
->and($greetUserTool->schema->description)->toBe('Greets a user by name.')
->and($greetUserTool->handler)->toBe([DiscoverableToolHandler::class, 'greet']);
expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name');
$repeatActionTool = $this->registry->getTool('repeatAction');
expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class)
->and($repeatActionTool->isManual)->toBeFalse()
->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.')
->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue();
expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']);
$invokableCalcTool = $this->registry->getTool('InvokableCalculator');
expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class)
->and($invokableCalcTool->isManual)->toBeFalse()
->and($invokableCalcTool->handler)->toBe([InvocableToolFixture::class, '__invoke']);
expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull();
expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull();
expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull();
$resources = $this->registry->getResources();
expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture
$appVersionRes = $this->registry->getResource('app://info/version');
expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class)
->and($appVersionRes->isManual)->toBeFalse()
->and($appVersionRes->schema->name)->toBe('app_version')
->and($appVersionRes->schema->mimeType)->toBe('text/plain');
$invokableStatusRes = $this->registry->getResource('invokable://config/status');
expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class)
->and($invokableStatusRes->isManual)->toBeFalse()
->and($invokableStatusRes->handler)->toBe([InvocableResourceFixture::class, '__invoke']);
$prompts = $this->registry->getPrompts();
expect($prompts)->toHaveCount(4); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture, content_creator
$storyPrompt = $this->registry->getPrompt('creative_story_prompt');
expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class)
->and($storyPrompt->isManual)->toBeFalse()
->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords
->and($storyPrompt->completionProviders['genre'])->toBe(CompletionProviderFixture::class);
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name
expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class)
->and($simplePrompt->isManual)->toBeFalse();
$invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class)
->and($invokableGreeter->isManual)->toBeFalse()
->and($invokableGreeter->handler)->toBe([InvocablePromptFixture::class, '__invoke']);
$contentCreatorPrompt = $this->registry->getPrompt('content_creator');
expect($contentCreatorPrompt)->toBeInstanceOf(RegisteredPrompt::class)
->and($contentCreatorPrompt->isManual)->toBeFalse()
->and($contentCreatorPrompt->completionProviders)->toHaveCount(3);
$templates = $this->registry->getResourceTemplates();
expect($templates)->toHaveCount(4); // product_details_template, getFileContent, InvocableResourceTemplateFixture, content_template
$productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}');
expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
->and($productTemplate->isManual)->toBeFalse()
->and($productTemplate->schema->name)->toBe('product_details_template')
->and($productTemplate->completionProviders['region'])->toBe(CompletionProviderFixture::class);
expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']);
$invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}');
expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
->and($invokableUserTemplate->isManual)->toBeFalse()
->and($invokableUserTemplate->handler)->toBe([InvocableResourceTemplateFixture::class, '__invoke']);
});
it('does not discover elements from excluded directories', function () {
$this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull();
$this->registry->clear();
$this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']);
expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull();
});
it('handles empty directories or directories with no PHP files', function () {
$this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']);
expect($this->registry->getTools())->toBeEmpty();
});
it('correctly infers names and descriptions from methods/classes if not set in attribute', function () {
$this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
$repeatActionTool = $this->registry->getTool('repeatAction');
expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name
expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt');
expect($simplePrompt->schema->description)->toBeNull();
$invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr
expect($invokableCalc->schema->name)->toBe('InvokableCalculator');
expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.');
});
it('discovers enhanced completion providers with values and enum attributes', function () {
$this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
$contentPrompt = $this->registry->getPrompt('content_creator');
expect($contentPrompt)->toBeInstanceOf(RegisteredPrompt::class);
expect($contentPrompt->completionProviders)->toHaveCount(3);
$typeProvider = $contentPrompt->completionProviders['type'];
expect($typeProvider)->toBeInstanceOf(ListCompletionProvider::class);
$statusProvider = $contentPrompt->completionProviders['status'];
expect($statusProvider)->toBeInstanceOf(EnumCompletionProvider::class);
$priorityProvider = $contentPrompt->completionProviders['priority'];
expect($priorityProvider)->toBeInstanceOf(EnumCompletionProvider::class);
$contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}');
expect($contentTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class);
expect($contentTemplate->completionProviders)->toHaveCount(1);
$categoryProvider = $contentTemplate->completionProviders['category'];
expect($categoryProvider)->toBeInstanceOf(ListCompletionProvider::class);
});
```
--------------------------------------------------------------------------------
/tests/Unit/Session/SessionManagerTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Session;
use Mockery;
use Mockery\MockInterface;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Session\ArraySessionHandler;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
use Psr\Log\LoggerInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
const SESSION_ID_MGR_1 = 'manager-session-1';
const SESSION_ID_MGR_2 = 'manager-session-2';
const DEFAULT_TTL_MGR = 3600;
const GC_INTERVAL_MGR = 5;
beforeEach(function () {
/** @var MockInterface&SessionHandlerInterface $sessionHandler */
$this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
/** @var MockInterface&LoggerInterface $logger */
$this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
$this->loop = Loop::get();
$this->sessionManager = new SessionManager(
$this->sessionHandler,
$this->logger,
$this->loop,
DEFAULT_TTL_MGR
);
$this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault();
$this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault();
$this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault();
$this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault();
});
it('creates a new session with default hydrated values and saves it', function () {
$this->sessionHandler->shouldReceive('write')
->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
$data = json_decode($dataJson, true);
expect($data['initialized'])->toBeFalse();
expect($data['client_info'])->toBeNull();
expect($data['protocol_version'])->toBeNull();
expect($data['subscriptions'])->toEqual([]);
expect($data['message_queue'])->toEqual([]);
expect($data['log_level'])->toBeNull();
return true;
}))->once()->andReturn(true);
$sessionCreatedEmitted = false;
$emittedSessionId = null;
$emittedSessionObj = null;
$this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) {
$sessionCreatedEmitted = true;
$emittedSessionId = $id;
$emittedSessionObj = $session;
});
$session = $this->sessionManager->createSession(SESSION_ID_MGR_1);
expect($session)->toBeInstanceOf(SessionInterface::class);
expect($session->getId())->toBe(SESSION_ID_MGR_1);
expect($session->get('initialized'))->toBeFalse();
$this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]);
expect($sessionCreatedEmitted)->toBeTrue();
expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
expect($emittedSessionObj)->toBe($session);
});
it('gets an existing session if handler read returns data', function () {
$existingData = ['user_id' => 123, 'initialized' => true];
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData));
$session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
expect($session)->toBeInstanceOf(SessionInterface::class);
expect($session->getId())->toBe(SESSION_ID_MGR_1);
expect($session->get('user_id'))->toBe(123);
});
it('returns null from getSession if session does not exist (handler read returns false)', function () {
$this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false);
$session = $this->sessionManager->getSession('non-existent');
expect($session)->toBeNull();
});
it('returns null from getSession if session data is empty after load', function () {
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
$session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
expect($session)->toBeNull();
});
it('deletes a session successfully and emits event', function () {
$this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true);
$sessionDeletedEmitted = false;
$emittedSessionId = null;
$this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) {
$sessionDeletedEmitted = true;
$emittedSessionId = $id;
});
$success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
expect($success)->toBeTrue();
$this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]);
expect($sessionDeletedEmitted)->toBeTrue();
expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
});
it('logs warning and does not emit event if deleteSession fails', function () {
$this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
$sessionDeletedEmitted = false;
$this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) {
$sessionDeletedEmitted = true;
});
$success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
expect($success)->toBeFalse();
$this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]);
expect($sessionDeletedEmitted)->toBeFalse();
});
it('queues message for existing session', function () {
$sessionData = ['message_queue' => []];
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
$message = '{"id":1}';
$this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) use ($message) {
$data = json_decode($dataJson, true);
expect($data['message_queue'])->toEqual([$message]);
return true;
}))->once()->andReturn(true);
$this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message);
});
it('does nothing on queueMessage if session does not exist', function () {
$this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
$this->sessionHandler->shouldNotReceive('write');
$this->sessionManager->queueMessage('no-such-session', '{"id":1}');
});
it('dequeues messages from existing session', function () {
$messages = ['{"id":1}', '{"id":2}'];
$sessionData = ['message_queue' => $messages];
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
$this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
$data = json_decode($dataJson, true);
expect($data['message_queue'])->toEqual([]);
return true;
}))->once()->andReturn(true);
$dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1);
expect($dequeued)->toEqual($messages);
});
it('returns empty array from dequeueMessages if session does not exist', function () {
$this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]);
});
it('checks hasQueuedMessages for existing session', function () {
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']]));
expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue();
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []]));
expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse();
});
it('returns false from hasQueuedMessages if session does not exist', function () {
$this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse();
});
it('can stop GC timer on stopGcTimer ', function () {
$loop = Mockery::mock(LoopInterface::class);
$loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class));
$loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once();
$manager = new SessionManager($this->sessionHandler, $this->logger, $loop);
$manager->startGcTimer();
$manager->stopGcTimer();
});
it('GC timer callback deletes expired sessions', function () {
$clock = new FixedClock();
$sessionHandler = new ArraySessionHandler(60, $clock);
$sessionHandler->write('sess_expired', 'data');
// $clock->addSeconds(100);
$manager = new SessionManager(
$sessionHandler,
$this->logger,
ttl: 30,
gcInterval: 0.01
);
$session = $manager->getSession('sess_expired');
expect($session)->toBeNull();
});
it('does not start GC timer if already started', function () {
$this->loop = Mockery::mock(LoopInterface::class);
$this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class));
$manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop);
$manager->startGcTimer();
});
```
--------------------------------------------------------------------------------
/src/Elements/RegisteredElement.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Elements;
use InvalidArgumentException;
use JsonSerializable;
use PhpMcp\Server\Context;
use PhpMcp\Server\Exception\McpServerException;
use Psr\Container\ContainerInterface;
use ReflectionException;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use Throwable;
use TypeError;
class RegisteredElement implements JsonSerializable
{
/** @var callable|array|string */
public readonly mixed $handler;
public readonly bool $isManual;
public function __construct(
callable|array|string $handler,
bool $isManual = false,
) {
$this->handler = $handler;
$this->isManual = $isManual;
}
public function handle(ContainerInterface $container, array $arguments, Context $context): mixed
{
if (is_string($this->handler)) {
if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) {
$reflection = new \ReflectionMethod($this->handler, '__invoke');
$arguments = $this->prepareArguments($reflection, $arguments, $context);
$instance = $container->get($this->handler);
return call_user_func($instance, ...$arguments);
}
if (function_exists($this->handler)) {
$reflection = new \ReflectionFunction($this->handler);
$arguments = $this->prepareArguments($reflection, $arguments, $context);
return call_user_func($this->handler, ...$arguments);
}
}
if (is_callable($this->handler)) {
$reflection = $this->getReflectionForCallable($this->handler);
$arguments = $this->prepareArguments($reflection, $arguments, $context);
return call_user_func($this->handler, ...$arguments);
}
if (is_array($this->handler)) {
[$className, $methodName] = $this->handler;
$reflection = new \ReflectionMethod($className, $methodName);
$arguments = $this->prepareArguments($reflection, $arguments, $context);
$instance = $container->get($className);
return call_user_func([$instance, $methodName], ...$arguments);
}
throw new \InvalidArgumentException('Invalid handler type');
}
protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments, Context $context): array
{
$finalArgs = [];
foreach ($reflection->getParameters() as $parameter) {
// TODO: Handle variadic parameters.
$paramName = $parameter->getName();
$paramType = $parameter->getType();
$paramPosition = $parameter->getPosition();
if ($paramType instanceof ReflectionNamedType && $paramType->getName() === Context::class) {
$finalArgs[$paramPosition] = $context;
continue;
}
if (isset($arguments[$paramName])) {
$argument = $arguments[$paramName];
try {
$finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
} catch (InvalidArgumentException $e) {
throw McpServerException::invalidParams($e->getMessage(), $e);
} catch (Throwable $e) {
throw McpServerException::internalError(
"Error processing parameter `{$paramName}`: {$e->getMessage()}",
$e
);
}
} elseif ($parameter->isDefaultValueAvailable()) {
$finalArgs[$paramPosition] = $parameter->getDefaultValue();
} elseif ($parameter->allowsNull()) {
$finalArgs[$paramPosition] = null;
} elseif ($parameter->isOptional()) {
continue;
} else {
$reflectionName = $reflection instanceof \ReflectionMethod
? $reflection->class . '::' . $reflection->name
: 'Closure';
throw McpServerException::internalError(
"Missing required argument `{$paramName}` for {$reflectionName}."
);
}
}
return array_values($finalArgs);
}
/**
* Gets a ReflectionMethod or ReflectionFunction for a callable.
*/
private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
{
if (is_string($handler)) {
return new \ReflectionFunction($handler);
}
if ($handler instanceof \Closure) {
return new \ReflectionFunction($handler);
}
if (is_array($handler) && count($handler) === 2) {
[$class, $method] = $handler;
return new \ReflectionMethod($class, $method);
}
throw new \InvalidArgumentException('Cannot create reflection for this callable type');
}
/**
* Attempts type casting based on ReflectionParameter type hints.
*
* @throws InvalidArgumentException If casting is impossible for the required type.
* @throws TypeError If internal PHP casting fails unexpectedly.
*/
private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed
{
$type = $parameter->getType();
if ($argument === null) {
if ($type && $type->allowsNull()) {
return null;
}
}
if (! $type instanceof ReflectionNamedType) {
return $argument;
}
$typeName = $type->getName();
if (enum_exists($typeName)) {
if (is_object($argument) && $argument instanceof $typeName) {
return $argument;
}
if (is_subclass_of($typeName, \BackedEnum::class)) {
$value = $typeName::tryFrom($argument);
if ($value === null) {
throw new InvalidArgumentException(
"Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.",
);
}
return $value;
} else {
if (is_string($argument)) {
foreach ($typeName::cases() as $case) {
if ($case->name === $argument) {
return $case;
}
}
$validNames = array_map(fn($c) => $c->name, $typeName::cases());
throw new InvalidArgumentException(
"Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
);
} else {
throw new InvalidArgumentException(
"Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."
);
}
}
}
try {
return match (strtolower($typeName)) {
'int', 'integer' => $this->castToInt($argument),
'string' => (string) $argument,
'bool', 'boolean' => $this->castToBoolean($argument),
'float', 'double' => $this->castToFloat($argument),
'array' => $this->castToArray($argument),
default => $argument,
};
} catch (TypeError $e) {
throw new InvalidArgumentException(
"Value cannot be cast to required type `{$typeName}`.",
0,
$e
);
}
}
/** Helper to cast strictly to boolean */
private function castToBoolean(mixed $argument): bool
{
if (is_bool($argument)) {
return $argument;
}
if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') {
return true;
}
if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') {
return false;
}
throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
}
/** Helper to cast strictly to integer */
private function castToInt(mixed $argument): int
{
if (is_int($argument)) {
return $argument;
}
if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) {
return (int) $argument;
}
if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
return (int) $argument;
}
throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
}
/** Helper to cast strictly to float */
private function castToFloat(mixed $argument): float
{
if (is_float($argument)) {
return $argument;
}
if (is_int($argument)) {
return (float) $argument;
}
if (is_numeric($argument)) {
return (float) $argument;
}
throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
}
/** Helper to cast strictly to array */
private function castToArray(mixed $argument): array
{
if (is_array($argument)) {
return $argument;
}
throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
}
public function toArray(): array
{
return [
'handler' => $this->handler,
'isManual' => $this->isManual,
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
```
--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockSseClient.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Tests\Mocks\Clients;
use Psr\Http\Message\ResponseInterface;
use React\EventLoop\Loop;
use React\Http\Browser;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Stream\ReadableStreamInterface;
use function React\Promise\reject;
class MockSseClient
{
public Browser $browser;
private ?ReadableStreamInterface $stream = null;
private string $buffer = '';
private array $receivedMessages = []; // Stores decoded JSON-RPC messages
private array $receivedSseEvents = []; // Stores raw SSE events (type, data, id)
public ?string $endpointUrl = null; // The /message endpoint URL provided by server
public ?string $clientId = null; // The clientId from the /message endpoint URL
public ?ResponseInterface $lastConnectResponse = null; // Last connect response for header testing
public function __construct(int $timeout = 2)
{
$this->browser = (new Browser())->withTimeout($timeout);
}
public function connect(string $sseBaseUrl): PromiseInterface
{
return $this->browser->requestStreaming('GET', $sseBaseUrl)
->then(function (ResponseInterface $response) {
$this->lastConnectResponse = $response; // Store response for header testing
if ($response->getStatusCode() !== 200) {
$body = (string) $response->getBody();
throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}");
}
$stream = $response->getBody();
assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream");
$this->stream = $stream;
$this->stream->on('data', [$this, 'handleSseData']);
$this->stream->on('close', function () {
$this->stream = null;
});
return $this;
});
}
public function handleSseData(string $chunk): void
{
$this->buffer .= $chunk;
while (($eventPos = strpos($this->buffer, "\n\n")) !== false) {
$eventBlock = substr($this->buffer, 0, $eventPos);
$this->buffer = substr($this->buffer, $eventPos + 2);
$lines = explode("\n", $eventBlock);
$event = ['type' => 'message', 'data' => '', 'id' => null];
foreach ($lines as $line) {
if (str_starts_with($line, "event:")) {
$event['type'] = trim(substr($line, strlen("event:")));
} elseif (str_starts_with($line, "data:")) {
$event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:")));
} elseif (str_starts_with($line, "id:")) {
$event['id'] = trim(substr($line, strlen("id:")));
}
}
$this->receivedSseEvents[] = $event;
if ($event['type'] === 'endpoint' && $event['data']) {
$this->endpointUrl = $event['data'];
$query = parse_url($this->endpointUrl, PHP_URL_QUERY);
if ($query) {
parse_str($query, $params);
$this->clientId = $params['clientId'] ?? null;
}
} elseif ($event['type'] === 'message' && $event['data']) {
try {
$decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR);
$this->receivedMessages[] = $decodedJson;
} catch (\JsonException $e) {
}
}
}
}
public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface
{
$deferred = new Deferred();
$startTime = microtime(true);
$checkMessages = null;
$checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) {
foreach ($this->receivedMessages as $i => $msg) {
if (isset($msg['id']) && $msg['id'] === $expectedRequestId) {
unset($this->receivedMessages[$i]); // Consume message
$this->receivedMessages = array_values($this->receivedMessages);
$deferred->resolve($msg);
return;
}
}
if (microtime(true) - $startTime > $timeoutSecs) {
$deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'"));
return;
}
if ($this->stream) {
Loop::addTimer(0.05, $checkMessages);
} else {
$deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'"));
}
};
$checkMessages(); // Start checking
return $deferred->promise();
}
public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface
{
$deferred = new Deferred();
$startTime = microtime(true);
$checkMessages = null;
$checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) {
foreach ($this->receivedMessages as $i => $msg) {
if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) {
$isLikelyBatchResponse = true;
if (empty($msg) && $expectedItemCount === 0) {
} elseif (empty($msg) && $expectedItemCount > 0) {
$isLikelyBatchResponse = false;
} else {
foreach ($msg as $item) {
if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) {
$isLikelyBatchResponse = false;
break;
}
}
}
if ($isLikelyBatchResponse) {
unset($this->receivedMessages[$i]);
$this->receivedMessages = array_values($this->receivedMessages);
$deferred->resolve($msg);
return;
}
}
}
if (microtime(true) - $startTime > $timeoutSecs) {
$deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items."));
return;
}
if ($this->stream) {
Loop::addTimer(0.05, $checkMessages);
} else {
$deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response."));
}
};
$checkMessages();
return $deferred->promise();
}
public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface
{
if (!$this->endpointUrl || !$this->clientId) {
return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
}
$payload = [
'jsonrpc' => '2.0',
'id' => $requestId,
'method' => $method,
'params' => $params,
];
$body = json_encode($payload);
return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
->then(function (ResponseInterface $response) use ($requestId) {
$bodyContent = (string) $response->getBody();
if ($response->getStatusCode() !== 202) {
throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
}
return $response;
});
}
public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface
{
if (!$this->endpointUrl || !$this->clientId) {
return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
}
$body = json_encode($batchRequestObjects);
return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
->then(function (ResponseInterface $response) {
$bodyContent = (string) $response->getBody();
if ($response->getStatusCode() !== 202) {
throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
}
return $response;
});
}
public function sendHttpNotification(string $method, array $params = []): PromiseInterface
{
if (!$this->endpointUrl || !$this->clientId) {
return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
}
$payload = [
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
];
$body = json_encode($payload);
return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
->then(function (ResponseInterface $response) {
$bodyContent = (string) $response->getBody();
if ($response->getStatusCode() !== 202) {
throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}");
}
return null;
});
}
public function close(): void
{
if ($this->stream) {
$this->stream->close();
$this->stream = null;
}
}
}
```
--------------------------------------------------------------------------------
/tests/Unit/Session/SessionTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Session;
use Mockery;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Session\Session;
const SESSION_ID_SESS = 'test-session-obj-id';
beforeEach(function () {
$this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault();
});
it('implements SessionInterface', function () {
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session)->toBeInstanceOf(SessionInterface::class);
});
// --- Constructor and ID Generation ---
it('uses provided ID if given', function () {
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->getId())->toBe(SESSION_ID_SESS);
});
it('generates an ID if none is provided', function () {
$this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false);
$session = new Session($this->sessionHandler);
expect($session->getId())->toBeString()->toHaveLength(32);
});
it('loads data from handler on construction if session exists', function () {
$initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]];
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->all())->toEqual($initialData);
expect($session->get('foo'))->toBe('bar');
});
it('initializes with empty data if handler read returns false', function () {
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->all())->toBeEmpty();
});
it('initializes with empty data if handler read returns invalid JSON', function () {
$this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json');
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->all())->toBeEmpty();
});
it('saves current data to handler', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->set('name', 'Alice');
$session->set('level', 10);
$expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]);
$this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true);
$session->save();
});
it('sets and gets a top-level attribute', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->set('name', 'Bob');
expect($session->get('name'))->toBe('Bob');
expect($session->has('name'))->toBeTrue();
});
it('gets default value if attribute does not exist', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->get('nonexistent', 'default_val'))->toBe('default_val');
expect($session->has('nonexistent'))->toBeFalse();
});
it('sets and gets nested attributes using dot notation', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->set('user.profile.email', '[email protected]');
$session->set('user.profile.active', true);
$session->set('user.roles', ['admin', 'editor']);
expect($session->get('user.profile'))->toEqual(['email' => '[email protected]', 'active' => true]);
expect($session->get('user.roles'))->toEqual(['admin', 'editor']);
expect($session->has('user.profile.email'))->toBeTrue();
expect($session->has('user.other_profile.settings'))->toBeFalse();
});
it('set does not overwrite if overwrite is false and key exists', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->set('counter', 10);
$session->set('counter', 20, false);
expect($session->get('counter'))->toBe(10);
$session->set('user.id', 1);
$session->set('user.id', 2, false);
expect($session->get('user.id'))->toBe(1);
});
it('set overwrites if overwrite is true (default)', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->set('counter', 10);
$session->set('counter', 20);
expect($session->get('counter'))->toBe(20);
});
it('forgets a top-level attribute', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30]));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->forget('age');
expect($session->has('age'))->toBeFalse();
expect($session->has('name'))->toBeTrue();
expect($session->all())->toEqual(['name' => 'Alice']);
});
it('forgets a nested attribute using dot notation', function () {
$initialData = ['user' => ['profile' => ['email' => '[email protected]', 'status' => 'active'], 'id' => 1]];
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->forget('user.profile.status');
expect($session->has('user.profile.status'))->toBeFalse();
expect($session->has('user.profile.email'))->toBeTrue();
expect($session->get('user.profile'))->toEqual(['email' => '[email protected]']);
$session->forget('user.profile');
expect($session->has('user.profile'))->toBeFalse();
expect($session->get('user'))->toEqual(['id' => 1]);
});
it('forget does nothing if key does not exist', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test']));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->forget('nonexistent');
$session->forget('another_nonexistent');
expect($session->all())->toEqual(['name' => 'Test']);
});
it('pulls an attribute (gets and forgets)', function () {
$initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']];
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$pulledItem = $session->pull('item', 'default');
expect($pulledItem)->toBe('important');
expect($session->has('item'))->toBeFalse();
$pulledToken = $session->pull('user.token');
expect($pulledToken)->toBe('abc123xyz');
expect($session->has('user.token'))->toBeFalse();
expect($session->has('user'))->toBeTrue();
$pulledNonExistent = $session->pull('nonexistent', 'fallback');
expect($pulledNonExistent)->toBe('fallback');
});
it('clears all session data', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2]));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$session->clear();
expect($session->all())->toBeEmpty();
});
it('returns all data with all()', function () {
$data = ['a' => 1, 'b' => ['c' => 3]];
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->all())->toEqual($data);
});
it('hydrates session data, merging with defaults and removing id', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$newAttributes = [
'client_info' => ['name' => 'TestClient', 'version' => '1.1'],
'protocol_version' => '2024-custom',
'user_custom_key' => 'my_value',
'id' => 'should_be_ignored_on_hydrate'
];
$session->hydrate($newAttributes);
$allData = $session->all();
expect($allData['initialized'])->toBeFalse();
expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']);
expect($allData['protocol_version'])->toBe('2024-custom');
expect($allData['message_queue'])->toEqual([]);
expect($allData['log_level'])->toBeNull();
expect($allData['user_custom_key'])->toBe('my_value');
expect($allData)->not->toHaveKey('id');
});
it('queues messages correctly', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect($session->hasQueuedMessages())->toBeFalse();
$msg1 = '{"jsonrpc":"2.0","method":"n1"}';
$msg2 = '{"jsonrpc":"2.0","method":"n2"}';
$session->queueMessage($msg1);
$session->queueMessage($msg2);
expect($session->hasQueuedMessages())->toBeTrue();
expect($session->get('message_queue'))->toEqual([$msg1, $msg2]);
});
it('dequeues messages and clears queue', function () {
$this->sessionHandler->shouldReceive('read')->andReturn(false);
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
$msg1 = '{"id":1}';
$msg2 = '{"id":2}';
$session->queueMessage($msg1);
$session->queueMessage($msg2);
$dequeued = $session->dequeueMessages();
expect($dequeued)->toEqual([$msg1, $msg2]);
expect($session->hasQueuedMessages())->toBeFalse();
expect($session->get('message_queue', 'not_found'))->toEqual([]);
expect($session->dequeueMessages())->toEqual([]);
});
it('jsonSerializes to all session data', function () {
$data = ['serialize' => 'me', 'nested' => ['ok' => true]];
$this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
$session = new Session($this->sessionHandler, SESSION_ID_SESS);
expect(json_encode($session))->toBe(json_encode($data));
});
```
--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredResourceTemplateTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit\Elements;
use Mockery;
use PhpMcp\Schema\ResourceTemplate;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Elements\RegisteredResourceTemplate;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
use Psr\Container\ContainerInterface;
use PhpMcp\Schema\Annotations;
beforeEach(function () {
$this->container = Mockery::mock(ContainerInterface::class);
$this->handlerInstance = new ResourceHandlerFixture();
$this->container->shouldReceive('get')
->with(ResourceHandlerFixture::class)
->andReturn($this->handlerInstance)
->byDefault();
$this->templateUri = 'item://{category}/{itemId}/details';
$this->resourceTemplateSchema = ResourceTemplate::make(
$this->templateUri,
'item-details-template',
mimeType: 'application/json'
);
$this->defaultHandlerMethod = 'getUserDocument';
$this->matchingTemplateSchema = ResourceTemplate::make(
'user://{userId}/doc/{documentId}',
'user-doc-template',
mimeType: 'application/json'
);
$this->context = new Context(Mockery::mock(SessionInterface::class));
});
it('constructs correctly with schema, handler, and completion providers', function () {
$completionProviders = [
'userId' => CompletionProviderFixture::class,
'documentId' => 'Another\ProviderClass'
];
$schema = ResourceTemplate::make(
'user://{userId}/doc/{documentId}',
'user-doc-template',
mimeType: 'application/json'
);
$template = RegisteredResourceTemplate::make(
schema: $schema,
handler: [ResourceHandlerFixture::class, 'getUserDocument'],
completionProviders: $completionProviders
);
expect($template->schema)->toBe($schema);
expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
expect($template->isManual)->toBeFalse();
expect($template->completionProviders)->toEqual($completionProviders);
expect($template->completionProviders['userId'])->toBe(CompletionProviderFixture::class);
expect($template->completionProviders['documentId'])->toBe('Another\ProviderClass');
expect($template->completionProviders)->not->toHaveKey('nonExistentVar');
});
it('can be made as a manual registration', function () {
$schema = ResourceTemplate::make(
'user://{userId}/doc/{documentId}',
'user-doc-template',
mimeType: 'application/json'
);
$manualTemplate = RegisteredResourceTemplate::make(
schema: $schema,
handler: [ResourceHandlerFixture::class, 'getUserDocument'],
isManual: true
);
expect($manualTemplate->isManual)->toBeTrue();
});
dataset('uri_template_matching_cases', [
'simple_var' => ['user://{userId}', 'user://12345', ['userId' => '12345']],
'simple_var_alpha' => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']],
'no_match_missing_var_part' => ['user://{userId}', 'user://', null],
'no_match_prefix' => ['user://{userId}', 'users://12345', null],
'multi_var' => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']],
'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment
'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null],
'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']],
'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null],
'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']],
'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null],
'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null],
'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']],
]);
it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) {
$schema = ResourceTemplate::make($templateString, 'test-match');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
if ($expectedVariables !== null) {
expect($template->matches($uriToTest))->toBeTrue();
$reflection = new \ReflectionClass($template);
$prop = $reflection->getProperty('uriVariables');
$prop->setAccessible(true);
expect($prop->getValue($template))->toEqual($expectedVariables);
} else {
expect($template->matches($uriToTest))->toBeFalse();
}
})->with('uri_template_matching_cases');
it('gets variable names from compiled template', function () {
$schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']);
});
it('reads resource using handler with extracted URI variables', function () {
$uriTemplate = 'item://{category}/{itemId}?format={format}';
$uri = 'item://electronics/tv-123?format=json_pretty';
$schema = ResourceTemplate::make($uriTemplate, 'item-details-template');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
expect($template->matches($uri))->toBeTrue();
$resultContents = $template->read($this->container, $uri, $this->context);
expect($resultContents)->toBeArray()->toHaveCount(1);
$content = $resultContents[0];
expect($content)->toBeInstanceOf(TextResourceContents::class);
expect($content->uri)->toBe($uri);
expect($content->mimeType)->toBe('application/json');
$decodedText = json_decode($content->text, true);
expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty.");
expect($decodedText['category_received'])->toBe('electronics');
expect($decodedText['itemId_received'])->toBe('tv-123');
expect($decodedText['format_received'])->toBe('json_pretty');
});
it('uses mimeType from schema if handler result does not specify one', function () {
$uriTemplate = 'item://{category}/{itemId}?format={format}';
$uri = 'item://books/bestseller?format=json_pretty';
$schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
expect($template->matches($uri))->toBeTrue();
$resultContents = $template->read($this->container, $uri, $this->context);
expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml');
});
it('formats a simple string result from handler correctly for template', function () {
$uri = 'item://tools/hammer';
$schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'returnStringText']);
expect($template->matches($uri))->toBeTrue();
$mockHandler = Mockery::mock(ResourceHandlerFixture::class);
$mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler');
$this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler);
$resultContents = $template->read($this->container, $uri, $this->context);
expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class)
->and($resultContents[0]->text)->toBe('Simple content from template handler')
->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema
});
it('propagates exceptions from handler during read', function () {
$uri = 'item://tools/hammer';
$schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
$template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
expect($template->matches($uri))->toBeTrue();
$template->read($this->container, $uri, $this->context);
})->throws(\DomainException::class, "Cannot read resource");
it('can be serialized to array and deserialized', function () {
$schema = ResourceTemplate::make(
'obj://{type}/{id}',
'my-template',
mimeType: 'application/template+json',
annotations: Annotations::make(priority: 0.7)
);
$providers = ['type' => CompletionProviderFixture::class];
$serializedProviders = ['type' => serialize(CompletionProviderFixture::class)];
$original = RegisteredResourceTemplate::make(
$schema,
[ResourceHandlerFixture::class, 'getUserDocument'],
true,
$providers
);
$array = $original->toArray();
expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}');
expect($array['schema']['name'])->toBe('my-template');
expect($array['schema']['mimeType'])->toBe('application/template+json');
expect($array['schema']['annotations']['priority'])->toBe(0.7);
expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
expect($array['isManual'])->toBeTrue();
expect($array['completionProviders'])->toEqual($serializedProviders);
$rehydrated = RegisteredResourceTemplate::fromArray($array);
expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class);
expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate);
expect($rehydrated->schema->name)->toEqual($original->schema->name);
expect($rehydrated->isManual)->toBeTrue();
expect($rehydrated->completionProviders)->toEqual($providers);
});
it('fromArray returns false on failure', function () {
$badData = ['schema' => ['uriTemplate' => 'fail']];
expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse();
});
```
--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockStreamHttpClient.php:
--------------------------------------------------------------------------------
```php
<?php
declare(strict_types=1);
namespace PhpMcp\Server\Tests\Mocks\Clients;
use Psr\Http\Message\ResponseInterface;
use React\EventLoop\Loop;
use React\Http\Browser;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Stream\ReadableStreamInterface;
use function React\Promise\reject;
class MockStreamHttpClient
{
public Browser $browser;
public string $baseMcpUrl;
public ?string $sessionId = null;
private ?ReadableStreamInterface $mainSseGetStream = null;
private string $mainSseGetBuffer = '';
private array $mainSseReceivedNotifications = [];
public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2)
{
$this->browser = (new Browser())->withTimeout($timeout);
$this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}";
}
public function connectMainSseStream(): PromiseInterface
{
if (!$this->sessionId) {
return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first."));
}
return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [
'Accept' => 'text/event-stream',
'Mcp-Session-Id' => $this->sessionId
])
->then(function (ResponseInterface $response) {
if ($response->getStatusCode() !== 200) {
$body = (string) $response->getBody();
throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}");
}
$stream = $response->getBody();
assert($stream instanceof ReadableStreamInterface);
$this->mainSseGetStream = $stream;
$this->mainSseGetStream->on('data', function ($chunk) {
$this->mainSseGetBuffer .= $chunk;
$this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications);
});
return $this;
});
}
private function processBufferForNotifications(string &$buffer, array &$targetArray): void
{
while (($eventPos = strpos($buffer, "\n\n")) !== false) {
$eventBlock = substr($buffer, 0, $eventPos);
$buffer = substr($buffer, $eventPos + 2);
$lines = explode("\n", $eventBlock);
$eventData = '';
foreach ($lines as $line) {
if (str_starts_with($line, "data:")) {
$eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:")));
}
}
if (!empty($eventData)) {
try {
$decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR);
if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) {
$targetArray[] = $decodedJson;
}
} catch (\JsonException $e) { /* ignore non-json data lines or log */
}
}
}
}
public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface
{
$payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id];
$headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
$body = json_encode($payload);
return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
->then(function (ResponseInterface $response) use ($id) {
$statusCode = $response->getStatusCode();
if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}");
}
$this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
$stream = $response->getBody();
assert($stream instanceof ReadableStreamInterface);
return $this->collectSingleSseResponse($stream, $id, "Initialize");
});
}
public function sendRequest(string $method, array $params, string $id, array $additionalHeaders = []): PromiseInterface
{
$payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id];
$headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
if ($this->sessionId) {
$headers['Mcp-Session-Id'] = $this->sessionId;
}
$headers += $additionalHeaders;
$body = json_encode($payload);
return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
->then(function (ResponseInterface $response) use ($id, $method) {
$statusCode = $response->getStatusCode();
if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
$bodyContent = (string) $response->getBody();
throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}");
}
$stream = $response->getBody();
assert($stream instanceof ReadableStreamInterface);
return $this->collectSingleSseResponse($stream, $id, $method);
});
}
public function sendBatchRequest(array $batchPayload): PromiseInterface
{
if (!$this->sessionId) {
return reject(new \LogicException("Session ID not set. Initialize first for batch request."));
}
$headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId];
$body = json_encode($batchPayload);
return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
->then(function (ResponseInterface $response) {
$statusCode = $response->getStatusCode();
if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}");
}
$stream = $response->getBody();
assert($stream instanceof ReadableStreamInterface);
return $this->collectSingleSseResponse($stream, null, "Batch", true);
});
}
private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface
{
$deferred = new Deferred();
$buffer = '';
$streamClosed = false;
$dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) {
if ($streamClosed) return;
$buffer .= $chunk;
if (str_contains($buffer, "event: message\n")) {
if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) {
$jsonData = trim($matches[1]);
try {
$decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR);
$isValid = false;
if ($expectBatchArray) {
$isValid = is_array($decoded) && !isset($decoded['jsonrpc']);
} else {
$isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId;
}
if ($isValid) {
$deferred->resolve($decoded);
$stream->removeListener('data', $dataListener);
$stream->close();
return;
}
} catch (\JsonException $e) {
$deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e));
$stream->removeListener('data', $dataListener);
$stream->close();
return;
}
}
}
};
$stream->on('data', $dataListener);
$stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) {
$streamClosed = true;
$deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received."));
});
$stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) {
$streamClosed = true;
$deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null));
});
return timeout($deferred->promise(), 2, Loop::get())
->finally(function () use ($stream, $dataListener) {
if ($stream->isReadable()) {
$stream->removeListener('data', $dataListener);
}
});
}
public function sendHttpNotification(string $method, array $params = []): PromiseInterface
{
if (!$this->sessionId) {
return reject(new \LogicException("Session ID not set for notification. Initialize first."));
}
$payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params];
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId];
$body = json_encode($payload);
return $this->browser->post($this->baseMcpUrl, $headers, $body)
->then(function (ResponseInterface $response) {
$statusCode = $response->getStatusCode();
if ($statusCode !== 202) {
throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody());
}
return ['statusCode' => $statusCode, 'body' => null];
});
}
public function sendDeleteRequest(): PromiseInterface
{
if (!$this->sessionId) {
return reject(new \LogicException("Session ID not set for DELETE request. Initialize first."));
}
$headers = ['Mcp-Session-Id' => $this->sessionId];
return $this->browser->request('DELETE', $this->baseMcpUrl, $headers)
->then(function (ResponseInterface $response) {
$statusCode = $response->getStatusCode();
return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()];
});
}
public function closeMainSseStream(): void
{
if ($this->mainSseGetStream) {
$this->mainSseGetStream->close();
$this->mainSseGetStream = null;
}
}
}
```
--------------------------------------------------------------------------------
/tests/Unit/ServerTest.php:
--------------------------------------------------------------------------------
```php
<?php
namespace PhpMcp\Server\Tests\Unit;
use LogicException;
use Mockery;
use Mockery\MockInterface;
use PhpMcp\Server\Configuration;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\DiscoveryException;
use PhpMcp\Schema\Implementation;
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Server\Protocol;
use PhpMcp\Server\Registry;
use PhpMcp\Server\Server;
use PhpMcp\Server\Session\ArraySessionHandler;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Utils\Discoverer;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
beforeEach(function () {
/** @var MockInterface&LoggerInterface $logger */
$this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
/** @var MockInterface&LoopInterface $loop */
$this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing();
/** @var MockInterface&CacheInterface $cache */
$this->cache = Mockery::mock(CacheInterface::class);
/** @var MockInterface&ContainerInterface $container */
$this->container = Mockery::mock(ContainerInterface::class);
$this->configuration = new Configuration(
serverInfo: Implementation::make('TestServerInstance', '1.0'),
capabilities: ServerCapabilities::make(),
logger: $this->logger,
loop: $this->loop,
cache: $this->cache,
container: $this->container
);
/** @var MockInterface&Registry $registry */
$this->registry = Mockery::mock(Registry::class);
/** @var MockInterface&Protocol $protocol */
$this->protocol = Mockery::mock(Protocol::class);
/** @var MockInterface&Discoverer $discoverer */
$this->discoverer = Mockery::mock(Discoverer::class);
$this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop);
$this->server = new Server(
$this->configuration,
$this->registry,
$this->protocol,
$this->sessionManager
);
$this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault();
$this->registry->allows('clear')->withAnyArgs()->byDefault();
$this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault();
});
afterEach(function () {
$this->sessionManager->stopGcTimer();
});
it('provides getters for core components', function () {
expect($this->server->getConfiguration())->toBe($this->configuration);
expect($this->server->getRegistry())->toBe($this->registry);
expect($this->server->getProtocol())->toBe($this->protocol);
expect($this->server->getSessionManager())->toBe($this->sessionManager);
});
it('provides a static make method returning ServerBuilder', function () {
expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class);
});
it('skips discovery if already run and not forced', function () {
$reflector = new \ReflectionClass($this->server);
$prop = $reflector->getProperty('discoveryRan');
$prop->setAccessible(true);
$prop->setValue($this->server, true);
$this->registry->shouldNotReceive('clear');
$this->discoverer->shouldNotReceive('discover');
$this->registry->shouldNotReceive('save');
$this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer);
$this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.');
});
it('forces discovery even if already run, calling injected discoverer', function () {
$reflector = new \ReflectionClass($this->server);
$prop = $reflector->getProperty('discoveryRan');
$prop->setAccessible(true);
$prop->setValue($this->server, true);
$basePath = realpath(sys_get_temp_dir());
$scanDirs = ['.', 'src'];
$this->registry->shouldReceive('clear')->once();
$this->discoverer->shouldReceive('discover')
->with($basePath, $scanDirs, Mockery::type('array'))
->once();
$this->registry->shouldReceive('save')->once()->andReturn(true);
$this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer);
expect($prop->getValue($this->server))->toBeTrue();
});
it('calls registry clear and discoverer, then saves to cache by default', function () {
$basePath = realpath(sys_get_temp_dir());
$scanDirs = ['app', 'lib'];
$userExcludeDirs = ['specific_exclude'];
$finalExcludeDirs = array_unique(array_merge(
['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'],
$userExcludeDirs
));
$this->registry->shouldReceive('clear')->once();
$this->discoverer->shouldReceive('discover')
->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) {
expect($arg)->toBeArray();
expect($arg)->toEqualCanonicalizing($finalExcludeDirs);
return true;
}))
->once();
$this->registry->shouldReceive('save')->once()->andReturn(true);
$this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer);
$reflector = new \ReflectionClass($this->server);
$prop = $reflector->getProperty('discoveryRan');
$prop->setAccessible(true);
expect($prop->getValue($this->server))->toBeTrue();
});
it('does not save to cache if saveToCache is false', function () {
$basePath = realpath(sys_get_temp_dir());
$this->registry->shouldReceive('clear')->once();
$this->discoverer->shouldReceive('discover')->once();
$this->registry->shouldNotReceive('save');
$this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer);
});
it('throws InvalidArgumentException for bad base path in discover', function () {
$this->discoverer->shouldNotReceive('discover');
$this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer);
})->throws(\InvalidArgumentException::class, 'Invalid discovery base path');
it('throws DiscoveryException if Discoverer fails during discovery', function () {
$basePath = realpath(sys_get_temp_dir());
$this->registry->shouldReceive('clear')->once();
$this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error'));
$this->registry->shouldNotReceive('save');
$this->server->discover($basePath, discoverer: $this->discoverer);
})->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error');
it('resets discoveryRan flag on Discoverer failure', function () {
$basePath = realpath(sys_get_temp_dir());
$this->registry->shouldReceive('clear')->once();
$this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure'));
try {
$this->server->discover($basePath, discoverer: $this->discoverer);
} catch (DiscoveryException $e) {
// Expected
}
$reflector = new \ReflectionClass($this->server);
$prop = $reflector->getProperty('discoveryRan');
$prop->setAccessible(true);
expect($prop->getValue($this->server))->toBeFalse();
});
// --- Listening Tests ---
it('throws LogicException if listen is called when already listening', function () {
$transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
$this->protocol->shouldReceive('bindTransport')->with($transport)->once();
$this->server->listen($transport, false);
$this->server->listen($transport, false);
})->throws(LogicException::class, 'Server is already listening');
it('warns if no elements and discovery not run when listen is called', function () {
$transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
$this->protocol->shouldReceive('bindTransport')->with($transport)->once();
$this->registry->shouldReceive('hasElements')->andReturn(false);
$this->logger->shouldReceive('warning')
->once()
->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/'));
$this->server->listen($transport, false);
});
it('injects logger and loop into aware transports during listen', function () {
$transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class);
$transport->shouldReceive('setLogger')->with($this->logger)->once();
$transport->shouldReceive('setLoop')->with($this->loop)->once();
$transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs();
$this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs();
$this->server->listen($transport);
});
it('binds protocol, starts transport listener, and runs loop by default', function () {
$transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
$transport->shouldReceive('listen')->once();
$this->protocol->shouldReceive('bindTransport')->with($transport)->once();
$this->loop->shouldReceive('run')->once();
$this->protocol->shouldReceive('unbindTransport')->once();
$this->server->listen($transport);
expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
});
it('does not run loop if runLoop is false in listen', function () {
$transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
$this->protocol->shouldReceive('bindTransport')->with($transport)->once();
$this->loop->shouldNotReceive('run');
$this->server->listen($transport, runLoop: false);
expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue();
$this->protocol->shouldReceive('unbindTransport');
$transport->shouldReceive('removeAllListeners');
$transport->shouldReceive('close');
$this->server->endListen($transport);
});
it('calls endListen if transport listen throws immediately', function () {
$transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
$transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use"));
$this->protocol->shouldReceive('bindTransport')->once();
$this->protocol->shouldReceive('unbindTransport')->once();
$this->loop->shouldNotReceive('run');
try {
$this->server->listen($transport);
} catch (\RuntimeException $e) {
expect($e->getMessage())->toBe("Port in use");
}
expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
});
it('endListen unbinds protocol and closes transport if listening', function () {
$transport = Mockery::mock(ServerTransportInterface::class);
$reflector = new \ReflectionClass($this->server);
$prop = $reflector->getProperty('isListening');
$prop->setAccessible(true);
$prop->setValue($this->server, true);
$this->protocol->shouldReceive('unbindTransport')->once();
$transport->shouldReceive('removeAllListeners')->with('close')->once();
$transport->shouldReceive('close')->once();
$this->server->endListen($transport);
expect($prop->getValue($this->server))->toBeFalse();
});
```