diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..47d2917 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,31 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index ab9b838..4566720 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ workbench playground # Log files -*.log \ No newline at end of file +*.log + +# Cache files +cache \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ca76662 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to `php-mcp/server` will be documented in this file. + +## Release v1.0.0 - Initial Release + +🚀 **Initial release of PHP MCP SERVER!** + +This release introduces the core implementation of the Model Context Protocol (MCP) server for PHP applications. The goal is to provide a robust, flexible, and developer-friendly way to expose parts of your PHP application as MCP Tools, Resources, and Prompts, enabling standardized communication with AI assistants like Claude, Cursor, and others. + +### ✨ Key Features: + +* **Attribute-Based Definitions:** Easily define MCP Tools (`#[McpTool]`), Resources (`#[McpResource]`, `#[McpResourceTemplate]`), and Prompts (`#[McpPrompt]`) using PHP 8 attributes directly on your methods. +* **Automatic Metadata Inference:** Leverages method signatures (parameters, type hints) and DocBlocks (`@param`, `@return`, summaries) to automatically generate MCP schemas and descriptions, minimizing boilerplate. +* **PSR Compliance:** Integrates seamlessly with standard PHP interfaces: + * `PSR-3` (LoggerInterface) for flexible logging. + * `PSR-11` (ContainerInterface) for dependency injection and class resolution. + * `PSR-16` (SimpleCacheInterface) for caching discovered elements and transport state. +* **Automatic Discovery:** Scans configured directories to find and register your annotated MCP elements. +* **Flexible Configuration:** Uses a configuration repository (`ConfigurationRepositoryInterface`) for fine-grained control over server behaviour, capabilities, and caching. +* **Multiple Transports:** + * Built-in support for the `stdio` transport, ideal for command-line driven clients. + * Includes `HttpTransportHandler` components for building standard `http` (HTTP+SSE) transports (requires integration into an HTTP server). + * Provides `ReactPhpHttpTransportHandler` for seamless integration with asynchronous ReactPHP applications. +* **Protocol Support:** Implements the `2024-11-05` version of the Model Context Protocol. +* **Framework Agnostic:** Designed to work in vanilla PHP projects or integrated into any framework. + +### 🚀 Getting Started + +Please refer to the [README.md](README.md) for detailed installation instructions, usage examples, and core concepts. Sample implementations for `stdio` and `reactphp` are available in the `samples/` directory. + +### ⚠️ Important Notes + +* When implementing the `http` transport using `HttpTransportHandler`, be aware of the critical server environment requirements detailed in the README regarding concurrent request handling for SSE. Standard synchronous PHP servers (like `php artisan serve` or basic Apache/Nginx setups) are generally **not suitable** without proper configuration for concurrency (e.g., PHP-FPM with multiple workers, Octane, Swoole, ReactPHP, RoadRunner, FrankenPHP). + +### Future Plans + +While this package focuses on the server implementation, future projects within the `php-mcp` organization may include client libraries and other utilities related to MCP in PHP. diff --git a/README.md b/README.md index 1512241..5b8bea6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ This package currently supports the `2024-11-05` version of the Model Context Pr ## Key Features -* **Attribute-Based Definition:** Define MCP elements (Tools, Resources, Prompts, Templates) using simple PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`, `#[McpTemplate]`). +* **Attribute-Based Definition:** Define MCP elements (Tools, Resources, Prompts, Templates) using simple PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`, `#[McpTemplate]`) on your methods or **directly on invokable classes**. +* **Manual Registration:** Programmatically register MCP elements using fluent methods on the `Server` instance (e.g., `->withTool()`, `->withResource()`). * **Automatic Metadata Inference:** Leverages method names, parameter names, PHP type hints (for schema), and DocBlocks (for schema and descriptions) to automatically generate MCP definitions, minimizing boilerplate code. * **PSR Compliant:** Integrates seamlessly with standard PHP interfaces: * `PSR-3` (LoggerInterface): Bring your own logger (e.g., Monolog). @@ -41,7 +42,7 @@ You can install the package via Composer: composer require php-mcp/server ``` -> **Note:** For Laravel applications, consider using the dedicated [`php-mcp/laravel-server`](https://github.com/php-mcp/laravel) package. It builds upon this core library, providing helpful integrations, configuration options, and Artisan commands specifically tailored for the Laravel framework. +> **Note:** For Laravel applications, consider using the dedicated [`php-mcp/laravel`](https://github.com/php-mcp/laravel) package. It builds upon this core library, providing helpful integrations, configuration options, and Artisan commands specifically tailored for the Laravel framework. ## Getting Started: A Simple `stdio` Server @@ -130,18 +131,32 @@ Now, when you connect your client, it should discover the `adder` tool. ## Core Concepts -The primary way to expose functionality through `php-mcp/server` is by decorating your PHP methods with specific Attributes. The server automatically discovers these attributes and translates them into the corresponding MCP definitions. +The primary ways to expose functionality through `php-mcp/server` are: -### `#[McpTool]` +1. **Attribute Discovery:** Decorating your PHP methods or invokable classes with specific Attributes (`#[McpTool]`, `#[McpResource]`, etc.). The server automatically discovers these during the `->discover()` process. +2. **Manual Registration:** Using fluent methods (`->withTool()`, `->withResource()`, etc.) on the `Server` instance before running it. -Marks a method as an MCP Tool. Tools represent actions or functions the client can invoke, often with parameters. +### Attributes for Discovery + +These attributes mark classes or methods to be found by the `->discover()` process. + +#### `#[McpTool]` + +Marks a method **or an invokable class** as an MCP Tool. Tools represent actions or functions the client can invoke, often with parameters. + +**Usage:** + +* **On a Method:** Place the attribute directly above a public, non-static method. +* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The `__invoke` method will be treated as the tool's handler. The attribute accepts the following parameters: -* `name` (optional): The name of the tool exposed to the client. Defaults to the method name (e.g., `addNumbers` becomes `addNumbers`). -* `description` (optional): A description for the tool. Defaults to the method's DocBlock summary. +* `name` (optional): The name of the tool exposed to the client. + * When on a method, defaults to the method name (e.g., `addNumbers` becomes `addNumbers`). + * When on an invokable class, defaults to the class's short name (e.g., `class AdderTool` becomes `AdderTool`). +* `description` (optional): A description for the tool. Defaults to the method's DocBlock summary (or the `__invoke` method's summary if on a class). -The method's parameters (including name, type hints, and defaults) define the tool's input schema. The method's return type hint defines the output schema. DocBlock `@param` and `@return` descriptions are used for parameter/output descriptions. +The parameters (including name, type hints, and defaults) of the target method (or `__invoke`) define the tool's input schema. The return type hint defines the output schema. DocBlock `@param` and `@return` descriptions are used for parameter/output descriptions. **Return Value Formatting** @@ -179,22 +194,43 @@ public function getPhpCode(): TextContent { return TextContent::code(' ['user']]`). -The method should return the content of the resource. +The target method (or `__invoke`) should return the content of the resource. **Return Value Formatting** @@ -215,18 +251,33 @@ public function getSystemLoad(): string { return file_get_contents('/proc/loadavg'); } + +/** + * An invokable class providing system load resource. + */ +#[McpResource(uri: 'status://system/load/invokable', mimeType: 'text/plain')] +class SystemLoadResource { + public function __invoke(): string { + return file_get_contents('/proc/loadavg'); + } +} ``` -### `#[McpResourceTemplate]` +#### `#[McpResourceTemplate]` + +Marks a method **or an invokable class** that can generate resource instances based on a template URI. This is useful for resources whose URI contains variable parts (like user IDs or document IDs). The target method (or `__invoke`) will be called when a client performs a `resources/read` matching the template. -Marks a method that can generate resource instances based on a template URI. This is useful for resources whose URI contains variable parts (like user IDs or document IDs). The method will be called when a client performs a `resources/read` matching the template. +**Usage:** + +* **On a Method:** Place the attribute directly above a public, non-static method. +* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The attribute accepts the following parameters: * `uriTemplate` (required): The URI template string, conforming to [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) (e.g., `user://{userId}/profile`, `document://{docId}?format={fmt}`). -* `name`, `description`, `mimeType`, `annotations` (optional): Similar to `#[McpResource]`, but describe the template itself. +* `name`, `description`, `mimeType`, `annotations` (optional): Similar to `#[McpResource]`, but describe the template itself. Defaults inferred from method/class name and DocBlocks. -The method parameters *must* match the variables defined in the `uriTemplate`. The method should return the content for the resolved resource instance. +The parameters of the target method (or `__invoke`) *must* match the variables defined in the `uriTemplate`. The method should return the content for the resolved resource instance. **Return Value Formatting** @@ -245,18 +296,39 @@ public function getUserProfile(string $userId): array // Fetch user profile for $userId return ['id' => $userId, /* ... */ ]; } + +/** + * An invokable class providing user profiles via template. + */ +#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile/invokable', name: 'user_profile_invokable', mimeType: 'application/json')] +class UserProfileTemplate { + /** + * Gets a user's profile data. + * @param string $userId The user ID from the URI. + * @return array The user profile. + */ + public function __invoke(string $userId): array { + // Fetch user profile for $userId + return ['id' => $userId, 'source' => 'invokable', /* ... */ ]; + } +} ``` -### `#[McpPrompt]` +#### `#[McpPrompt]` + +Marks a method **or an invokable class** as an MCP Prompt generator. Prompts are pre-defined templates or functions that generate conversational messages (like user or assistant turns) based on input parameters. -Marks a method as an MCP Prompt generator. Prompts are pre-defined templates or functions that generate conversational messages (like user or assistant turns) based on input parameters. +**Usage:** + +* **On a Method:** Place the attribute directly above a public, non-static method. +* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The attribute accepts the following parameters: -* `name` (optional): The prompt name. Defaults to method name. -* `description` (optional): Description. Defaults to DocBlock summary. +* `name` (optional): The prompt name. Defaults to method name or class short name. +* `description` (optional): Description. Defaults to DocBlock summary of the method or `__invoke`. -Method parameters define the prompt's input arguments. The method should return the prompt content, typically an array conforming to the MCP message structure. +Method parameters (or `__invoke` parameters) define the prompt's input arguments. The method should return the prompt content, typically an array conforming to the MCP message structure. **Return Value Formatting** @@ -286,42 +358,83 @@ public function generateSummaryPrompt(string $textToSummarize): array ['role' => 'user', 'content' => "Summarize the following text:\n\n{$textToSummarize}"], ]; } + +/** + * An invokable class generating a summary prompt. + */ +#[McpPrompt(name: 'summarize_invokable')] +class SummarizePrompt { + /** + * Generates a prompt to summarize text. + * @param string $textToSummarize The text to summarize. + * @return array The prompt messages. + */ + public function __invoke(string $textToSummarize): array { + return [ + ['role' => 'user', 'content' => "[Invokable] Summarize: + +{$textToSummarize}"], + ]; + } +} ``` ### The `Server` Fluent Interface -The `PhpMcp\Server\Server` class is the main entry point for configuring and running your MCP server. It provides a fluent interface (method chaining) to set up dependencies and parameters. - -* **`Server::make(): self`**: Static factory method to create a new server instance. -* **`->withLogger(LoggerInterface $logger): self`**: Provide a PSR-3 compliant logger implementation. Defaults to a basic `StreamLogger` writing to `STDERR` at `LogLevel::INFO`. -* **`->withCache(CacheInterface $cache): self`**: Provide a PSR-16 compliant cache implementation. Used for caching discovered MCP elements and potentially transport state. Defaults to a simple in-memory `ArrayCache`. -* **`->withContainer(ContainerInterface $container): self`**: Provide a PSR-11 compliant DI container. This container will be used to instantiate the classes containing your `#[Mcp*]` attributed methods when they need to be called. Defaults to a very basic `BasicContainer` that can only resolve explicitly set services. -* **`->withConfig(ConfigurationRepositoryInterface $config): self`**: Provide a custom configuration repository. This allows overriding all default settings, including enabled capabilities, protocol versions, cache keys/TTL, etc. Defaults to `ArrayConfigurationRepository` with predefined defaults. -* **`->withBasePath(string $path): self`**: Set the absolute base path for directory scanning during discovery. Defaults to the current working directory (`getcwd()`). -* **`->withScanDirectories(array $dirs): self`**: Specify an array of directory paths *relative* to the `basePath` where the server should look for annotated methods. Defaults to `['.']` (the base path itself). -* **`->discover(bool $clearCacheFirst = true): self`**: Initiates the discovery process. Scans the configured directories for attributes, builds the internal registry of MCP elements, and caches them using the provided cache implementation. Set `$clearCacheFirst` to `false` to attempt loading from cache without re-scanning. -* **`->run(?string $transport = null): int`**: Starts the server's main processing loop using the specified transport. +The `PhpMcp\Server\Server` class is the main entry point for configuring and running your MCP server. It provides a fluent interface (method chaining) to set up dependencies, parameters, and manually register elements. + +* **`Server::make(): self`**: Static factory method to create a new server instance. It initializes the server with default implementations for core services (Logger, Cache, Config, Container). +* **`->withLogger(LoggerInterface $logger): self`**: Provide a PSR-3 compliant logger implementation. By default, logging is disabled unless a logger is explicitly provided here (when using the default container) or registered in a custom container. + * **If using the default `BasicContainer`:** This method replaces the default no-op logger and updates the registration within the `BasicContainer`, enabling logging. + * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `LoggerInterface` directly within your container setup to enable logging. +* **`->withCache(CacheInterface $cache): self`**: Provide a PSR-16 compliant cache implementation. + * **If using the default `BasicContainer`:** This method replaces the default `FileCache` instance and updates the registration within the `BasicContainer`. + * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `CacheInterface` directly within your container setup. +* **`->withContainer(ContainerInterface $container): self`**: Provide a PSR-11 compliant DI container. + * When called, the server will **use this container** for all dependency resolution, including its internal needs and instantiating your handler classes. + * **Crucially, you MUST ensure this container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`**, as the server relies on these. + * If not called, the server uses its internal `BasicContainer` with built-in defaults. +* **`->withConfig(ConfigurationRepositoryInterface $config): self`**: Provide a custom configuration repository. + * **If using the default `BasicContainer`:** This method replaces the default `ArrayConfigurationRepository` instance and updates the registration within the `BasicContainer`. + * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `ConfigurationRepositoryInterface` directly within your container setup. +* **`->withBasePath(string $path): self`**: Set the absolute base path for directory scanning during discovery. Defaults to the parent directory of `vendor/php-mcp/server`. +* **`->withScanDirectories(array $dirs): self`**: Specify an array of directory paths *relative* to the `basePath` where the server should look for annotated classes/methods during discovery. Defaults to `['.', 'src/MCP']`. +* **`->withExcludeDirectories(array $dirs): self`**: Specify an array of directory paths *relative* to the `basePath` to *exclude* from scanning during discovery. Defaults to common directories like `['vendor', 'tests', 'storage', 'cache', 'node_modules']`. Added directories are merged with defaults. +* **`->withTool(array|string $handler, ?string $name = null, ?string $description = null): self`**: Manually registers a tool. +* **`->withResource(array|string $handler, string $uri, ?string $name = null, ...): self`**: Manually registers a resource. +* **`->withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self`**: Manually registers a prompt. +* **`->withResourceTemplate(array|string $handler, ?string $name = null, ..., string $uriTemplate, ...): self`**: Manually registers a resource template. +* **`->discover(bool $cache = true): self`**: Initiates the discovery process. Scans the configured directories for attributes, builds the internal registry of MCP elements, and caches them using the provided cache implementation (unless `$cache` is false). **Note:** Manually registered elements are always added to the registry, regardless of discovery or caching. +* **`->run(?string $transport = null): int`**: Starts the server's main processing loop using the specified transport. * If `$transport` is `'stdio'` (or `null` when running in CLI), it uses the `StdioTransportHandler` to communicate over standard input/output. - * If `$transport` is `'http'`, it throws an exception, as the HTTP transport needs to be integrated into an existing HTTP server loop (see Transports section). + * If `$transport` is `'http'` or `'reactphp'`, it throws an exception, as these transports needs to be integrated into an existing HTTP server loop (see Transports section). * Returns the exit code (relevant for `stdio`). -### Discovery +### Dependency Injection + +The `Server` relies on a PSR-11 `ContainerInterface` for two main purposes: -When you call `->discover()`, the server locates all files within the directories specified by `->withScanDirectories()` (relative to the `->withBasePath()`) and for each file, it attempts to parse the class definiton, reflect on the public, non-static methods of that class and check them for `#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, or `#[McpResourceTemplate]` attributes +1. **Resolving Server Dependencies:** The server itself needs instances of `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` to function (e.g., for logging internal operations, caching discovered elements, reading configuration values). +2. **Resolving Handler Dependencies:** When an MCP client calls a tool or reads a resource/prompt that maps to one of your attributed methods or a manually registered handler, the server uses the container to get an instance of the handler's class (e.g., `$container->get(MyHandlerClass::class)`). This allows your handler classes to use constructor injection for their own dependencies (like database connections, application services, etc.). -If an attribute it found, it extract the metadata from the attribute instance, the method signature (name, parameters, type hints), and the method's DocBlock. It then creates a corresponding `Definition` object (e.g., `ToolDefinition`, `ResourceDefinition`) and registers them in the Registry. Finally, the collected definitions are serialized and stored in the cache provided via `->withCache()` (using the configured cache key and TTL) to speed up subsequent server starts. +**Default Behavior (No `withContainer` Call):** -### Dependency Injection +If you *do not* call `->withContainer()`, the server uses its internal `PhpMcp\Server\Defaults\BasicContainer`. This basic container comes pre-configured with default implementations: +* `LoggerInterface` -> `Psr\Log\NullLogger` (Logging is effectively disabled) +* `CacheInterface` -> `PhpMcp\Server\Defaults\FileCache` (writes to `../cache/mcp_cache` relative to the package directory) +* `ConfigurationRepositoryInterface` -> `PhpMcp\Server\Defaults\ArrayConfigurationRepository` (uses built-in default configuration values) + +In this default mode, you *can* use the `->withLogger()`, `->withCache()`, and `->withConfig()` methods to replace these defaults. These methods update the instance used by the server and also update the registration within the internal `BasicContainer`. -When an MCP client calls a tool or reads a resource/prompt that maps to one of your attributed methods: +**Using a Custom Container (`->withContainer(MyContainer $c)`):** -1. The `Processor` identifies the target class and method from the `Registry`. -2. It uses the PSR-11 container provided via `->withContainer()` to retrieve an instance of the target class (e.g., `$container->get(MyMcpStuff::class)`). -3. This means your class constructors can inject any dependencies (database connections, services, etc.) that are configured in your container. -4. The processor then prepares the arguments based on the client request and the method signature. -5. Finally, it calls the target method on the retrieved class instance. +If you provide your own PSR-11 container instance using `->withContainer()`, the responsibility shifts entirely to you: -*Using the default `BasicContainer` is only suitable for very simple cases where your attributed methods are in classes with no constructor dependencies. For any real application, you should provide your own PSR-11 container instance.* `->withContainer(MyFrameworkContainer::getInstance())` +* **You MUST ensure your container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`.** The server will attempt to fetch these using `$container->get(...)` and will fail if they are not available. Providing a `NullLogger` for `LoggerInterface` will keep logging disabled. +* Your container will also be used to instantiate your handler classes, so ensure all their dependencies are also properly configured within your container. +* When using a custom container, the `->withLogger()`, `->withCache()`, and `->withConfig()` methods on the `Server` instance become largely ineffective for modifying the dependencies the server *actually uses* during request processing, as the server will always defer to retrieving these services from *your provided container*. Configure these services directly in your container's setup. + +Using the default `BasicContainer` is suitable for simple cases. For most applications, providing your own pre-configured PSR-11 container (from your framework or a library like PHP-DI) via `->withContainer()` is the recommended approach for proper dependency management. ### Configuration @@ -340,8 +453,6 @@ Key configuration values (using dot notation) include: * `mcp.capabilities.logging.enabled`: (bool) Enable/disable the `logging/setLevel` method. * `mcp.cache.ttl`: (int) Cache time-to-live in seconds. * `mcp.cache.prefix`: (string) Prefix for cache related to mcp. -* `mcp.discovery.base_path`: (string) Base path for discovery (overridden by `withBasePath`). -* `mcp.discovery.directories`: (array) Directories to scan (overridden by `withScanDirectories`). * `mcp.runtime.log_level`: (string) Default log level (used by default logger). You can create your own implementation of the interface or pass an instance of `ArrayConfigurationRepository` populated with your overrides to `->withConfig()`. If a capability flag (e.g., `mcp.capabilities.tools.enabled`) is set to `false`, attempts by a client to use methods related to that capability (e.g., `tools/list`, `tools/call`) will result in a "Method not found" error. @@ -377,23 +488,25 @@ Additionally, ensure your web server and PHP-FPM (if used) configurations allow 6. Pass this explicit `clientId` to `$httpHandler->handleInput(...)`. **Integration Steps (General):** -1. **SSE Endpoint:** Create an endpoint (e.g., `/mcp/sse`) for GET requests. Set `Content-Type: text/event-stream` and keep the connection open. -2. **POST Endpoint:** Create an endpoint (e.g., `/mcp/message`) for POST requests with `Content-Type: application/json`. -3. **SSE Handler Logic:** Determine the `clientId`, instantiate/inject `HttpTransportHandler`, generate the POST URI *with* the `clientId` query parameter, call `$httpHandler->handleSseConnection(...)` (or a similar method like `streamSseMessages` if available in your integration), and ensure `$httpHandler->cleanupClient(...)` is called when the connection closes. -4. **POST Handler Logic:** Retrieve the `clientId` from the query parameter, get the raw JSON request body, instantiate/inject `HttpTransportHandler`, call `$httpHandler->handleInput(...)` with the body and `clientId`, and return an appropriate HTTP response (e.g., 202 Accepted). +1. **Configure Server:** Create and configure your `PhpMcp\Server\Server` instance (e.g., `$server = Server::make()->withLogger(...)->discover();`). +2. **Instantiate Handler:** Get an instance of `HttpTransportHandler`, passing the configured `$server` instance to its constructor: `$httpHandler = new HttpTransportHandler($server);` (or use dependency injection configured to do this). +3. **SSE Endpoint:** Create an endpoint (e.g., `/mcp/sse`) for GET requests. Set `Content-Type: text/event-stream` and keep the connection open. +4. **POST Endpoint:** Create an endpoint (e.g., `/mcp/message`) for POST requests with `Content-Type: application/json`. +5. **SSE Handler Logic:** Determine the `clientId`, use the `$httpHandler`, generate the POST URI *with* the `clientId` query parameter, call `$httpHandler->handleSseConnection(...)`, and ensure `$httpHandler->cleanupClient(...)` is called when the connection closes. +6. **POST Handler Logic:** Retrieve the `clientId` from the query parameter, get the raw JSON request body, use the `$httpHandler`, call `$httpHandler->handleInput(...)` with the body and `clientId`, and return an appropriate HTTP response (e.g., 202 Accepted). #### ReactPHP HTTP Transport (`reactphp`) -This package includes `PhpMcp\Server\Transports\ReactPhpHttpTransportHandler`, a concrete transport handler that integrates the core MCP HTTP+SSE logic with the ReactPHP ecosystem. It replaces potentially synchronous or blocking loops (often found in basic integrations of `HttpTransportHandler`) with ReactPHP's fully asynchronous, non-blocking event loop and stream primitives. This enables efficient handling of concurrent SSE connections within a ReactPHP-based application server. See the `samples/reactphp_http/server.php` example for a practical implementation. +This package includes `PhpMcp\Server\Transports\ReactPhpHttpTransportHandler`, a concrete transport handler that integrates the core MCP HTTP+SSE logic with the ReactPHP ecosystem. It replaces potentially synchronous or blocking loops (often found in basic integrations of `HttpTransportHandler`) with ReactPHP's fully asynchronous, non-blocking event loop and stream primitives. Instantiate it by passing your configured `Server` instance: `$reactHandler = new ReactPhpHttpTransportHandler($server);`. This enables efficient handling of concurrent SSE connections within a ReactPHP-based application server. See the `samples/reactphp_http/server.php` example for a practical implementation. #### Custom Transports You can create your own transport handlers if `stdio` or `http` don't fit your specific needs (e.g., WebSockets, custom RPC mechanisms). Two main approaches exist: 1. **Implement the Interface:** Create a class that implements `PhpMcp\Server\Contracts\TransportHandlerInterface`. This gives you complete control over the communication lifecycle. -2. **Extend Existing Handlers:** Inherit from `PhpMcp\Server\Transports\StdioTransportHandler` or `PhpMcp\Server\Transports\HttpTransportHandler`. Override specific methods to adapt the behavior (e.g., `sendResponse`, connection lifecycle methods like `handleSseConnection`, client cleanup like `cleanupClient`). The `ReactPhpHttpTransportHandler` serves as a good example of extending `HttpTransportHandler`. +2. **Extend Existing Handlers:** Inherit from `PhpMcp\Server\Transports\StdioTransportHandler`, `PhpMcp\Server\Transports\HttpTransportHandler`, or `PhpMcp\Server\Transports\ReactPhpHttpTransportHandler`. Override specific methods to adapt the behavior (e.g., `sendResponse`, `handleSseConnection`, `cleanupClient`). Remember to call the parent constructor correctly if extending HTTP handlers: `parent::__construct($server)`. The `ReactPhpHttpTransportHandler` serves as a good example of extending `HttpTransportHandler`. -Examine the source code of the provided handlers (`StdioTransportHandler`, `HttpTransportHandler`, `ReactPhpHttpTransportHandler`) to understand the interaction with the `Processor` and how to manage the request/response flow and client state. +Examine the source code of the provided handlers to understand the interaction with the `Processor` and how to manage the request/response flow and client state. ## Advanced Usage & Recipes @@ -480,6 +593,12 @@ Here are some examples of how to integrate `php-mcp/server` with common librarie class McpController extends AbstractController { + private readonly HttpTransportHandler $mcpHandler; + private readonly LoggerInterface $logger; + + // Inject HttpTransportHandler directly. + // The Symfony service definition for HttpTransportHandler MUST be configured + // to receive the fully configured PhpMcp\Server\Server instance as an argument. public function __construct( private readonly HttpTransportHandler $mcpHandler, private readonly LoggerInterface $logger @@ -488,6 +607,12 @@ Here are some examples of how to integrate `php-mcp/server` with common librarie #[Route('/mcp', name: 'mcp_post', methods: ['POST'])] public function handlePost(Request $request): Response { + $clientId = $request->query('clientId'); + if (! $clientId) { + // Or: $session = $request->getSession(); $session->start(); $clientId = $session->getId(); + return new Response('Missing clientId', 400); + } + if (! $request->isJson()) { return new Response('Content-Type must be application/json', 415); } @@ -515,45 +640,36 @@ Here are some examples of how to integrate `php-mcp/server` with common librarie #[Route('/mcp/sse', name: 'mcp_sse', methods: ['GET'])] public function handleSse(Request $request): StreamedResponse { - $session = $request->getSession(); - $session->start(); + // Retrieve/generate clientId (e.g., from session or generate new one) + $session = $request->getSession(); + $session->start(); $clientId = $session->getId(); + // Or: $clientId = 'client_'.bin2hex(random_bytes(16)); + $this->logger->info('MCP SSE connection opening', ['client_id' => $clientId]); $response = new StreamedResponse(function () use ($clientId, $request) { - $sendEventCallback = function (string $event, string $data, ?string $id = null) use ($clientId): void { - try { - echo "event: {$event}\n"; - if ($id !== null) echo "id: {$id}\n"; - echo "data: {$data}\n\n"; - flush(); - } catch (\Throwable $e) { - $this->logger->error('SSE send error', ['exception' => $e, 'clientId' => $clientId]); - throw new RuntimeException('SSE send error', 0, $e); - } - }; - try { - $postEndpointUri = $this->generateUrl('mcp_post', ['client_id' => $clientId], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::RELATIVE_URL); - $this->mcpHandler->streamSseMessages( - $sendEventCallback, - $clientId, - $postEndpointUri - ); + $postEndpointUri = $this->generateUrl('mcp_post', ['clientId' => $clientId], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); + + // Use the handler's method to manage the SSE loop + $this->mcpHandler->handleSseConnection($clientId, $postEndpointUri); } catch (\Throwable $e) { if (! ($e instanceof RuntimeException && str_contains($e->getMessage(), 'disconnected'))) { - $this->logger->error('SSE stream loop terminated', ['exception' => $e, 'clientId' => $clientId]); + $this->logger->error('SSE stream loop terminated unexpectedly', ['exception' => $e, 'clientId' => $clientId]); } } finally { + // Ensure cleanup happens when the loop exits $this->mcpHandler->cleanupClient($clientId); $this->logger->info('SSE connection closed', ['client_id' => $clientId]); } }); + // Set headers for SSE $response->headers->set('Content-Type', 'text/event-stream'); $response->headers->set('Cache-Control', 'no-cache'); $response->headers->set('Connection', 'keep-alive'); - $response->headers->set('X-Accel-Buffering', 'no'); + $response->headers->set('X-Accel-Buffering', 'no'); // Important for Nginx return $response; } } diff --git a/samples/php_http/server.php b/samples/php_http/server.php index aaf4df3..c6077b5 100644 --- a/samples/php_http/server.php +++ b/samples/php_http/server.php @@ -19,10 +19,12 @@ // --- MCP Server Setup --- $logger = new StreamLogger(__DIR__.'/vanilla_server.log', 'debug'); -$server = Server::make()->withLogger($logger)->withBasePath(__DIR__)->discover(); -$processor = $server->getProcessor(); -$state = $server->getStateManager(); -$httpHandler = new HttpTransportHandler($processor, $state, $logger); +$server = Server::make() + ->withLogger($logger) + ->withBasePath(__DIR__) + ->discover(); + +$httpHandler = new HttpTransportHandler($server); // --- Basic Routing & Client ID --- $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; diff --git a/samples/php_stdio/server.php b/samples/php_stdio/server.php index 3c69c76..c1b7201 100644 --- a/samples/php_stdio/server.php +++ b/samples/php_stdio/server.php @@ -4,6 +4,7 @@ use PhpMcp\Server\Defaults\StreamLogger; use PhpMcp\Server\Server; +use Test\SampleMcpElements; // --- Instructions --- // 1. composer install @@ -24,8 +25,10 @@ $logger = new StreamLogger(__DIR__.'/mcp.log', 'debug'); $server = Server::make() - ->withLogger($logger) ->withBasePath(__DIR__) + ->withLogger($logger) + ->withTool([SampleMcpElements::class, 'simpleTool'], 'greeter') + ->withResource([SampleMcpElements::class, 'getUserData'], 'user://data') ->discover(); $exitCode = $server->run('stdio'); diff --git a/samples/reactphp_http/server.php b/samples/reactphp_http/server.php index bb29689..621decc 100644 --- a/samples/reactphp_http/server.php +++ b/samples/reactphp_http/server.php @@ -7,7 +7,6 @@ use PhpMcp\Server\Transports\ReactPhpHttpTransportHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Loop; use React\Http\HttpServer; use React\Http\Message\Response; use React\Promise\Promise; @@ -34,10 +33,7 @@ ->withBasePath(__DIR__) ->discover(); -$processor = $server->getProcessor(); -$state = $server->getStateManager(); - -$transportHandler = new ReactPhpHttpTransportHandler($processor, $state, $logger, Loop::get()); +$transportHandler = new ReactPhpHttpTransportHandler($server); // --- ReactPHP HTTP Server Setup --- $postEndpoint = '/mcp/message'; diff --git a/src/Attributes/McpPrompt.php b/src/Attributes/McpPrompt.php index 6ba853e..61aeb50 100644 --- a/src/Attributes/McpPrompt.php +++ b/src/Attributes/McpPrompt.php @@ -8,7 +8,7 @@ * Marks a PHP method as an MCP Prompt generator. * The method should return the prompt messages, potentially using arguments for templating. */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpPrompt { /** @@ -18,6 +18,5 @@ final class McpPrompt public function __construct( public ?string $name = null, public ?string $description = null, - ) { - } + ) {} } diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index d980a74..7713f20 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -8,7 +8,7 @@ * Marks a PHP class as representing or handling a specific MCP Resource instance. * Used primarily for the 'resources/list' discovery. */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpResource { /** @@ -17,7 +17,7 @@ final class McpResource * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. * @param ?string $mimeType The MIME type, if known and constant for this resource. * @param ?int $size The size in bytes, if known and constant. - * @param ?array $annotations Optional annotations following the MCP spec (e.g., ['audience' => ['user'], 'priority' => 0.5]). + * @param array $annotations Optional annotations following the MCP spec (e.g., ['audience' => ['user'], 'priority' => 0.5]). */ public function __construct( public string $uri, @@ -25,7 +25,6 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, - public ?array $annotations = null, - ) { - } + public array $annotations = [], + ) {} } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index e676bdd..f383a8b 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -8,7 +8,7 @@ * Marks a PHP class definition as representing an MCP Resource Template. * This is informational, used for 'resources/templates/list'. */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpResourceTemplate { /** @@ -16,14 +16,13 @@ final class McpResourceTemplate * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. * @param ?string $description Optional description. Defaults to class DocBlock summary. * @param ?string $mimeType Optional default MIME type for matching resources. - * @param ?array $annotations Optional annotations following the MCP spec. + * @param array $annotations Optional annotations following the MCP spec. */ public function __construct( public string $uriTemplate, public ?string $name = null, public ?string $description = null, public ?string $mimeType = null, - public ?array $annotations = null, - ) { - } + public array $annotations = [], + ) {} } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 82f621b..378ddd4 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class McpTool { /** @@ -14,6 +14,5 @@ class McpTool public function __construct( public ?string $name = null, public ?string $description = null, - ) { - } + ) {} } diff --git a/src/Defaults/FileCache.php b/src/Defaults/FileCache.php new file mode 100644 index 0000000..8ac19b0 --- /dev/null +++ b/src/Defaults/FileCache.php @@ -0,0 +1,338 @@ +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) { + // TODO: Log error + return []; + } + + try { + if (! flock($handle, LOCK_SH)) { + // TODO: Log error + return []; + } + $content = stream_get_contents($handle); + flock($handle, LOCK_UN); + + if ($content === false || $content === '') { + return []; + } + + $data = json_decode($content, true); + if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) { + // TODO: Log error, potentially unlink corrupt file + return []; + } + + return $data; + } finally { + if (is_resource($handle)) { + fclose($handle); + } + } + } + + private function writeCacheFile(array $data): bool + { + $jsonData = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (json_last_error() !== JSON_ERROR_NONE) { + // TODO: Log error + return false; + } + + $handle = @fopen($this->cacheFile, 'cb'); + if ($handle === false) { + // TODO: Log error + return false; + } + + try { + if (! flock($handle, LOCK_EX)) { + // TODO: Log error + return false; + } + if (! ftruncate($handle, 0)) { + // TODO: Log error + return false; + } + if (fwrite($handle, $jsonData) === false) { + // TODO: Log error + return false; + } + fflush($handle); + flock($handle, LOCK_UN); + @chmod($this->cacheFile, $this->filePermission); + + return true; + } catch (Throwable $e) { + // TODO: Log error + 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)) { + // TODO: Log error + 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) { + // TODO: Log error + return null; + } + } + // TODO: Log warning + 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); // Reuse sanitize validation + } + } + + private function iterableToArray(iterable $iterable): array + { + if (is_array($iterable)) { + return $iterable; + } + + return iterator_to_array($iterable); + } +} diff --git a/src/Definitions/PromptDefinition.php b/src/Definitions/PromptDefinition.php index cf6f258..9bdfd44 100644 --- a/src/Definitions/PromptDefinition.php +++ b/src/Definitions/PromptDefinition.php @@ -2,7 +2,6 @@ namespace PhpMcp\Server\Definitions; -use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Support\DocBlockParser; /** @@ -136,13 +135,14 @@ className: $data['className'], */ public static function fromReflection( \ReflectionMethod $method, - McpPrompt $attribute, + ?string $overrideName, + ?string $overrideDescription, DocBlockParser $docBlockParser ): self { $className = $method->getDeclaringClass()->getName(); $methodName = $method->getName(); $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $attribute->description ?? $docBlockParser->getSummary($docBlock) ?? null; + $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; $arguments = []; $paramTags = $docBlockParser->getParamTags($docBlock); // Get all param tags first @@ -162,7 +162,7 @@ public static function fromReflection( return new self( className: $className, methodName: $methodName, - promptName: $attribute->name ?? $methodName, + promptName: $overrideName ?? $methodName, description: $description, arguments: $arguments ); diff --git a/src/Definitions/ResourceDefinition.php b/src/Definitions/ResourceDefinition.php index 5ce6805..6c74eee 100644 --- a/src/Definitions/ResourceDefinition.php +++ b/src/Definitions/ResourceDefinition.php @@ -167,21 +167,26 @@ className: $data['className'], */ public static function fromReflection( ReflectionMethod $method, - McpResource $attribute, + ?string $overrideName, + ?string $overrideDescription, + string $uri, + ?string $mimeType, + ?int $size, + ?array $annotations, DocBlockParser $docBlockParser ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $attribute->description ?? $docBlockParser->getSummary($docBlock) ?? null; + $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; return new self( className: $method->getDeclaringClass()->getName(), methodName: $method->getName(), - uri: $attribute->uri, - name: $attribute->name ?? $method->getName(), + uri: $uri, + name: $overrideName ?? $method->getName(), description: $description, - mimeType: $attribute->mimeType, - size: $attribute->size, - annotations: $attribute->annotations ?? [] + mimeType: $mimeType, + size: $size, + annotations: $annotations ); } } diff --git a/src/Definitions/ResourceTemplateDefinition.php b/src/Definitions/ResourceTemplateDefinition.php index 19dcced..f6695ec 100644 --- a/src/Definitions/ResourceTemplateDefinition.php +++ b/src/Definitions/ResourceTemplateDefinition.php @@ -150,25 +150,33 @@ className: $data['className'], * Create a ResourceTemplateDefinition from reflection data. * * @param ReflectionMethod $method The reflection method marked with McpResourceTemplate. - * @param McpResourceTemplate $attribute The attribute instance. + * @param string|null $overrideName The name for the resource. + * @param string|null $overrideDescription The description for the resource. + * @param string $uriTemplate The URI template for the resource. + * @param string|null $mimeType The MIME type for the resource. + * @param array|null $annotations The annotations for the resource. * @param DocBlockParser $docBlockParser Utility to parse docblocks. */ public static function fromReflection( ReflectionMethod $method, - McpResourceTemplate $attribute, + ?string $overrideName, + ?string $overrideDescription, + string $uriTemplate, + ?string $mimeType, + ?array $annotations, DocBlockParser $docBlockParser ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $attribute->description ?? $docBlockParser->getSummary($docBlock) ?? null; + $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; return new self( className: $method->getDeclaringClass()->getName(), methodName: $method->getName(), - uriTemplate: $attribute->uriTemplate, - name: $attribute->name ?? $method->getName(), + uriTemplate: $uriTemplate, + name: $overrideName ?? $method->getName(), description: $description, - mimeType: $attribute->mimeType, - annotations: $attribute->annotations ?? [] + mimeType: $mimeType, + annotations: $annotations ); } } diff --git a/src/Definitions/ToolDefinition.php b/src/Definitions/ToolDefinition.php index fb9d99d..3efb5b6 100644 --- a/src/Definitions/ToolDefinition.php +++ b/src/Definitions/ToolDefinition.php @@ -128,18 +128,19 @@ className: $data['className'], */ public static function fromReflection( ReflectionMethod $method, - McpTool $attribute, + ?string $overrideName, + ?string $overrideDescription, DocBlockParser $docBlockParser, SchemaGenerator $schemaGenerator ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $description = $attribute->description ?? $docBlockParser->getSummary($docBlock) ?? null; + $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $schemaGenerator->fromMethodParameters($method); return new self( className: $method->getDeclaringClass()->getName(), methodName: $method->getName(), - toolName: $attribute->name ?? $method->getName(), + toolName: $overrideName ?? $method->getName(), description: $description, inputSchema: $inputSchema, ); diff --git a/src/Processor.php b/src/Processor.php index c4cb33e..ff29aef 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -40,6 +40,12 @@ class Processor */ protected array $supportedProtocolVersions = ['2024-11-05']; + protected ConfigurationRepositoryInterface $config; + + protected TransportState $transportState; + + protected LoggerInterface $logger; + protected SchemaValidator $schemaValidator; protected ArgumentPreparer $argumentPreparer; @@ -49,15 +55,15 @@ class Processor */ public function __construct( protected ContainerInterface $container, - protected ConfigurationRepositoryInterface $config, protected Registry $registry, - protected TransportState $transportState, - protected LoggerInterface $logger, + ?TransportState $transportState = null, ?SchemaValidator $schemaValidator = null, ?ArgumentPreparer $argumentPreparer = null ) { - $this->supportedProtocolVersions = $this->config->get('mcp.protocol_versions', ['2024-11-05']); - $this->registry->loadElements(); + $this->config = $this->container->get(ConfigurationRepositoryInterface::class); + $this->logger = $this->container->get(LoggerInterface::class); + + $this->transportState = $transportState ?? new TransportState($this->container); $this->schemaValidator = $schemaValidator ?? new SchemaValidator($this->logger); $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->logger); } @@ -250,7 +256,7 @@ private function handleInitialize(array $params, string $clientId): InitializeRe $capabilities['prompts'] = ['listChanged' => $this->config->get('mcp.capabilities.prompts.listChanged', false)]; } if ($this->config->get('mcp.capabilities.logging.enabled', false)) { - $capabilities['logging'] = new \stdClass(); + $capabilities['logging'] = new \stdClass; } $instructions = $this->config->get('mcp.instructions'); @@ -261,7 +267,7 @@ private function handleInitialize(array $params, string $clientId): InitializeRe private function handlePing(string $clientId): EmptyResult { // Ping response has no specific content, just acknowledges - return new EmptyResult(); + return new EmptyResult; } // --- Notification Handlers --- @@ -270,7 +276,7 @@ private function handleNotificationInitialized(array $params, string $clientId): { $this->transportState->markInitialized($clientId); - return new EmptyResult(); + return new EmptyResult; } // --- Tool Handlers --- @@ -302,7 +308,7 @@ private function handleToolCall(array $params): CallToolResult } if (empty($argumentsRaw)) { - $argumentsRaw = new stdClass(); + $argumentsRaw = new stdClass; } $definition = $this->registry->findTool($toolName); @@ -443,7 +449,7 @@ private function handleResourceSubscribe(array $params, string $clientId): Empty $this->transportState->addResourceSubscription($clientId, $uri); - return new EmptyResult(); + return new EmptyResult; } private function handleResourceUnsubscribe(array $params, string $clientId): EmptyResult @@ -455,7 +461,7 @@ private function handleResourceUnsubscribe(array $params, string $clientId): Emp $this->transportState->removeResourceSubscription($clientId, $uri); - return new EmptyResult(); + return new EmptyResult; } // --- Prompt Handlers --- @@ -545,7 +551,7 @@ private function handleLoggingSetLevel(array $params): EmptyResult $this->logger->info('MCP logging level set request: '.$level); $this->config->set('mcp.runtime.log_level', strtolower($level)); // Example: Store in runtime config - return new EmptyResult(); // Success is empty result + return new EmptyResult; // Success is empty result } // --- Pagination Helpers --- diff --git a/src/Registry.php b/src/Registry.php index 4144650..fbd0505 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -5,6 +5,7 @@ namespace PhpMcp\Server; use ArrayObject; +use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface as ConfigRepository; use PhpMcp\Server\Definitions\PromptDefinition; use PhpMcp\Server\Definitions\ResourceDefinition; use PhpMcp\Server\Definitions\ResourceTemplateDefinition; @@ -12,12 +13,19 @@ use PhpMcp\Server\JsonRpc\Notification; use PhpMcp\Server\State\TransportState; use PhpMcp\Server\Support\UriTemplateMatcher; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Throwable; class Registry { + private CacheInterface $cache; + + private LoggerInterface $logger; + + private TransportState $transportState; + /** @var ArrayObject */ private ArrayObject $tools; @@ -41,28 +49,33 @@ class Registry /** @var callable|null */ private $notifyPromptsChanged = null; - // Add others like templates if needed - private string $cacheKey = ''; + private string $cacheKey; public function __construct( - private readonly CacheInterface $cache, - private readonly LoggerInterface $logger, - private readonly TransportState $transportState, - private readonly ?string $cachePrefix = 'mcp_' + private readonly ContainerInterface $container, + ?TransportState $transportState = null, ) { - $this->initializeEmptyCollections(); + $this->cache = $this->container->get(CacheInterface::class); + $this->logger = $this->container->get(LoggerInterface::class); + $config = $this->container->get(ConfigRepository::class); + + $this->initializeCollections(); $this->initializeDefaultNotifiers(); - $this->cacheKey = $cachePrefix.'elements'; + $this->transportState = $transportState ?? new TransportState($this->container); + + $this->cacheKey = $config->get('mcp.cache.prefix', 'mcp_').'elements'; + + $this->loadElementsFromCache(); } - private function initializeEmptyCollections(): void + private function initializeCollections(): void { - $this->tools = new ArrayObject(); - $this->resources = new ArrayObject(); - $this->prompts = new ArrayObject(); - $this->resourceTemplates = new ArrayObject(); + $this->tools = new ArrayObject; + $this->resources = new ArrayObject; + $this->prompts = new ArrayObject; + $this->resourceTemplates = new ArrayObject; } private function initializeDefaultNotifiers(): void @@ -99,26 +112,6 @@ public function setPromptsChangedNotifier(?callable $notifier): void $this->notifyPromptsChanged = $notifier; } - public function loadElements(): void - { - if ($this->isLoaded) { - return; - } - - $cached = $this->cache->get($this->cacheKey); - - if (is_array($cached) && isset($cached['tools'])) { - $this->logger->debug('MCP: Loading elements from cache.', ['key' => $this->cacheKey]); - $this->setElementsFromArray($cached); - $this->isLoaded = true; - } - - if (! $this->isLoaded) { - $this->initializeEmptyCollections(); - $this->isLoaded = true; - } - } - public function isLoaded(): bool { return $this->isLoaded; @@ -126,7 +119,6 @@ public function isLoaded(): bool public function registerTool(ToolDefinition $tool): void { - $this->loadElements(); $toolName = $tool->getName(); $alreadyExists = $this->tools->offsetExists($toolName); if ($alreadyExists) { @@ -141,7 +133,6 @@ public function registerTool(ToolDefinition $tool): void public function registerResource(ResourceDefinition $resource): void { - $this->loadElements(); $uri = $resource->getUri(); $alreadyExists = $this->resources->offsetExists($uri); if ($alreadyExists) { @@ -156,7 +147,6 @@ public function registerResource(ResourceDefinition $resource): void public function registerResourceTemplate(ResourceTemplateDefinition $template): void { - $this->loadElements(); $uriTemplate = $template->getUriTemplate(); $alreadyExists = $this->resourceTemplates->offsetExists($uriTemplate); if ($alreadyExists) { @@ -167,7 +157,6 @@ public function registerResourceTemplate(ResourceTemplateDefinition $template): public function registerPrompt(PromptDefinition $prompt): void { - $this->loadElements(); $promptName = $prompt->getName(); $alreadyExists = $this->prompts->offsetExists($promptName); if ($alreadyExists) { @@ -180,7 +169,42 @@ public function registerPrompt(PromptDefinition $prompt): void } } - public function cacheElements(): bool + public function loadElementsFromCache(bool $force = false): void + { + if ($this->isLoaded && ! $force) { + return; + } + + $cached = $this->cache->get($this->cacheKey); + + if (is_array($cached) && isset($cached['tools'])) { + $this->logger->debug('MCP: Loading elements from cache.', ['key' => $this->cacheKey]); + + foreach ($cached['tools'] ?? [] as $tool) { + $toolDefinition = $tool instanceof ToolDefinition ? $tool : ToolDefinition::fromArray($tool); + $this->registerTool($toolDefinition); + } + + foreach ($cached['resources'] ?? [] as $resource) { + $resourceDefinition = $resource instanceof ResourceDefinition ? $resource : ResourceDefinition::fromArray($resource); + $this->registerResource($resourceDefinition); + } + + foreach ($cached['prompts'] ?? [] as $prompt) { + $promptDefinition = $prompt instanceof PromptDefinition ? $prompt : PromptDefinition::fromArray($prompt); + $this->registerPrompt($promptDefinition); + } + + foreach ($cached['resourceTemplates'] ?? [] as $template) { + $resourceTemplateDefinition = $template instanceof ResourceTemplateDefinition ? $template : ResourceTemplateDefinition::fromArray($template); + $this->registerResourceTemplate($resourceTemplateDefinition); + } + } + + $this->isLoaded = true; + } + + public function saveElementsToCache(): bool { $data = [ 'tools' => $this->tools->getArrayCopy(), @@ -204,7 +228,7 @@ public function clearCache(): void { try { $this->cache->delete($this->cacheKey); - $this->initializeEmptyCollections(); + $this->initializeCollections(); $this->isLoaded = false; $this->logger->debug('MCP: Element cache cleared.'); @@ -223,14 +247,6 @@ public function clearCache(): void } } - private function setElementsFromArray(array $data): void - { - $this->tools = new ArrayObject($data['tools'] ?? []); - $this->resources = new ArrayObject($data['resources'] ?? []); - $this->prompts = new ArrayObject($data['prompts'] ?? []); - $this->resourceTemplates = new ArrayObject($data['resourceTemplates'] ?? []); - } - public function findTool(string $name): ?ToolDefinition { return $this->tools[$name] ?? null; diff --git a/src/Server.php b/src/Server.php index 1596053..13b748b 100644 --- a/src/Server.php +++ b/src/Server.php @@ -4,135 +4,89 @@ namespace PhpMcp\Server; +use InvalidArgumentException; use LogicException; use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface; -use PhpMcp\Server\Defaults\ArrayCache; use PhpMcp\Server\Defaults\ArrayConfigurationRepository; use PhpMcp\Server\Defaults\BasicContainer; -use PhpMcp\Server\Defaults\StreamLogger; -use PhpMcp\Server\State\TransportState; +use PhpMcp\Server\Defaults\FileCache; +use PhpMcp\Server\Definitions\PromptDefinition; +use PhpMcp\Server\Definitions\ResourceDefinition; +use PhpMcp\Server\Definitions\ResourceTemplateDefinition; +use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Support\Discoverer; +use PhpMcp\Server\Support\DocBlockParser; +use PhpMcp\Server\Support\SchemaGenerator; use PhpMcp\Server\Transports\StdioTransportHandler; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -use Psr\Log\LogLevel; +use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; +use ReflectionMethod; /** * Main MCP Server class providing a fluent interface for configuration and running. */ class Server { - private ?ConfigurationRepositoryInterface $config = null; - private ?LoggerInterface $logger = null; - private ?CacheInterface $cache = null; - private ?ContainerInterface $container = null; private ?Registry $registry = null; - private ?TransportState $transportState = null; + private string $basePath; - private ?Processor $processor = null; + private array $scanDirs; - private ?Discoverer $discoverer = null; + private array $excludeDirs; - private ?string $basePath = null; + public function __construct() + { + $container = new BasicContainer; - private ?array $scanDirs = null; + $config = new ArrayConfigurationRepository($this->getDefaultConfigValues()); + $logger = new NullLogger; + $cache = new FileCache(__DIR__.'/../cache/mcp_cache'); - public function __construct() {} + $container->set(ConfigurationRepositoryInterface::class, $config); + $container->set(LoggerInterface::class, $logger); + $container->set(CacheInterface::class, $cache); + + $this->basePath = realpath(__DIR__.'/..') ?: __DIR__.'/..'; + $this->scanDirs = ['.', 'src/MCP']; + $this->excludeDirs = ['vendor', 'tests', 'test', 'samples', 'docs', 'storage', 'cache', 'node_modules']; + $this->container = $container; + } /** * Static factory method to create a new Server instance. */ public static function make(): self { - return new self; + $instance = new self; + + return $instance; } - /** - * Initializes core dependencies and dependent services if not already done. - */ - private function initialize(): void + private function getDefaultConfigValues(): array { - // 1. Apply Defaults if Dependencies are Null - if ($this->logger === null) { - $this->logger = new StreamLogger(STDERR, LogLevel::INFO); // Log to STDERR by default - } - if ($this->cache === null) { - $this->cache = new ArrayCache; - } - if ($this->container === null) { - $this->container = new BasicContainer; - } - - // Initialize or update config - if ($this->config === null) { - // --- Use explicit paths if set, otherwise use defaults --- - $defaultBasePath = $this->basePath ?? getcwd(); - $defaultScanDirs = $this->scanDirs ?? ['.']; - - $defaultConfigValues = [ - 'mcp' => [ - 'server' => ['name' => 'PHP MCP Server', 'version' => '1.0.0'], - 'protocol_versions' => ['2024-11-05'], - 'pagination_limit' => 50, - 'capabilities' => [ - 'tools' => ['enabled' => true, 'listChanged' => true], - 'resources' => ['enabled' => true, 'subscribe' => true, 'listChanged' => true], - 'prompts' => ['enabled' => true, 'listChanged' => true], - 'logging' => ['enabled' => true], - ], - 'cache' => ['key' => 'mcp.elements.cache', 'ttl' => 3600, 'prefix' => 'mcp_state_'], - 'discovery' => ['base_path' => $defaultBasePath, 'directories' => $defaultScanDirs], - 'runtime' => ['log_level' => 'info'], + return [ + 'mcp' => [ + 'server' => ['name' => 'PHP MCP Server', 'version' => '1.0.0'], + 'protocol_versions' => ['2024-11-05'], + 'pagination_limit' => 50, + 'capabilities' => [ + 'tools' => ['enabled' => true, 'listChanged' => true], + 'resources' => ['enabled' => true, 'subscribe' => true, 'listChanged' => true], + 'prompts' => ['enabled' => true, 'listChanged' => true], + 'logging' => ['enabled' => false], ], - ]; - $this->config = new ArrayConfigurationRepository($defaultConfigValues); - } else { - $basePath = $this->basePath ?? $this->config->get('mcp.discovery.base_path', getcwd()); - $scanDirs = $this->scanDirs ?? $this->config->get('mcp.discovery.directories', ['.']); - - $this->config->set('mcp.discovery.base_path', $basePath); - $this->config->set('mcp.discovery.directories', $scanDirs); - $this->config->set('mcp.cache.prefix', $this->config->get('mcp.cache.prefix', 'mcp_')); - } - - // 2. Instantiate Dependent Services - $this->transportState = new TransportState( - $this->cache, - $this->logger, - $this->config->get('mcp.cache.prefix'), - $this->config->get('mcp.cache.ttl') - ); - - $this->registry ??= new Registry( - $this->cache, - $this->logger, - $this->transportState, - $this->config->get('mcp.cache.prefix') - ); - - $this->processor ??= new Processor( - $this->container, // Processor requires a container - $this->config, - $this->registry, - $this->transportState, - $this->logger - ); - - $this->discoverer ??= new Discoverer($this->registry, $this->logger); - - // 3. Add core services to BasicContainer if it's being used - if ($this->container instanceof BasicContainer) { - $this->container->set(LoggerInterface::class, $this->logger); - $this->container->set(CacheInterface::class, $this->cache); - $this->container->set(ConfigurationRepositoryInterface::class, $this->config); - $this->container->set(Registry::class, $this->registry); - } + 'cache' => ['key' => 'mcp.elements.cache', 'ttl' => 3600, 'prefix' => 'mcp_state_'], + 'runtime' => ['log_level' => 'info'], + ], + ]; } public function withContainer(ContainerInterface $container): self @@ -146,30 +100,39 @@ public function withLogger(LoggerInterface $logger): self { $this->logger = $logger; + if ($this->container instanceof BasicContainer) { + $this->container->set(LoggerInterface::class, $logger); + } + return $this; } public function withCache(CacheInterface $cache): self { - $this->cache = $cache; + if ($this->container instanceof BasicContainer) { + $this->container->set(CacheInterface::class, $cache); + } return $this; } public function withConfig(ConfigurationRepositoryInterface $config): self { - $this->config = $config; + if ($this->container instanceof BasicContainer) { + $this->container->set(ConfigurationRepositoryInterface::class, $config); + } return $this; } public function withBasePath(string $path): self { - $this->basePath = $path; - if ($this->config) { - $this->config->set('mcp.discovery.base_path', $path); + if (! is_dir($path)) { + throw new InvalidArgumentException("Base path is not a valid directory: {$path}"); } + $this->basePath = realpath($path) ?: $path; + return $this; } @@ -182,102 +145,248 @@ public function withBasePath(string $path): self public function withScanDirectories(array $dirs): self { $this->scanDirs = $dirs; - if ($this->config) { - $this->config->set('mcp.discovery.directories', $dirs); - } return $this; } - // --- Core Actions --- // + /** + * Explicitly set the directories to exclude from the scan, relative to the base path. + * + * @param string[] $dirs Array of relative directory paths (e.g., ['vendor', 'tests', 'test', 'samples', 'docs', 'storage', 'cache', 'node_modules']). + * @return $this + */ + public function withExcludeDirectories(array $dirs): self + { + $this->excludeDirs = array_merge($this->excludeDirs, $dirs); + + return $this; + } - public function discover(bool $clearCacheFirst = true): self + /** + * Manually register a tool with the server. + * + * @param array|class-string $handler The handler to register, containing a class name and method name. + * @param string|null $name The name of the tool. + * @param string|null $description The description of the tool. + * @return $this + */ + public function withTool(array|string $handler, ?string $name = null, ?string $description = null): self { - $this->initialize(); // Ensures config is correctly set using explicit paths if provided + $reflectionMethod = $this->validateAndGetReflectionMethod($handler); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $isInvokable = $methodName === '__invoke'; - if ($clearCacheFirst) { - $this->registry->clearCache(); - } + $docBlockParser = new DocBlockParser($this->container); + $schemaGenerator = new SchemaGenerator($docBlockParser); - // Now read the finalized paths from config - $basePath = $this->config->get('mcp.discovery.base_path'); - $scanDirectories = $this->config->get('mcp.discovery.directories'); + $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); + $definition = ToolDefinition::fromReflection($reflectionMethod, $name, $description, $docBlockParser, $schemaGenerator); - $this->discoverer->discover($basePath, $scanDirectories); - $this->registry->cacheElements(); + $registry = $this->getRegistry(); + $registry->registerTool($definition); + + $this->logger->debug('MCP: Manually registered tool.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); return $this; } - public function run(?string $transport = null): int + /** + * Manually register a resource with the server. + * + * @param array|class-string $handler The handler to register, containing a class name and method name. + * @param string $uri The URI of the resource. + * @param string|null $name The name of the resource. + * @param string|null $description The description of the resource. + * @param string|null $mimeType The MIME type of the resource. + * @param int|null $size The size of the resource. + * @param array|null $annotations The annotations of the resource. + */ + public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?array $annotations = []): self { - $this->initialize(); + $reflectionMethod = $this->validateAndGetReflectionMethod($handler); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $isInvokable = $methodName === '__invoke'; - $this->registry->loadElements(); + $docBlockParser = new DocBlockParser($this->container); - if ($transport === null) { - $sapi = php_sapi_name(); - $transport = (str_starts_with($sapi, 'cli') || str_starts_with($sapi, 'phpdbg')) ? 'stdio' : 'http'; - $this->logger->info('Auto-detected transport', ['sapi' => $sapi, 'transport' => $transport]); - } + $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); + $definition = ResourceDefinition::fromReflection($reflectionMethod, $name, $description, $uri, $mimeType, $size, $annotations, $docBlockParser); - $handler = match (strtolower($transport)) { - 'stdio' => new StdioTransportHandler($this->processor, $this->transportState, $this->logger), - 'http' => throw new LogicException("Cannot run HTTP transport directly via Server::run(). Instantiate \PhpMcp\Server\Transports\HttpTransportHandler and integrate it into your HTTP server/framework."), - default => throw new LogicException("Unsupported transport: {$transport}"), - }; + $registry = $this->getRegistry(); + $registry->registerResource($definition); - return $handler->start(); + $this->logger->debug('MCP: Manually registered resource.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); + + return $this; } - // --- Component Getters (Ensure initialization) --- // + /** + * Manually register a prompt with the server. + * + * @param array|class-string $handler The handler to register, containing a class name and method name. + * @param string|null $name The name of the prompt. + * @param string|null $description The description of the prompt. + */ + public function withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self + { + $reflectionMethod = $this->validateAndGetReflectionMethod($handler); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $isInvokable = $methodName === '__invoke'; + + $docBlockParser = new DocBlockParser($this->container); + $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); + $definition = PromptDefinition::fromReflection($reflectionMethod, $name, $description, $docBlockParser); - public function getProcessor(): Processor + $registry = $this->getRegistry(); + $registry->registerPrompt($definition); + + $this->logger->debug('MCP: Manually registered prompt.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); + + return $this; + } + + /** + * Manually register a resource template with the server. + * + * @param array|class-string $handler The handler to register, containing a class name and method name. + * @param string|null $name The name of the resource template. + * @param string|null $description The description of the resource template. + * @param string|null $uriTemplate The URI template of the resource template. + * @param string|null $mimeType The MIME type of the resource template. + * @param array|null $annotations The annotations of the resource template. + */ + public function withResourceTemplate(array|string $handler, ?string $name = null, ?string $description = null, ?string $uriTemplate = null, ?string $mimeType = null, ?array $annotations = []): self { - $this->initialize(); + $reflectionMethod = $this->validateAndGetReflectionMethod($handler); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $isInvokable = $methodName === '__invoke'; + + $docBlockParser = new DocBlockParser($this->container); + $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); + $definition = ResourceTemplateDefinition::fromReflection($reflectionMethod, $name, $description, $uriTemplate, $mimeType, $annotations, $docBlockParser); + + $registry = $this->getRegistry(); + $registry->registerResourceTemplate($definition); + + $this->logger->debug('MCP: Manually registered resource template.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); - return $this->processor; + return $this; } - public function getRegistry(): Registry + /** + * Validates a handler and returns its ReflectionMethod. + * + * @param array|string $handler The handler to validate + * @return ReflectionMethod The reflection method for the handler + * + * @throws InvalidArgumentException If the handler is invalid + */ + private function validateAndGetReflectionMethod(array|string $handler): ReflectionMethod { - $this->initialize(); + $className = null; + $methodName = null; + + if (is_array($handler)) { + if (count($handler) !== 2 || ! is_string($handler[0]) || ! is_string($handler[1])) { + throw new InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'].'); + } + [$className, $methodName] = $handler; + if (! class_exists($className)) { + throw new InvalidArgumentException("Class '{$className}' not found for array handler."); + } + if (! method_exists($className, $methodName)) { + throw new InvalidArgumentException("Method '{$methodName}' not found in class '{$className}' for array handler."); + } + } elseif (is_string($handler) && class_exists($handler)) { + $className = $handler; + $methodName = '__invoke'; + if (! method_exists($className, $methodName)) { + throw new InvalidArgumentException("Invokable class '{$className}' must have a public '__invoke' method."); + } + } else { + throw new InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); + } - return $this->registry; + try { + $reflectionMethod = new ReflectionMethod($className, $methodName); + + if ($reflectionMethod->isStatic()) { + throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be static."); + } + if (! $reflectionMethod->isPublic()) { + throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); + } + if ($reflectionMethod->isAbstract()) { + throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); + } + if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { + throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); + } + + return $reflectionMethod; + + } catch (\ReflectionException $e) { + throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); + } } - public function getStateManager(): TransportState + // --- Core Actions --- // + + public function discover(bool $cache = true): self { - $this->initialize(); + $registry = $this->getRegistry(); + + $discoverer = new Discoverer($this->container, $registry); + + $discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); - return $this->transportState; + if ($cache) { + $registry->saveElementsToCache(); + } + + return $this; } - public function getConfig(): ConfigurationRepositoryInterface + public function run(?string $transport = null): int { - $this->initialize(); + if ($transport === null) { + $sapi = php_sapi_name(); + $transport = (str_starts_with($sapi, 'cli') || str_starts_with($sapi, 'phpdbg')) ? 'stdio' : 'http'; + $this->logger->info('Auto-detected transport', ['sapi' => $sapi, 'transport' => $transport]); + } + + $handler = match (strtolower($transport)) { + 'stdio' => new StdioTransportHandler($this), + 'reactphp' => throw new LogicException('MCP: reactphp transport cannot be run directly via Server::run(). Integrate ReactPhpHttpTransportHandler into your ReactPHP server.'), + 'http' => throw new LogicException("Cannot run HTTP transport directly via Server::run(). Instantiate \PhpMcp\Server\Transports\HttpTransportHandler and integrate it into your HTTP server/framework."), + default => throw new LogicException("Unsupported transport: {$transport}"), + }; - return $this->config; + return $handler->start(); } - public function getLogger(): LoggerInterface + public function getRegistry(): Registry { - $this->initialize(); + if (is_null($this->registry)) { + $this->registry = new Registry($this->container); + } - return $this->logger; + return $this->registry; } - public function getCache(): CacheInterface + public function getProcessor(): Processor { - $this->initialize(); + $registry = $this->getRegistry(); - return $this->cache; + return new Processor($this->container, $registry); } public function getContainer(): ContainerInterface { - $this->initialize(); - return $this->container; } } diff --git a/src/State/TransportState.php b/src/State/TransportState.php index 24c9e61..d4ae4f4 100644 --- a/src/State/TransportState.php +++ b/src/State/TransportState.php @@ -2,7 +2,9 @@ namespace PhpMcp\Server\State; +use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface as ConfigRepository; use PhpMcp\Server\JsonRpc\Message; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -11,23 +13,22 @@ */ class TransportState { - private string $cachePrefix = 'mcp:'; - - private int $cacheTtl = 3600; // Default TTL - - public function __construct( - private CacheInterface $cache, - private LoggerInterface $logger, - ?string $cachePrefix = null, - ?int $ttl = null - ) { - if ($cachePrefix !== null) { - $cleanPrefix = preg_replace('/[^a-zA-Z0-9_.-]/', '', $cachePrefix); // Allow PSR-6 safe chars - $this->cachePrefix = ! empty($cleanPrefix) ? rtrim($cleanPrefix, '_').'_' : ''; - } - if ($ttl !== null) { - $this->cacheTtl = $ttl; - } + private CacheInterface $cache; + + private LoggerInterface $logger; + + private string $cachePrefix; + + private int $cacheTtl; + + public function __construct(private ContainerInterface $container) + { + $this->cache = $this->container->get(CacheInterface::class); + $this->logger = $this->container->get(LoggerInterface::class); + $config = $this->container->get(ConfigRepository::class); + + $this->cachePrefix = $config->get('mcp.cache.prefix', 'mcp_'); + $this->cacheTtl = $config->get('mcp.cache.ttl', 3600); } private function getCacheKey(string $key, ?string $clientId = null): string diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index 467da0b..2f23b43 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -1,5 +1,7 @@ registry = $registry; - $this->logger = $logger; - $this->attributeFinder = $attributeFinder ?? new AttributeFinder(); - $this->docBlockParser = $docBlockParser ?? new DocBlockParser(); + $this->logger = $this->container->get(LoggerInterface::class); + $this->attributeFinder = $attributeFinder ?? new AttributeFinder; + $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->container); $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); } @@ -51,59 +55,77 @@ public function __construct( * * @param string $basePath The base path for resolving directories. * @param array $directories List of directories (relative to base path) to scan. + * @param array $excludeDirs List of directories (relative to base path) to exclude from the scan. */ - public function discover(string $basePath, array $directories): void + public function discover(string $basePath, array $directories, array $excludeDirs = []): void { - $this->logger->debug('MCP: Starting element discovery.', ['paths' => $directories]); + $this->logger->debug('MCP: Starting attribute discovery.', ['basePath' => $basePath, 'paths' => $directories]); $startTime = microtime(true); + $discoveredCount = [ + 'tools' => 0, + 'resources' => 0, + 'prompts' => 0, + 'resourceTemplates' => 0, + ]; try { - $finder = new Finder(); - $absolutePaths = array_map(fn ($dir) => rtrim($basePath, '/').'/'.ltrim($dir, '/'), $directories); - $existingPaths = array_filter($absolutePaths, 'is_dir'); - if (empty($existingPaths)) { - $this->logger->warning('No valid discovery directories found.', ['paths' => $directories, 'absolute' => $absolutePaths]); + $finder = new Finder; + $absolutePaths = []; + foreach ($directories as $dir) { + $path = rtrim($basePath, '/').'/'.ltrim($dir, '/'); + if (is_dir($path)) { + $absolutePaths[] = $path; + } + } + + if (empty($absolutePaths)) { + $this->logger->warning('No valid discovery directories found to scan.', ['configured_paths' => $directories, 'base_path' => $basePath]); return; } - $finder->files()->in($existingPaths)->name('*.php'); - - $discoveredCount = [ - 'tools' => 0, - 'resources' => 0, - 'prompts' => 0, - 'resourceTemplates' => 0, - ]; + $finder->files() + ->in($absolutePaths) + ->exclude($excludeDirs) + ->name('*.php'); foreach ($finder as $file) { $this->processFile($file, $discoveredCount); } - $duration = microtime(true) - $startTime; - $this->logger->info('MCP: Element discovery finished.', [ - 'duration_sec' => round($duration, 3), - 'tools' => $discoveredCount['tools'], - 'resources' => $discoveredCount['resources'], - 'prompts' => $discoveredCount['prompts'], - 'resourceTemplates' => $discoveredCount['resourceTemplates'], - ]); - - // Note: Caching is handled separately by calling $registry->cacheElements() after discovery. } catch (Throwable $e) { - $this->logger->error('Unexpected error discovering files', ['exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); + $this->logger->error('Error during file finding process for MCP discovery', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); } + + $duration = microtime(true) - $startTime; + $this->logger->info('MCP: Attribute discovery finished.', [ + 'duration_sec' => round($duration, 3), + 'tools' => $discoveredCount['tools'], + 'resources' => $discoveredCount['resources'], + 'prompts' => $discoveredCount['prompts'], + 'resourceTemplates' => $discoveredCount['resourceTemplates'], + ]); } /** - * Process a single PHP file for MCP elements. + * Process a single PHP file for MCP elements on classes or methods. */ private function processFile(SplFileInfo $file, array &$discoveredCount): void { $filePath = $file->getRealPath(); - $className = $this->getClassFromFile($filePath); + if ($filePath === false) { + $this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]); + return; + } + + $className = $this->getClassFromFile($filePath); if (! $className) { + $this->logger->warning('No valid class found in file', ['file' => $filePath]); + return; } @@ -114,89 +136,144 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void return; } - foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->getDeclaringClass()->getName() !== $reflectionClass->getName()) { - continue; + $processedViaClassAttribute = false; + if ($reflectionClass->hasMethod('__invoke')) { + $invokeMethod = $reflectionClass->getMethod('__invoke'); + if ($invokeMethod->isPublic() && ! $invokeMethod->isStatic()) { + $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; + foreach ($attributeTypes as $attributeType) { + $classAttribute = $this->attributeFinder->getFirstClassAttribute( + $reflectionClass, + $attributeType + ); + if ($classAttribute) { + $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); + $processedViaClassAttribute = true; + break; + } + } } + } - $this->processMethod($method, $discoveredCount); + if (! $processedViaClassAttribute) { + foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $reflectionClass->getName() || + $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || $method->getName() === '__invoke') { + continue; + } + $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; + foreach ($attributeTypes as $attributeType) { + $methodAttribute = $this->attributeFinder->getFirstMethodAttribute( + $method, + $attributeType + ); + if ($methodAttribute) { + $this->processMethod($method, $discoveredCount, $methodAttribute); + break; + } + } + } } + } catch (ReflectionException $e) { - $this->logger->error('Reflection error discovering file', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]); - } catch (McpException $e) { - $this->logger->error('MCP definition error', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]); + $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]); } catch (Throwable $e) { - $this->logger->error('Unexpected error discovering file', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); + $this->logger->error('Unexpected error processing file for MCP discovery', [ + 'file' => $filePath, + 'class' => $className, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); } } /** - * Process a single method for MCP attributes. + * Process a method with a given MCP attribute instance. + * Can be called for regular methods or the __invoke method of an invokable class. + * + * @param ReflectionMethod $method The target method (e.g., regular method or __invoke). + * @param array $discoveredCount Pass by reference to update counts. + * @param ReflectionAttribute $attribute The ReflectionAttribute instance found (on method or class). */ - private function processMethod(ReflectionMethod $method, array &$discoveredCount): void + private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void { $className = $method->getDeclaringClass()->getName(); $methodName = $method->getName(); + $attributeClassName = $attribute->getName(); - $toolAttribute = $this->attributeFinder->getFirstMethodAttribute($method, McpTool::class); - if ($toolAttribute) { - try { - $instance = $toolAttribute->newInstance(); - $definition = ToolDefinition::fromReflection($method, $instance, $this->docBlockParser, $this->schemaGenerator); - $this->registry->registerTool($definition); - $discoveredCount['tools']++; - } catch (Throwable $e) { - $this->logger->error('Failed to process McpTool', ['class' => $className, 'method' => $methodName, 'exception' => $e->getMessage()]); - } - - return; - } - - $resourceAttribute = $this->attributeFinder->getFirstMethodAttribute($method, McpResource::class); - if ($resourceAttribute) { - try { - $instance = $resourceAttribute->newInstance(); - $definition = ResourceDefinition::fromReflection($method, $instance, $this->docBlockParser); - $this->registry->registerResource($definition); - $discoveredCount['resources']++; - } catch (Throwable $e) { - $this->logger->error('Failed to process McpResource', ['class' => $className, 'method' => $methodName, 'exception' => $e->getMessage()]); - } - - return; - } - - $promptAttribute = $this->attributeFinder->getFirstMethodAttribute($method, McpPrompt::class); - if ($promptAttribute) { - try { - $instance = $promptAttribute->newInstance(); - $definition = PromptDefinition::fromReflection($method, $instance, $this->docBlockParser); - $this->registry->registerPrompt($definition); - $discoveredCount['prompts']++; - } catch (Throwable $e) { - $this->logger->error('Failed to process McpPrompt', ['class' => $className, 'method' => $methodName, 'exception' => $e->getMessage()]); + try { + $instance = $attribute->newInstance(); + + switch ($attributeClassName) { + case McpTool::class: + $definition = ToolDefinition::fromReflection( + $method, + $instance->name ?? null, + $instance->description ?? null, + $this->docBlockParser, + $this->schemaGenerator + ); + $this->registry->registerTool($definition); + $discoveredCount['tools']++; + break; + + case McpResource::class: + if (! isset($instance->uri)) { + throw new McpException("McpResource attribute on {$className}::{$methodName} requires a 'uri'."); + } + $definition = ResourceDefinition::fromReflection( + $method, + $instance->name ?? null, + $instance->description ?? null, + $instance->uri, + $instance->mimeType ?? null, + $instance->size ?? null, + $instance->annotations ?? [], + $this->docBlockParser + ); + $this->registry->registerResource($definition); + $discoveredCount['resources']++; + break; + + case McpPrompt::class: + $definition = PromptDefinition::fromReflection( + $method, + $instance->name ?? null, + $instance->description ?? null, + $this->docBlockParser + ); + $this->registry->registerPrompt($definition); + $discoveredCount['prompts']++; + break; + + case McpResourceTemplate::class: + if (! isset($instance->uriTemplate)) { + throw new McpException("McpResourceTemplate attribute on {$className}::{$methodName} requires a 'uriTemplate'."); + } + $definition = ResourceTemplateDefinition::fromReflection( + $method, + $instance->name ?? null, + $instance->description ?? null, + $instance->uriTemplate, + $instance->mimeType ?? null, + $instance->annotations ?? [], + $this->docBlockParser + ); + $this->registry->registerResourceTemplate($definition); + $discoveredCount['resourceTemplates']++; + break; } - return; - } - - $templateAttribute = $this->attributeFinder->getFirstMethodAttribute($method, McpResourceTemplate::class); - if ($templateAttribute) { - try { - $instance = $templateAttribute->newInstance(); - $definition = ResourceTemplateDefinition::fromReflection($method, $instance, $this->docBlockParser); - $this->registry->registerResourceTemplate($definition); - $discoveredCount['resourceTemplates']++; - } catch (Throwable $e) { - $this->logger->error('Failed to process McpResourceTemplate', ['class' => $className, 'method' => $methodName, 'exception' => $e->getMessage()]); - } + } catch (McpException $e) { + $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]); + } catch (Throwable $e) { + $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); } } /** * Attempt to determine the FQCN from a PHP file path. * Uses tokenization to extract namespace and class name. - * (Adapted from LaravelMcp\\Discovery\\McpElementScanner) * * @param string $filePath Absolute path to the PHP file. * @return class-string|null The FQCN or null if not found/determinable. @@ -216,51 +293,49 @@ private function getClassFromFile(string $filePath): ?string return null; } + if (strlen($content) > 500 * 1024) { + $this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]); + + return null; + } $tokens = token_get_all($content); } catch (Throwable $e) { - $this->logger->warning("Failed to read or tokenize file: {$filePath}", ['exception' => $e->getMessage()]); + $this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]); return null; } $namespace = ''; - $className = null; $namespaceFound = false; - $classFound = false; $level = 0; - - $count = count($tokens); - for ($i = 0; $i < $count; $i++) { - $token = $tokens[$i]; - if (is_array($token)) { - if ($token[0] === T_NAMESPACE) { - $namespace = ''; - for ($j = $i + 1; $j < $count; $j++) { - $nextToken = $tokens[$j]; - if (is_array($nextToken) && in_array($nextToken[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { - continue; - } - if (is_array($nextToken) && ($nextToken[0] === T_STRING || $nextToken[0] === T_NAME_QUALIFIED || $nextToken[0] === T_NS_SEPARATOR)) { - $namespace .= $nextToken[1]; - } elseif ($nextToken === ';' || $nextToken === '{') { - $namespaceFound = true; - $i = $j; - break; - } else { - break; - } - } - if ($namespaceFound) { + $potentialClasses = []; + + $tokenCount = count($tokens); + for ($i = 0; $i < $tokenCount; $i++) { + if (is_array($tokens[$i]) && $tokens[$i][0] === T_NAMESPACE) { + $namespace = ''; + for ($j = $i + 1; $j < $tokenCount; $j++) { + if ($tokens[$j] === ';' || $tokens[$j] === '{') { + $namespaceFound = true; + $i = $j; break; } + if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_STRING, T_NAME_QUALIFIED])) { + $namespace .= $tokens[$j][1]; + } elseif ($tokens[$j][0] === T_NS_SEPARATOR) { + $namespace .= '\\'; + } + } + if ($namespaceFound) { + break; } } } + $namespace = trim($namespace, '\\'); - for ($i = 0; $i < $count; $i++) { + for ($i = 0; $i < $tokenCount; $i++) { $token = $tokens[$i]; - if ($token === '{') { $level++; @@ -272,27 +347,35 @@ private function getClassFromFile(string $filePath): ?string continue; } - if ($level === ($namespaceFound && str_contains($content, "namespace $namespace {") ? 1 : 0)) { + if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) { if (is_array($token) && in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, defined('T_ENUM') ? T_ENUM : -1])) { - for ($j = $i + 1; $j < $count; $j++) { - if (is_array($tokens[$j])) { - if (in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { - continue; - } - if ($tokens[$j][0] === T_STRING) { - $className = $tokens[$j][1]; - $classFound = true; - break 2; - } + for ($j = $i + 1; $j < $tokenCount; $j++) { + if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) { + $className = $tokens[$j][1]; + $potentialClasses[] = $namespace ? $namespace.'\\'.$className : $className; + $i = $j; + break; + } + if ($tokens[$j] === ';' || $tokens[$j] === '{' || $tokens[$j] === ')') { + break; } - break; } } } } - if ($classFound && $className) { - return $namespace ? rtrim($namespace, '\\').'\\'.$className : $className; + foreach ($potentialClasses as $potentialClass) { + if (class_exists($potentialClass, true)) { + return $potentialClass; + } + } + + if (! empty($potentialClasses)) { + if (! class_exists($potentialClasses[0], false)) { + $this->logger->debug('getClassFromFile returning potential non-class type', ['file' => $filePath, 'type' => $potentialClasses[0]]); + } + + return $potentialClasses[0]; } return null; diff --git a/src/Support/DocBlockParser.php b/src/Support/DocBlockParser.php index 1ee2f19..21084a0 100644 --- a/src/Support/DocBlockParser.php +++ b/src/Support/DocBlockParser.php @@ -6,8 +6,8 @@ use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlockFactory; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Throwable; /** @@ -19,10 +19,10 @@ class DocBlockParser private LoggerInterface $logger; - public function __construct(?LoggerInterface $logger = null) + public function __construct(ContainerInterface $container) { $this->docBlockFactory = DocBlockFactory::createInstance(); - $this->logger = $logger ?? new NullLogger(); + $this->logger = $container->get(LoggerInterface::class); } /** diff --git a/src/Transports/HttpTransportHandler.php b/src/Transports/HttpTransportHandler.php index 3e0f2a6..4e09225 100644 --- a/src/Transports/HttpTransportHandler.php +++ b/src/Transports/HttpTransportHandler.php @@ -9,6 +9,7 @@ use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\Processor; +use PhpMcp\Server\Server; use PhpMcp\Server\State\TransportState; use Psr\Log\LoggerInterface; use Throwable; @@ -19,15 +20,23 @@ */ class HttpTransportHandler implements TransportHandlerInterface { - public function __construct( - private readonly Processor $processor, - private readonly TransportState $transportState, - private readonly LoggerInterface $logger - ) {} + protected Processor $processor; + + protected TransportState $transportState; + + protected LoggerInterface $logger; + + public function __construct(protected readonly Server $server, ?TransportState $transportState = null) + { + $container = $server->getContainer(); + $this->processor = $server->getProcessor(); + + $this->transportState = $transportState ?? new TransportState($container); + $this->logger = $container->get(LoggerInterface::class); + } public function start(): int { - // throw an exception, this should never be called throw new \Exception('This method should never be called'); } diff --git a/src/Transports/ReactPhpHttpTransportHandler.php b/src/Transports/ReactPhpHttpTransportHandler.php index 66d2341..5f36cdb 100644 --- a/src/Transports/ReactPhpHttpTransportHandler.php +++ b/src/Transports/ReactPhpHttpTransportHandler.php @@ -2,9 +2,8 @@ namespace PhpMcp\Server\Transports; -use PhpMcp\Server\Processor; -use PhpMcp\Server\State\TransportState; -use Psr\Log\LoggerInterface; +use PhpMcp\Server\Server; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Stream\WritableStreamInterface; use Throwable; @@ -15,6 +14,8 @@ */ class ReactPhpHttpTransportHandler extends HttpTransportHandler { + protected LoopInterface $loop; + /** * Client timeout in seconds (5 minutes) */ @@ -37,13 +38,11 @@ class ReactPhpHttpTransportHandler extends HttpTransportHandler */ private array $clientSseStreams = []; - public function __construct( - private readonly Processor $processor, - private readonly TransportState $transportState, - private readonly LoggerInterface $logger, - private readonly LoopInterface $loop, - ) { - parent::__construct($processor, $transportState, $logger); + public function __construct(Server $server) + { + parent::__construct($server); + + $this->loop = Loop::get(); $this->startGlobalCleanupTimer(); } diff --git a/src/Transports/StdioTransportHandler.php b/src/Transports/StdioTransportHandler.php index 201eaf4..2c4c3fe 100644 --- a/src/Transports/StdioTransportHandler.php +++ b/src/Transports/StdioTransportHandler.php @@ -9,6 +9,7 @@ use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\Processor; +use PhpMcp\Server\Server; use PhpMcp\Server\State\TransportState; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; @@ -24,6 +25,8 @@ */ class StdioTransportHandler implements TransportHandlerInterface { + protected Processor $processor; + /** * The event loop instance. */ @@ -46,6 +49,10 @@ class StdioTransportHandler implements TransportHandlerInterface private const CLIENT_ID = 'stdio_client'; + private TransportState $transportState; + + private LoggerInterface $logger; + /** * Create a new STDIO transport handler. * @@ -54,11 +61,20 @@ class StdioTransportHandler implements TransportHandlerInterface * @param LoggerInterface $logger The PSR logger. */ public function __construct( - private readonly Processor $processor, - private readonly TransportState $transportState, - private readonly LoggerInterface $logger + protected readonly Server $server, + ?TransportState $transportState = null, + ?ReadableStreamInterface $inputStream = null, + ?WritableStreamInterface $outputStream = null, + ?LoopInterface $loop = null ) { - $this->loop = Loop::get(); + $this->loop = $loop ?? Loop::get(); + $this->inputStream = $inputStream ?? new ReadableResourceStream(STDIN, $this->loop); + $this->outputStream = $outputStream ?? new WritableResourceStream(STDOUT, $this->loop); + + $container = $server->getContainer(); + $this->processor = $server->getProcessor(); + $this->transportState = $transportState ?? new TransportState($container); + $this->logger = $container->get(LoggerInterface::class); } /** @@ -72,9 +88,6 @@ public function start(): int $this->logger->info('MCP: Starting STDIO Transport Handler.'); fwrite(STDERR, "MCP: Starting STDIO Transport Handler...\n"); - $this->inputStream = new ReadableResourceStream(STDIN, $this->loop); - $this->outputStream = new WritableResourceStream(STDOUT, $this->loop); - $this->inputStream->on('error', function (Throwable $error) { $this->logger->error('MCP: Input stream error', ['exception' => $error]); $this->stop(); diff --git a/tests/Attributes/McpResourceTemplateTest.php b/tests/Attributes/McpResourceTemplateTest.php index 557026c..55053a0 100644 --- a/tests/Attributes/McpResourceTemplateTest.php +++ b/tests/Attributes/McpResourceTemplateTest.php @@ -36,7 +36,7 @@ name: null, description: null, mimeType: null, - annotations: null // Assuming constructor allows null for array + annotations: [] ); // Assert @@ -44,7 +44,7 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBeNull(); // Or maybe defaults to []? + expect($attribute->annotations)->toBe([]); }); test('constructor handles missing optional arguments for McpResourceTemplate', function () { @@ -58,5 +58,5 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBeNull(); + expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Attributes/McpResourceTest.php b/tests/Attributes/McpResourceTest.php index 9a4eb30..edde1c3 100644 --- a/tests/Attributes/McpResourceTest.php +++ b/tests/Attributes/McpResourceTest.php @@ -40,7 +40,7 @@ description: null, mimeType: null, size: null, - annotations: null // Assuming constructor allows null for array + annotations: [] ); // Assert @@ -49,7 +49,7 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBeNull(); // Or maybe defaults to []? + expect($attribute->annotations)->toBe([]); }); test('constructor handles missing optional arguments for McpResource', function () { @@ -64,5 +64,5 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBeNull(); + expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Definitions/PromptDefinitionTest.php b/tests/Definitions/PromptDefinitionTest.php index fba1f39..fd48af5 100644 --- a/tests/Definitions/PromptDefinitionTest.php +++ b/tests/Definitions/PromptDefinitionTest.php @@ -2,15 +2,15 @@ namespace Tests\Definitions; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\PromptArgumentDefinition; +use Mockery; use PhpMcp\Server\Attributes\McpPrompt; +use PhpMcp\Server\Definitions\PromptArgumentDefinition; +use PhpMcp\Server\Definitions\PromptDefinition; use PhpMcp\Server\Support\DocBlockParser; -use Mockery; -use ReflectionMethod; -use ReflectionParameter; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\AllElementsStub; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\ToolOnlyStub; +use ReflectionMethod; +use ReflectionParameter; // --- Constructor Validation Tests --- @@ -54,7 +54,12 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); // Act - $definition = PromptDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = PromptDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser + ); // Assert expect($definition->getName())->toBe('explicit-prompt-name'); @@ -69,11 +74,11 @@ className: AllElementsStub::class, test('fromReflection uses method name and docblock summary as defaults', function () { // Arrange $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpPrompt(); + $attribute = new McpPrompt; $docComment = $reflectionMethod->getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; @@ -84,7 +89,12 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); // Act - $definition = PromptDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = PromptDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser + ); // Assert expect($definition->getName())->toBe('templateMethod'); // Default to method name @@ -97,7 +107,7 @@ className: AllElementsStub::class, test('fromReflection handles missing docblock summary', function () { // Arrange $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpPrompt(); + $attribute = new McpPrompt; $docComment = $reflectionMethod->getDocComment() ?: null; // Mock parser @@ -106,7 +116,12 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); // Act - $definition = PromptDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = PromptDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser + ); // Assert expect($definition->getName())->toBe('tool1'); @@ -179,11 +194,11 @@ className: ToolOnlyStub::class, 'name' => 'mcp-prompt', 'description' => 'MCP Description', 'arguments' => [ - ['name' => 'id', 'required' => true] - ] + ['name' => 'id', 'required' => true], + ], ]); expect($arrayMinimal)->toBe([ - 'name' => 'mcp-minimal' + 'name' => 'mcp-minimal', ]); expect($arrayMinimal)->not->toHaveKeys(['description', 'arguments']); }); diff --git a/tests/Definitions/ResourceDefinitionTest.php b/tests/Definitions/ResourceDefinitionTest.php index f8da605..31f38ae 100644 --- a/tests/Definitions/ResourceDefinitionTest.php +++ b/tests/Definitions/ResourceDefinitionTest.php @@ -2,13 +2,13 @@ namespace Tests\Definitions; -use PhpMcp\Server\Definitions\ResourceDefinition; +use Mockery; use PhpMcp\Server\Attributes\McpResource; +use PhpMcp\Server\Definitions\ResourceDefinition; use PhpMcp\Server\Support\DocBlockParser; -use Mockery; -use ReflectionMethod; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\AllElementsStub; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\ResourceOnlyStub; +use ReflectionMethod; // --- Constructor Validation Tests --- @@ -88,7 +88,16 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); // Act - $definition = ResourceDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uri, + $attribute->mimeType, + $attribute->size, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getUri())->toBe('test://explicit/uri'); @@ -108,7 +117,7 @@ className: AllElementsStub::class, $docComment = $reflectionMethod->getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); preg_match('/\/\*\*(.*?)\*\/\s+public function resourceMethod/s', $stubContent, $matches); $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; @@ -117,7 +126,16 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); // Act - $definition = ResourceDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uri, + $attribute->mimeType, + $attribute->size, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getUri())->toBe('test://default/uri'); @@ -140,7 +158,16 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); // Act - $definition = ResourceDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uri, + $attribute->mimeType, + $attribute->size, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getName())->toBe('resource2'); @@ -218,11 +245,11 @@ className: ResourceOnlyStub::class, 'description' => 'MCP Description', 'mimeType' => 'text/markdown', 'size' => 555, - 'annotations' => ['a' => 'b'] + 'annotations' => ['a' => 'b'], ]); expect($arrayMinimal)->toBe([ 'uri' => 'mcp://minimal', - 'name' => 'mcp-minimal' + 'name' => 'mcp-minimal', ]); expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'size', 'annotations']); }); diff --git a/tests/Definitions/ResourceTemplateDefinitionTest.php b/tests/Definitions/ResourceTemplateDefinitionTest.php index df351c8..7172bd2 100644 --- a/tests/Definitions/ResourceTemplateDefinitionTest.php +++ b/tests/Definitions/ResourceTemplateDefinitionTest.php @@ -2,12 +2,12 @@ namespace Tests\Definitions; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; +use Mockery; use PhpMcp\Server\Attributes\McpResourceTemplate; +use PhpMcp\Server\Definitions\ResourceTemplateDefinition; use PhpMcp\Server\Support\DocBlockParser; -use Mockery; -use ReflectionMethod; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\AllElementsStub; +use ReflectionMethod; // --- Constructor Validation Tests --- @@ -86,7 +86,15 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); // Act - $definition = ResourceTemplateDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceTemplateDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uriTemplate, + $attribute->mimeType, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getUriTemplate())->toBe('test://explicit/{id}/uri'); @@ -105,7 +113,7 @@ className: AllElementsStub::class, $docComment = $reflectionMethod->getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; @@ -115,7 +123,15 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); // Act - $definition = ResourceTemplateDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceTemplateDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uriTemplate, + $attribute->mimeType, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getUriTemplate())->toBe('test://default/{tmplId}'); @@ -139,7 +155,15 @@ className: AllElementsStub::class, $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); // Mock no summary // Act - $definition = ResourceTemplateDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser); + $definition = ResourceTemplateDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $attribute->uriTemplate, + $attribute->mimeType, + $attribute->annotations, + $this->docBlockParser + ); // Assert expect($definition->getName())->toBe('templateMethod'); // Still defaults to method name @@ -211,11 +235,11 @@ className: AllElementsStub::class, 'name' => 'mcp-tmpl', 'description' => 'MCP Description', 'mimeType' => 'application/vnd.api+json', - 'annotations' => ['version' => '1.0'] + 'annotations' => ['version' => '1.0'], ]); expect($arrayMinimal)->toBe([ 'uriTemplate' => 'mcp://minimal/{key}', - 'name' => 'mcp-minimal' + 'name' => 'mcp-minimal', ]); expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'annotations']); }); diff --git a/tests/Definitions/ToolDefinitionTest.php b/tests/Definitions/ToolDefinitionTest.php index 6eb53f0..07b754c 100644 --- a/tests/Definitions/ToolDefinitionTest.php +++ b/tests/Definitions/ToolDefinitionTest.php @@ -2,14 +2,14 @@ namespace Tests\Definitions; -use PhpMcp\Server\Definitions\ToolDefinition; +use Mockery; use PhpMcp\Server\Attributes\McpTool; +use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Support\DocBlockParser; use PhpMcp\Server\Support\SchemaGenerator; -use Mockery; -use ReflectionMethod; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\AllElementsStub; use PhpMcp\Server\Tests\Mocks\DiscoveryStubs\ToolOnlyStub; +use ReflectionMethod; // --- Constructor Validation Tests --- @@ -54,7 +54,13 @@ className: AllElementsStub::class, $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); // Act - $definition = ToolDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser, $this->schemaGenerator); + $definition = ToolDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser, + $this->schemaGenerator + ); // Assert expect($definition->getName())->toBe('explicit-tool-name'); @@ -67,13 +73,13 @@ className: AllElementsStub::class, test('fromReflection uses method name and docblock summary as defaults', function () { // Arrange $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpTool(); + $attribute = new McpTool; $expectedSchema = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; $docComment = $reflectionMethod->getDocComment() ?: null; // Read the actual summary from the stub file to make the test robust - $stubContent = file_get_contents(__DIR__ . '/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; // First line is summary @@ -83,7 +89,13 @@ className: AllElementsStub::class, $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); // Act - $definition = ToolDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser, $this->schemaGenerator); + $definition = ToolDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser, + $this->schemaGenerator + ); // Assert expect($definition->getName())->toBe('templateMethod'); // Default to method name @@ -96,7 +108,7 @@ className: AllElementsStub::class, test('fromReflection handles missing docblock summary', function () { // Arrange $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpTool(); + $attribute = new McpTool; $expectedSchema = ['type' => 'object', 'properties' => []]; // tool1 has no params $docComment = $reflectionMethod->getDocComment() ?: null; // Will be null/empty @@ -105,7 +117,13 @@ className: AllElementsStub::class, $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); // Act - $definition = ToolDefinition::fromReflection($reflectionMethod, $attribute, $this->docBlockParser, $this->schemaGenerator); + $definition = ToolDefinition::fromReflection( + $reflectionMethod, + $attribute->name, + $attribute->description, + $this->docBlockParser, + $this->schemaGenerator + ); // Assert expect($definition->getName())->toBe('tool1'); @@ -168,11 +186,11 @@ className: ToolOnlyStub::class, expect($array)->toBe([ 'name' => 'mcp-tool', 'description' => 'MCP Description', - 'inputSchema' => ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]] + 'inputSchema' => ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]], ]); expect($arrayNoDesc)->toBe([ 'name' => 'mcp-tool-no-desc', - 'inputSchema' => ['type' => 'object'] + 'inputSchema' => ['type' => 'object'], ]); expect($arrayNoDesc)->not->toHaveKey('description'); }); diff --git a/tests/Mocks/DiscoveryStubs/InvokablePromptStub.php b/tests/Mocks/DiscoveryStubs/InvokablePromptStub.php new file mode 100644 index 0000000..af44a26 --- /dev/null +++ b/tests/Mocks/DiscoveryStubs/InvokablePromptStub.php @@ -0,0 +1,25 @@ + 'user', 'content' => "Generate something about {$topic}"], + ]; + } +} diff --git a/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php b/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php new file mode 100644 index 0000000..bbd0397 --- /dev/null +++ b/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php @@ -0,0 +1,22 @@ + $id, 'data' => 'Invoked template data']; + } +} diff --git a/tests/Mocks/DiscoveryStubs/InvokableToolStub.php b/tests/Mocks/DiscoveryStubs/InvokableToolStub.php new file mode 100644 index 0000000..2b1bb03 --- /dev/null +++ b/tests/Mocks/DiscoveryStubs/InvokableToolStub.php @@ -0,0 +1,23 @@ + 'user', 'content' => "Prompt for {$topic}"]]; + } + + public function templateHandler(string $id): array + { + return ['id' => $id, 'content' => 'Template data']; + } +} diff --git a/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php b/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php new file mode 100644 index 0000000..e4b9ae3 --- /dev/null +++ b/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php @@ -0,0 +1,14 @@ +inputStream = $inputStream; - } - - if ($outputStream) { - $this->outputStream = $outputStream; - } - - if ($loop) { - $this->loop = $loop; - } - } -} diff --git a/tests/ProcessorTest.php b/tests/ProcessorTest.php index 237293a..7b24b77 100644 --- a/tests/ProcessorTest.php +++ b/tests/ProcessorTest.php @@ -31,9 +31,6 @@ use PhpMcp\Server\State\TransportState; use PhpMcp\Server\Support\ArgumentPreparer; use PhpMcp\Server\Support\SchemaValidator; -use PhpMcp\Server\Tests\TestDoubles\DummyPromptClass; -use PhpMcp\Server\Tests\TestDoubles\DummyResourceClass; -use PhpMcp\Server\Tests\TestDoubles\DummyToolClass; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use stdClass; @@ -48,8 +45,7 @@ $this->configMock = Mockery::mock(ConfigurationRepositoryInterface::class); $this->registryMock = Mockery::mock(Registry::class); $this->transportStateMock = Mockery::mock(TransportState::class); - /** @var MockInterface&LoggerInterface */ - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); // Ignore general logging + $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); $this->schemaValidatorMock = Mockery::mock(SchemaValidator::class); $this->argumentPreparerMock = Mockery::mock(ArgumentPreparer::class); @@ -74,17 +70,17 @@ $this->registryMock->allows('allResources')->withNoArgs()->andReturn(new \ArrayObject)->byDefault(); $this->registryMock->allows('allResourceTemplates')->withNoArgs()->andReturn(new \ArrayObject)->byDefault(); $this->registryMock->allows('allPrompts')->withNoArgs()->andReturn(new \ArrayObject)->byDefault(); - $this->registryMock->shouldReceive('loadElements')->once()->byDefault(); // Default transport state (not initialized) $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(false)->byDefault(); + $this->containerMock->allows('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->configMock); + $this->containerMock->allows('get')->with(LoggerInterface::class)->andReturn($this->loggerMock); + $this->processor = new Processor( $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock ); @@ -308,8 +304,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string }); test('handleToolList returns tools without pagination', function () { - $tool1 = new ToolDefinition(DummyToolClass::class, 'methodA', 'tool1', 'desc1', []); - $tool2 = new ToolDefinition(DummyToolClass::class, 'methodB', 'tool2', 'desc2', []); + $tool1 = new ToolDefinition('DummyToolClass', 'methodA', 'tool1', 'desc1', []); + $tool2 = new ToolDefinition('DummyToolClass', 'methodB', 'tool2', 'desc2', []); $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); @@ -327,8 +323,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(1); - $tool1 = new ToolDefinition(DummyToolClass::class, 'methodA', 'tool1', 'desc1', []); - $tool2 = new ToolDefinition(DummyToolClass::class, 'methodB', 'tool2', 'desc2', []); + $tool1 = new ToolDefinition('DummyToolClass', 'methodA', 'tool1', 'desc1', []); + $tool2 = new ToolDefinition('DummyToolClass', 'methodB', 'tool2', 'desc2', []); $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); // First page @@ -362,16 +358,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $formattedResult = [new TextContent(json_encode($toolResult))]; // Assume formatter JSON encodes $definition = Mockery::mock(ToolDefinition::class); - $definition->allows('getClassName')->andReturn(DummyToolClass::class); + $definition->allows('getClassName')->andReturn('DummyToolClass'); $definition->allows('getMethodName')->andReturn('execute'); $definition->allows('getInputSchema')->andReturn($inputSchema); - $toolInstance = Mockery::mock(DummyToolClass::class); + $toolInstance = Mockery::mock('DummyToolClass'); - $this->registryMock->shouldReceive('loadElements')->once(); $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->with($rawArgs, $inputSchema)->andReturn([]); // No errors - $this->containerMock->shouldReceive('get')->once()->with(DummyToolClass::class)->andReturn($toolInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyToolClass')->andReturn($toolInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($toolInstance, 'execute', $rawArgs, $inputSchema) @@ -382,10 +377,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string /** @var MockInterface&Processor */ $processorSpy = Mockery::mock(Processor::class, [ $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock, ])->makePartial(); @@ -451,16 +444,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $errorContent = [new TextContent('Tool execution failed: '.$exceptionMessage.' (Type: RuntimeException)')]; $definition = Mockery::mock(ToolDefinition::class); - $definition->allows('getClassName')->andReturn(DummyToolClass::class); + $definition->allows('getClassName')->andReturn('DummyToolClass'); $definition->allows('getMethodName')->andReturn('execute'); $definition->allows('getInputSchema')->andReturn($inputSchema); - $toolInstance = Mockery::mock(DummyToolClass::class); + $toolInstance = Mockery::mock('DummyToolClass'); - $this->registryMock->shouldReceive('loadElements')->once(); $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->with($rawArgs, $inputSchema)->andReturn([]); - $this->containerMock->shouldReceive('get')->once()->with(DummyToolClass::class)->andReturn($toolInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyToolClass')->andReturn($toolInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($toolInstance, 'execute', $rawArgs, $inputSchema) @@ -472,10 +464,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string /** @var MockInterface&Processor */ $processorSpy = Mockery::mock(Processor::class, [ $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock, ])->makePartial(); @@ -655,15 +645,14 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $formattedContents = [new TextContent($contents)]; $resourceDef = Mockery::mock(ResourceDefinition::class); - $resourceDef->allows('getClassName')->andReturn(DummyResourceClass::class); + $resourceDef->allows('getClassName')->andReturn('DummyResourceClass'); $resourceDef->allows('getMethodName')->andReturn('getResource'); $resourceDef->allows('getMimeType')->andReturn($mimeType); - $resourceInstance = Mockery::mock(DummyResourceClass::class); + $resourceInstance = Mockery::mock('DummyResourceClass'); - $this->registryMock->shouldReceive('loadElements')->once(); $this->registryMock->shouldReceive('findResourceByUri')->once()->with($uri)->andReturn($resourceDef); - $this->containerMock->shouldReceive('get')->once()->with(DummyResourceClass::class)->andReturn($resourceInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($resourceInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($resourceInstance, 'getResource', ['uri' => $uri], []) @@ -676,10 +665,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string /** @var MockInterface&Processor */ $processorSpy = Mockery::mock(Processor::class, [ $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock, ])->makePartial(); @@ -706,16 +693,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $formattedContents = [new TextContent($contents)]; $templateDef = Mockery::mock(ResourceTemplateDefinition::class); - $templateDef->allows('getClassName')->andReturn(DummyResourceClass::class); + $templateDef->allows('getClassName')->andReturn('DummyResourceClass'); $templateDef->allows('getMethodName')->andReturn('getTemplate'); $templateDef->allows('getMimeType')->andReturn($mimeType); - $resourceInstance = Mockery::mock(DummyResourceClass::class); + $resourceInstance = Mockery::mock('DummyResourceClass'); - $this->registryMock->shouldReceive('loadElements')->once(); $this->registryMock->shouldReceive('findResourceByUri')->once()->with($requestedUri)->andReturn(null); $this->registryMock->shouldReceive('findResourceTemplateByUri')->once()->with($requestedUri)->andReturn(['definition' => $templateDef, 'variables' => $templateParams]); - $this->containerMock->shouldReceive('get')->once()->with(DummyResourceClass::class)->andReturn($resourceInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($resourceInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($resourceInstance, 'getTemplate', array_merge($templateParams, ['uri' => $requestedUri]), []) @@ -728,10 +714,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string /** @var MockInterface&Processor */ $processorSpy = Mockery::mock(Processor::class, [ $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock, ])->makePartial(); @@ -780,15 +764,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $exception = new \RuntimeException($exceptionMessage); $definition = Mockery::mock(\PhpMcp\Server\Definitions\ResourceDefinition::class); - $definition->allows('getClassName')->andReturn(DummyResourceClass::class); + $definition->allows('getClassName')->andReturn('DummyResourceClass'); $definition->allows('getMethodName')->andReturn('getResource'); $definition->allows('getMimeType')->andReturn($mimeType); - $handlerInstance = Mockery::mock(DummyResourceClass::class); + $handlerInstance = Mockery::mock('DummyResourceClass'); $this->registryMock->shouldReceive('findResourceByUri')->once()->with($uri)->andReturn($definition); - $this->containerMock->shouldReceive('get')->once()->with(DummyResourceClass::class)->andReturn($handlerInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($handlerInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($handlerInstance, 'getResource', ['uri' => $uri], []) @@ -809,7 +793,7 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $this->configMock->allows('get')->with('mcp.capabilities.resources.subscribe', Mockery::any())->andReturn(true); $uri = 'file://subscribable'; - $resource = new ResourceDefinition(DummyResourceClass::class, 'getResource', $uri, 'testResource', null, 'text/plain', 1024); + $resource = new ResourceDefinition('DummyResourceClass', 'getResource', $uri, 'testResource', null, 'text/plain', 1024); $this->transportStateMock->shouldReceive('addResourceSubscription')->once()->with(CLIENT_ID, $uri)->andReturn(true); @@ -824,7 +808,7 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); $uri = 'file://subscribable'; - $resource = new ResourceDefinition(DummyResourceClass::class, 'getResource', $uri, 'testResource', null, 'text/plain', 1024); + $resource = new ResourceDefinition('DummyResourceClass', 'getResource', $uri, 'testResource', null, 'text/plain', 1024); $this->transportStateMock->shouldReceive('removeResourceSubscription')->once()->with(CLIENT_ID, $uri)->andReturn(true); @@ -918,16 +902,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $formattedMessages = array_map(fn ($message) => new PromptMessage($message['role'], new TextContent($message['content'])), $rawResult); $promptDef = Mockery::mock(PromptDefinition::class); - $promptDef->allows('getClassName')->andReturn(DummyPromptClass::class); + $promptDef->allows('getClassName')->andReturn('DummyPromptClass'); $promptDef->allows('getMethodName')->andReturn('getGreetingPrompt'); $promptDef->allows('getArguments')->andReturn([]); $promptDef->allows('getDescription')->andReturn($promptDescription); - $promptInstance = Mockery::mock(DummyPromptClass::class); + $promptInstance = Mockery::mock('DummyPromptClass'); - $this->registryMock->shouldReceive('loadElements')->once(); $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn($promptDef); - $this->containerMock->shouldReceive('get')->once()->with(DummyPromptClass::class)->andReturn($promptInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyPromptClass')->andReturn($promptInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($promptInstance, 'getGreetingPrompt', $promptArgs, []) @@ -939,10 +922,8 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string /** @var MockInterface&Processor */ $processorSpy = Mockery::mock(Processor::class, [ $this->containerMock, - $this->configMock, $this->registryMock, $this->transportStateMock, - $this->loggerMock, $this->schemaValidatorMock, $this->argumentPreparerMock, ])->makePartial(); @@ -1026,14 +1007,14 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $exception = new \RuntimeException($exceptionMessage); $promptDef = Mockery::mock(PromptDefinition::class); - $promptDef->allows('getClassName')->andReturn(DummyPromptClass::class); + $promptDef->allows('getClassName')->andReturn('DummyPromptClass'); $promptDef->allows('getMethodName')->andReturn('getErrorPrompt'); $promptDef->allows('getArguments')->andReturn([]); - $promptInstance = Mockery::mock(DummyPromptClass::class); + $promptInstance = Mockery::mock('DummyPromptClass'); $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn($promptDef); - $this->containerMock->shouldReceive('get')->once()->with(DummyPromptClass::class)->andReturn($promptInstance); + $this->containerMock->shouldReceive('get')->once()->with('DummyPromptClass')->andReturn($promptInstance); $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') ->once() ->with($promptInstance, 'getErrorPrompt', $promptArgs, []) diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php index 0d9dab8..eb8fb25 100644 --- a/tests/RegistryTest.php +++ b/tests/RegistryTest.php @@ -3,8 +3,14 @@ namespace Tests\Discovery; // Adjust namespace if needed based on your structure use Mockery; +use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface; +use PhpMcp\Server\Definitions\PromptDefinition; +use PhpMcp\Server\Definitions\ResourceDefinition; +use PhpMcp\Server\Definitions\ResourceTemplateDefinition; +use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Registry; use PhpMcp\Server\State\TransportState; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Throwable; @@ -14,20 +20,22 @@ // Mocks and SUT instance beforeEach(function () { - /** @var CacheInterface|Mockery\MockInterface */ + $this->containerMock = Mockery::mock(ContainerInterface::class); $this->cache = Mockery::mock(CacheInterface::class); - /** @var LoggerInterface|Mockery\MockInterface */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - /** @var TransportState|Mockery\MockInterface */ + $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); $this->transportState = Mockery::mock(TransportState::class)->shouldIgnoreMissing(); - // Instantiate Registry, providing the mocked dependencies and prefix - $this->registry = new Registry( - $this->cache, - $this->logger, - $this->transportState, - REGISTRY_CACHE_PREFIX // Pass the prefix - ); + $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn(REGISTRY_CACHE_PREFIX); + $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(3600); + + $this->cache->allows('get')->with(EXPECTED_CACHE_KEY)->andReturn(null)->byDefault(); + + $this->containerMock->shouldReceive('get')->with(CacheInterface::class)->andReturn($this->cache); + $this->containerMock->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); + $this->containerMock->shouldReceive('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); + + $this->registry = new Registry($this->containerMock, $this->transportState); }); // --- Registration and Basic Retrieval Tests --- @@ -35,8 +43,6 @@ test('can register and find a tool', function () { // Arrange $tool = createTestTool('my-tool'); - // Expect initial cache check - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); // Act $this->registry->registerTool($tool); @@ -51,7 +57,6 @@ test('can register and find a resource by URI', function () { // Arrange $resource = createTestResource('file:///exact/match.txt'); - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); // Act $this->registry->registerResource($resource); @@ -66,7 +71,6 @@ test('can register and find a prompt', function () { // Arrange $prompt = createTestPrompt('my-prompt'); - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); // Act $this->registry->registerPrompt($prompt); @@ -81,7 +85,6 @@ test('can register and find a resource template by URI', function () { // Arrange $template = createTestTemplate('user://{userId}/profile'); - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); // Act $this->registry->registerResourceTemplate($template); @@ -99,8 +102,6 @@ test('can retrieve all registered elements of each type', function () { // Arrange - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); - $tool1 = createTestTool('t1'); $tool2 = createTestTool('t2'); $resource1 = createTestResource('file:///valid/r1'); @@ -130,9 +131,6 @@ test('can cache registered elements', function () { // Arrange - // Expect initial cache check on first registration - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); - $tool = createTestTool('cache-tool'); $resource = createTestResource('cache://res'); $prompt = createTestPrompt('cache-prompt'); @@ -167,7 +165,7 @@ ->andReturn(true); // Act - $result = $this->registry->cacheElements(); + $result = $this->registry->saveElementsToCache(); // Assert expect($result)->toBeTrue(); @@ -181,10 +179,10 @@ $template = createTestTemplate('cached://tmpl/{id}'); $cachedData = [ - 'tools' => [$tool->getName() => $tool], - 'resources' => [$resource->getUri() => $resource], - 'prompts' => [$prompt->getName() => $prompt], - 'resourceTemplates' => [$template->getUriTemplate() => $template], + 'tools' => [$tool->getName() => json_decode(json_encode($tool), true)], + 'resources' => [$resource->getUri() => json_decode(json_encode($resource), true)], + 'prompts' => [$prompt->getName() => json_decode(json_encode($prompt), true)], + 'resourceTemplates' => [$template->getUriTemplate() => json_decode(json_encode($template), true)], ]; $this->cache->shouldReceive('get')->once() @@ -192,7 +190,7 @@ ->andReturn($cachedData); // Act - $this->registry->loadElements(); + $this->registry->loadElementsFromCache(true); // Assert that loading occurred and elements are present $foundTool = $this->registry->findTool('cached-tool'); @@ -200,24 +198,27 @@ $foundPrompt = $this->registry->findPrompt('cached-prompt'); $foundTemplateMatch = $this->registry->findResourceTemplateByUri('cached://tmpl/123'); - expect($foundTool)->toBe($tool); - expect($foundResource)->toBe($resource); - expect($foundPrompt)->toBe($prompt); + expect($foundTool)->toBeInstanceOf(ToolDefinition::class) + ->and($foundTool->getName())->toBe($tool->getName()) + ->and($foundTool->getDescription())->toBe($tool->getDescription()) + ->and($foundTool->getInputSchema())->toBe($tool->getInputSchema()); + expect($foundResource)->toBeInstanceOf(ResourceDefinition::class) + ->and($foundResource->getUri())->toBe($resource->getUri()) + ->and($foundResource->getDescription())->toBe($resource->getDescription()) + ->and($foundResource->getMimeType())->toBe($resource->getMimeType()); + expect($foundPrompt)->toBeInstanceOf(PromptDefinition::class) + ->and($foundPrompt->getName())->toBe($prompt->getName()) + ->and($foundPrompt->getDescription())->toBe($prompt->getDescription()); expect($foundTemplateMatch)->toBeArray() - ->and($foundTemplateMatch['definition'])->toBe($template) + ->and($foundTemplateMatch['definition'])->toBeInstanceOf(ResourceTemplateDefinition::class) ->and($foundTemplateMatch['variables'])->toBe(['id' => '123']); expect($this->registry->isLoaded())->toBeTrue(); }); test('load elements ignores cache and initializes empty if cache is empty or invalid', function ($cacheReturnValue) { - // Arrange - $this->cache->shouldReceive('get')->once() - ->with(EXPECTED_CACHE_KEY) - ->andReturn($cacheReturnValue); - // Act - $this->registry->loadElements(); // loadElements returns void + $this->registry->loadElementsFromCache(); // loadElements returns void // Assert registry is empty and loaded flag is set expect($this->registry->allTools()->count())->toBe(0); @@ -267,9 +268,6 @@ $promptNotifierCalled = true; }); - // Need initial cache load expectation for the FIRST register call - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); - // Act - Register elements to trigger notifiers $this->registry->registerTool(createTestTool()); $this->registry->registerResource(createTestResource()); @@ -288,9 +286,6 @@ $this->registry->setResourcesChangedNotifier(null); $this->registry->setPromptsChangedNotifier(null); - // Need initial cache load expectation for the FIRST register call - $this->cache->shouldReceive('get')->once()->with(EXPECTED_CACHE_KEY)->andReturn(null); - // Act & Assert - Expect no exceptions when registering (which calls notify methods) expect(fn () => $this->registry->registerTool(createTestTool()))->not->toThrow(Throwable::class); expect(fn () => $this->registry->registerResource(createTestResource()))->not->toThrow(Throwable::class); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 7876c40..34d623f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -5,27 +5,40 @@ use LogicException; use Mockery; use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface; -use PhpMcp\Server\Defaults\ArrayConfigurationRepository; use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Definitions\PromptDefinition; +use PhpMcp\Server\Definitions\ResourceDefinition; +use PhpMcp\Server\Definitions\ResourceTemplateDefinition; +use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Processor; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; use PhpMcp\Server\State\TransportState; use PhpMcp\Server\Support\Discoverer; +use PhpMcp\Server\Tests\Mocks\ManualRegistrationStubs\HandlerStub; +use PhpMcp\Server\Tests\Mocks\ManualRegistrationStubs\InvokableHandlerStub; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; beforeEach(function () { // Mock dependencies - $this->mockLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->mockCache = Mockery::mock(CacheInterface::class); - $this->mockConfig = Mockery::mock(ConfigurationRepositoryInterface::class); - $this->mockContainer = Mockery::mock(ContainerInterface::class); - $this->mockRegistry = Mockery::mock(Registry::class); - $this->mockDiscoverer = Mockery::mock(Discoverer::class); - $this->mockTransportState = Mockery::mock(TransportState::class); - $this->mockProcessor = Mockery::mock(Processor::class); + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $this->cache = Mockery::mock(CacheInterface::class)->shouldIgnoreMissing(); + $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); + $this->container = Mockery::mock(ContainerInterface::class); + $this->registry = Mockery::mock(Registry::class); + $this->discoverer = Mockery::mock(Discoverer::class); + $this->transportState = Mockery::mock(TransportState::class); + $this->processor = Mockery::mock(Processor::class); + + $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn('mcp_'); + $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(3600); + + $this->container->allows('get')->with(LoggerInterface::class)->andReturn($this->logger); + $this->container->allows('get')->with(CacheInterface::class)->andReturn($this->cache); + $this->container->allows('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); // Setup test path $this->basePath = sys_get_temp_dir().'/mcp-server-test'; @@ -63,330 +76,233 @@ test('it can be configured with a custom container', function () { $server = new Server; - $result = $server->withContainer($this->mockContainer); + $result = $server->withContainer($this->container); expect($result)->toBe($server); // Fluent interface returns self - expect($server->getContainer())->toBe($this->mockContainer); + expect($server->getContainer())->toBe($this->container); }); -test('it can be configured with a custom logger', function () { - $server = new Server; - $result = $server->withLogger($this->mockLogger); - - expect($result)->toBe($server); - expect($server->getLogger())->toBe($this->mockLogger); -}); - -test('it can be configured with a custom cache', function () { - $this->mockCache->shouldReceive('get')->andReturn(null); - - $server = new Server; - $result = $server->withCache($this->mockCache); - - expect($result)->toBe($server); - expect($server->getCache())->toBe($this->mockCache); -}); - -test('it can be configured with a custom config', function () { - $this->mockConfig->shouldReceive('get')->withAnyArgs()->andReturnUsing(function ($key, $default = null) { - if ($key === 'mcp.protocol_versions') { - return ['2024-11-05']; - } - if ($key === 'mcp.cache.prefix') { - return 'mcp:'; - } - if ($key === 'mcp.cache.ttl') { - return 3600; - } - if ($key === 'mcp.cache.key') { - return 'mcp.elements.cache'; - } +// --- Initialization Tests --- - return $default; - }); - $this->mockConfig->shouldReceive('set')->withAnyArgs()->andReturn(true); +// test('it registers core services to BasicContainer', function () { +// $container = Mockery::mock(BasicContainer::class); +// $container->shouldReceive('set')->times(4)->withAnyArgs(); - // Need to mock cache for the TransportState - $cacheMock = Mockery::mock(CacheInterface::class); - $cacheMock->shouldReceive('get')->withAnyArgs()->andReturn(null); +// $server = new Server; +// $server->withContainer($container); - $server = new Server; - $server->withCache($cacheMock); - $result = $server->withConfig($this->mockConfig); +// // Force initialization +// $server->getProcessor(); - expect($result)->toBe($server); - expect($server->getConfig())->toBe($this->mockConfig); -}); +// // With shouldReceive above we're just verifying it was called 4 times +// expect(true)->toBeTrue(); +// }); -test('it can be configured with a base path', function () { - $basePath = '/custom/path'; - - $configMock = Mockery::mock(ArrayConfigurationRepository::class); - $configMock->allows('get')->andReturn(null); - $configMock->shouldReceive('set')->once()->with('mcp.discovery.base_path', $basePath); +// --- Run Tests --- +test('it throws exception for unsupported transport', function () { $server = new Server; - $server->withConfig($configMock); - $result = $server->withBasePath($basePath); - expect($result)->toBe($server); + expect(fn () => $server->run('unsupported'))->toThrow(LogicException::class, 'Unsupported transport: unsupported'); }); -test('it can be configured with scan directories', function () { - $scanDirs = ['src', 'app/MCP']; - - $configMock = Mockery::mock(ArrayConfigurationRepository::class); - $configMock->allows('get')->andReturn(null); - $configMock->shouldReceive('set')->once()->with('mcp.discovery.directories', $scanDirs); - +test('it throws exception when trying to run HTTP transport directly', function () { $server = new Server; - $server->withConfig($configMock); - $result = $server->withScanDirectories($scanDirs); - expect($result)->toBe($server); + expect(fn () => $server->run('http'))->toThrow(LogicException::class, 'Cannot run HTTP transport directly'); }); -// --- Initialization Tests --- +// --- Component Getter Tests --- -test('it creates default dependencies when none provided', function () { +test('it returns the processor instance', function () { $server = new Server; - - expect($server->getLogger())->toBeInstanceOf(LoggerInterface::class); - expect($server->getCache())->toBeInstanceOf(CacheInterface::class); - expect($server->getContainer())->toBeInstanceOf(ContainerInterface::class); - expect($server->getConfig())->toBeInstanceOf(ConfigurationRepositoryInterface::class); + $processor = $server->getProcessor(); + expect($processor)->toBeInstanceOf(Processor::class); }); -test('it registers core services to BasicContainer', function () { - $container = Mockery::mock(BasicContainer::class); - $container->shouldReceive('set')->times(4)->withAnyArgs(); - +test('it returns the registry instance', function () { $server = new Server; - $server->withContainer($container); - - // Force initialization - $server->getProcessor(); - - // With shouldReceive above we're just verifying it was called 4 times - expect(true)->toBeTrue(); + $registry = $server->getRegistry(); + expect($registry)->toBeInstanceOf(Registry::class); }); -test('it initializes with default configuration values when no config provided', function () { +test('it returns the container instance', function () { $server = new Server; - $config = $server->getConfig(); - - expect($config->get('mcp.server.name'))->toBe('PHP MCP Server'); - expect($config->get('mcp.server.version'))->toBe('1.0.0'); - expect($config->get('mcp.protocol_versions'))->toContain('2024-11-05'); - expect($config->get('mcp.pagination_limit'))->toBe(50); - expect($config->get('mcp.capabilities.tools.enabled'))->toBeTrue(); - expect($config->get('mcp.capabilities.resources.enabled'))->toBeTrue(); - expect($config->get('mcp.capabilities.prompts.enabled'))->toBeTrue(); - expect($config->get('mcp.capabilities.logging.enabled'))->toBeTrue(); + $container = $server->getContainer(); + expect($container)->toBeInstanceOf(ContainerInterface::class); }); -test('it applies custom base path and scan directories to config', function () { - $basePath = '/custom/base/path'; - $scanDirs = ['app', 'src/MCP']; - - $server = new Server; - $server->withBasePath($basePath); - $server->withScanDirectories($scanDirs); - - $config = $server->getConfig(); +// --- Manual Registration Tests --- - expect($config->get('mcp.discovery.base_path'))->toBe($basePath); - expect($config->get('mcp.discovery.directories'))->toBe($scanDirs); -}); +test('it can manually register a tool using array handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); -// --- Discovery Tests --- + $this->registry->shouldReceive('registerTool') + ->once() + ->with(Mockery::on(function (ToolDefinition $def) { + return $def->getName() === 'customTool' + && $def->getDescription() === 'Custom Description'; + })); -test('it performs discovery using the discoverer', function () { - $basePath = '/test/path'; - $scanDirs = ['src']; + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); - $cacheMock = Mockery::mock(CacheInterface::class); - $cacheMock->shouldReceive('get')->withAnyArgs()->andReturn(null); - $cacheMock->shouldReceive('set')->withAnyArgs()->andReturn(true); - $cacheMock->shouldReceive('delete')->withAnyArgs()->andReturn(true); + $result = $server->withTool([HandlerStub::class, 'toolHandler'], 'customTool', 'Custom Description'); - $configMock = Mockery::mock(ConfigurationRepositoryInterface::class); - $configMock->shouldReceive('get')->withAnyArgs()->andReturnUsing(function ($key, $default = null) use ($basePath, $scanDirs) { - if ($key === 'mcp.discovery.base_path') { - return $basePath; - } - if ($key === 'mcp.discovery.directories') { - return $scanDirs; - } - if ($key === 'mcp.protocol_versions') { - return ['2024-11-05']; - } - if ($key === 'mcp.cache.prefix') { - return 'mcp:'; - } - if ($key === 'mcp.cache.ttl') { - return 3600; - } - if ($key === 'mcp.cache.key') { - return 'mcp.elements.cache'; - } + expect($result)->toBe($server); +}); - return $default; - }); - $configMock->shouldReceive('set')->withAnyArgs()->andReturn(true); +test('it can manually register a tool using invokable handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); - $registryMock = Mockery::mock(Registry::class); - $registryMock->shouldReceive('clearCache')->once(); - $registryMock->shouldReceive('cacheElements')->once(); - $registryMock->shouldReceive('loadElements')->zeroOrMoreTimes(); + $this->registry->shouldReceive('registerTool') + ->once() + ->with(Mockery::on(function (ToolDefinition $def) { + return $def->getName() === 'InvokableHandlerStub'; + })); - $discovererMock = Mockery::mock(Discoverer::class); - $discovererMock->shouldReceive('discover')->once()->with($basePath, $scanDirs); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); - // Use reflection to inject mocks - $server = new Server; - $server->withCache($cacheMock); // Set the cache before other dependencies + $result = $server->withTool(InvokableHandlerStub::class); - $reflection = new \ReflectionClass($server); + expect($result)->toBe($server); +}); - $configProp = $reflection->getProperty('config'); - $configProp->setAccessible(true); - $configProp->setValue($server, $configMock); +test('it can manually register a resource using array handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); - $registryProp = $reflection->getProperty('registry'); - $registryProp->setAccessible(true); - $registryProp->setValue($server, $registryMock); + $this->registry->shouldReceive('registerResource') + ->once() + ->with(Mockery::on(function (ResourceDefinition $def) { + return $def->getName() === 'customResource' + && $def->getUri() === 'my://resource'; + })); - $discovererProp = $reflection->getProperty('discoverer'); - $discovererProp->setAccessible(true); - $discovererProp->setValue($server, $discovererMock); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); - // Call discover - $result = $server->discover(); + $result = $server->withResource([HandlerStub::class, 'resourceHandler'], 'my://resource', 'customResource'); expect($result)->toBe($server); }); -test('it skips cache clearing when specified in discover method', function () { - $basePath = '/test/path'; - $scanDirs = ['src']; - - $cacheMock = Mockery::mock(CacheInterface::class); - $cacheMock->shouldReceive('get')->withAnyArgs()->andReturn(null); - $cacheMock->shouldReceive('set')->withAnyArgs()->andReturn(true); - $cacheMock->shouldReceive('delete')->withAnyArgs()->andReturn(true); - - $configMock = Mockery::mock(ConfigurationRepositoryInterface::class); - $configMock->shouldReceive('get')->withAnyArgs()->andReturnUsing(function ($key, $default = null) use ($basePath, $scanDirs) { - if ($key === 'mcp.discovery.base_path') { - return $basePath; - } - if ($key === 'mcp.discovery.directories') { - return $scanDirs; - } - if ($key === 'mcp.protocol_versions') { - return ['2024-11-05']; - } - if ($key === 'mcp.cache.prefix') { - return 'mcp:'; - } - if ($key === 'mcp.cache.ttl') { - return 3600; - } - if ($key === 'mcp.cache.key') { - return 'mcp.elements.cache'; - } +test('it can manually register a resource using invokable handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); - return $default; - }); - $configMock->shouldReceive('set')->withAnyArgs()->andReturn(true); + $this->registry->shouldReceive('registerResource') + ->once() + ->with(Mockery::on(function (ResourceDefinition $def) { + return $def->getName() === 'InvokableHandlerStub' + && $def->getUri() === 'invokable://resource'; + })); - $registryMock = Mockery::mock(Registry::class); - $registryMock->shouldNotReceive('clearCache'); // Should not be called - $registryMock->shouldReceive('cacheElements')->once(); - $registryMock->shouldReceive('loadElements')->zeroOrMoreTimes(); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); - $discovererMock = Mockery::mock(Discoverer::class); - $discovererMock->shouldReceive('discover')->once()->with($basePath, $scanDirs); + $result = $server->withResource(InvokableHandlerStub::class, 'invokable://resource'); - // Use reflection to inject mocks - $server = new Server; - $server->withCache($cacheMock); // Set the cache before other dependencies - - $reflection = new \ReflectionClass($server); + expect($result)->toBe($server); +}); - $configProp = $reflection->getProperty('config'); - $configProp->setAccessible(true); - $configProp->setValue($server, $configMock); +test('it can manually register a prompt using array handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); - $registryProp = $reflection->getProperty('registry'); - $registryProp->setAccessible(true); - $registryProp->setValue($server, $registryMock); + $this->registry->shouldReceive('registerPrompt') + ->once() + ->with(Mockery::on(function (PromptDefinition $def) { + return $def->getName() === 'customPrompt'; + })); - $discovererProp = $reflection->getProperty('discoverer'); - $discovererProp->setAccessible(true); - $discovererProp->setValue($server, $discovererMock); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); - // Call discover with false to skip cache clearing - $result = $server->discover(false); + $result = $server->withPrompt([HandlerStub::class, 'promptHandler'], 'customPrompt'); expect($result)->toBe($server); }); -// --- Run Tests --- +test('it can manually register a prompt using invokable handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); -test('it throws exception for unsupported transport', function () { - $server = new Server; + $this->registry->shouldReceive('registerPrompt') + ->once() + ->with(Mockery::on(function (PromptDefinition $def) { + return $def->getName() === 'InvokableHandlerStub'; + })); - expect(fn () => $server->run('unsupported'))->toThrow(LogicException::class, 'Unsupported transport: unsupported'); -}); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); -test('it throws exception when trying to run HTTP transport directly', function () { - $server = new Server; + $result = $server->withPrompt(InvokableHandlerStub::class); - expect(fn () => $server->run('http'))->toThrow(LogicException::class, 'Cannot run HTTP transport directly'); + expect($result)->toBe($server); }); -// --- Component Getter Tests --- +test('it can manually register a resource template using array handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); -test('it returns the processor instance', function () { - $server = new Server; - $processor = $server->getProcessor(); - expect($processor)->toBeInstanceOf(Processor::class); -}); + $this->registry->shouldReceive('registerResourceTemplate') + ->once() + ->with(Mockery::on(function (ResourceTemplateDefinition $def) { + return $def->getName() === 'customTemplate' + && $def->getUriTemplate() === 'my://template/{id}'; + })); -test('it returns the registry instance', function () { - $server = new Server; - $registry = $server->getRegistry(); - expect($registry)->toBeInstanceOf(Registry::class); -}); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); -test('it returns the state manager instance', function () { - $server = new Server; - $stateManager = $server->getStateManager(); - expect($stateManager)->toBeInstanceOf(TransportState::class); -}); + $result = $server->withResourceTemplate([HandlerStub::class, 'templateHandler'], 'customTemplate', null, 'my://template/{id}'); -test('it returns the config instance', function () { - $server = new Server; - $config = $server->getConfig(); - expect($config)->toBeInstanceOf(ConfigurationRepositoryInterface::class); + expect($result)->toBe($server); }); -test('it returns the logger instance', function () { - $server = new Server; - $logger = $server->getLogger(); - expect($logger)->toBeInstanceOf(LoggerInterface::class); -}); +test('it can manually register a resource template using invokable handler', function () { + $server = Server::make(); + $server->withContainer($this->container); + $server->withLogger($this->logger); -test('it returns the cache instance', function () { - $server = new Server; - $cache = $server->getCache(); - expect($cache)->toBeInstanceOf(CacheInterface::class); -}); + $this->registry->shouldReceive('registerResourceTemplate') + ->once() + ->with(Mockery::on(function (ResourceTemplateDefinition $def) { + return $def->getName() === 'InvokableHandlerStub' + && $def->getUriTemplate() === 'invokable://template/{id}'; + })); -test('it returns the container instance', function () { - $server = new Server; - $container = $server->getContainer(); - expect($container)->toBeInstanceOf(ContainerInterface::class); + $serverReflection = new ReflectionClass($server); + $registryProperty = $serverReflection->getProperty('registry'); + $registryProperty->setAccessible(true); + $registryProperty->setValue($server, $this->registry); + + $result = $server->withResourceTemplate(InvokableHandlerStub::class, null, null, 'invokable://template/{id}'); + + expect($result)->toBe($server); }); diff --git a/tests/State/TransportStateTest.php b/tests/State/TransportStateTest.php index 1de35a5..178ea87 100644 --- a/tests/State/TransportStateTest.php +++ b/tests/State/TransportStateTest.php @@ -1,12 +1,12 @@ container = Mockery::mock(ContainerInterface::class); + $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); $this->cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&LoggerInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); // Ignore log calls unless specified - - $this->transportState = new TransportState( - $this->cache, - $this->logger, - CACHE_PREFIX, - CACHE_TTL - ); + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + + $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn(CACHE_PREFIX); + $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(CACHE_TTL); + + $this->container->shouldReceive('get')->with(CacheInterface::class)->andReturn($this->cache); + $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); + $this->container->shouldReceive('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); + + $this->transportState = new TransportState($this->container); }); -// Add Mockery close after each test to verify expectations and clean up afterEach(function () { Mockery::close(); }); @@ -378,7 +378,7 @@ function getCacheKey(string $key, ?string $clientId = null): string getCacheKey('messages', $inactiveClient), getCacheKey('client_subscriptions', $inactiveClient), ])->andReturn(true); - + $activeClientsLowThreshold = $this->transportState->getActiveClients(50); // Use custom threshold // Assert diff --git a/tests/Support/DiscovererTest.php b/tests/Support/DiscovererTest.php index 4d8213d..e31d829 100644 --- a/tests/Support/DiscovererTest.php +++ b/tests/Support/DiscovererTest.php @@ -3,7 +3,6 @@ namespace Tests\Discovery; use Mockery; -use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PhpMcp\Server\Definitions\PromptDefinition; use PhpMcp\Server\Definitions\ResourceDefinition; use PhpMcp\Server\Definitions\ResourceTemplateDefinition; @@ -13,26 +12,27 @@ use PhpMcp\Server\Support\Discoverer; use PhpMcp\Server\Support\DocBlockParser; use PhpMcp\Server\Support\SchemaGenerator; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -uses(MockeryPHPUnitIntegration::class); - beforeEach(function () { setupTempDir(); + $this->container = Mockery::mock(ContainerInterface::class); $this->registry = Mockery::mock(Registry::class); - /** @var LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $attributeFinder = new AttributeFinder(); - $docBlockParser = new DocBlockParser(); + $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); + + $attributeFinder = new AttributeFinder; + $docBlockParser = new DocBlockParser($this->container); $schemaGenerator = new SchemaGenerator($docBlockParser, $attributeFinder); $this->discoverer = new Discoverer( + $this->container, $this->registry, - $this->logger, - $attributeFinder, $docBlockParser, - $schemaGenerator + $schemaGenerator, + $attributeFinder, ); }); @@ -107,7 +107,7 @@ $this->registry->shouldNotReceive('registerResourceTemplate'); // Assert logging - $this->logger->shouldReceive('warning')->with('No valid discovery directories found.', Mockery::any())->twice(); + $this->logger->shouldReceive('warning')->with('No valid discovery directories found to scan.', Mockery::any())->twice(); // Act $this->discoverer->discover($nonExistentDir, ['.']); // Base path doesn't exist @@ -189,3 +189,30 @@ // Cleanup permissions chmod($invalidFile, 0644); }); + +test('discovers attributes placed directly on invokable classes', function (string $stubName, string $registryMethod, string $expectedNameOrUri) { + // Arrange + createDiscoveryTestFile($stubName); + + // Assert registry interactions + $this->registry->shouldReceive($registryMethod) + ->once() + ->with(Mockery::on(function ($arg) use ($expectedNameOrUri, $stubName) { + // Check if it's the correct definition type and name/uri + return ($arg instanceof ToolDefinition && $arg->getName() === $expectedNameOrUri) + || ($arg instanceof ResourceDefinition && $arg->getUri() === $expectedNameOrUri) + || ($arg instanceof PromptDefinition && $arg->getName() === $expectedNameOrUri) + || ($arg instanceof ResourceTemplateDefinition && $arg->getUriTemplate() === $expectedNameOrUri) + // Verify the definition points to the __invoke method + && $arg->getMethodName() === '__invoke' + && str_ends_with($arg->getClassName(), $stubName); + })); + + // Act + $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); +})->with([ + 'Invokable Tool' => ['InvokableToolStub', 'registerTool', 'invokable-tool'], + 'Invokable Resource' => ['InvokableResourceStub', 'registerResource', 'invokable://resource'], + 'Invokable Prompt' => ['InvokablePromptStub', 'registerPrompt', 'invokable-prompt'], + 'Invokable Template' => ['InvokableTemplateStub', 'registerResourceTemplate', 'invokable://template/{id}'], +]); diff --git a/tests/Support/DocBlockParserTest.php b/tests/Support/DocBlockParserTest.php index 02540a6..a2410bd 100644 --- a/tests/Support/DocBlockParserTest.php +++ b/tests/Support/DocBlockParserTest.php @@ -2,18 +2,26 @@ namespace PhpMcp\Server\Tests\Support; -use PhpMcp\Server\Support\DocBlockParser; -use PhpMcp\Server\Tests\Mocks\SupportStubs\DocBlockTestStub; -use ReflectionMethod; +use Mockery; +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\Throws; -use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; use phpDocumentor\Reflection\DocBlock\Tags\See; -use phpDocumentor\Reflection\DocBlock; +use phpDocumentor\Reflection\DocBlock\Tags\Throws; +use PhpMcp\Server\Support\DocBlockParser; +use PhpMcp\Server\Tests\Mocks\SupportStubs\DocBlockTestStub; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use ReflectionMethod; beforeEach(function () { - $this->parser = new DocBlockParser(); + $this->containerMock = Mockery::mock(ContainerInterface::class); + $this->loggerMock = Mockery::mock(LoggerInterface::class); + + $this->containerMock->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->loggerMock); + + $this->parser = new DocBlockParser($this->containerMock); }); // Helper function to get reflection method diff --git a/tests/TestDoubles/DummyPromptClass.php b/tests/TestDoubles/DummyPromptClass.php deleted file mode 100644 index f39afe2..0000000 --- a/tests/TestDoubles/DummyPromptClass.php +++ /dev/null @@ -1,36 +0,0 @@ - 'system', 'content' => 'Be polite and friendly'], - ['role' => 'user', 'content' => "Greet {$name} in {$language}"] - ]; - } - - /** - * A simple prompt with no arguments - */ - public function getSimplePrompt(): array - { - return [ - ['role' => 'system', 'content' => 'You are a helpful assistant'], - ['role' => 'user', 'content' => 'Tell me about PHP'] - ]; - } - - /** - * A prompt that generates error for testing - */ - public function getErrorPrompt(): array - { - throw new \RuntimeException('Failed to generate prompt'); - } -} diff --git a/tests/TestDoubles/DummyResourceClass.php b/tests/TestDoubles/DummyResourceClass.php deleted file mode 100644 index a178210..0000000 --- a/tests/TestDoubles/DummyResourceClass.php +++ /dev/null @@ -1,36 +0,0 @@ - $param1, - 'param2' => $param2, - 'status' => 'success', - ]; - } - - /** - * Another method for testing different tools - */ - public function methodB(): string - { - return 'Method B result'; - } - - /** - * Generic execution method used in many tests - */ - public function execute(string $param1 = '', int $param2 = 0): array - { - return [ - 'executed' => true, - 'params' => [$param1, $param2], - ]; - } -} diff --git a/tests/Transports/HttpTransportHandlerTest.php b/tests/Transports/HttpTransportHandlerTest.php index 32162a1..49fa8e6 100644 --- a/tests/Transports/HttpTransportHandlerTest.php +++ b/tests/Transports/HttpTransportHandlerTest.php @@ -5,37 +5,38 @@ use JsonException; use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use Mockery\MockInterface; use PhpMcp\Server\Exceptions\McpException; use PhpMcp\Server\JsonRpc\Notification; use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\Processor; +use PhpMcp\Server\Server; use PhpMcp\Server\State\TransportState; use PhpMcp\Server\Transports\HttpTransportHandler; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use RuntimeException; uses(MockeryPHPUnitIntegration::class); beforeEach(function () { - // Mock dependencies + $this->container = Mockery::mock(ContainerInterface::class); + $this->server = Mockery::mock(Server::class); $this->processor = Mockery::mock(Processor::class); $this->transportState = Mockery::mock(TransportState::class); - /** @var MockInterface&LoggerInterface */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - // Create handler with mocked dependencies - // Use partial mock to allow testing protected methods like sendSseEvent if needed, - // but primarily focus on interactions with dependencies. + $this->server->shouldReceive('getContainer')->andReturn($this->container); + $this->server->shouldReceive('getProcessor')->andReturn($this->processor); + + $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); + $this->handler = Mockery::mock(HttpTransportHandler::class, [ - $this->processor, + $this->server, $this->transportState, - $this->logger, ])->makePartial()->shouldAllowMockingProtectedMethods(); - // Define test client ID $this->clientId = 'test_client_id'; }); @@ -43,11 +44,7 @@ test('constructs with dependencies', function () { // Re-create without mock for this specific test - $handler = new HttpTransportHandler( - $this->processor, - $this->transportState, - $this->logger - ); + $handler = new HttpTransportHandler($this->server, $this->transportState); expect($handler)->toBeInstanceOf(HttpTransportHandler::class); }); @@ -356,7 +353,7 @@ $this->logger->shouldReceive('info')->never(); // cleanupClient no longer logs directly // Need to use the non-mocked handler for this test - $handler = new HttpTransportHandler($this->processor, $this->transportState, $this->logger); + $handler = new HttpTransportHandler($this->server, $this->transportState); $handler->cleanupClient($this->clientId); }); @@ -365,7 +362,7 @@ test('handleError converts JsonException to parse error', function () { $exception = new JsonException('Invalid JSON'); // Use the real handler instance for this utility method test - $handler = new HttpTransportHandler($this->processor, $this->transportState, $this->logger); + $handler = new HttpTransportHandler($this->server, $this->transportState); $result = $handler->handleError($exception); @@ -375,7 +372,7 @@ test('handleError preserves McpException error codes', function () { $exception = McpException::methodNotFound('Method not found'); - $handler = new HttpTransportHandler($this->processor, $this->transportState, $this->logger); + $handler = new HttpTransportHandler($this->server, $this->transportState); $result = $handler->handleError($exception); @@ -385,7 +382,7 @@ test('handleError converts generic exceptions to internal error', function () { $exception = new RuntimeException('Unexpected error'); - $handler = new HttpTransportHandler($this->processor, $this->transportState, $this->logger); + $handler = new HttpTransportHandler($this->server, $this->transportState); $result = $handler->handleError($exception); diff --git a/tests/Transports/StdioTransportHandlerTest.php b/tests/Transports/StdioTransportHandlerTest.php index 2f34a36..06724d3 100644 --- a/tests/Transports/StdioTransportHandlerTest.php +++ b/tests/Transports/StdioTransportHandlerTest.php @@ -11,9 +11,10 @@ use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\Processor; +use PhpMcp\Server\Server; use PhpMcp\Server\State\TransportState; -use PhpMcp\Server\Tests\Mocks\StdioTransportHandlerMock; use PhpMcp\Server\Transports\StdioTransportHandler; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use React\EventLoop\LoopInterface; use React\Stream\ReadableStreamInterface; @@ -22,28 +23,29 @@ use RuntimeException; beforeEach(function () { - // Mock dependencies + $this->container = Mockery::mock(ContainerInterface::class); + $this->server = Mockery::mock(Server::class); $this->processor = Mockery::mock(Processor::class); $this->transportState = Mockery::mock(TransportState::class); - /** @var MockInterface&LoggerInterface */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - // Mock React components using interfaces $this->loop = Mockery::mock(LoopInterface::class); $this->inputStream = Mockery::mock(ReadableStreamInterface::class); $this->outputStream = Mockery::mock(WritableStreamInterface::class); - // Create our test handler with mocked dependencies - $this->handler = new StdioTransportHandlerMock( - $this->processor, + $this->server->shouldReceive('getContainer')->andReturn($this->container); + $this->server->shouldReceive('getProcessor')->andReturn($this->processor); + + $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); + + $this->handler = new StdioTransportHandler( + $this->server, $this->transportState, - $this->logger, $this->inputStream, $this->outputStream, - $this->loop + $this->loop, ); - // Define test client ID $this->clientId = 'stdio_client'; }); @@ -70,11 +72,10 @@ $input = '{"jsonrpc":"2.0","method":"test"}'."\n".'{"jsonrpc":"2.0","id":1,"method":"ping"}'."\n"; // Create a spy to track handleInput calls - /** @var MockInterface&StdioTransportHandlerMock */ - $handlerSpy = Mockery::mock(StdioTransportHandlerMock::class.'[handleInput]', [ - $this->processor, + /** @var MockInterface&StdioTransportHandler */ + $handlerSpy = Mockery::mock(StdioTransportHandler::class.'[handleInput]', [ + $this->server, $this->transportState, - $this->logger, $this->inputStream, $this->outputStream, $this->loop, @@ -92,11 +93,10 @@ test('handle ignores empty lines', function () { $input = "\n\n\n"; - /** @var MockInterface&StdioTransportHandlerMock */ - $handlerSpy = Mockery::mock(StdioTransportHandlerMock::class.'[handleInput]', [ - $this->processor, + /** @var MockInterface&StdioTransportHandler */ + $handlerSpy = Mockery::mock(StdioTransportHandler::class.'[handleInput]', [ + $this->server, $this->transportState, - $this->logger, $this->inputStream, $this->outputStream, $this->loop,