From d79647bd2eb55739074fce591e47024e42a41fa1 Mon Sep 17 00:00:00 2001 From: tsztodd <43433787+tsztodd@users.noreply.github.com> Date: Wed, 7 May 2025 02:16:30 +0800 Subject: [PATCH 01/39] feat: `McpServiceProvider` file and `loadElements` function not found --- README.md | 6 +++--- src/Commands/ListCommand.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 905421a..d278225 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ This package handles: ```bash composer require php-mcp/laravel ``` -2. The `LaravelMcpServiceProvider` will be automatically discovered and registered by Laravel. +2. The `McpServiceProvider` will be automatically discovered and registered by Laravel. 3. Publish the configuration file: ```bash - php artisan vendor:publish --provider="PhpMcp\Laravel\Server\LaravelMcpServiceProvider" --tag="mcp-config" + php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" ``` This will create a `config/mcp.php` file where you can customize the server's behavior. @@ -249,4 +249,4 @@ The MIT License (MIT). Please see [License File](LICENSE) for more information. ## Support & Feedback -Please open an issue on the [GitHub repository](https://github.com/php-mcp/laravel) for bugs, questions, or feedback. \ No newline at end of file +Please open an issue on the [GitHub repository](https://github.com/php-mcp/laravel) for bugs, questions, or feedback. diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index cacba79..45b7daf 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -33,7 +33,7 @@ class ListCommand extends Command */ public function handle(Registry $registry): int { - $registry->loadElements(); // Ensure elements are loaded + $registry->loadElementsFromCache(); // Ensure elements are loaded $type = $this->argument('type'); $outputJson = $this->option('json'); From 45a49f641ad8195ce431077eb64b636a11879eb0 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 12:55:03 +0100 Subject: [PATCH 02/39] feat: Overhaul package for php-mcp/server v2.x compatibility and improved DX --- .github/workflows/changelog.yml | 31 + .github/workflows/tests.yml | 38 + CHANGELOG.md | 81 ++ README.md | 405 ++++--- composer.json | 16 +- config/mcp.php | 105 +- routes/{mcp.php => mcp_http_integrated.php} | 2 +- .../app/Mcp/GenerateSeoKeywordsPrompt.php | 26 + .../basic/app/Mcp/GenerateWelcomeMessage.php | 17 + samples/basic/app/Mcp/GetAppVersion.php | 16 + samples/basic/app/Mcp/GetArticleContent.php | 23 + samples/basic/app/Mcp/MyLaravelTools.php | 7 +- samples/basic/composer.json | 4 +- samples/basic/composer.lock | 1029 ++++++++++++----- samples/basic/config/mcp.php | 99 +- samples/basic/routes/mcp.php | 19 + src/Adapters/ConfigAdapter.php | 43 - src/Blueprints/PromptBlueprint.php | 29 + src/Blueprints/ResourceBlueprint.php | 58 + src/Blueprints/ResourceTemplateBlueprint.php | 49 + src/Blueprints/ToolBlueprint.php | 30 + src/Commands/DiscoverCommand.php | 71 +- src/Commands/ListCommand.php | 74 +- src/Commands/ServeCommand.php | 95 +- src/Events/McpNotificationEvent.php | 2 +- src/Events/PromptsListChanged.php | 2 +- src/Events/ResourceUpdated.php | 2 +- src/Events/ResourcesListChanged.php | 2 +- src/Events/ToolsListChanged.php | 2 +- src/Facades/Mcp.php | 27 + src/Http/Controllers/McpController.php | 125 +- src/Listeners/McpNotificationListener.php | 26 +- src/McpRegistrar.php | 139 +++ src/McpServiceProvider.php | 148 ++- src/Transports/LaravelHttpTransport.php | 102 ++ .../Feature/Commands/DiscoverCommandTest.php | 52 + tests/Feature/Commands/ListCommandTest.php | 134 +++ tests/Feature/Commands/ServeCommandTest.php | 129 +++ tests/Feature/ManualRegistrationTest.php | 136 +++ tests/Feature/McpServiceProviderTest.php | 128 ++ tests/Pest.php | 5 + tests/Stubs/App/Mcp/DiscoverableTool.php | 14 + tests/Stubs/App/Mcp/ManualTestHandler.php | 49 + .../App/Mcp/ManualTestInvokableHandler.php | 18 + tests/Stubs/routes/mcp-definitions.php | 1 + tests/TestCase.php | 63 + 46 files changed, 2913 insertions(+), 760 deletions(-) create mode 100644 .github/workflows/changelog.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md rename routes/{mcp.php => mcp_http_integrated.php} (92%) create mode 100644 samples/basic/app/Mcp/GenerateSeoKeywordsPrompt.php create mode 100644 samples/basic/app/Mcp/GenerateWelcomeMessage.php create mode 100644 samples/basic/app/Mcp/GetAppVersion.php create mode 100644 samples/basic/app/Mcp/GetArticleContent.php create mode 100644 samples/basic/routes/mcp.php delete mode 100644 src/Adapters/ConfigAdapter.php create mode 100644 src/Blueprints/PromptBlueprint.php create mode 100644 src/Blueprints/ResourceBlueprint.php create mode 100644 src/Blueprints/ResourceTemplateBlueprint.php create mode 100644 src/Blueprints/ToolBlueprint.php create mode 100644 src/Facades/Mcp.php create mode 100644 src/McpRegistrar.php create mode 100644 src/Transports/LaravelHttpTransport.php create mode 100644 tests/Feature/Commands/DiscoverCommandTest.php create mode 100644 tests/Feature/Commands/ListCommandTest.php create mode 100644 tests/Feature/Commands/ServeCommandTest.php create mode 100644 tests/Feature/ManualRegistrationTest.php create mode 100644 tests/Feature/McpServiceProviderTest.php create mode 100644 tests/Pest.php create mode 100644 tests/Stubs/App/Mcp/DiscoverableTool.php create mode 100644 tests/Stubs/App/Mcp/ManualTestHandler.php create mode 100644 tests/Stubs/App/Mcp/ManualTestInvokableHandler.php create mode 100644 tests/Stubs/routes/mcp-definitions.php create mode 100644 tests/TestCase.php 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/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b1cce97 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [8.1, 8.2, 8.3, 8.4] + max-parallel: 4 + + name: Tests PHP${{ matrix.php }} + + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.composer/cache/files + key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install Composer dependencies + run: composer update --no-interaction --prefer-dist + + - name: Run Tests + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec7d523 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to `php-mcp/laravel` will be documented in this file. + +## v1.1.1 - 2025-05-12 + +### What's Changed +* McpServiceProvider File And loadElements function are not found by @tsztodd in https://github.com/php-mcp/laravel/pull/2 + +### New Contributors +* @tsztodd made their first contribution in https://github.com/php-mcp/laravel/pull/2 + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/1.1.0...1.1.1 + +## v1.1.0 - 2025-05-01 + +This release updates the package for compatibility with `php-mcp/server` v1.1.0. + +### What Changed + +* Updated dependency requirement to `php-mcp/server: ^1.1.0`. +* Modified `McpServiceProvider` to correctly provide `ConfigurationRepositoryInterface`, `LoggerInterface`, and `CacheInterface` bindings to the underlying `Server` instance when resolved from the Laravel container. +* Updated `ServeCommand` and `McpController` to inject the `Server` instance and instantiate `TransportHandler` classes according to `php-mcp/server` v1.1.0 constructor changes. + +### Fixed + +* Ensures compatibility with the refactored dependency injection and transport handler instantiation logic in `php-mcp/server` v1.1.0. + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/1.0.0...1.1.0 + +# Release v1.0.0 - Initial Release + +**Initial Release** + +Welcome to the first release of `php-mcp/laravel`! This package provides seamless integration of the core [`php-mcp/server`](https://github.com/php-mcp/server) package with your Laravel application, allowing you to expose application functionality as Model Context Protocol (MCP) tools, resources, and prompts using simple PHP attributes. + +## Key Features + +* **Effortless Integration:** Automatically wires up Laravel's Cache, Logger, and Service Container for use by the MCP server. +* **Attribute-Based Definition:** Define MCP tools, resources, and prompts using PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) within your Laravel application structure. Leverage Laravel's Dependency Injection within your MCP element classes. +* **Configuration:** Provides a publishable configuration file (`config/mcp.php`) for fine-grained control over discovery, transports, caching, and capabilities. +* **Artisan Commands:** Includes commands for element discovery (`mcp:discover`), listing discovered elements (`mcp:list`), and running the server via stdio (`mcp:serve`). +* **HTTP+SSE Transport:** Sets up routes (`/mcp/message`, `/mcp/sse` by default) and a controller for handling MCP communication over HTTP, enabling browser-based clients and other HTTP consumers. +* **Automatic Discovery (Dev):** Automatically discovers MCP elements in development environments on first use, improving developer experience (no need to manually run `mcp:discover` after changes). +* **Manual Discovery (Prod):** Requires manual discovery (`mcp:discover`) for production environments, ensuring optimal performance via caching (integrates well with deployment workflows). +* **Event Integration:** Dispatches Laravel events (`ToolsListChanged`, `ResourcesListChanged`, `PromptsListChanged`) when element lists change, allowing for custom integrations or notifications. + +## Installation + +Installation is straightforward using Composer. See the [README Installation Guide](https://github.com/php-mcp/laravel/blob/main/README.md#installation) for full details. + +```bash +# 1. Require the package +composer require php-mcp/laravel + +# 2. Publish the configuration file (optional but recommended) +php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" +``` + +## Getting Started + +1. **Define Elements:** Create PHP classes with methods annotated with `#[McpTool]`, `#[McpResource]`, etc., within directories specified in `config/mcp.php` (e.g., `app/Mcp`). Inject dependencies as needed. See [Defining MCP Elements](https://github.com/php-mcp/laravel/blob/main/README.md#defining-mcp-elements). +2. **Discovery:** + * In development, discovery runs automatically when needed. + * In production, run `php artisan mcp:discover` during your deployment process. See [Automatic Discovery vs. Manual Discovery](https://github.com/php-mcp/laravel/blob/main/README.md#automatic-discovery-development-vs-manual-discovery-production). +3. **Run the Server:** + * For **Stdio Transport:** Use `php artisan mcp:serve` and configure your client to execute this command (using the full path to `artisan`). + * For **HTTP+SSE Transport:** Ensure `transports.http.enabled` is true, run your Laravel app on a suitable web server (Nginx+FPM, Octane, etc. - **not** `php artisan serve`), exclude the MCP route from CSRF protection, and configure your client with the SSE URL (e.g., `http://your-app.test/mcp/sse`). See [Running the Server](https://github.com/php-mcp/laravel/blob/main/README.md#running-the-server) for critical details. + +## Important Notes + +* **HTTP Transport Server Requirement:** The standard `php artisan serve` development server is **not suitable** for the HTTP+SSE transport due to its single-process nature. Use a proper web server setup like Nginx/Apache + PHP-FPM or Laravel Octane. +* **CSRF Exclusion:** If using the default `web` middleware group for the HTTP transport, you **must** exclude the MCP message route (default: `mcp` or `mcp/*`) from CSRF protection in your application to avoid `419` errors. +* **Dependencies:** Requires PHP >= 8.1 and Laravel >= 10.0. + +## Links + +* **GitHub Repository:** https://github.com/php-mcp/laravel +* **Packagist:** https://packagist.org/packages/php-mcp/laravel + +Please report any issues or provide feedback on the GitHub repository. \ No newline at end of file diff --git a/README.md b/README.md index d278225..edef830 100644 --- a/README.md +++ b/README.md @@ -4,249 +4,298 @@ [![Total Downloads](https://img.shields.io/packagist/dt/php-mcp/laravel.svg?style=flat-square)](https://packagist.org/packages/php-mcp/laravel) [![License](https://img.shields.io/packagist/l/php-mcp/laravel.svg?style=flat-square)](LICENSE) -Integrates the core [`php-mcp/server`](https://github.com/php-mcp/server) package seamlessly into your Laravel application, allowing you to expose parts of your application as **Model Context Protocol (MCP)** tools, resources, and prompts using simple PHP attributes. +**Seamlessly integrate the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) into your Laravel applications.** -This package handles: +This package is the official Laravel wrapper for the powerful [`php-mcp/server`](https://github.com/php-mcp/server) library. It allows you to effortlessly expose parts of your Laravel application as MCP **Tools**, **Resources**, and **Prompts**, enabling standardized communication with AI assistants like Anthropic's Claude, Cursor IDE, and others. -* Automatically wiring Laravel's Cache, Logger, and Container for use by the MCP server. -* Providing configuration options via `config/mcp.php`. -* Registering Artisan commands (`mcp:serve`, `mcp:discover`, `mcp:list`). -* Setting up HTTP+SSE transport routes and controllers. -* Integrating with Laravel's event system for dynamic updates. +**Key Features:** + +* **Effortless Integration:** Designed from the ground up for Laravel, leveraging its service container, configuration, caching, logging, and Artisan console. +* **Fluent Element Definition:** Define MCP elements programmatically with a clean, Laravely API using the `Mcp` Facade (e.g., `Mcp::tool(...)->description(...)`). +* **Attribute-Based Discovery:** Alternatively, use PHP 8 attributes (`#[McpTool]`, etc.) on your classes and methods, then run a simple Artisan command to discover and cache them. +* **Flexible Transports:** + * **Integrated HTTP+SSE:** Serve MCP requests directly through your Laravel application's routes, ideal for many setups. + * **Dedicated HTTP+SSE Server:** Launch a high-performance, standalone ReactPHP-based HTTP server via an Artisan command for demanding scenarios. + * **STDIO:** Run an MCP server over standard input/output, perfect for CLI-driven clients. +* **Robust Configuration:** Manage all aspects of your MCP server via the `config/mcp.php` file. +* **Artisan Commands:** Includes commands for serving, discovering elements, and listing registered components. +* **Event-Driven Updates:** Integrates with Laravel's event system to notify clients of dynamic changes to your MCP elements. + +This package utilizes `php-mcp/server` v2.1.0+ which supports the `2024-11-05` version of the Model Context Protocol. ## Requirements * PHP >= 8.1 -* Laravel >= 10.0 (May work with older versions, but tested with 10+) -* [`php-mcp/server`](https://github.com/php-mcp/server) (Installed as a dependency) +* Laravel >= 10.0 +* [`php-mcp/server`](https://github.com/php-mcp/server) ^2.1.0 (automatically installed) ## Installation -1. Require the package via Composer: +1. **Require the Package:** ```bash composer require php-mcp/laravel ``` -2. The `McpServiceProvider` will be automatically discovered and registered by Laravel. -3. Publish the configuration file: + +2. **Publish Configuration:** ```bash php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" ``` - This will create a `config/mcp.php` file where you can customize the server's behavior. ## Configuration -The primary way to configure the MCP server in Laravel is through the `config/mcp.php` file. - -* **`server`**: Basic server information (name, version). -* **`discovery`**: - * `base_path`: The root path for discovery (defaults to `base_path()`). - * `directories`: An array of paths *relative* to `base_path` to scan for MCP attributes (defaults to `['app/Mcp']`). Add the directories where you define your MCP element classes here. -* **`cache`**: - * `store`: The Laravel cache store to use (e.g., `file`, `redis`). Uses the default store if `null`. - * `prefix`: The cache prefix to use for caching internally. - * `ttl`: Default cache TTL in seconds for discovered elements and transport state. -* **`transports`**: - * **`http`**: Configures the built-in HTTP+SSE transport. - * `enabled`: Set to `false` to disable the HTTP routes. - * `prefix`: URL prefix for the MCP routes (defaults to `mcp`, resulting in `/mcp` and `/mcp/sse`). - * `middleware`: Array of middleware groups to apply. Defaults to `['web']`. **Important:** The `web` middleware group (or another group that enables sessions) is generally required for the HTTP transport to correctly identify clients using session IDs. - * `domain`: Optional route domain. - * **`stdio`**: Configures the stdio transport. - * `enabled`: Set to `false` to disable the `mcp:serve` command. -* **`protocol_versions`**: Array of supported MCP protocol versions (only `'2024-11-05'` currently). -* **`pagination_limit`**: Default number of items returned by list methods. -* **`capabilities`**: Enable/disable specific MCP features (tools, resources, prompts, logging) and list change notifications. -* **`logging`**: - * `channel`: Specific Laravel log channel to use. Defaults to the application's default channel. - * `level`: Default log level if not provided by the core server. - -## Usage - -### Defining MCP Elements - -Define your MCP Tools, Resources, and Prompts by decorating methods **or invokable classes** with attributes from the `php-mcp/server` package (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`). - -Place these classes in a directory included in the `discovery.directories` config array (e.g., `app/Mcp/MyTools.php`). - -**Example (`app/Mcp/MyTools.php`):** +All MCP server settings are managed in `config/mcp.php`. Here are the key sections: + +### Server Information +* **`server`**: Basic server identity settings + * `name`: Your MCP server's name (default: 'Laravel MCP') + * `version`: Server version number + * `instructions`: Optional initialization instructions for clients + +### Discovery Settings +* **`discovery`**: Controls how MCP elements are discovered + * `base_path`: Root directory for scanning (defaults to `base_path()`) + * `directories`: Paths to scan for MCP attributes (default: `['app/Mcp']`) + * `exclude_dirs`: Directories to skip during scans (e.g., 'vendor', 'tests', etc.) + * `definitions_file`: Path to manual element definitions (default: `routes/mcp.php`) + * `auto_discover`: Enable automatic discovery in development (default: `true`) + * `save_to_cache`: Cache discovery results (default: `true`) + +### Transport Configuration +* **`transports`**: Available communication methods + * **`stdio`**: CLI-based transport + * `enabled`: Enable the `mcp:serve` command + * **`http_dedicated`**: Standalone HTTP server + * `enabled`, `host`, `port`, `path_prefix` settings + * **`http_integrated`**: Laravel route-based server + * `enabled`: Serve through Laravel routes + * `prefix`: URL prefix (default: 'mcp') + * `middleware`: Applied middleware (default: 'web') + +### Cache & Performance +* **`cache`**: Caching configuration + * `store`: Laravel cache store to use + * `ttl`: Cache lifetime in seconds +* **`pagination_limit`**: Maximum items returned in list operations + +### Feature Control +* **`capabilities`**: Toggle MCP features + * Enable/disable tools, resources, prompts + * Control subscriptions and change notifications +* **`logging`**: Server logging configuration + * `channel`: Laravel log channel + * `level`: Default log level + +Review the published `config/mcp.php` file for detailed documentation of all available options and their descriptions. + +## Defining MCP Elements + +PHP MCP Laravel provides two approaches to define your MCP elements: manual registration using a fluent API or attribute-based discovery. + +### Manual Registration + +The recommended approach is using the fluent `Mcp` facade to manually register your elements in `routes/mcp.php` (this path can be changed in config/mcp.php via the discovery.definitions_file key): + ```php -description('Adds two numbers using Laravel.'); + +Mcp::resource('config://app/name', [MyLaravelTools::class, 'getAppName']) + ->name('laravel_app_name') + ->mimeType('text/plain'); + +Mcp::prompt('topic_summarizer', TopicSummarizer::class); + +Mcp::resourceTemplate('user://{userId}/profile', [MyLaravelTools::class, 'getUserProfile']) + ->mimeType('application/json'); +``` + +The package automatically resolves handlers through Laravel's service container, allowing you to inject dependencies through constructor injection. Each registration method accepts either an invokable class or a `[class, method]` array. + +The fluent methods like `description()`, `name()`, and `mimeType()` are optional. When omitted, the package intelligently infers these values from your handler's method signatures, return types, and DocBlocks. Use these methods only when you need to override the automatically generated metadata. + +Manually registered elements are always available regardless of cache status and take precedence over discovered elements with the same identifier. + +### Attribute-Based Discovery + +As an alternative, you can use PHP 8 attributes to mark your methods or invokable classes as MCP elements: + +```php namespace App\Mcp; -use Illuminate\Support\Facades\Config; -use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpTool; -use Psr\Log\LoggerInterface; +use PhpMcp\Server\Attributes\McpResource; -class MyTools +class DiscoveredElements { - public function __construct(private LoggerInterface $logger) {} - - #[McpResource(uri: 'laravel://config/app.name', mimeType: 'text/plain')] - public function getAppName(): string + #[McpTool(name: 'echo_discovered')] + public function echoMessage(string $message): string { - return Config::get('app.name', 'Laravel'); + return "Discovered echo: {$message}"; } - - #[McpTool] - public function add(int $a, int $b): int + + #[McpResource(uri: 'status://server/health', mimeType: 'application/json')] + public function getServerHealth(): array { - $this->logger->info('Adding numbers via MCP'); - return $a + $b; + return ['status' => 'healthy', 'uptime' => 123]; } } ``` -* **Dependency Injection:** Your classes' constructors (or invokable classes) will be resolved using Laravel's service container, so you can inject any application dependencies (like the `LoggerInterface` above). -* **Attribute Usage:** Refer to the [`php-mcp/server` README](https://github.com/php-mcp/server/blob/main/README.md#attributes-for-discovery) for detailed information on defining elements (both on methods and invokable classes) and formatting return values. +In development environments with `auto_discover` enabled in your config, these elements are automatically discovered when needed. For production or to manually trigger discovery, run: -### Automatic Discovery (Development) vs. Manual Discovery (Production) +```bash +php artisan mcp:discover +``` -The server needs to discover your annotated elements before clients can use them. +This command scans the configured directories, registers the discovered elements, and caches the results for improved performance. Use the `--no-cache` flag to skip caching or `--force` to perform a fresh scan regardless of cache status. -* **Development:** In non-production environments (e.g., `APP_ENV=local`), the server will **automatically discover** elements the first time the MCP server is needed (like on the first relevant HTTP request or Artisan command). You generally **do not** need to run the command manually during development after adding or changing elements. +See the [`php-mcp/server` documentation](https://github.com/php-mcp/server?tab=readme-ov-file#attribute-details--return-formatting) for detailed information on attribute parameters and return value formatting. -* **Production:** For performance reasons, automatic discovery is **disabled** in production environments (`APP_ENV=production`). You **must run the discovery command manually** as part of your deployment process: +## Running the MCP Server - ```bash - php artisan mcp:discover - ``` +PHP MCP Laravel offers three transport options to serve your MCP elements. - This command scans the configured directories and caches the found elements using the configured Laravel cache store. Running it during deployment ensures your production environment uses the pre-discovered, cached elements for optimal performance. +### Integrated HTTP+SSE via Laravel Routes - *(You can still run `mcp:discover` manually in development if you wish, for example, to pre-populate the cache.)* +The most convenient option for getting started is serving MCP directly through your Laravel application's routes: -### Running the Server +```php +// Client connects to: http://your-app.test/mcp/sse +// No additional processes needed +``` -You can expose your MCP server using either the stdio or HTTP+SSE transport. +**Configuration**: +- Ensure `mcp.transports.http_integrated.enabled` is `true` in your config +- The package registers routes at `/mcp/sse` (GET) and `/mcp/message` (POST) by default +- You can customize the prefix, middleware, and domain in `config/mcp.php` -**Stdio Transport:** +**CSRF Protection**: You must exclude the MCP message endpoint from CSRF verification: -* Configure your MCP client (Cursor, Claude Desktop) to connect via command. **Important:** Ensure you provide the **full path** to your project's `artisan` file. +For Laravel 11+: +```php +// bootstrap/app.php +->withMiddleware(function (Middleware $middleware) { + $middleware->validateCsrfTokens(except: [ + config('mcp.transports.http_integrated.route_prefix') . '/message', + ]); +}) +``` - *Example Client Config (e.g., `.cursor/mcp.json`):* - ```json - { - "mcpServers": { - "my-laravel-mcp": { - "command": "php", - "args": [ - "/full/path/to/your/laravel/project/artisan", - "mcp:serve" - ], - } - } - } - ``` - *(Replace `/full/path/to/...` with the correct absolute paths)* - -**HTTP+SSE Transport:** - -* **Enable:** Ensure `transports.http.enabled` is `true` in `config/mcp.php`. -* **Routes:** The package automatically registers two routes (by default `/mcp/sse` [GET] and `/mcp/message` [POST]) using the configured prefix and middleware (`web` by default). -* -* **Web Server Environment (CRITICAL):** - * The built-in `php artisan serve` development server **cannot reliably handle** the concurrent nature of SSE (long-running GET request) and subsequent POST requests from the MCP client. This is because it runs as a single PHP process. You will likely encounter hangs or requests not being processed. - * For the HTTP+SSE transport to function correctly, you **must** run your Laravel application using a web server setup capable of handling concurrent requests properly: - * **Nginx + PHP-FPM** or **Apache + PHP-FPM** (Recommended for typical deployments): Ensure FPM is configured to handle multiple worker processes. - * **Laravel Octane** (with Swoole or RoadRunner): Optimized for high concurrency and suitable for this use case. - * Other async runtimes capable of handling concurrent I/O. - * You also need to ensure your web server (Nginx/Apache) and PHP-FPM configurations allow for long-running requests (`set_time_limit(0)` is handled by the controller, but server/FPM timeouts might interfere) and do *not* buffer the `text/event-stream` response (e.g., `X-Accel-Buffering: no` for Nginx). -* -* **Middleware:** Make sure the middleware applied (usually `web` in `config/mcp.php`) correctly handles sessions or provides another way to consistently identify the client across requests if you modify the default `McpController` behaviour. -* -* **CSRF Protection Exclusion (Important!):** The default `web` middleware group includes CSRF protection. Since MCP clients do not send CSRF tokens, you **must** exclude the MCP POST route from CSRF verification to prevent `419` errors. - * The specific URI to exclude depends on the `prefix` configured in `config/mcp.php`. By default, the prefix is `mcp`, so you should exclude `mcp` or `mcp/*`. - * **Laravel 10 and below:** Add the pattern to the `$except` array in `app/Http/Middleware/VerifyCsrfToken.php`: - ```php - // app/Http/Middleware/VerifyCsrfToken.php - protected $except = [ - // ... other routes - 'mcp', // Or config('mcp.transports.http.prefix', 'mcp').'/*' - ]; - ``` - * **Laravel 11+:** Add the pattern within the `bootstrap/app.php` file's `withMiddleware` call: - ```php - // bootstrap/app.php - ->withMiddleware(function (Middleware $middleware) { - $mcpPrefix = config('mcp.transports.http.prefix', 'mcp'); - $middleware->validateCsrfTokens(except: [ - $mcpPrefix, // Or $mcpPrefix.'/*' - // ... other routes - ]); - }) - ``` -* **Client Configuration:** Configure your MCP client to connect via URL, using the **SSE endpoint URL**. - - *Example Client Config:* - ```json - { - "mcpServers": { - "my-laravel-mcp-http": { - "url": "http://your-laravel-app.test/mcp/sse" // Adjust URL as needed - } +For Laravel 10 and below: +```php +// app/Http/Middleware/VerifyCsrfToken.php +protected $except = [ + 'mcp/message', // Adjust if you changed the route prefix +]; +``` + +**Server Environment Considerations**: +Standard synchronous servers like PHP's built-in server or basic PHP-FPM setups can struggle with SSE connections. For eg, a single PHP-FPM worker will be tied up for each active SSE connection. For production, consider using Laravel Octane with Swoole/RoadRunner or properly configured Nginx with sufficient PHP-FPM workers. + +### Dedicated HTTP+SSE Server (Recommended) + +For production environments or high-traffic applications, the dedicated HTTP server provides better performance and isolation: + +```bash +php artisan mcp:serve --transport=http +``` + +This launches a standalone ReactPHP-based HTTP server specifically for MCP traffic: + +**Configuration**: +- Ensure `mcp.transports.http_dedicated.enabled` is `true` in your config +- Default server listens on `127.0.0.1:8090` with path prefix `/mcp` +- Configure through any of these methods: + - Environment variables: `MCP_HTTP_DEDICATED_HOST`, `MCP_HTTP_DEDICATED_PORT`, `MCP_HTTP_DEDICATED_PATH_PREFIX` + - Edit values directly in `config/mcp.php` + - Override at runtime: `--host=0.0.0.0 --port=8091 --path-prefix=custom_mcp` + +This is a blocking, long-running process that should be managed with Supervisor, systemd, or Docker in production environments. + +### STDIO Transport for Direct Client Integration + +Ideal for Cursor IDE and other MCP clients that directly launch server processes: + +```bash +php artisan mcp:serve +# or explicitly: +php artisan mcp:serve --transport=stdio +``` + +**Client Configuration**: +Configure your MCP client to execute this command directly. For example, in Cursor: + +```json +// .cursor/mcp.json +{ + "mcpServers": { + "my-laravel-stdio": { + "command": "php", + "args": [ + "/full/path/to/your/laravel/project/artisan", + "mcp:serve" + ] } } - ``` - The server will automatically inform the client about the correct POST endpoint URL (including a unique `?clientId=...` query parameter) via the initial `endpoint` event sent over the SSE connection. +} +``` -### Other Commands +**Important**: When using STDIO transport, your handler code must not write to STDOUT using echo, print, or similar functions. Use Laravel's logger or STDERR for any debugging output. -* **List Elements:** View the discovered MCP elements. - ```bash - php artisan mcp:list - # Or list specific types: - php artisan mcp:list tools - php artisan mcp:list resources - php artisan mcp:list prompts - php artisan mcp:list templates - # Output as JSON: - php artisan mcp:list --json - ``` +## Listing Registered Elements -### Dynamic Updates (Notifications) +To see which MCP elements your server has registered (both manual and discovered/cached): -If the list of available tools, resources, or prompts changes while the server is running, or if a specific resource's content is updated, you can notify connected clients (primarily useful for HTTP+SSE). +```bash +php artisan mcp:list +# Specific types: +php artisan mcp:list tools +php artisan mcp:list resources +# JSON output: +php artisan mcp:list --json +``` + +## Dynamic Updates (Events) -* **List Changes:** Dispatch the corresponding event from anywhere in your Laravel application: +If your available MCP elements or resource content change while the server is running, you can notify connected clients (most relevant for HTTP transports). + +* **List Changes (Tools, Resources, Prompts):** + Dispatch the corresponding Laravel event. The package includes listeners to send the appropriate MCP notification. ```php use PhpMcp\Laravel\Server\Events\ToolsListChanged; use PhpMcp\Laravel\Server\Events\ResourcesListChanged; use PhpMcp\Laravel\Server\Events\PromptsListChanged; - // When tools have changed: ToolsListChanged::dispatch(); - - // When resources have changed: - ResourcesListChanged::dispatch(); - - // When prompts have changed: - PromptsListChanged::dispatch(); + // ResourcesListChanged::dispatch(); + // PromptsListChanged::dispatch(); ``` - The service provider includes listeners that automatically send the appropriate `*ListChanged` notifications to clients. -* **Specific Resource Content Change:** Inject or resolve the `PhpMcp\Server\Registry` and call `notifyResourceChanged`: +* **Specific Resource Content Update:** + Dispatch the `PhpMcp\Laravel\Server\Events\ResourceUpdated` event with the URI of the changed resource. ```php - use PhpMcp\Server\Registry; - - public function updateMyResource(Registry $registry, string $resourceUri) - { - // ... update the resource data ... + use PhpMcp\Laravel\Server\Events\ResourceUpdated; - $registry->notifyResourceChanged($resourceUri); - } + $resourceUri = 'file:///path/to/updated_file.txt'; + // ... your logic that updates the resource ... + ResourceUpdated::dispatch($resourceUri); ``` - This will trigger a `resources/didChange` notification for clients subscribed to that specific URI. + The `McpNotificationListener` will handle sending the `notifications/resource/updated` MCP notification to clients subscribed to that URI. -## Contributing +## Testing -Please see CONTRIBUTING.md for details. +For your application tests, you can mock the `Mcp` facade or specific MCP handlers as needed. When testing MCP functionality itself, consider integration tests that make HTTP requests to your integrated MCP endpoints (if used) or command tests for Artisan commands. -## License +## Contributing -The MIT License (MIT). Please see [License File](LICENSE) for more information. +Please see [CONTRIBUTING.md](CONTRIBUTING.md) in the main [`php-mcp/server`](https://github.com/php-mcp/server) repository for general contribution guidelines. For issues or PRs specific to this Laravel package, please use this repository's issue tracker. -## Support & Feedback +## License -Please open an issue on the [GitHub repository](https://github.com/php-mcp/laravel) for bugs, questions, or feedback. +The MIT License (MIT). See [LICENSE](LICENSE). \ No newline at end of file diff --git a/composer.json b/composer.json index ac10bfb..4b70bf6 100644 --- a/composer.json +++ b/composer.json @@ -13,33 +13,35 @@ "license": "MIT", "authors": [ { - "name": "Kyrian", - "email": "okeowoaderukyrian@gmail.com", + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com", "role": "Developer" } ], "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^1.1.0" + "php-mcp/server": "^2.2" }, "require-dev": { "laravel/pint": "^1.13", + "mockery/mockery": "^1.6", + "orchestra/pest-plugin-testbench": "^2.1", "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", + "pestphp/pest-plugin-drift": "^2.6", "pestphp/pest-plugin-laravel": "^2.0", - "mockery/mockery": "^1.6", "phpunit/phpunit": "^10.0 || ^11.0", "react/http": "^1.11" }, "autoload": { "psr-4": { - "PhpMcp\\Laravel\\Server\\": "src/" + "PhpMcp\\Laravel\\": "src/" } }, "autoload-dev": { "psr-4": { - "PhpMcp\\Laravel\\Server\\Tests\\": "tests/" + "PhpMcp\\Laravel\\Tests\\": "tests/" } }, "scripts": { @@ -56,7 +58,7 @@ "extra": { "laravel": { "providers": [ - "PhpMcp\\Laravel\\Server\\McpServiceProvider" + "PhpMcp\\Laravel\\McpServiceProvider" ] } }, diff --git a/config/mcp.php b/config/mcp.php index f02a278..82e580f 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -5,42 +5,61 @@ |-------------------------------------------------------------------------- | MCP Server Information |-------------------------------------------------------------------------- + | + | This section defines basic information about your MCP server instance, + | including its name, version, and any initialization instructions that + | should be provided to clients during the initial handshake. + | */ 'server' => [ 'name' => env('MCP_SERVER_NAME', 'Laravel MCP'), 'version' => env('MCP_SERVER_VERSION', '1.0.0'), + 'instructions' => env('MCP_SERVER_INSTRUCTIONS'), ], /* |-------------------------------------------------------------------------- | MCP Discovery Configuration |-------------------------------------------------------------------------- + | + | These options control how the MCP server discovers and registers tools, + | resources and prompts in your application. You can configure which + | directories to scan, what to exclude, and how discovery behaves. + | */ 'discovery' => [ 'base_path' => base_path(), - - // Relative paths from project root (base_path()) to scan for MCP elements. - 'directories' => [ - env('MCP_DISCOVERY_PATH', 'app/Mcp'), - // Add more paths if needed + 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_PATH', 'app/Mcp'))), + 'exclude_dirs' => [ + 'vendor', + 'tests', + 'storage', + 'public', + 'resources', + 'bootstrap', + 'config', + 'database', + 'routes', + 'node_modules', + '.git', ], + 'definitions_file' => base_path('routes/mcp.php'), + 'auto_discover' => env('MCP_AUTO_DISCOVER', true), + 'save_to_cache' => env('MCP_DISCOVERY_SAVE_TO_CACHE', true), ], /* |-------------------------------------------------------------------------- | MCP Cache Configuration |-------------------------------------------------------------------------- - | Configures caching for both discovered elements (via Registry) and - | transport state (via TransportState). Uses Laravel's cache system. + | + | Configure how the MCP server caches discovered elements and transport + | state using Laravel's cache system. You can specify which store to use + | and how long items should be cached. + | */ 'cache' => [ - // The Laravel cache store to use (e.g., 'file', 'redis', 'database'). 'store' => env('MCP_CACHE_STORE', config('cache.default')), - - // The prefix for the cache keys. - 'prefix' => env('MCP_CACHE_PREFIX', 'mcp_'), - - // Default TTL in seconds for cached items (null = forever). 'ttl' => env('MCP_CACHE_TTL', 3600), ], @@ -48,36 +67,55 @@ |-------------------------------------------------------------------------- | MCP Transport Configuration |-------------------------------------------------------------------------- + | + | Configure the available transports for MCP communication. Three types are + | supported: stdio for CLI clients, http_dedicated for a standalone server, + | and http_integrated for serving through Laravel's routing system. + | */ 'transports' => [ + 'stdio' => [ + 'enabled' => env('MCP_STDIO_ENABLED', true), + ], - 'http' => [ - 'enabled' => env('MCP_HTTP_ENABLED', true), - - // URL path prefix for the HTTP endpoints (e.g., /mcp and /mcp/sse). - 'prefix' => env('MCP_HTTP_PREFIX', 'mcp'), - - // Middleware group(s) to apply to the HTTP routes. - 'middleware' => ['web'], - - // Optional domain for the HTTP routes. - 'domain' => env('MCP_HTTP_DOMAIN'), + 'http_dedicated' => [ + 'enabled' => env('MCP_HTTP_DEDICATED_ENABLED', true), + 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), + 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), + 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), + 'ssl_context_options' => [], ], - 'stdio' => [ - 'enabled' => env('MCP_STDIO_ENABLED', true), + 'http_integrated' => [ + 'enabled' => env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), + 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), + 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), + 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), ], ], /* |-------------------------------------------------------------------------- - | MCP Protocol & Capabilities + | Pagination Limit |-------------------------------------------------------------------------- + | + | This value determines the maximum number of items that will be returned + | by list methods in the MCP server. + | */ - - // Max items for list methods. 'pagination_limit' => env('MCP_PAGINATION_LIMIT', 50), + /* + |-------------------------------------------------------------------------- + | MCP Capabilities Configuration + |-------------------------------------------------------------------------- + | + | Define which MCP features are enabled in your server instance. This includes + | support for tools, resources, prompts, and their related functionality like + | subscriptions and change notifications. + | + */ 'capabilities' => [ 'tools' => [ 'enabled' => env('MCP_CAP_TOOLS_ENABLED', true), @@ -86,7 +124,7 @@ 'resources' => [ 'enabled' => env('MCP_CAP_RESOURCES_ENABLED', true), - 'subscribe' => env('MCP_CAP_RESOURCES_SUBSCRIBE', true), // Enable resource subscriptions + 'subscribe' => env('MCP_CAP_RESOURCES_SUBSCRIBE', true), 'listChanged' => env('MCP_CAP_RESOURCES_LIST_CHANGED', true), ], @@ -105,12 +143,13 @@ |-------------------------------------------------------------------------- | Logging Configuration |-------------------------------------------------------------------------- + | + | Configure how the MCP server handles logging. You can specify which Laravel + | log channel to use and set the default log level. + | */ 'logging' => [ - // Log channel to use for MCP logs. Uses default channel if null. 'channel' => env('MCP_LOG_CHANNEL', config('logging.default')), - - // Default log level for the MCP logger (used by Server if not overridden). 'level' => env('MCP_LOG_LEVEL', 'info'), ], ]; diff --git a/routes/mcp.php b/routes/mcp_http_integrated.php similarity index 92% rename from routes/mcp.php rename to routes/mcp_http_integrated.php index bbbdc12..15a9811 100644 --- a/routes/mcp.php +++ b/routes/mcp_http_integrated.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; -use PhpMcp\Laravel\Server\Http\Controllers\McpController; +use PhpMcp\Laravel\Http\Controllers\McpController; /* |-------------------------------------------------------------------------- diff --git a/samples/basic/app/Mcp/GenerateSeoKeywordsPrompt.php b/samples/basic/app/Mcp/GenerateSeoKeywordsPrompt.php new file mode 100644 index 0000000..046ce33 --- /dev/null +++ b/samples/basic/app/Mcp/GenerateSeoKeywordsPrompt.php @@ -0,0 +1,26 @@ + 'user', + 'content' => "Please generate 5 SEO-friendly keywords for the following topic: {$topic}.", + ], + [ + 'role' => 'assistant', + 'content' => "Okay, I will generate 5 SEO-friendly keywords for '{$topic}'. Here they are:" + ] + ]; + } +} diff --git a/samples/basic/app/Mcp/GenerateWelcomeMessage.php b/samples/basic/app/Mcp/GenerateWelcomeMessage.php new file mode 100644 index 0000000..2874d39 --- /dev/null +++ b/samples/basic/app/Mcp/GenerateWelcomeMessage.php @@ -0,0 +1,17 @@ + $articleId, + 'title' => 'Manually Registered Article Example', + 'content' => "This is sample content for article {$articleId} provided by a manually registered ResourceTemplate.", + 'author' => 'MCP Assistant', + 'published_at' => now()->toIso8601String(), + ]; + } +} diff --git a/samples/basic/app/Mcp/MyLaravelTools.php b/samples/basic/app/Mcp/MyLaravelTools.php index 8eb5386..ac67d2e 100644 --- a/samples/basic/app/Mcp/MyLaravelTools.php +++ b/samples/basic/app/Mcp/MyLaravelTools.php @@ -3,14 +3,12 @@ namespace App\Mcp; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Config; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpTool; -use Psr\Log\LoggerInterface; // Example dependency injection +use Psr\Log\LoggerInterface; class MyLaravelTools { - // Example: Injecting a dependency via constructor public function __construct(private LoggerInterface $logger) { $this->logger->info('MyLaravelTools instance created via container.'); @@ -24,7 +22,7 @@ public function __construct(private LoggerInterface $logger) #[McpResource(uri: 'config://app/name', name: 'laravel_app_name', mimeType: 'text/plain')] public function getAppName(): string { - $appName = Config::get('app.name', 'Laravel'); // Access Laravel config + $appName = config('app.name', 'Laravel'); $this->logger->debug('MCP Resource Read', ['uri' => 'config://app/name', 'value' => $appName]); return $appName; @@ -43,7 +41,6 @@ public function add(int $a, int $b): int $sum = $a + $b; $this->logger->info('MCP Tool Called', ['tool' => 'laravel_adder', 'result' => $sum]); - // Example: Interact with Laravel Cache Cache::put('last_mcp_sum', $sum, now()->addMinutes(5)); return $sum; diff --git a/samples/basic/composer.json b/samples/basic/composer.json index 29003f9..8b9368d 100644 --- a/samples/basic/composer.json +++ b/samples/basic/composer.json @@ -12,7 +12,7 @@ "php": "^8.2", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", - "php-mcp/laravel": "@dev" + "php-mcp/laravel": "*" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -83,4 +83,4 @@ "url": "../../" } ] -} \ No newline at end of file +} diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index fd3d429..87bfb12 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7f990c460c70eb62ad8feba0827b55df", + "content-hash": "0657b476192ef74ed23544ff24c8dac5", "packages": [ { "name": "brick/math", - "version": "0.12.3", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -605,6 +605,62 @@ }, "time": "2023-08-08T05:53:35+00:00" }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -1151,20 +1207,20 @@ }, { "name": "laravel/framework", - "version": "v12.12.0", + "version": "v12.17.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7" + "reference": "8729d084510480fdeec9b6ad198180147d4a7f06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/8f6cd73696068c28f30f5964556ec9d14e5d90d7", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7", + "url": "https://api.github.com/repos/laravel/framework/zipball/8729d084510480fdeec9b6ad198180147d4a7f06", + "reference": "8729d084510480fdeec9b6ad198180147d4a7f06", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1181,7 +1237,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.6", + "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1273,7 +1329,7 @@ "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3", + "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", @@ -1305,7 +1361,7 @@ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", @@ -1362,7 +1418,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-01T16:13:12+00:00" + "time": "2025-06-03T14:04:18+00:00" }, { "name": "laravel/prompts", @@ -1552,16 +1608,16 @@ }, { "name": "league/commonmark", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94" + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", "shasum": "" }, "require": { @@ -1598,7 +1654,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.7-dev" + "dev-main": "2.8-dev" } }, "autoload": { @@ -1655,7 +1711,7 @@ "type": "tidelift" } ], - "time": "2025-04-18T21:09:27+00:00" + "time": "2025-05-05T12:20:28+00:00" }, { "name": "league/config", @@ -2206,16 +2262,16 @@ }, { "name": "nesbot/carbon", - "version": "3.9.0", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6d16a8a015166fe54e22c042e0805c5363aef50d" + "reference": "ced71f79398ece168e24f7f7710462f462310d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6d16a8a015166fe54e22c042e0805c5363aef50d", - "reference": "6d16a8a015166fe54e22c042e0805c5363aef50d", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d", + "reference": "ced71f79398ece168e24f7f7710462f462310d4d", "shasum": "" }, "require": { @@ -2308,7 +2364,7 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:57:33+00:00" + "time": "2025-05-01T19:51:51+00:00" }, { "name": "nette/schema", @@ -2374,16 +2430,16 @@ }, { "name": "nette/utils", - "version": "v4.0.6", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "ce708655043c7050eb050df361c5e313cf708309" + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309", - "reference": "ce708655043c7050eb050df361c5e313cf708309", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", "shasum": "" }, "require": { @@ -2454,22 +2510,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.6" + "source": "https://github.com/nette/utils/tree/v4.0.7" }, - "time": "2025-03-30T21:06:30+00:00" + "time": "2025-06-03T04:55:08+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -2512,37 +2568,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.1.8" + "symfony/console": "^7.2.6" }, "require-dev": { - "illuminate/console": "^11.33.2", - "laravel/pint": "^1.18.2", + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^7.1.8", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -2585,7 +2641,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" }, "funding": [ { @@ -2601,7 +2657,7 @@ "type": "github" } ], - "time": "2024-11-21T10:39:51+00:00" + "time": "2025-05-08T08:14:37+00:00" }, { "name": "opis/json-schema", @@ -2799,18 +2855,20 @@ "dist": { "type": "path", "url": "../..", - "reference": "dbc9787595cef6a6160242b5cd2a7684b60bed1c" + "reference": "44b8217a32cb8031bf2ed8c9000e007a24c33cba" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^1.1.0" + "php-mcp/server": "^2.2" }, "require-dev": { "laravel/pint": "^1.13", "mockery/mockery": "^1.6", + "orchestra/pest-plugin-testbench": "^2.1", "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", + "pestphp/pest-plugin-drift": "^2.6", "pestphp/pest-plugin-laravel": "^2.0", "phpunit/phpunit": "^10.0 || ^11.0", "react/http": "^1.11" @@ -2819,18 +2877,18 @@ "extra": { "laravel": { "providers": [ - "PhpMcp\\Laravel\\Server\\McpServiceProvider" + "PhpMcp\\Laravel\\McpServiceProvider" ] } }, "autoload": { "psr-4": { - "PhpMcp\\Laravel\\Server\\": "src/" + "PhpMcp\\Laravel\\": "src/" } }, "autoload-dev": { "psr-4": { - "PhpMcp\\Laravel\\Server\\Tests\\": "tests/" + "PhpMcp\\Laravel\\Tests\\": "tests/" } }, "scripts": { @@ -2849,8 +2907,8 @@ ], "authors": [ { - "name": "Kyrian", - "email": "okeowoaderukyrian@gmail.com", + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com", "role": "Developer" } ], @@ -2870,16 +2928,16 @@ }, { "name": "php-mcp/server", - "version": "1.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "2ff0f7dde2178741fbcf7a64c767f5f3e272b392" + "reference": "9892dd32793a6dff324c5024d812645d10cdf786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mcp/server/zipball/2ff0f7dde2178741fbcf7a64c767f5f3e272b392", - "reference": "2ff0f7dde2178741fbcf7a64c767f5f3e272b392", + "url": "https://api.github.com/repos/php-mcp/server/zipball/9892dd32793a6dff324c5024d812645d10cdf786", + "reference": "9892dd32793a6dff324c5024d812645d10cdf786", "shasum": "" }, "require": { @@ -2891,6 +2949,7 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", + "react/http": "^1.11", "react/stream": "^1.4", "symfony/finder": "^6.4 || ^7.2" }, @@ -2898,7 +2957,7 @@ "friendsofphp/php-cs-fixer": "^3.75", "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", - "react/http": "^1.11", + "react/async": "^4.0", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { @@ -2931,9 +2990,9 @@ ], "support": { "issues": "https://github.com/php-mcp/server/issues", - "source": "https://github.com/php-mcp/server/tree/1.1.0" + "source": "https://github.com/php-mcp/server/tree/2.2.0" }, - "time": "2025-05-01T16:56:46+00:00" + "time": "2025-06-03T23:05:08+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -3492,16 +3551,16 @@ }, { "name": "psr/http-message", - "version": "2.0", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { @@ -3510,7 +3569,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -3525,7 +3584,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -3539,9 +3598,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, - "time": "2023-04-04T09:54:51+00:00" + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/log", @@ -3845,20 +3904,20 @@ }, { "name": "ramsey/uuid", - "version": "4.7.6", + "version": "4.8.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", + "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", "ext-json": "*", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" @@ -3867,26 +3926,23 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "captainhook/captainhook": "^5.10", + "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.8", - "ergebnis/composer-normalize": "^2.15", - "mockery/mockery": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.2", - "php-mock/php-mock-mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpbench/phpbench": "^1.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9", - "ramsey/composer-repl": "^1.4", - "slevomat/coding-standard": "^8.4", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.9" + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", @@ -3921,19 +3977,157 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.6" + "source": "https://github.com/ramsey/uuid/tree/4.8.1" + }, + "time": "2025-06-01T06:28:46+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" }, "funding": [ { - "url": "https://github.com/ramsey", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" }, { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2024-04-27T21:32:50+00:00" + "time": "2024-06-13T14:18:03+00:00" }, { "name": "react/event-loop", @@ -4007,6 +4201,250 @@ ], "time": "2023-11-13T13:48:05+00:00" }, + { + "name": "react/http", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/http.git", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/http/zipball/8db02de41dcca82037367f67a2d4be365b1c4db9", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "fig/http-message-util": "^1.1", + "php": ">=5.3.0", + "psr/http-message": "^1.0", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" + }, + "require-dev": { + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.2 || ^3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Http\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": [ + "async", + "client", + "event-driven", + "http", + "http client", + "http server", + "https", + "psr-7", + "reactphp", + "server", + "streaming" + ], + "support": { + "issues": "https://github.com/reactphp/http/issues", + "source": "https://github.com/reactphp/http/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-11-20T15:24:08+00:00" + }, + { + "name": "react/promise", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-05-24T10:39:05+00:00" + }, + { + "name": "react/socket", + "version": "v1.16.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-07-26T10:38:09+00:00" + }, { "name": "react/stream", "version": "v1.4.0", @@ -4087,7 +4525,7 @@ }, { "name": "symfony/clock", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", @@ -4141,7 +4579,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.2.0" + "source": "https://github.com/symfony/clock/tree/v7.3.0" }, "funding": [ { @@ -4161,23 +4599,24 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -4234,7 +4673,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.3.0" }, "funding": [ { @@ -4250,11 +4689,11 @@ "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" + "time": "2025-05-24T10:34:04+00:00" }, { "name": "symfony/css-selector", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -4299,7 +4738,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -4319,16 +4758,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -4341,7 +4780,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4366,7 +4805,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -4382,20 +4821,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/error-handler", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b" + "reference": "cf68d225bc43629de4ff54778029aee6dc191b83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/cf68d225bc43629de4ff54778029aee6dc191b83", + "reference": "cf68d225bc43629de4ff54778029aee6dc191b83", "shasum": "" }, "require": { @@ -4408,9 +4847,11 @@ "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0" + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -4441,7 +4882,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.5" + "source": "https://github.com/symfony/error-handler/tree/v7.3.0" }, "funding": [ { @@ -4457,20 +4898,20 @@ "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-05-29T07:19:49+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { @@ -4521,7 +4962,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -4537,20 +4978,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -4564,7 +5005,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4597,7 +5038,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -4613,20 +5054,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/finder", - "version": "v7.2.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", "shasum": "" }, "require": { @@ -4661,7 +5102,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/finder/tree/v7.3.0" }, "funding": [ { @@ -4677,20 +5118,20 @@ "type": "tidelift" } ], - "time": "2024-12-30T19:00:17+00:00" + "time": "2024-12-30T19:00:26+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "371272aeb6286f8135e028ca535f8e4d6f114126" + "reference": "4236baf01609667d53b20371486228231eb135fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/371272aeb6286f8135e028ca535f8e4d6f114126", - "reference": "371272aeb6286f8135e028ca535f8e4d6f114126", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/4236baf01609667d53b20371486228231eb135fd", + "reference": "4236baf01609667d53b20371486228231eb135fd", "shasum": "" }, "require": { @@ -4707,6 +5148,7 @@ "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -4739,7 +5181,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.2.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.0" }, "funding": [ { @@ -4755,20 +5197,20 @@ "type": "tidelift" } ], - "time": "2025-03-25T15:54:33+00:00" + "time": "2025-05-12T14:48:23+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54" + "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b1fe91bc1fa454a806d3f98db4ba826eb9941a54", - "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ac7b8e163e8c83dce3abcc055a502d4486051a9f", + "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f", "shasum": "" }, "require": { @@ -4776,8 +5218,8 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -4853,7 +5295,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.0" }, "funding": [ { @@ -4869,20 +5311,20 @@ "type": "tidelift" } ], - "time": "2025-03-28T13:32:50+00:00" + "time": "2025-05-29T07:47:32+00:00" }, { "name": "symfony/mailer", - "version": "v7.2.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3" + "reference": "0f375bbbde96ae8c78e4aa3e63aabd486e33364c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f3871b182c44997cf039f3b462af4a48fb85f9d3", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3", + "url": "https://api.github.com/repos/symfony/mailer/zipball/0f375bbbde96ae8c78e4aa3e63aabd486e33364c", + "reference": "0f375bbbde96ae8c78e4aa3e63aabd486e33364c", "shasum": "" }, "require": { @@ -4933,7 +5375,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.3" + "source": "https://github.com/symfony/mailer/tree/v7.3.0" }, "funding": [ { @@ -4949,20 +5391,20 @@ "type": "tidelift" } ], - "time": "2025-01-27T11:08:17+00:00" + "time": "2025-04-04T09:51:09+00:00" }, { "name": "symfony/mime", - "version": "v7.2.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b" + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b", + "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", "shasum": "" }, "require": { @@ -5017,7 +5459,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.4" + "source": "https://github.com/symfony/mime/tree/v7.3.0" }, "funding": [ { @@ -5033,11 +5475,11 @@ "type": "tidelift" } ], - "time": "2025-02-19T08:51:20+00:00" + "time": "2025-02-19T08:51:26+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -5096,7 +5538,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -5116,7 +5558,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -5174,7 +5616,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -5194,16 +5636,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -5257,7 +5699,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" }, "funding": [ { @@ -5273,11 +5715,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5338,7 +5780,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -5358,19 +5800,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5418,7 +5861,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -5434,20 +5877,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -5498,7 +5941,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -5514,11 +5957,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -5574,7 +6017,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -5594,7 +6037,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -5653,7 +6096,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" }, "funding": [ { @@ -5673,16 +6116,16 @@ }, { "name": "symfony/process", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -5714,7 +6157,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.5" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -5730,20 +6173,20 @@ "type": "tidelift" } ], - "time": "2025-03-13T12:21:46+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/routing", - "version": "v7.2.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996" + "reference": "8e213820c5fea844ecea29203d2a308019007c15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996", + "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", + "reference": "8e213820c5fea844ecea29203d2a308019007c15", "shasum": "" }, "require": { @@ -5795,7 +6238,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.3" + "source": "https://github.com/symfony/routing/tree/v7.3.0" }, "funding": [ { @@ -5811,20 +6254,20 @@ "type": "tidelift" } ], - "time": "2025-01-17T10:56:55+00:00" + "time": "2025-05-24T20:43:28+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -5842,7 +6285,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -5878,7 +6321,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -5894,20 +6337,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -5965,7 +6408,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -5981,20 +6424,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/translation", - "version": "v7.2.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a" + "reference": "4aba29076a29a3aa667e09b791e5f868973a8667" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a", + "url": "https://api.github.com/repos/symfony/translation/zipball/4aba29076a29a3aa667e09b791e5f868973a8667", + "reference": "4aba29076a29a3aa667e09b791e5f868973a8667", "shasum": "" }, "require": { @@ -6004,6 +6447,7 @@ "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { + "nikic/php-parser": "<5.0", "symfony/config": "<6.4", "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", @@ -6017,7 +6461,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", "symfony/config": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", @@ -6060,7 +6504,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.4" + "source": "https://github.com/symfony/translation/tree/v7.3.0" }, "funding": [ { @@ -6076,20 +6520,20 @@ "type": "tidelift" } ], - "time": "2025-02-13T10:27:23+00:00" + "time": "2025-05-29T07:19:49+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { @@ -6102,7 +6546,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -6138,7 +6582,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -6154,20 +6598,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { "name": "symfony/uid", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426" + "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426", + "url": "https://api.github.com/repos/symfony/uid/zipball/7beeb2b885cd584cd01e126c5777206ae4c3c6a3", + "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3", "shasum": "" }, "require": { @@ -6212,7 +6656,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.2.0" + "source": "https://github.com/symfony/uid/tree/v7.3.0" }, "funding": [ { @@ -6228,24 +6672,25 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-05-24T14:28:13+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.2.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/548f6760c54197b1084e1e5c71f6d9d523f2f78e", + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -6295,7 +6740,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.0" }, "funding": [ { @@ -6311,7 +6756,7 @@ "type": "tidelift" } ], - "time": "2025-01-17T11:39:41+00:00" + "time": "2025-04-27T18:39:23+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6805,16 +7250,16 @@ }, { "name": "filp/whoops", - "version": "2.18.0", + "version": "2.18.1", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e" + "reference": "8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", + "url": "https://api.github.com/repos/filp/whoops/zipball/8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26", + "reference": "8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26", "shasum": "" }, "require": { @@ -6864,7 +7309,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.0" + "source": "https://github.com/filp/whoops/tree/2.18.1" }, "funding": [ { @@ -6872,7 +7317,7 @@ "type": "github" } ], - "time": "2025-03-15T12:00:00+00:00" + "time": "2025-06-03T18:56:14+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7065,16 +7510,16 @@ }, { "name": "laravel/pint", - "version": "v1.22.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", "shasum": "" }, "require": { @@ -7086,11 +7531,11 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.3.1", + "illuminate/view": "^11.44.7", + "larastan/larastan": "^3.4.0", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -7127,20 +7572,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-04-08T22:11:45+00:00" + "time": "2025-05-08T08:38:12+00:00" }, { "name": "laravel/sail", - "version": "v1.42.0", + "version": "v1.43.1", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6" + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", + "url": "https://api.github.com/repos/laravel/sail/zipball/3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72", "shasum": "" }, "require": { @@ -7190,7 +7635,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-04-29T14:26:46+00:00" + "time": "2025-05-19T13:19:21+00:00" }, { "name": "mockery/mockery", @@ -8751,23 +9196,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -8803,15 +9248,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -9354,16 +9811,16 @@ }, { "name": "symfony/yaml", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912" + "reference": "cea40a48279d58dc3efee8112634cb90141156c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", + "url": "https://api.github.com/repos/symfony/yaml/zipball/cea40a48279d58dc3efee8112634cb90141156c2", + "reference": "cea40a48279d58dc3efee8112634cb90141156c2", "shasum": "" }, "require": { @@ -9406,7 +9863,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.5" + "source": "https://github.com/symfony/yaml/tree/v7.3.0" }, "funding": [ { @@ -9422,7 +9879,7 @@ "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-04-04T10:10:33+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -9536,9 +9993,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "php-mcp/laravel": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index f614f83..768e914 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -5,35 +5,63 @@ |-------------------------------------------------------------------------- | MCP Server Information |-------------------------------------------------------------------------- + | + | This section defines basic information about your MCP server instance, + | including its name, version, and any initialization instructions that + | should be provided to clients during the initial handshake. + | */ 'server' => [ - 'name' => env('MCP_SERVER_NAME', 'Laravel MCP Sample'), + 'name' => env('MCP_SERVER_NAME', 'Laravel MCP'), 'version' => env('MCP_SERVER_VERSION', '1.0.0'), + 'instructions' => env('MCP_SERVER_INSTRUCTIONS'), ], /* |-------------------------------------------------------------------------- | MCP Discovery Configuration |-------------------------------------------------------------------------- + | + | These options control how the MCP server discovers and registers tools, + | resources and prompts in your application. You can configure which + | directories to scan, what to exclude, and how discovery behaves. + | */ 'discovery' => [ - // Relative paths from project root (base_path()) to scan for MCP elements. + 'base_path' => base_path(), 'directories' => [ env('MCP_DISCOVERY_PATH', 'app/Mcp'), ], - // If true, discovery cache will be cleared when DiscoverCommand runs. - 'clear_cache_on_discover' => true, + 'exclude_dirs' => [ + 'vendor', + 'tests', + 'storage', + 'public', + 'resources', + 'bootstrap', + 'config', + 'database', + 'routes', + 'node_modules', + '.git', + ], + 'definitions_file' => base_path('routes/mcp.php'), + 'auto_discover' => env('MCP_AUTO_DISCOVER', true), + 'save_to_cache' => env('MCP_DISCOVERY_SAVE_TO_CACHE', true), ], /* |-------------------------------------------------------------------------- | MCP Cache Configuration |-------------------------------------------------------------------------- + | + | Configure how the MCP server caches discovered elements and transport + | state using Laravel's cache system. You can specify which store to use + | and how long items should be cached. + | */ 'cache' => [ - 'store' => env('MCP_CACHE_STORE', null), - 'elements_key' => env('MCP_CACHE_ELEMENTS_KEY', 'mcp:elements'), - 'state_prefix' => env('MCP_CACHE_STATE_PREFIX', 'mcp:state:'), + 'store' => env('MCP_CACHE_STORE', config('cache.default')), 'ttl' => env('MCP_CACHE_TTL', 3600), ], @@ -41,44 +69,75 @@ |-------------------------------------------------------------------------- | MCP Transport Configuration |-------------------------------------------------------------------------- + | + | Configure the available transports for MCP communication. Three types are + | supported: stdio for CLI clients, http_dedicated for a standalone server, + | and http_integrated for serving through Laravel's routing system. + | */ 'transports' => [ - 'http' => [ - 'enabled' => env('MCP_HTTP_ENABLED', true), - 'path' => env('MCP_HTTP_PATH', 'mcp'), - 'middleware' => ['web'], - 'domain' => env('MCP_HTTP_DOMAIN'), - ], 'stdio' => [ 'enabled' => env('MCP_STDIO_ENABLED', true), ], + + 'http_dedicated' => [ + 'enabled' => env('MCP_HTTP_DEDICATED_ENABLED', true), + 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), + 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), + 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), + 'ssl_context_options' => [], + ], + + 'http_integrated' => [ + 'enabled' => env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), + 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), + 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), + 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), + ], ], /* |-------------------------------------------------------------------------- - | MCP Protocol & Capabilities + | Pagination Limit |-------------------------------------------------------------------------- + | + | This value determines the maximum number of items that will be returned + | by list methods in the MCP server. + | */ - 'protocol_versions' => [ - '2024-11-05', - ], 'pagination_limit' => env('MCP_PAGINATION_LIMIT', 50), + + /* + |-------------------------------------------------------------------------- + | MCP Capabilities Configuration + |-------------------------------------------------------------------------- + | + | Define which MCP features are enabled in your server instance. This includes + | support for tools, resources, prompts, and their related functionality like + | subscriptions and change notifications. + | + */ 'capabilities' => [ 'tools' => [ 'enabled' => env('MCP_CAP_TOOLS_ENABLED', true), 'listChanged' => env('MCP_CAP_TOOLS_LIST_CHANGED', true), ], + 'resources' => [ 'enabled' => env('MCP_CAP_RESOURCES_ENABLED', true), 'subscribe' => env('MCP_CAP_RESOURCES_SUBSCRIBE', true), 'listChanged' => env('MCP_CAP_RESOURCES_LIST_CHANGED', true), ], + 'prompts' => [ 'enabled' => env('MCP_CAP_PROMPTS_ENABLED', true), 'listChanged' => env('MCP_CAP_PROMPTS_LIST_CHANGED', true), ], + 'logging' => [ 'enabled' => env('MCP_CAP_LOGGING_ENABLED', true), + 'setLevel' => env('MCP_CAP_LOGGING_SET_LEVEL', false), ], ], @@ -86,9 +145,13 @@ |-------------------------------------------------------------------------- | Logging Configuration |-------------------------------------------------------------------------- + | + | Configure how the MCP server handles logging. You can specify which Laravel + | log channel to use and set the default log level. + | */ 'logging' => [ - 'channel' => env('MCP_LOG_CHANNEL'), + 'channel' => env('MCP_LOG_CHANNEL', config('logging.default')), 'level' => env('MCP_LOG_LEVEL', 'info'), ], ]; diff --git a/samples/basic/routes/mcp.php b/samples/basic/routes/mcp.php new file mode 100644 index 0000000..3d8d775 --- /dev/null +++ b/samples/basic/routes/mcp.php @@ -0,0 +1,19 @@ +name('laravel_app_version') + ->mimeType('text/plain'); + +Mcp::resourceTemplate('content://articles/{articleId}', GetArticleContent::class) + ->name('article_content') + ->mimeType('application/json'); + +Mcp::prompt('seo_keywords_generator', GenerateSeoKeywordsPrompt::class); diff --git a/src/Adapters/ConfigAdapter.php b/src/Adapters/ConfigAdapter.php deleted file mode 100644 index a6194ac..0000000 --- a/src/Adapters/ConfigAdapter.php +++ /dev/null @@ -1,43 +0,0 @@ -config->get($key, $default); - } - - /** - * {@inheritdoc} - * Note: Persisting config changes depends on Laravel's setup - * (e.g., packages like `config-writer` might be needed for `set` to persist). - * This adapter mainly reads config and supports runtime changes. - */ - public function set(string $key, mixed $value): void - { - $this->config->set($key, $value); - } - - /** - * {@inheritdoc} - */ - public function has(string $key): bool - { - return $this->config->has($key); - } -} diff --git a/src/Blueprints/PromptBlueprint.php b/src/Blueprints/PromptBlueprint.php new file mode 100644 index 0000000..d097a09 --- /dev/null +++ b/src/Blueprints/PromptBlueprint.php @@ -0,0 +1,29 @@ +name = $name; + + return $this; + } + + public function description(string $description): static + { + $this->description = $description; + + return $this; + } +} diff --git a/src/Blueprints/ResourceBlueprint.php b/src/Blueprints/ResourceBlueprint.php new file mode 100644 index 0000000..bb8e203 --- /dev/null +++ b/src/Blueprints/ResourceBlueprint.php @@ -0,0 +1,58 @@ +name = $name; + + return $this; + } + + public function description(string $description): static + { + $this->description = $description; + + return $this; + } + + public function mimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function size(int $size): static + { + $this->size = $size; + + return $this; + } + + public function annotations(array $annotations): static + { + $this->annotations = $annotations; + + return $this; + } +} diff --git a/src/Blueprints/ResourceTemplateBlueprint.php b/src/Blueprints/ResourceTemplateBlueprint.php new file mode 100644 index 0000000..6f71ebd --- /dev/null +++ b/src/Blueprints/ResourceTemplateBlueprint.php @@ -0,0 +1,49 @@ +name = $name; + + return $this; + } + + public function description(string $description): static + { + $this->description = $description; + + return $this; + } + + public function mimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function annotations(array $annotations): static + { + $this->annotations = $annotations; + + return $this; + } +} diff --git a/src/Blueprints/ToolBlueprint.php b/src/Blueprints/ToolBlueprint.php new file mode 100644 index 0000000..c51c4aa --- /dev/null +++ b/src/Blueprints/ToolBlueprint.php @@ -0,0 +1,30 @@ +name = $name; + + return $this; + } + + public function description(string $description): static + { + $this->description = $description; + + return $this; + } +} diff --git a/src/Commands/DiscoverCommand.php b/src/Commands/DiscoverCommand.php index 4eb106f..1da5112 100644 --- a/src/Commands/DiscoverCommand.php +++ b/src/Commands/DiscoverCommand.php @@ -2,13 +2,10 @@ declare(strict_types=1); -namespace PhpMcp\Laravel\Server\Commands; +namespace PhpMcp\Laravel\Commands; use Illuminate\Console\Command; -use PhpMcp\Server\Registry; use PhpMcp\Server\Server; -use Psr\Log\LoggerInterface; -use Throwable; class DiscoverCommand extends Command { @@ -17,7 +14,9 @@ class DiscoverCommand extends Command * * @var string */ - protected $signature = 'mcp:discover {--no-cache : Perform discovery but do not update the cache}'; + protected $signature = 'mcp:discover + {--no-cache : Perform discovery but do not update the cache} + {--force : Force discovery even if already run or cache seems fresh (in dev)}'; /** * The console command description. @@ -29,48 +28,52 @@ class DiscoverCommand extends Command /** * Execute the console command. */ - public function handle(Server $server, Registry $registry, LoggerInterface $logger): int + public function handle(Server $server): int { $noCache = $this->option('no-cache'); + $forceDiscovery = $this->option('force') ?? true; $this->info('Starting MCP element discovery...'); if ($noCache) { - $this->warn('Performing discovery without updating the cache.'); + $this->warn('Discovery results will NOT be saved to cache.'); } try { - $server->discover(true); - - $toolsCount = $registry->allTools()->count(); - $resourcesCount = $registry->allResources()->count(); - $templatesCount = $registry->allResourceTemplates()->count(); - $promptsCount = $registry->allPrompts()->count(); - - $this->info('Discovery complete.'); - $this->table( - ['Element Type', 'Count'], - [ - ['Tools', $toolsCount], - ['Resources', $resourcesCount], - ['Resource Templates', $templatesCount], - ['Prompts', $promptsCount], - ] + $server->discover( + basePath: config('mcp.discovery.base_path', base_path()), + scanDirs: config('mcp.discovery.directories', ['app/Mcp']), + excludeDirs: config('mcp.discovery.exclude_dirs', []), + force: $forceDiscovery, + saveToCache: ! $noCache ); + } catch (\Exception $e) { + $this->error('Discovery failed: ' . $e->getMessage()); + return Command::FAILURE; + } - if (! $noCache) { - $this->info('Element cache updated.'); - } + $registry = $server->getRegistry(); - return Command::SUCCESS; - } catch (Throwable $e) { - $logger->error('MCP Discovery failed', ['exception' => $e]); - $this->error('Discovery failed: '.$e->getMessage()); - if ($this->getOutput()->isVeryVerbose()) { - $this->line($e->getTraceAsString()); - } + $toolsCount = $registry->allTools()->count(); + $resourcesCount = $registry->allResources()->count(); + $templatesCount = $registry->allResourceTemplates()->count(); + $promptsCount = $registry->allPrompts()->count(); - return Command::FAILURE; + $this->info('Discovery complete.'); + $this->table( + ['Element Type', 'Count'], + [ + ['Tools', $toolsCount], + ['Resources', $resourcesCount], + ['Resource Templates', $templatesCount], + ['Prompts', $promptsCount], + ] + ); + + if (! $noCache && $registry->discoveryRanOrCached()) { + $this->info('MCP element definitions updated and cached.'); } + + return Command::SUCCESS; } } diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index 45b7daf..00dbd85 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace PhpMcp\Laravel\Server\Commands; +namespace PhpMcp\Laravel\Commands; use Illuminate\Console\Command; use Illuminate\Support\Collection; +use Illuminate\Support\Str; 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\Server; class ListCommand extends Command { @@ -19,7 +20,9 @@ class ListCommand extends Command * * @var string */ - protected $signature = 'mcp:list {type? : The type of element to list (tools, resources, prompts, templates)} {--json : Output the list as JSON}'; + protected $signature = 'mcp:list + {type? : The type of element to list (tools, resources, prompts, templates)} + {--json : Output the list as JSON}'; /** * The console command description. @@ -31,9 +34,16 @@ class ListCommand extends Command /** * Execute the console command. */ - public function handle(Registry $registry): int + public function handle(Server $server): int { - $registry->loadElementsFromCache(); // Ensure elements are loaded + $registry = $server->getRegistry(); + + if (! $registry->hasElements() && ! $registry->discoveryRanOrCached()) { + $this->comment('No MCP elements are manually registered, and discovery has not run (or cache is empty).'); + $this->comment('Run `php artisan mcp:discover` or ensure auto-discovery is enabled in dev.'); + } elseif (! $registry->hasElements() && $registry->discoveryRanOrCached()) { + $this->comment('Discovery/cache load ran, but no MCP elements were found.'); + } $type = $this->argument('type'); $outputJson = $this->option('json'); @@ -41,7 +51,7 @@ public function handle(Registry $registry): int $validTypes = ['tools', 'resources', 'prompts', 'templates']; if ($type && ! in_array($type, $validTypes)) { - $this->error("Invalid element type '{$type}'. Valid types are: ".implode(', ', $validTypes)); + $this->error("Invalid element type '{$type}'. Valid types are: " . implode(', ', $validTypes)); return Command::INVALID; } @@ -82,44 +92,35 @@ public function handle(Registry $registry): int private function displayTable(string $type, Collection $collection): void { if ($collection->isEmpty()) { - $this->info(ucfirst($type).': None found.'); + $this->info(ucfirst($type) . ': None found.'); return; } - $this->info(ucfirst($type).':'); + $this->info(ucfirst($type) . ':'); $data = match ($type) { - 'tools' => $collection->map(fn (ToolDefinition $def) => [ - 'name' => $def->getName(), - 'description' => $def->getDescription(), - 'class' => $def->getClassName(), - 'method' => $def->getMethodName(), - // 'inputSchema' => json_encode($def->getInputSchema(), JSON_UNESCAPED_SLASHES), + 'tools' => $collection->map(fn(ToolDefinition $def) => [ + 'Name' => $def->getName(), + 'Description' => Str::limit($def->getDescription() ?? '-', 60), + 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), ])->all(), - 'resources' => $collection->map(fn (ResourceDefinition $def) => [ - 'uri' => $def->getUri(), - 'name' => $def->getName(), - 'description' => $def->getDescription(), - 'mimeType' => $def->getMimeType(), - 'class' => $def->getClassName(), - 'method' => $def->getMethodName(), + 'resources' => $collection->map(fn(ResourceDefinition $def) => [ + 'URI' => $def->getUri(), + 'Name' => $def->getName(), + 'MIME' => $def->getMimeType() ?? '-', + 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), ])->all(), - 'prompts' => $collection->map(fn (PromptDefinition $def) => [ - 'name' => $def->getName(), - 'description' => $def->getDescription(), - 'class' => $def->getClassName(), - 'method' => $def->getMethodName(), - // 'inputSchema' => json_encode($def->getInputSchema(), JSON_UNESCAPED_SLASHES), + 'prompts' => $collection->map(fn(PromptDefinition $def) => [ + 'Name' => $def->getName(), + 'Description' => Str::limit($def->getDescription() ?? '-', 60), + 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), ])->all(), - 'templates' => $collection->map(fn (ResourceTemplateDefinition $def) => [ - 'uriTemplate' => $def->getUriTemplate(), - 'name' => $def->getName(), - 'description' => $def->getDescription(), - 'mimeType' => $def->getMimeType(), - 'class' => $def->getClassName(), - 'method' => $def->getMethodName(), - // 'inputSchema' => json_encode($def->getInputSchema(), JSON_UNESCAPED_SLASHES), + 'templates' => $collection->map(fn(ResourceTemplateDefinition $def) => [ + 'URI Template' => $def->getUriTemplate(), + 'Name' => $def->getName(), + 'MIME' => $def->getMimeType() ?? '-', + 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), ])->all(), default => [], }; @@ -135,7 +136,6 @@ private function displayTable(string $type, Collection $collection): void private function formatCollectionForJson(Collection $collection): array { - // Convert definitions to arrays for JSON output - return $collection->map(fn ($item) => $item instanceof \JsonSerializable ? $item->jsonSerialize() : (array) $item)->values()->all(); + return $collection->map(fn($item) => $item instanceof \JsonSerializable ? $item->jsonSerialize() : (array) $item)->values()->all(); } } diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index 5e493c2..f05ecde 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace PhpMcp\Laravel\Server\Commands; +namespace PhpMcp\Laravel\Commands; use Illuminate\Console\Command; use PhpMcp\Server\Server; -use PhpMcp\Server\Transports\StdioTransportHandler; -use Psr\Log\LoggerInterface; +use PhpMcp\Server\Transports\HttpServerTransport; +use PhpMcp\Server\Transports\StdioServerTransport; + +use function Laravel\Prompts\select; class ServeCommand extends Command { @@ -16,14 +18,18 @@ class ServeCommand extends Command * * @var string */ - protected $signature = 'mcp:serve'; + protected $signature = 'mcp:serve + {--transport= : The transport to use (stdio or http)} + {--H|host= : Host for the HTTP transport (overrides config)} + {--P|port= : Port for the HTTP transport (overrides config)} + {--path-prefix= : URL path prefix for the HTTP transport (overrides config)}'; /** * The console command description. * * @var string */ - protected $description = 'Starts the MCP server using the configured STDIO transport.'; + protected $description = 'Starts the MCP server using the specified transport (stdio or http).'; /** * Execute the console command. @@ -33,23 +39,78 @@ class ServeCommand extends Command */ public function handle(Server $server): int { - if (! config('mcp.transports.stdio.enabled', false)) { - $this->error('MCP STDIO transport is disabled. Cannot run mcp:serve.'); - - return Command::FAILURE; + $transportOption = $this->option('transport'); + if ($transportOption === null) { + if ($this->input->isInteractive()) { + $transportOption = select( + label: 'Choose transport protocol for MCP server communication', + options: [ + 'stdio' => 'STDIO', + 'http' => 'HTTP', + ], + default: 'stdio', + ); + } else { + $transportOption = 'stdio'; + } } - $handler = new StdioTransportHandler($server); - $logger = app(LoggerInterface::class); + $host = $this->option('host'); + $port = $this->option('port'); + $pathPrefix = $this->option('path-prefix'); + + if ($transportOption === 'stdio') { + if (! config('mcp.transports.stdio.enabled', true)) { + $this->error('MCP STDIO transport is disabled in config/mcp.php.'); + + return Command::FAILURE; + } + + $this->info('Starting MCP server with STDIO transport...'); + + try { + $transport = new StdioServerTransport; + $server->listen($transport); + } catch (\Exception $e) { + $this->error("Failed to start MCP server with STDIO transport: {$e->getMessage()}"); - $logger->info('Starting MCP server via mcp:serve (STDIO)...'); - $this->info('MCP server starting via STDIO. Listening for requests...'); + return Command::FAILURE; + } + } elseif ($transportOption === 'http') { + if (! config('mcp.transports.http_dedicated.enabled', true)) { + $this->error('Dedicated MCP HTTP transport is disabled in config/mcp.php.'); - $exitCode = $handler->start(); + return Command::FAILURE; + } + + $host = $this->option('host') ?? config('mcp.transports.http_dedicated.host', '127.0.0.1'); + $port = (int) ($this->option('port') ?? config('mcp.transports.http_dedicated.port', 8090)); + $pathPrefix = $this->option('path-prefix') ?? config('mcp.transports.http_dedicated.path_prefix', 'mcp_server'); + $sslContextOptions = config('mcp.transports.http_dedicated.ssl_context_options'); // For HTTPS + + $this->info("Starting MCP server with dedicated HTTP transport on http://{$host}:{$port} (prefix: /{$pathPrefix})..."); + $transport = new HttpServerTransport( + host: $host, + port: $port, + mcpPathPrefix: $pathPrefix, + sslContext: $sslContextOptions + ); + + try { + $server->listen($transport); + } catch (\Exception $e) { + $this->error("Failed to start MCP server with dedicated HTTP transport: {$e->getMessage()}"); + + return Command::FAILURE; + } + } else { + $this->error("Invalid transport specified: {$transportOption}. Use 'stdio' or 'http'."); + + return Command::INVALID; + } - $logger->info('MCP server (mcp:serve) stopped.', ['exitCode' => $exitCode]); - $this->info('MCP server stopped.'); + $this->info("MCP Server ({$transportOption}) stopped."); - return $exitCode; + return Command::SUCCESS; } } diff --git a/src/Events/McpNotificationEvent.php b/src/Events/McpNotificationEvent.php index cb2e1c4..d443488 100644 --- a/src/Events/McpNotificationEvent.php +++ b/src/Events/McpNotificationEvent.php @@ -1,6 +1,6 @@ handler = new HttpTransportHandler($server); - $this->logger = app(LoggerInterface::class); + $this->clientStateManager = $server->getClientStateManager(); + + $server->listen($this->transport, false); } /** @@ -34,9 +34,8 @@ public function __construct(Server $server) */ public function handleMessage(Request $request): Response { - // Confirm request is JSON if (! $request->isJson()) { - $this->logger->warning('MCP POST request with invalid Content-Type'); + Log::warning('MCP POST request with invalid Content-Type'); return response()->json([ 'jsonrpc' => '2.0', @@ -47,35 +46,35 @@ public function handleMessage(Request $request): Response ], 400); } - // Confirm request body is not empty - $content = $request->getContent(); - if ($content === false || empty($content)) { - $this->logger->warning('MCP POST request with empty body'); + $clientId = $request->query('clientId'); + + if (! $clientId || ! is_string($clientId)) { + Log::error('MCP: Missing or invalid clientId'); return response()->json([ 'jsonrpc' => '2.0', 'error' => [ 'code' => -32600, - 'message' => 'Invalid Request: Empty body', + 'message' => 'Invalid Request: Missing or invalid clientId query parameter', ], ], 400); } - $clientId = $request->query('client_id'); - - if (! $clientId || ! is_string($clientId)) { - $this->logger->error('MCP: Missing or invalid clientId'); + // Confirm request body is not empty + $content = $request->getContent(); + if ($content === false || empty($content)) { + Log::warning('MCP POST request with empty body'); return response()->json([ 'jsonrpc' => '2.0', 'error' => [ 'code' => -32600, - 'message' => 'Invalid Request: Missing or invalid clientId query parameter', + 'message' => 'Invalid Request: Empty body', ], ], 400); } - $this->handler->handleInput($content, $clientId); + $this->transport->emit('message', [$content, $clientId]); return response()->json([ 'jsonrpc' => '2.0', @@ -91,38 +90,80 @@ public function handleSse(Request $request): Response { $clientId = $request->hasSession() ? $request->session()->getId() : Str::uuid()->toString(); - if (! $clientId) { - $this->logger->error('MCP: SSE connection failed - Could not determine Client ID.'); + $this->transport->emit('client_connected', [$clientId]); - return response()->json([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32600, - 'message' => 'Could not determine Client ID', - ], - ], 400); + $pollInterval = (int) config('mcp.transports.http_integrated.sse_poll_interval', 1); + if ($pollInterval < 1) { + $pollInterval = 1; } - $this->logger->info('MCP: SSE connection opening', ['client_id' => $clientId]); + return response()->stream(function () use ($clientId, $pollInterval) { + @set_time_limit(0); - set_time_limit(0); - - return response()->stream(function () use ($clientId) { try { - $postEndpointUri = route('mcp.message', ['client_id' => $clientId], false); + $postEndpointUri = route('mcp.message', ['clientId' => $clientId], false); - $this->handler->handleSseConnection($clientId, $postEndpointUri); + $this->sendSseEvent('endpoint', $postEndpointUri, "mcp-endpoint-{$clientId}"); } catch (Throwable $e) { - $this->logger->error('MCP: SSE stream loop terminated', ['client_id' => $clientId, 'reason' => $e->getMessage()]); - } finally { - $this->handler->cleanupClient($clientId); - $this->logger->info('MCP: SSE connection closed and client cleaned up', ['client_id' => $clientId]); + Log::error('MCP: SSE stream loop terminated', ['client_id' => $clientId, 'reason' => $e->getMessage()]); + + return; } + + while (true) { + if (connection_aborted()) { + break; + } + + $messages = $this->clientStateManager->getQueuedMessages($clientId); + foreach ($messages as $message) { + $this->sendSseEvent('message', rtrim($message, "\n")); + } + + static $keepAliveCounter = 0; + if (($keepAliveCounter++ % (15 / $pollInterval)) == 0) { + echo ": keep-alive\n\n"; + $this->flushOutput(); + } + + usleep($pollInterval * 1000000); + } + + $this->transport->emit('client_disconnected', [$clientId, 'Laravel SSE stream shutdown']); + $this->server->endListen($this->transport); }, headers: [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', // Prevent buffering by proxies like nginx + 'X-Accel-Buffering' => 'no', + 'Access-Control-Allow-Origin' => '*', // TODO: Make this configurable ]); } + + private function sendSseEvent(string $event, string $data, ?string $id = null): void + { + if (connection_aborted()) { + return; + } + + echo "event: {$event}\n"; + if ($id !== null) { + echo "id: {$id}\n"; + } + + foreach (explode("\n", $data) as $line) { + echo "data: {$line}\n"; + } + + echo "\n"; + $this->flushOutput(); + } + + private function flushOutput(): void + { + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + } } diff --git a/src/Listeners/McpNotificationListener.php b/src/Listeners/McpNotificationListener.php index 51bf6a5..d3a9630 100644 --- a/src/Listeners/McpNotificationListener.php +++ b/src/Listeners/McpNotificationListener.php @@ -1,22 +1,26 @@ clientStateManager = $server->getClientStateManager(); + } /** * Handle the event. @@ -37,10 +41,11 @@ public function handle(McpNotificationEvent $event): void */ private function handleResourceUpdated(ResourceUpdated $event): void { - $subscribers = $this->transportState->getResourceSubscribers($event->uri); + $subscribers = $this->clientStateManager->getResourceSubscribers($event->uri); + $message = json_encode($event->toNotification()->toArray()); foreach ($subscribers as $clientId) { - $this->transportState->queueMessage($clientId, $event->toNotification()); + $this->clientStateManager->queueMessage($clientId, $message); } } @@ -49,10 +54,11 @@ private function handleResourceUpdated(ResourceUpdated $event): void */ private function handleListChanged(McpNotificationEvent $event): void { - $activeClients = $this->transportState->getActiveClients(); + $activeClients = $this->clientStateManager->getActiveClients(); + $message = json_encode($event->toNotification()->toArray()); foreach ($activeClients as $clientId) { - $this->transportState->queueMessage($clientId, $event->toNotification()); + $this->clientStateManager->queueMessage($clientId, $message); } } } diff --git a/src/McpRegistrar.php b/src/McpRegistrar.php new file mode 100644 index 0000000..812eabc --- /dev/null +++ b/src/McpRegistrar.php @@ -0,0 +1,139 @@ +pendingTools[] = $pendingTool; + + return $pendingTool; + } + + /** + * Register a new resource. + */ + public function resource(string $uri, array|string $handler): ResourceBlueprint + { + $pendingResource = new ResourceBlueprint($uri, $handler); + $this->pendingResources[] = $pendingResource; + + return $pendingResource; + } + + /** + * Register a new resource template. + */ + public function resourceTemplate(string $uriTemplate, array|string $handler): ResourceTemplateBlueprint + { + $pendingResourceTemplate = new ResourceTemplateBlueprint($uriTemplate, $handler); + $this->pendingResourceTemplates[] = $pendingResourceTemplate; + + return $pendingResourceTemplate; + } + + /** + * Register a new prompt. + * + * Usage: + * Mcp::prompt('prompt_name', $handler) + * Mcp::prompt($handler) // Name will be inferred + */ + public function prompt(string|array ...$args): PromptBlueprint + { + $name = null; + $handler = null; + + if (count($args) === 1 && (is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { + $handler = $args[0]; + } elseif (count($args) === 2 && is_string($args[0]) && (is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { + $name = $args[0]; + $handler = $args[1]; + } else { + throw new InvalidArgumentException('Invalid arguments for Mcp::prompt(). Expected (handler) or (name, handler).'); + } + + $pendingPrompt = new PromptBlueprint($handler, $name); + $this->pendingPrompts[] = $pendingPrompt; + + return $pendingPrompt; + } + + public function applyBlueprints(ServerBuilder $builder): void + { + foreach ($this->pendingTools as $pendingTool) { + $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description); + } + + foreach ($this->pendingResources as $pendingResource) { + $builder->withResource( + $pendingResource->handler, + $pendingResource->uri, + $pendingResource->name, + $pendingResource->description, + $pendingResource->mimeType, + $pendingResource->size, + $pendingResource->annotations + ); + } + + foreach ($this->pendingResourceTemplates as $pendingTemplate) { + $builder->withResourceTemplate( + $pendingTemplate->handler, + $pendingTemplate->uriTemplate, + $pendingTemplate->name, + $pendingTemplate->description, + $pendingTemplate->mimeType, + $pendingTemplate->annotations + ); + } + + foreach ($this->pendingPrompts as $pendingPrompt) { + $builder->withPrompt($pendingPrompt->handler, $pendingPrompt->name, $pendingPrompt->description); + } + } +} diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index 39ae703..60c7e5d 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -2,25 +2,25 @@ declare(strict_types=1); -namespace PhpMcp\Laravel\Server; +namespace PhpMcp\Laravel; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use PhpMcp\Laravel\Server\Adapters\ConfigAdapter; -use PhpMcp\Laravel\Server\Commands\DiscoverCommand; -use PhpMcp\Laravel\Server\Commands\ListCommand; -use PhpMcp\Laravel\Server\Commands\ServeCommand; -use PhpMcp\Laravel\Server\Events\PromptsListChanged; -use PhpMcp\Laravel\Server\Events\ResourcesListChanged; -use PhpMcp\Laravel\Server\Events\ToolsListChanged; -use PhpMcp\Laravel\Server\Listeners\McpNotificationListener; -use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface; +use PhpMcp\Laravel\Commands\DiscoverCommand; +use PhpMcp\Laravel\Commands\ListCommand; +use PhpMcp\Laravel\Commands\ServeCommand; +use PhpMcp\Laravel\Events\PromptsListChanged; +use PhpMcp\Laravel\Events\ResourcesListChanged; +use PhpMcp\Laravel\Events\ToolsListChanged; +use PhpMcp\Laravel\Listeners\McpNotificationListener; +use PhpMcp\Laravel\Transports\LaravelHttpTransport; +use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Server\Registry; use PhpMcp\Server\Server; -use Psr\Log\LoggerInterface; -use Psr\SimpleCache\CacheInterface; -class McpServiceProvider extends ServiceProvider +class McpServiceProvider extends ServiceProvider implements DeferrableProvider { /** * The event listener mappings for the application. @@ -41,60 +41,116 @@ class McpServiceProvider extends ServiceProvider public function register(): void { - $this->mergeConfigFrom(__DIR__.'/../config/mcp.php', 'mcp'); + $this->mergeConfigFrom(__DIR__ . '/../config/mcp.php', 'mcp'); + $this->app->singleton(McpRegistrar::class, fn() => new McpRegistrar()); + + $this->app->alias(McpRegistrar::class, 'mcp.registrar'); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides(): array + { + return [Server::class, LaravelHttpTransport::class]; + } + + public function boot(): void + { + $this->loadMcpDefinitions(); + $this->buildServer(); + $this->bootConfig(); + $this->bootRoutes(); + $this->bootCommands(); + $this->bootEventListeners(); + } + + protected function loadMcpDefinitions(): void + { + $definitionsPath = config('mcp.discovery.definitions_file', base_path('routes/mcp.php')); + if ($definitionsPath && file_exists($definitionsPath)) { + require $definitionsPath; + } + } + + protected function buildServer(): void + { $this->app->singleton(Server::class, function (Application $app) { - $server = Server::make() + $serverName = config('mcp.server.name', config('app.name', 'Laravel') . ' MCP Server'); + $serverVersion = config('mcp.server.version', '1.0.0'); + $logger = $app['log']->channel(config('mcp.logging.channel')); + $cache = $app['cache']->store($app['config']->get('mcp.cache.store')); + $capabilities = Capabilities::forServer( + toolsEnabled: config('mcp.capabilities.tools.enabled', true), + toolsListChanged: config('mcp.capabilities.tools.listChanged', true), + resourcesEnabled: config('mcp.capabilities.resources.enabled', true), + resourcesSubscribe: config('mcp.capabilities.resources.subscribe', true), + resourcesListChanged: config('mcp.capabilities.resources.listChanged', true), + promptsEnabled: config('mcp.capabilities.prompts.enabled', true), + promptsListChanged: config('mcp.capabilities.prompts.listChanged', true), + loggingEnabled: config('mcp.capabilities.logging.enabled', true), + instructions: config('mcp.server.instructions') + ); + + $builder = Server::make() + ->withServerInfo($serverName, $serverVersion) + ->withLogger($logger) ->withContainer($app) - ->withBasePath(base_path()) - ->withScanDirectories($app['config']->get('mcp.discovery.directories', ['app/Mcp'])); + ->withCache($cache, (int) config('mcp.cache.ttl', 3600)) + ->withCapabilities($capabilities); - if (! $this->app->environment('production')) { - $server->discover(); - } + $registrar = $app->make(McpRegistrar::class); + $registrar->applyBlueprints($builder); - $registry = $server->getRegistry(); + $server = $builder->build(); - $registry->setToolsChangedNotifier(fn () => ToolsListChanged::dispatch()); - $registry->setResourcesChangedNotifier(fn () => ResourcesListChanged::dispatch()); - $registry->setPromptsChangedNotifier(fn () => PromptsListChanged::dispatch()); + if (config('mcp.discovery.auto_discover', true)) { + $server->discover( + basePath: config('mcp.discovery.base_path', base_path()), + scanDirs: config('mcp.discovery.directories', ['app/Mcp']), + excludeDirs: config('mcp.discovery.exclude_dirs', []), + saveToCache: config('mcp.discovery.save_to_cache', true) + ); + } return $server; }); - $this->app->bind(ConfigurationRepositoryInterface::class, fn (Application $app) => new ConfigAdapter($app['config'])); - $this->app->bind(LoggerInterface::class, fn (Application $app) => $app['log']->channel($app['config']->get('mcp.logging.channel'))); - $this->app->bind(CacheInterface::class, fn (Application $app) => $app['cache']->store($app['config']->get('mcp.cache.store'))); - } + $this->app->singleton(Registry::class, fn($app) => $app->make(Server::class)->getRegistry()); - public function boot(): void - { - $this->bootConfig(); - $this->bootRoutes(); - $this->bootCommands(); + $this->app->alias(Server::class, 'mcp.server'); + $this->app->alias(Registry::class, 'mcp.registry'); + + $this->app->singleton(LaravelHttpTransport::class, function (Application $app) { + $server = $app->make(Server::class); + + return new LaravelHttpTransport($server->getClientStateManager()); + }); } protected function bootConfig(): void { if ($this->app->runningInConsole()) { - $this->publishes([__DIR__.'/../config/mcp.php' => config_path('mcp.php')], 'mcp-config'); + $this->publishes([__DIR__ . '/../config/mcp.php' => config_path('mcp.php')], 'mcp-config'); } } protected function bootRoutes(): void { - $config = $this->app['config']; - if ($config->get('mcp.transports.http.enabled', true)) { - $prefix = $config->get('mcp.transports.http.prefix', 'mcp'); - $middleware = $config->get('mcp.transports.http.middleware', ['web']); - $domain = $config->get('mcp.transports.http.domain'); + if (config('mcp.transports.http_integrated.enabled', true)) { + $routePrefix = config('mcp.transports.http_integrated.route_prefix', 'mcp'); + $middleware = config('mcp.transports.http_integrated.middleware', ['web']); + $domain = config('mcp.transports.http_integrated.domain'); Route::group([ 'domain' => $domain, - 'prefix' => $prefix, + 'prefix' => $routePrefix, 'middleware' => $middleware, ], function () { - $this->loadRoutesFrom(__DIR__.'/../routes/mcp.php'); + $this->loadRoutesFrom(__DIR__ . '/../routes/mcp_http_integrated.php'); }); } } @@ -109,4 +165,14 @@ protected function bootCommands(): void ]); } } + + protected function bootEventListeners(): void + { + $server = $this->app->make(Server::class); + $registry = $server->getRegistry(); + + $registry->setToolsChangedNotifier(ToolsListChanged::dispatch(...)); + $registry->setResourcesChangedNotifier(ResourcesListChanged::dispatch(...)); + $registry->setPromptsChangedNotifier(PromptsListChanged::dispatch(...)); + } } diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/LaravelHttpTransport.php new file mode 100644 index 0000000..0fe7043 --- /dev/null +++ b/src/Transports/LaravelHttpTransport.php @@ -0,0 +1,102 @@ + Tracks active client IDs managed by this transport */ + private array $activeClients = []; + + public function __construct(ClientStateManager $clientStateManager) + { + $this->clientStateManager = $clientStateManager; + $this->logger = new NullLogger; + + $this->on('client_connected', function (string $clientId) { + $this->activeClients[$clientId] = true; + $this->clientStateManager->updateClientActivity($clientId); + }); + + $this->on('client_disconnected', function (string $clientId, string $reason) { + unset($this->activeClients[$clientId]); + }); + + $this->on('message', function (string $message, string $clientId) { + $this->clientStateManager->updateClientActivity($clientId); + }); + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + /** + * For this integrated transport, 'listen' doesn't start a network listener. + * It signifies the transport is ready to be used by the Protocol handler. + * The actual listening is done by Laravel's HTTP kernel. + */ + public function listen(): void + { + $this->emit('ready'); + } + + /** + * Queues a message to be sent to the client via the ClientStateManager. + * The McpController's SSE loop will pick this up. + * The $rawFramedMessage is expected to be a complete JSON-RPC string (usually ending with \n, but we'll trim). + */ + public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + { + if (! isset($this->activeClients[$clientId])) { + $this->logger->warning('Attempted to send message to inactive or unknown client.', ['clientId' => $clientId]); + + return reject(new TransportException("Client '{$clientId}' is not actively managed by this transport.")); + } + + $messagePayload = rtrim($rawFramedMessage, "\n"); + + if (empty($messagePayload)) { + return resolve(null); + } + + $this->clientStateManager->queueMessage($clientId, $messagePayload); + + return resolve(null); + } + + /** + * 'Closes' the transport. + */ + public function close(): void + { + $activeClientIds = array_keys($this->activeClients); + + foreach ($activeClientIds as $clientId) { + $this->emit('client_disconnected', [$clientId, 'Transport globally closed']); + $this->emit('close', ['Transport closed.']); + } + + $this->removeAllListeners(); + } +} diff --git a/tests/Feature/Commands/DiscoverCommandTest.php b/tests/Feature/Commands/DiscoverCommandTest.php new file mode 100644 index 0000000..1bad728 --- /dev/null +++ b/tests/Feature/Commands/DiscoverCommandTest.php @@ -0,0 +1,52 @@ +shouldReceive('allTools->count')->andReturn(2); + $registryMock->shouldReceive('allResources->count')->andReturn(1); + $registryMock->shouldReceive('allResourceTemplates->count')->andReturn(0); + $registryMock->shouldReceive('allPrompts->count')->andReturn(3); + $registryMock->shouldReceive('discoveryRanOrCached')->andReturn(true); + + $serverMock = $this->mock(Server::class, function ($mock) use ($registryMock) { + $mock->shouldReceive('discover')->once(); + $mock->shouldReceive('getRegistry')->andReturn($registryMock); + }); + $this->app->instance(Server::class, $serverMock); + $this->app->instance(Registry::class, $registryMock); + + + $this->artisan('mcp:discover') + ->expectsTable(['Element Type', 'Count'], [ + ['Tools', 2], + ['Resources', 1], + ['Resource Templates', 0], + ['Prompts', 3], + ]) + ->assertSuccessful(); + } + + public function test_discover_command_handles_discovery_exception_gracefully() + { + $serverMock = $this->mock(Server::class, function ($mock) { + $mock->shouldReceive('discover')->andThrow(new \RuntimeException("Simulated discovery failure!")); + $mock->shouldAllowMockingProtectedMethods()->shouldIgnoreMissing(); + }); + $this->app->instance(Server::class, $serverMock); + + + $this->artisan('mcp:discover') + ->expectsOutputToContain('Discovery failed: Simulated discovery failure!') + ->assertFailed(); + } +} diff --git a/tests/Feature/Commands/ListCommandTest.php b/tests/Feature/Commands/ListCommandTest.php new file mode 100644 index 0000000..666655a --- /dev/null +++ b/tests/Feature/Commands/ListCommandTest.php @@ -0,0 +1,134 @@ +set('mcp.discovery.auto_discover', false); + $app['config']->set('mcp.discovery.definitions_file', null); + } + + private function populateRegistry(Registry $registry) + { + $logger = new NullLogger; + $docBlockParser = new DocBlockParser($logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + + $tool1 = ToolDefinition::fromReflection( + new \ReflectionMethod(ManualTestHandler::class, 'handleTool'), + 'list_tool_1', + 'Desc 1', + $docBlockParser, + $schemaGenerator + ); + $resource1 = ResourceDefinition::fromReflection( + new \ReflectionMethod(ManualTestHandler::class, 'handleResource'), + 'list_res_1', + 'Desc Res 1', + 'res://list/1', + 'text/plain', + null, + [], + $docBlockParser + ); + $registry->registerTool($tool1, true); + $registry->registerResource($resource1, true); + } + + public function test_list_command_shows_all_types_by_default() + { + $server = $this->app->make(Server::class); + $this->populateRegistry($server->getRegistry()); + + $this->artisan('mcp:list') + ->expectsOutputToContain('Tools:') + ->expectsOutputToContain('list_tool_1') + ->expectsOutputToContain('Resources:') + ->expectsOutputToContain('res://list/1') + ->expectsOutputToContain('Prompts: None found.') + ->expectsOutputToContain('Templates: None found.') + ->assertSuccessful(); + } + + public function test_list_command_shows_specific_type_tools() + { + $server = $this->app->make(Server::class); + $this->populateRegistry($server->getRegistry()); + + $this->artisan('mcp:list tools') + ->expectsOutputToContain('Tools:') + ->expectsOutputToContain('list_tool_1') + ->doesntExpectOutputToContain('Resources:') + ->assertSuccessful(); + } + + public function test_list_command_json_output_is_correct() + { + $server = $this->app->make(Server::class); + $this->populateRegistry($server->getRegistry()); + + Artisan::call('mcp:list --json'); + + $output = Artisan::output(); + $jsonData = json_decode($output, true); + + $this->assertIsArray($jsonData); + $this->assertArrayHasKey('tools', $jsonData); + $this->assertArrayHasKey('resources', $jsonData); + $this->assertCount(1, $jsonData['tools']); + $this->assertEquals('list_tool_1', $jsonData['tools'][0]['toolName']); + $this->assertEquals('res://list/1', $jsonData['resources'][0]['uri']); + } + + public function test_list_command_handles_empty_registry_for_type() + { + $server = $this->app->make(Server::class); + $this->populateRegistry($server->getRegistry()); + + $this->artisan('mcp:list prompts') + ->expectsOutputToContain('Prompts: None found.') + ->assertSuccessful(); + } + + public function test_list_command_warns_if_discovery_not_run_and_no_manual_elements() + { + $this->artisan('mcp:list') + ->expectsOutputToContain('No MCP elements are manually registered, and discovery has not run') + ->assertSuccessful(); + } + + public function test_list_command_warns_if_discovery_ran_but_no_elements_found() + { + $registryMock = $this->mock(Registry::class); + $registryMock->shouldReceive('hasElements')->andReturn(false); + $registryMock->shouldReceive('discoveryRanOrCached')->andReturn(true); // Key difference + $registryMock->shouldReceive('allTools')->andReturn(new ArrayObject()); + $registryMock->shouldReceive('allResources')->andReturn(new ArrayObject()); + $registryMock->shouldReceive('allPrompts')->andReturn(new ArrayObject()); + $registryMock->shouldReceive('allResourceTemplates')->andReturn(new ArrayObject()); + + + $serverMock = $this->mock(Server::class, function ($mock) use ($registryMock) { + $mock->shouldReceive('getRegistry')->andReturn($registryMock); + }); + $this->app->instance(Server::class, $serverMock); + + $this->artisan('mcp:list') + ->expectsOutputToContain('Discovery/cache load ran, but no MCP elements were found.') + ->assertSuccessful(); + } +} diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php new file mode 100644 index 0000000..c572bf7 --- /dev/null +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -0,0 +1,129 @@ +set('mcp.discovery.auto_discover', false); + } + + protected function setHttpDedicatedTransportConfig($app) + { + $app['config']->set('mcp.transports.http_dedicated.enabled', true); + $app['config']->set('mcp.transports.http_dedicated.host', '0.0.0.0'); + $app['config']->set('mcp.transports.http_dedicated.port', 8888); + $app['config']->set('mcp.transports.http_dedicated.path_prefix', 'configured_prefix'); + } + + protected function disableStdioTransport($app) + { + $app['config']->set('mcp.transports.stdio.enabled', false); + } + + protected function disableHttpDedicatedTransport($app) + { + $app['config']->set('mcp.transports.http_dedicated.enabled', false); + } + + public function test_serve_command_defaults_to_stdio_and_calls_server_listen() + { + $serverMock = $this->spy(Server::class); + $this->app->instance(Server::class, $serverMock); + + $serverMock->shouldReceive('listen')->once()->with( + Mockery::type(StdioServerTransport::class) + ); + + $this->artisan('mcp:serve --transport=stdio') + ->expectsOutputToContain('Starting MCP server with STDIO transport...') + ->assertSuccessful(); + } + + public function test_serve_command_uses_http_transport_when_specified() + { + $serverMock = $this->spy(Server::class); + $this->app->instance(Server::class, $serverMock); + + $serverMock->shouldReceive('listen')->once()->with( + Mockery::type(HttpServerTransport::class), + ); + + $this->artisan('mcp:serve --transport=http --host=localhost --port=9091 --path-prefix=mcp_test_http') + ->expectsOutputToContain('Starting MCP server with dedicated HTTP transport on http://localhost:9091 (prefix: /mcp_test_http)...') + ->assertSuccessful(); + } + + #[DefineEnvironment('setHttpDedicatedTransportConfig')] + public function test_serve_command_uses_http_transport_config_fallbacks() + { + $serverMock = $this->spy(Server::class); + $this->app->instance(Server::class, $serverMock); + + $serverMock->shouldReceive('listen')->once()->with( + Mockery::on(function ($transport) { + $reflection = new \ReflectionClass($transport); + $hostProp = $reflection->getProperty('host'); + $hostProp->setAccessible(true); + $portProp = $reflection->getProperty('port'); + $portProp->setAccessible(true); + $prefixProp = $reflection->getProperty('mcpPathPrefix'); + $prefixProp->setAccessible(true); + + return $transport instanceof HttpServerTransport && + $hostProp->getValue($transport) === '0.0.0.0' && + $portProp->getValue($transport) === 8888 && + $prefixProp->getValue($transport) === 'configured_prefix'; + }), + ); + + $this->artisan('mcp:serve --transport=http') // No CLI overrides + ->expectsOutputToContain('Starting MCP server with dedicated HTTP transport on http://0.0.0.0:8888 (prefix: /configured_prefix)...') + ->assertSuccessful(); + } + + #[DefineEnvironment('disableStdioTransport')] + public function test_serve_command_fails_if_stdio_disabled_in_config() + { + $this->artisan('mcp:serve --transport=stdio') + ->expectsOutputToContain('MCP STDIO transport is disabled in config/mcp.php.') + ->assertFailed(); + } + + #[DefineEnvironment('disableHttpDedicatedTransport')] + public function test_serve_command_fails_if_http_dedicated_disabled_in_config() + { + $this->artisan('mcp:serve --transport=http') + ->expectsOutputToContain('Dedicated MCP HTTP transport is disabled in config/mcp.php.') + ->assertFailed(); + } + + public function test_serve_command_fails_for_invalid_transport_option() + { + $this->artisan('mcp:serve --transport=websocket') + ->expectsOutputToContain("Invalid transport specified: websocket. Use 'stdio' or 'http'.") + ->assertFailed(); + } + + public function test_serve_command_handles_server_listen_exception() + { + $serverMock = $this->mock(Server::class, function ($mock) { + $mock->shouldReceive('listen')->andThrow(new \RuntimeException("Simulated listen failure!")); + $mock->shouldIgnoreMissing(); + }); + $this->app->instance(Server::class, $serverMock); + + + $this->artisan('mcp:serve --transport=stdio') + ->expectsOutputToContain('Simulated listen failure!') + ->assertFailed(); + } +} diff --git a/tests/Feature/ManualRegistrationTest.php b/tests/Feature/ManualRegistrationTest.php new file mode 100644 index 0000000..c253f85 --- /dev/null +++ b/tests/Feature/ManualRegistrationTest.php @@ -0,0 +1,136 @@ +description('A manually registered test tool.'); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + + $tool = $registry->findTool('manual_test_tool'); + + $this->assertInstanceOf(ToolDefinition::class, $tool); + $this->assertEquals('manual_test_tool', $tool->getName()); + $this->assertEquals('A manually registered test tool.', $tool->getDescription()); + $this->assertEquals(ManualTestHandler::class, $tool->getClassName()); + $this->assertEquals('handleTool', $tool->getMethodName()); + $this->assertArrayHasKey('input', $tool->getInputSchema()['properties']); + $this->assertEquals('string', $tool->getInputSchema()['properties']['input']['type']); + } + + public function test_can_manually_register_tool_using_handler_only() + { + $definitionsContent = <<<'PHP' + setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + $tool = $registry->findTool('handleTool'); + + $this->assertNotNull($tool); + $this->assertEquals(ManualTestHandler::class, $tool->getClassName()); + $this->assertEquals('handleTool', $tool->getMethodName()); + $this->assertEquals('A sample tool handler.', $tool->getDescription()); + } + + public function test_can_manually_register_a_resource() + { + $definitionsContent = <<<'PHP' + name('manual_app_setting') + ->mimeType('application/json') + ->size(1024) + ->annotations(['category' => 'config']); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + $resource = $registry->findResourceByUri('manual://config/app-setting'); + + $this->assertInstanceOf(ResourceDefinition::class, $resource); + $this->assertEquals('manual_app_setting', $resource->getName()); + $this->assertEquals('A sample resource handler.', $resource->getDescription()); + $this->assertEquals('application/json', $resource->getMimeType()); + $this->assertEquals(1024, $resource->getSize()); + $this->assertEquals(['category' => 'config'], $resource->getAnnotations()); + $this->assertEquals(ManualTestHandler::class, $resource->getClassName()); + $this->assertEquals('handleResource', $resource->getMethodName()); + } + + public function test_can_manually_register_a_prompt_with_invokable_class_handler() + { + $definitionsContent = <<<'PHP' + description('A prompt handled by an invokable class.'); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + $prompt = $registry->findPrompt('manual_invokable_prompt'); + + $this->assertInstanceOf(PromptDefinition::class, $prompt); + $this->assertEquals('manual_invokable_prompt', $prompt->getName()); + $this->assertEquals('A prompt handled by an invokable class.', $prompt->getDescription()); + $this->assertEquals(ManualTestInvokableHandler::class, $prompt->getClassName()); + $this->assertEquals('__invoke', $prompt->getMethodName()); + } + + public function test_can_manually_register_a_resource_template_via_facade() + { + $definitionsContent = <<<'PHP' + name('manual_item_details_template') + ->mimeType('application/vnd.api+json'); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + $templateMatch = $registry->findResourceTemplateByUri('manual://item/123/details'); + + $this->assertNotNull($templateMatch); + $template = $templateMatch['definition']; + $this->assertInstanceOf(ResourceTemplateDefinition::class, $template); + $this->assertEquals('manual://item/{itemId}/details', $template->getUriTemplate()); + $this->assertEquals('manual_item_details_template', $template->getName()); + $this->assertEquals('A sample resource template handler.', $template->getDescription()); + $this->assertEquals('application/vnd.api+json', $template->getMimeType()); + $this->assertEquals(ManualTestHandler::class, $template->getClassName()); + $this->assertEquals('handleTemplate', $template->getMethodName()); + } +} diff --git a/tests/Feature/McpServiceProviderTest.php b/tests/Feature/McpServiceProviderTest.php new file mode 100644 index 0000000..2ef8d77 --- /dev/null +++ b/tests/Feature/McpServiceProviderTest.php @@ -0,0 +1,128 @@ +set('mcp.server.name', 'My Awesome MCP Test Server'); + $app['config']->set('mcp.server.version', 'v2.test'); + $app['config']->set('mcp.server.instructions', 'Test instructions from config.'); + $app['config']->set('mcp.cache.ttl', 7200); + } + + protected function disableAutoDiscovery($app) + { + $app['config']->set('mcp.discovery.auto_discover', false); + } + + protected function disableHttpIntegratedRoutes($app) + { + $app['config']->set('mcp.transports.http_integrated.enabled', false); + } + + public function test_provider_is_registered_and_boots_core_server_and_components() + { + $providers = $this->app->getLoadedProviders(); + $this->assertArrayHasKey(McpServiceProvider::class, $providers); + $this->assertTrue($providers[McpServiceProvider::class]); + + $server1 = $this->app->make('mcp.server'); + $this->assertInstanceOf(Server::class, $server1); + + $server2 = $this->app->make(Server::class); + $this->assertSame($server1, $server2, "Server should be a singleton."); + + $this->assertInstanceOf(Registry::class, $server1->getRegistry()); + $this->assertInstanceOf(Protocol::class, $server1->getProtocol()); + $this->assertInstanceOf(ClientStateManager::class, $server1->getClientStateManager()); + $this->assertInstanceOf(McpRegistrar::class, $this->app->make('mcp.registrar')); + $this->assertInstanceOf(LaravelHttpTransport::class, $this->app->make(LaravelHttpTransport::class)); + + $configVO = $server1->getConfiguration(); + $this->assertInstanceOf(LoggerInterface::class, $configVO->logger); + $this->assertInstanceOf(LoopInterface::class, $configVO->loop); + $this->assertInstanceOf(CacheInterface::class, $configVO->cache); + $this->assertInstanceOf(Container::class, $configVO->container); + } + + #[DefineEnvironment('useTestServerConfig')] + public function test_configuration_values_are_correctly_applied_to_server() + { + $server = $this->app->make('mcp.server'); + $configVO = $server->getConfiguration(); + + $this->assertEquals('My Awesome MCP Test Server', $configVO->serverName); + $this->assertEquals('v2.test', $configVO->serverVersion); + $this->assertEquals('Test instructions from config.', $configVO->capabilities->instructions); + $this->assertEquals(7200, $configVO->definitionCacheTtl); + $this->assertTrue($configVO->capabilities->promptsEnabled); + } + + public function test_auto_discovery_is_triggered_when_enabled() + { + $server = $this->app->make('mcp.server'); + $registry = $server->getRegistry(); + $this->assertNotNull($registry->findTool('stub_tool_one'), "Discovered tool 'stub_tool_one' not found in registry."); + } + + #[DefineEnvironment('disableAutoDiscovery')] + public function test_auto_discovery_is_skipped_if_disabled() + { + $server = $this->app->make('mcp.server'); + $registry = $server->getRegistry(); + + $this->assertNull($registry->findTool('stub_tool_one'), "Tool 'stub_tool_one' should not be found if auto-discovery is off."); + } + + public function test_event_notifiers_are_set_on_core_registry_and_dispatch_laravel_events() + { + Event::fake(); + + $server = $this->app->make('mcp.server'); + $registry = $server->getRegistry(); + + $newToolName = 'dynamic_tool_for_event_test'; + $this->assertNull($registry->findTool($newToolName)); + + $registry->registerTool( + new ToolDefinition(ManualTestHandler::class, 'handleTool', $newToolName, 'd', []) + ); + + Event::assertDispatched(ToolsListChanged::class); + } + + public function test_http_integrated_routes_are_registered_if_enabled() + { + $this->assertTrue(Route::has('mcp.sse')); + $this->assertTrue(Route::has('mcp.message')); + $this->assertStringContainsString('/mcp/sse', route('mcp.sse')); + } + + #[DefineEnvironment('disableHttpIntegratedRoutes')] + public function test_http_integrated_routes_are_not_registered_if_disabled() + { + $this->assertFalse(Route::has('mcp.sse')); + $this->assertFalse(Route::has('mcp.message')); + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..c0cde01 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in('Feature', 'Unit'); diff --git a/tests/Stubs/App/Mcp/DiscoverableTool.php b/tests/Stubs/App/Mcp/DiscoverableTool.php new file mode 100644 index 0000000..fac4183 --- /dev/null +++ b/tests/Stubs/App/Mcp/DiscoverableTool.php @@ -0,0 +1,14 @@ + 'manual resource content', 'timestamp' => time()]; + } + + /** + * A sample prompt handler. + * @param string $topic The topic for the prompt. + * @return array Prompt messages. + */ + public function handlePrompt(string $topic, int $count = 1): array + { + return [ + ['role' => 'user', 'content' => "Generate {$count} idea(s) about {$topic}."] + ]; + } + + /** + * A sample resource template handler. + * @param string $itemId The ID from the URI. + * @return array Item details. + */ + public function handleTemplate(string $itemId): array + { + return ['id' => $itemId, 'name' => "Item {$itemId}", 'source' => 'manual_template']; + } + + public function anotherTool(): void {} // For testing name override +} diff --git a/tests/Stubs/App/Mcp/ManualTestInvokableHandler.php b/tests/Stubs/App/Mcp/ManualTestInvokableHandler.php new file mode 100644 index 0000000..9a6a99c --- /dev/null +++ b/tests/Stubs/App/Mcp/ManualTestInvokableHandler.php @@ -0,0 +1,18 @@ + 'user', 'content' => "Invokable prompt responding to: {$query}"] + ]; + } +} diff --git a/tests/Stubs/routes/mcp-definitions.php b/tests/Stubs/routes/mcp-definitions.php new file mode 100644 index 0000000..b97e20c --- /dev/null +++ b/tests/Stubs/routes/mcp-definitions.php @@ -0,0 +1 @@ +definitionsFilePath = __DIR__ . '/Stubs/routes/mcp-definitions.php'; + + $app['config']->set('mcp.discovery.definitions_file', $this->definitionsFilePath); + $app['config']->set('mcp.discovery.base_path', __DIR__ . '/Stubs'); + + $app['config']->set('mcp.logging.channel', 'null'); + } + + /** + * Overwrites the content of the test MCP definitions file and refreshes the application. + */ + protected function setMcpDefinitions(string $content): void + { + file_put_contents($this->definitionsFilePath, $content); + $this->refreshApplication(); + } + + /** + * Creates a temporary MCP handler class file within the Stubs/App/Mcp directory. + */ + protected function createStubMcpHandlerFile(string $className, string $content, string $subDir = 'App/Mcp'): string + { + $basePath = __DIR__ . '/Stubs/' . $subDir; + if (!is_dir($basePath)) { + mkdir($basePath, 0777, true); + } + $filePath = $basePath . '/' . $className . '.php'; + file_put_contents($filePath, $content); + return $filePath; + } + + protected function tearDown(): void + { + file_put_contents($this->definitionsFilePath, ' Date: Wed, 4 Jun 2025 12:58:55 +0100 Subject: [PATCH 03/39] chore: Remove pest-plugin-drift dependency from composer.json --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 4b70bf6..5d56a64 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "orchestra/pest-plugin-testbench": "^2.1", "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", - "pestphp/pest-plugin-drift": "^2.6", "pestphp/pest-plugin-laravel": "^2.0", "phpunit/phpunit": "^10.0 || ^11.0", "react/http": "^1.11" From 6b73e53bfb02a25fe3605621ffb426c92783c078 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 13:16:28 +0100 Subject: [PATCH 04/39] refactor: Enhance MCP server command handling and update README for transport options --- .github/workflows/tests.yml | 2 +- README.md | 7 +- src/Commands/ServeCommand.php | 111 +++++++++++++++---------- src/Http/Controllers/McpController.php | 3 +- src/McpServiceProvider.php | 34 ++++---- 5 files changed, 89 insertions(+), 68 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1cce97..1b40067 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: coverage: none - name: Install Composer dependencies - run: composer update --no-interaction --prefer-dist + run: composer install --no-interaction --prefer-dist - name: Run Tests run: composer test diff --git a/README.md b/README.md index edef830..31ba205 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,13 @@ All MCP server settings are managed in `config/mcp.php`. Here are the key sectio ### Transport Configuration * **`transports`**: Available communication methods * **`stdio`**: CLI-based transport - * `enabled`: Enable the `mcp:serve` command + * `enabled`: Enable the `mcp:serve` command with `stdio` option. * **`http_dedicated`**: Standalone HTTP server - * `enabled`, `host`, `port`, `path_prefix` settings + * `enabled`: Enable the `mcp:serve` command with `http` option. + * `host`, `port`, `path_prefix` settings * **`http_integrated`**: Laravel route-based server * `enabled`: Serve through Laravel routes - * `prefix`: URL prefix (default: 'mcp') + * `route_prefix`: URL prefix (default: 'mcp') * `middleware`: Applied middleware (default: 'web') ### Cache & Performance diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index f05ecde..12729e5 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -38,8 +38,20 @@ class ServeCommand extends Command * for reading STDIN and writing to STDOUT. */ public function handle(Server $server): int + { + $transportOption = $this->getTransportOption(); + + return match ($transportOption) { + 'stdio' => $this->handleStdioTransport($server), + 'http' => $this->handleHttpTransport($server), + default => $this->handleInvalidTransport($transportOption), + }; + } + + private function getTransportOption(): string { $transportOption = $this->option('transport'); + if ($transportOption === null) { if ($this->input->isInteractive()) { $transportOption = select( @@ -55,62 +67,71 @@ public function handle(Server $server): int } } - $host = $this->option('host'); - $port = $this->option('port'); - $pathPrefix = $this->option('path-prefix'); + return $transportOption; + } - if ($transportOption === 'stdio') { - if (! config('mcp.transports.stdio.enabled', true)) { - $this->error('MCP STDIO transport is disabled in config/mcp.php.'); + private function handleStdioTransport(Server $server): int + { + if (! config('mcp.transports.stdio.enabled', true)) { + $this->error('MCP STDIO transport is disabled in config/mcp.php.'); - return Command::FAILURE; - } + return Command::FAILURE; + } - $this->info('Starting MCP server with STDIO transport...'); + $this->info('Starting MCP server with STDIO transport...'); - try { - $transport = new StdioServerTransport; - $server->listen($transport); - } catch (\Exception $e) { - $this->error("Failed to start MCP server with STDIO transport: {$e->getMessage()}"); + try { + $transport = new StdioServerTransport; + $server->listen($transport); + } catch (\Exception $e) { + $this->error("Failed to start MCP server with STDIO transport: {$e->getMessage()}"); - return Command::FAILURE; - } - } elseif ($transportOption === 'http') { - if (! config('mcp.transports.http_dedicated.enabled', true)) { - $this->error('Dedicated MCP HTTP transport is disabled in config/mcp.php.'); + return Command::FAILURE; + } - return Command::FAILURE; - } + $this->info("MCP Server (STDIO) stopped."); - $host = $this->option('host') ?? config('mcp.transports.http_dedicated.host', '127.0.0.1'); - $port = (int) ($this->option('port') ?? config('mcp.transports.http_dedicated.port', 8090)); - $pathPrefix = $this->option('path-prefix') ?? config('mcp.transports.http_dedicated.path_prefix', 'mcp_server'); - $sslContextOptions = config('mcp.transports.http_dedicated.ssl_context_options'); // For HTTPS - - $this->info("Starting MCP server with dedicated HTTP transport on http://{$host}:{$port} (prefix: /{$pathPrefix})..."); - $transport = new HttpServerTransport( - host: $host, - port: $port, - mcpPathPrefix: $pathPrefix, - sslContext: $sslContextOptions - ); - - try { - $server->listen($transport); - } catch (\Exception $e) { - $this->error("Failed to start MCP server with dedicated HTTP transport: {$e->getMessage()}"); - - return Command::FAILURE; - } - } else { - $this->error("Invalid transport specified: {$transportOption}. Use 'stdio' or 'http'."); + return Command::SUCCESS; + } - return Command::INVALID; + private function handleHttpTransport(Server $server): int + { + if (! config('mcp.transports.http_dedicated.enabled', true)) { + $this->error('Dedicated MCP HTTP transport is disabled in config/mcp.php.'); + + return Command::FAILURE; } - $this->info("MCP Server ({$transportOption}) stopped."); + $host = $this->option('host') ?? config('mcp.transports.http_dedicated.host', '127.0.0.1'); + $port = (int) ($this->option('port') ?? config('mcp.transports.http_dedicated.port', 8090)); + $pathPrefix = $this->option('path-prefix') ?? config('mcp.transports.http_dedicated.path_prefix', 'mcp_server'); + $sslContextOptions = config('mcp.transports.http_dedicated.ssl_context_options'); // For HTTPS + + $this->info("Starting MCP server with dedicated HTTP transport on http://{$host}:{$port} (prefix: /{$pathPrefix})..."); + $transport = new HttpServerTransport( + host: $host, + port: $port, + mcpPathPrefix: $pathPrefix, + sslContext: $sslContextOptions + ); + + try { + $server->listen($transport); + } catch (\Exception $e) { + $this->error("Failed to start MCP server with dedicated HTTP transport: {$e->getMessage()}"); + + return Command::FAILURE; + } + + $this->info("MCP Server (HTTP) stopped."); return Command::SUCCESS; } + + private function handleInvalidTransport(string $transportOption): int + { + $this->error("Invalid transport specified: {$transportOption}. Use 'stdio' or 'http'."); + + return Command::INVALID; + } } diff --git a/src/Http/Controllers/McpController.php b/src/Http/Controllers/McpController.php index 3c46821..e63511f 100644 --- a/src/Http/Controllers/McpController.php +++ b/src/Http/Controllers/McpController.php @@ -121,7 +121,8 @@ public function handleSse(Request $request): Response } static $keepAliveCounter = 0; - if (($keepAliveCounter++ % (15 / $pollInterval)) == 0) { + $keepAliveInterval = (int) round(15 / $pollInterval); + if (($keepAliveCounter++ % $keepAliveInterval) == 0) { echo ": keep-alive\n\n"; $this->flushOutput(); } diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index 60c7e5d..ef3f2bd 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use PhpMcp\Laravel\Commands\DiscoverCommand; @@ -22,23 +23,6 @@ class McpServiceProvider extends ServiceProvider implements DeferrableProvider { - /** - * The event listener mappings for the application. - * - * @var array> - */ - protected $listen = [ - ToolsListChanged::class => [ - McpNotificationListener::class, - ], - ResourcesListChanged::class => [ - McpNotificationListener::class, - ], - PromptsListChanged::class => [ - McpNotificationListener::class, - ], - ]; - public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/mcp.php', 'mcp'); @@ -55,7 +39,12 @@ public function register(): void */ public function provides(): array { - return [Server::class, LaravelHttpTransport::class]; + return [ + McpRegistrar::class, + Server::class, + Registry::class, + LaravelHttpTransport::class, + ]; } public function boot(): void @@ -64,6 +53,7 @@ public function boot(): void $this->buildServer(); $this->bootConfig(); $this->bootRoutes(); + $this->bootEvents(); $this->bootCommands(); $this->bootEventListeners(); } @@ -166,6 +156,14 @@ protected function bootCommands(): void } } + protected function bootEvents(): void + { + Event::listen( + [ToolsListChanged::class, ResourcesListChanged::class, PromptsListChanged::class], + McpNotificationListener::class, + ); + } + protected function bootEventListeners(): void { $server = $this->app->make(Server::class); From 311142e04e0d87f74ecf79853e7053f8b5cf82d6 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 13:18:40 +0100 Subject: [PATCH 05/39] chore: Update test command in GitHub Actions --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b40067..c90d9a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,4 +35,4 @@ jobs: run: composer install --no-interaction --prefer-dist - name: Run Tests - run: composer test + run: composer run test From ba618b90d1f78f1ed9868f4f8358dc28efb9d583 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 13:22:32 +0100 Subject: [PATCH 06/39] chore: Add phpunit.xml configuration to VCS --- .github/workflows/tests.yml | 2 +- .gitignore | 1 - phpunit.xml | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 phpunit.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c90d9a1..950f8c5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: coverage: none - name: Install Composer dependencies - run: composer install --no-interaction --prefer-dist + run: composer update --no-interaction --prefer-dist - name: Run Tests run: composer run test diff --git a/.gitignore b/.gitignore index 6d108d7..0e52713 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /composer.lock # PHPUnit -/phpunit.xml .phpunit.result.cache # PHP CS Fixer diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..17993a8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + ./tests/Feature + + + + + ./app + ./src + + + + + + + + + + + + + + + + + + + + + From 0f173fe75ba64a0a19c3e2c8b0ef9b5a21f67947 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 13:29:33 +0100 Subject: [PATCH 07/39] refactor: Ensure environment variables are cast to appropriate types in MCP configuration --- config/mcp.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/config/mcp.php b/config/mcp.php index 82e580f..0167b63 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -44,8 +44,8 @@ '.git', ], 'definitions_file' => base_path('routes/mcp.php'), - 'auto_discover' => env('MCP_AUTO_DISCOVER', true), - 'save_to_cache' => env('MCP_DISCOVERY_SAVE_TO_CACHE', true), + 'auto_discover' => (bool) env('MCP_AUTO_DISCOVER', true), + 'save_to_cache' => (bool) env('MCP_DISCOVERY_SAVE_TO_CACHE', true), ], /* @@ -75,11 +75,11 @@ */ 'transports' => [ 'stdio' => [ - 'enabled' => env('MCP_STDIO_ENABLED', true), + 'enabled' => (bool) env('MCP_STDIO_ENABLED', true), ], 'http_dedicated' => [ - 'enabled' => env('MCP_HTTP_DEDICATED_ENABLED', true), + 'enabled' => (bool) env('MCP_HTTP_DEDICATED_ENABLED', true), 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), @@ -87,7 +87,7 @@ ], 'http_integrated' => [ - 'enabled' => env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), @@ -118,24 +118,24 @@ */ 'capabilities' => [ 'tools' => [ - 'enabled' => env('MCP_CAP_TOOLS_ENABLED', true), - 'listChanged' => env('MCP_CAP_TOOLS_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), + 'listChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), ], 'resources' => [ - 'enabled' => env('MCP_CAP_RESOURCES_ENABLED', true), - 'subscribe' => env('MCP_CAP_RESOURCES_SUBSCRIBE', true), - 'listChanged' => env('MCP_CAP_RESOURCES_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), + 'subscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), + 'listChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), ], 'prompts' => [ - 'enabled' => env('MCP_CAP_PROMPTS_ENABLED', true), - 'listChanged' => env('MCP_CAP_PROMPTS_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), + 'listChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), ], 'logging' => [ - 'enabled' => env('MCP_CAP_LOGGING_ENABLED', true), - 'setLevel' => env('MCP_CAP_LOGGING_SET_LEVEL', false), + 'enabled' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), + 'setLevel' => (bool) env('MCP_CAP_LOGGING_SET_LEVEL', false), ], ], From 12164c0582def9d58a46b61a1bb333dd2de44771 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 13:50:50 +0100 Subject: [PATCH 08/39] fix: explicitly provide discovery directory in tests --- config/mcp.php | 2 +- phpunit.xml | 2 +- tests/TestCase.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/mcp.php b/config/mcp.php index 0167b63..d8952c5 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -29,7 +29,7 @@ */ 'discovery' => [ 'base_path' => base_path(), - 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_PATH', 'app/Mcp'))), + 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_DIRECTORIES', 'app/Mcp'))), 'exclude_dirs' => [ 'vendor', 'tests', diff --git a/phpunit.xml b/phpunit.xml index 17993a8..49fc3c2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,7 +28,7 @@ - + diff --git a/tests/TestCase.php b/tests/TestCase.php index f4c426e..3a4bcb0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace PhpMcp\Laravel\Tests; +use Illuminate\Config\Repository as ConfigRepository; use Orchestra\Testbench\TestCase as OrchestraTestCase; use PhpMcp\Laravel\McpServiceProvider; @@ -27,9 +28,8 @@ protected function defineEnvironment($app): void $this->definitionsFilePath = __DIR__ . '/Stubs/routes/mcp-definitions.php'; $app['config']->set('mcp.discovery.definitions_file', $this->definitionsFilePath); + $app['config']->set('mcp.discovery.directories', ['App/Mcp']); $app['config']->set('mcp.discovery.base_path', __DIR__ . '/Stubs'); - - $app['config']->set('mcp.logging.channel', 'null'); } /** From d034ff76e31c37156a6fbba9364dcbf9cbf736f6 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 4 Jun 2025 14:31:47 +0100 Subject: [PATCH 09/39] docs: Expand README.md with detailed explanations of MCP registration methods and their configurations [skip ci] --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 31ba205..a9ec83d 100644 --- a/README.md +++ b/README.md @@ -94,27 +94,61 @@ PHP MCP Laravel provides two approaches to define your MCP elements: manual regi ### Manual Registration -The recommended approach is using the fluent `Mcp` facade to manually register your elements in `routes/mcp.php` (this path can be changed in config/mcp.php via the discovery.definitions_file key): +The recommended approach is using the fluent `Mcp` facade to manually register your elements in `routes/mcp.php` (this path can be changed in config/mcp.php via the discovery.definitions_file key). ```php -// routes/mcp.php +Mcp::tool([MyHandlers::class, 'calculateSum']); -use App\Mcp\MyLaravelTools; -use App\Mcp\TopicSummarizer; -use PhpMcp\Laravel\Server\Facades\Mcp; +Mcp::resource( 'status://app/health', [MyHandlers::class, 'getAppStatus']); -Mcp::tool('laravel_adder', [MyLaravelTools::class, 'add']) - ->description('Adds two numbers using Laravel.'); +Mcp::prompt(MyInvokableTool::class); -Mcp::resource('config://app/name', [MyLaravelTools::class, 'getAppName']) - ->name('laravel_app_name') - ->mimeType('text/plain'); +Mcp::resourceTemplate('user://{id}/data', [MyHandlers::class, 'getUserData']); +``` -Mcp::prompt('topic_summarizer', TopicSummarizer::class); +The facade provides several registration methods, each with optional fluent configuration methods: + +#### Tools (`Mcp::tool()`) + +Defines an action or function the MCP client can invoke. Register a tool by providing either: +- Just the handler: `Mcp::tool(MyTool::class)` +- Name and handler: `Mcp::tool('my_tool', [MyClass::class, 'method'])` + +Available configuration methods: +- `name()`: Override the inferred name +- `description()`: Set a custom description + +#### Resources (`Mcp::resource()`) + +Defines a specific, static piece of content or data identified by a URI. Register a resource by providing: +- `$uri` (`required`): The unique URI for this resource instance (e.g., `config://app/settings`). +- `$handler`: The handler that will return the resource's content. + +Available configuration methods: +- `name(string $name): self`: Sets a human-readable name. Inferred if omitted. +- `description(string $description): self`: Sets a description. Inferred if omitted. +- `mimeType(string $mimeType): self`: Specifies the resource's MIME type. Can sometimes be inferred from the handler's return type or content. +- `size(?int $size): self`: Specifies the resource size in bytes, if known. +- `annotations(array $annotations): self`: Adds MCP-standard annotations (e.g., ['audience' => ['user']]). + +#### Resource Template (`Mcp::resourceTemplate()`) + +Defines a handler for resource URIs that contain variable parts, allowing dynamic resource instance generation. Register a resource template by providing: +- `$uriTemplate` (`required`): The URI template string (`RFC 6570`), e.g., `user://{userId}/profile`. +- `$handler`: The handler method. Its parameters must match the variables in the `$uriTemplate`. + +Available configuration methods: +- `name(string $name): self`: Sets a human-readable name for the template type. +- `description(string $description): self`: Sets a description for the template. +- `mimeType(string $mimeType): self`: Sets a default MIME type for resources resolved by this template. +- `annotations(array $annotations): self`: Adds MCP-standard annotations. + +#### Prompts (`Mcp::prompt()`) + +Defines a generator for MCP prompt messages, often used to construct conversations for an LLM. Register a prompt by providing just the handler, or the name and handler. +- `$name` (`optional`): The MCP prompt name. Inferred if omitted. +- `$handler`: The handler method. Its parameters become the prompt's input arguments. -Mcp::resourceTemplate('user://{userId}/profile', [MyLaravelTools::class, 'getUserProfile']) - ->mimeType('application/json'); -``` The package automatically resolves handlers through Laravel's service container, allowing you to inject dependencies through constructor injection. Each registration method accepts either an invokable class or a `[class, method]` array. @@ -124,7 +158,7 @@ Manually registered elements are always available regardless of cache status and ### Attribute-Based Discovery -As an alternative, you can use PHP 8 attributes to mark your methods or invokable classes as MCP elements: +As an alternative, you can use PHP 8 attributes to mark your methods or invokable classes as MCP elements. That way, you don't have to manually register them in the definitions file: ```php namespace App\Mcp; @@ -148,7 +182,7 @@ class DiscoveredElements } ``` -In development environments with `auto_discover` enabled in your config, these elements are automatically discovered when needed. For production or to manually trigger discovery, run: +When `auto_discover` enabled in your config, these elements are automatically discovered when needed. For production or to manually trigger discovery, run: ```bash php artisan mcp:discover From b4b99a2c3a42f38bf126a52b06774b979fe66ede Mon Sep 17 00:00:00 2001 From: CodeWithKyrian <48791154+CodeWithKyrian@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:43:25 +0000 Subject: [PATCH 10/39] Update CHANGELOG --- CHANGELOG.md | 128 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7d523..dc66dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,86 @@ All notable changes to `php-mcp/laravel` will be documented in this file. +## v2.0.0 - 2025-06-04 + +This release marks a **major overhaul**, bringing it into full alignment with `php-mcp/server` v2.1.0+ and introducing a significantly improved, more "Laravely" developer experience. + +### Added + +* **Fluent Manual Registration API:** + * Introduced the `Mcp` Facade (`PhpMcp\Laravel\Facades\Mcp`). + * Define Tools, Resources, Prompts, and Resource Templates fluently (e.g., `Mcp::tool(...)->description(...)`). + * Definitions are typically placed in `routes/mcp.php` (configurable). + * Handlers are resolved via Laravel's service container, allowing dependency injection. + +* **Dedicated HTTP Server Transport via `mcp:serve`:** + * The `php artisan mcp:serve --transport=http` command now launches a standalone, high-performance ReactPHP-based HTTP server using `\PhpMcp\Server\Transports\HttpServerTransport`. + * Configuration for this dedicated server is in `config/mcp.php` under `transports.http_dedicated`. + * CLI options (`--host`, `--port`, `--path-prefix`) can override config defaults. + +* **`LaravelHttpTransport` for Integrated HTTP:** + * New `PhpMcp\Laravel\Transports\LaravelHttpTransport` class implements `ServerTransportInterface` to bridge Laravel's HTTP request lifecycle with the core MCP `Protocol` handler. + +* **Configurable Auto-Discovery:** + * `config('mcp.discovery.auto_discover')` (default: `true`) now controls whether discovery runs automatically or not. You can set it to false in production.. + +* **Interactive Prompt for `mcp:serve`:** If `--transport` is not specified, the command now interactively prompts the user to choose between `stdio` and `http`. + +### Changed + +* **Core Server Integration:** Now uses `\PhpMcp\Server\Server::make()` (ServerBuilder) for all server instantiation, fully leveraging `php-mcp/server` v2.x architecture. +* **Namespace:** Base package namespace changed from `PhpMcp\Laravel\Server` to **`PhpMcp\Laravel`**. +* **Configuration (`config/mcp.php`):** + * Significantly restructured and updated to align with `ServerBuilder` options. + * Clearer separation of settings for `http_dedicated` vs. `http_integrated` transports. + * Simplified cache TTL (`cache.ttl`) and discovery (`discovery.save_to_cache_on_discover`) keys. + * Added `server.instructions` for the `initialize` MCP response. + * Added `discovery.exclude_dirs` and `discovery.definitions_file`. + +* **`McpServiceProvider`:** + * Completely rewritten to correctly build and configure the `\PhpMcp\Server\Server` instance using Laravel's services for logging, caching (with fallback to core `FileCache`), container, and event loop. + * Loads manual definitions from the configured `definitions_file` via `McpRegistrar`. + * Sets up core `Registry` notifiers to dispatch Laravel events for list changes. + +* **`McpController` (Integrated HTTP):** More robustly handles the integrated server behavior, working with a custom `LaravelHttpTransport`. +* **Artisan Commands:** + * `mcp:discover`: Now directly calls `Server::discover()` with configured/CLI parameters. `force` option behavior clarified. + * `mcp:list`: Fetches elements from the live, fully configured `Registry` from the resolved `Server` instance. + * `mcp:serve`: Refactored to use core `StdioServerTransport` or `HttpServerTransport` directly. + +* **Dependency:** Updated `php-mcp/server` to `^2.2.0` + +### Fixed + +* More robust error handling and logging in Artisan commands and `McpController`. +* Improved clarity and consistency in how core server components are resolved and used within the Laravel context. + +### Removed + +* `PhpMcp\Laravel\Server\Adapters\ConfigAdapter`: No longer needed due to changes in `php-mcp/server` v2.x. + +### BREAKING CHANGES + +* **Namespace Change:** The primary package namespace has changed from `PhpMcp\Laravel\Server` to `PhpMcp\Laravel`. Update all `use` statements and FQCN references in your application. You may have to uninstall and reinstall the package to avoid conflicts. +* **Configuration File:** The `config/mcp.php` file has been significantly restructured. You **must** republish and merge your customizations: + ```bash + php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" --force + + ``` +* **`mcp:serve` for HTTP:** The `--transport=http` option for `mcp:serve` now launches a *dedicated* ReactPHP-based server process. For serving MCP via your main Laravel application routes, ensure the `http_integrated` transport is enabled in `config/mcp.php` and your web server is configured appropriately. +* **Event Handling:** If you were directly listening to internal events from the previous version, these may have changed. Rely on the documented Laravel events (`ToolsListChanged`, etc.). +* **Removed Classes:** `PhpMcp\Laravel\Server\Adapters\ConfigAdapter` is removed. + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/1.1.1...2.0.0 + ## v1.1.1 - 2025-05-12 ### What's Changed + * McpServiceProvider File And loadElements function are not found by @tsztodd in https://github.com/php-mcp/laravel/pull/2 ### New Contributors + * @tsztodd made their first contribution in https://github.com/php-mcp/laravel/pull/2 **Full Changelog**: https://github.com/php-mcp/laravel/compare/1.1.0...1.1.1 @@ -18,13 +92,13 @@ This release updates the package for compatibility with `php-mcp/server` v1.1.0. ### What Changed -* Updated dependency requirement to `php-mcp/server: ^1.1.0`. -* Modified `McpServiceProvider` to correctly provide `ConfigurationRepositoryInterface`, `LoggerInterface`, and `CacheInterface` bindings to the underlying `Server` instance when resolved from the Laravel container. -* Updated `ServeCommand` and `McpController` to inject the `Server` instance and instantiate `TransportHandler` classes according to `php-mcp/server` v1.1.0 constructor changes. +* Updated dependency requirement to `php-mcp/server: ^1.1.0`. +* Modified `McpServiceProvider` to correctly provide `ConfigurationRepositoryInterface`, `LoggerInterface`, and `CacheInterface` bindings to the underlying `Server` instance when resolved from the Laravel container. +* Updated `ServeCommand` and `McpController` to inject the `Server` instance and instantiate `TransportHandler` classes according to `php-mcp/server` v1.1.0 constructor changes. ### Fixed -* Ensures compatibility with the refactored dependency injection and transport handler instantiation logic in `php-mcp/server` v1.1.0. +* Ensures compatibility with the refactored dependency injection and transport handler instantiation logic in `php-mcp/server` v1.1.0. **Full Changelog**: https://github.com/php-mcp/laravel/compare/1.0.0...1.1.0 @@ -36,14 +110,14 @@ Welcome to the first release of `php-mcp/laravel`! This package provides seamles ## Key Features -* **Effortless Integration:** Automatically wires up Laravel's Cache, Logger, and Service Container for use by the MCP server. -* **Attribute-Based Definition:** Define MCP tools, resources, and prompts using PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) within your Laravel application structure. Leverage Laravel's Dependency Injection within your MCP element classes. -* **Configuration:** Provides a publishable configuration file (`config/mcp.php`) for fine-grained control over discovery, transports, caching, and capabilities. -* **Artisan Commands:** Includes commands for element discovery (`mcp:discover`), listing discovered elements (`mcp:list`), and running the server via stdio (`mcp:serve`). -* **HTTP+SSE Transport:** Sets up routes (`/mcp/message`, `/mcp/sse` by default) and a controller for handling MCP communication over HTTP, enabling browser-based clients and other HTTP consumers. -* **Automatic Discovery (Dev):** Automatically discovers MCP elements in development environments on first use, improving developer experience (no need to manually run `mcp:discover` after changes). -* **Manual Discovery (Prod):** Requires manual discovery (`mcp:discover`) for production environments, ensuring optimal performance via caching (integrates well with deployment workflows). -* **Event Integration:** Dispatches Laravel events (`ToolsListChanged`, `ResourcesListChanged`, `PromptsListChanged`) when element lists change, allowing for custom integrations or notifications. +* **Effortless Integration:** Automatically wires up Laravel's Cache, Logger, and Service Container for use by the MCP server. +* **Attribute-Based Definition:** Define MCP tools, resources, and prompts using PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) within your Laravel application structure. Leverage Laravel's Dependency Injection within your MCP element classes. +* **Configuration:** Provides a publishable configuration file (`config/mcp.php`) for fine-grained control over discovery, transports, caching, and capabilities. +* **Artisan Commands:** Includes commands for element discovery (`mcp:discover`), listing discovered elements (`mcp:list`), and running the server via stdio (`mcp:serve`). +* **HTTP+SSE Transport:** Sets up routes (`/mcp/message`, `/mcp/sse` by default) and a controller for handling MCP communication over HTTP, enabling browser-based clients and other HTTP consumers. +* **Automatic Discovery (Dev):** Automatically discovers MCP elements in development environments on first use, improving developer experience (no need to manually run `mcp:discover` after changes). +* **Manual Discovery (Prod):** Requires manual discovery (`mcp:discover`) for production environments, ensuring optimal performance via caching (integrates well with deployment workflows). +* **Event Integration:** Dispatches Laravel events (`ToolsListChanged`, `ResourcesListChanged`, `PromptsListChanged`) when element lists change, allowing for custom integrations or notifications. ## Installation @@ -55,27 +129,29 @@ composer require php-mcp/laravel # 2. Publish the configuration file (optional but recommended) php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" -``` +``` ## Getting Started -1. **Define Elements:** Create PHP classes with methods annotated with `#[McpTool]`, `#[McpResource]`, etc., within directories specified in `config/mcp.php` (e.g., `app/Mcp`). Inject dependencies as needed. See [Defining MCP Elements](https://github.com/php-mcp/laravel/blob/main/README.md#defining-mcp-elements). -2. **Discovery:** - * In development, discovery runs automatically when needed. - * In production, run `php artisan mcp:discover` during your deployment process. See [Automatic Discovery vs. Manual Discovery](https://github.com/php-mcp/laravel/blob/main/README.md#automatic-discovery-development-vs-manual-discovery-production). -3. **Run the Server:** - * For **Stdio Transport:** Use `php artisan mcp:serve` and configure your client to execute this command (using the full path to `artisan`). - * For **HTTP+SSE Transport:** Ensure `transports.http.enabled` is true, run your Laravel app on a suitable web server (Nginx+FPM, Octane, etc. - **not** `php artisan serve`), exclude the MCP route from CSRF protection, and configure your client with the SSE URL (e.g., `http://your-app.test/mcp/sse`). See [Running the Server](https://github.com/php-mcp/laravel/blob/main/README.md#running-the-server) for critical details. +1. **Define Elements:** Create PHP classes with methods annotated with `#[McpTool]`, `#[McpResource]`, etc., within directories specified in `config/mcp.php` (e.g., `app/Mcp`). Inject dependencies as needed. See [Defining MCP Elements](https://github.com/php-mcp/laravel/blob/main/README.md#defining-mcp-elements). +2. **Discovery:** + * In development, discovery runs automatically when needed. + * In production, run `php artisan mcp:discover` during your deployment process. See [Automatic Discovery vs. Manual Discovery](https://github.com/php-mcp/laravel/blob/main/README.md#automatic-discovery-development-vs-manual-discovery-production). + +3. **Run the Server:** + * For **Stdio Transport:** Use `php artisan mcp:serve` and configure your client to execute this command (using the full path to `artisan`). + * For **HTTP+SSE Transport:** Ensure `transports.http.enabled` is true, run your Laravel app on a suitable web server (Nginx+FPM, Octane, etc. - **not** `php artisan serve`), exclude the MCP route from CSRF protection, and configure your client with the SSE URL (e.g., `http://your-app.test/mcp/sse`). See [Running the Server](https://github.com/php-mcp/laravel/blob/main/README.md#running-the-server) for critical details. + ## Important Notes -* **HTTP Transport Server Requirement:** The standard `php artisan serve` development server is **not suitable** for the HTTP+SSE transport due to its single-process nature. Use a proper web server setup like Nginx/Apache + PHP-FPM or Laravel Octane. -* **CSRF Exclusion:** If using the default `web` middleware group for the HTTP transport, you **must** exclude the MCP message route (default: `mcp` or `mcp/*`) from CSRF protection in your application to avoid `419` errors. -* **Dependencies:** Requires PHP >= 8.1 and Laravel >= 10.0. +* **HTTP Transport Server Requirement:** The standard `php artisan serve` development server is **not suitable** for the HTTP+SSE transport due to its single-process nature. Use a proper web server setup like Nginx/Apache + PHP-FPM or Laravel Octane. +* **CSRF Exclusion:** If using the default `web` middleware group for the HTTP transport, you **must** exclude the MCP message route (default: `mcp` or `mcp/*`) from CSRF protection in your application to avoid `419` errors. +* **Dependencies:** Requires PHP >= 8.1 and Laravel >= 10.0. ## Links -* **GitHub Repository:** https://github.com/php-mcp/laravel -* **Packagist:** https://packagist.org/packages/php-mcp/laravel +* **GitHub Repository:** https://github.com/php-mcp/laravel +* **Packagist:** https://packagist.org/packages/php-mcp/laravel -Please report any issues or provide feedback on the GitHub repository. \ No newline at end of file +Please report any issues or provide feedback on the GitHub repository. From a78c433b711ea084c230b549e4f0083074ea0fb9 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 5 Jun 2025 13:58:20 -0500 Subject: [PATCH 11/39] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9ec83d..a88cf4a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Seamlessly integrate the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) into your Laravel applications.** -This package is the official Laravel wrapper for the powerful [`php-mcp/server`](https://github.com/php-mcp/server) library. It allows you to effortlessly expose parts of your Laravel application as MCP **Tools**, **Resources**, and **Prompts**, enabling standardized communication with AI assistants like Anthropic's Claude, Cursor IDE, and others. +This package is a Laravel compatible wrapper for the powerful [`php-mcp/server`](https://github.com/php-mcp/server) library. It allows you to effortlessly expose parts of your Laravel application as MCP **Tools**, **Resources**, and **Prompts**, enabling standardized communication with AI assistants like Anthropic's Claude, Cursor IDE, and others. **Key Features:** @@ -333,4 +333,4 @@ Please see [CONTRIBUTING.md](CONTRIBUTING.md) in the main [`php-mcp/server`](htt ## License -The MIT License (MIT). See [LICENSE](LICENSE). \ No newline at end of file +The MIT License (MIT). See [LICENSE](LICENSE). From 3cf3a67466cc7946fcec2793edfabc4a9891386c Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 12 Jun 2025 13:34:08 +0100 Subject: [PATCH 12/39] docs: Update README.md to correct service provider namespace and event imports --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a88cf4a..2ad3b32 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This package utilizes `php-mcp/server` v2.1.0+ which supports the `2024-11-05` v 2. **Publish Configuration:** ```bash - php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" + php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" ``` ## Configuration @@ -303,9 +303,9 @@ If your available MCP elements or resource content change while the server is ru * **List Changes (Tools, Resources, Prompts):** Dispatch the corresponding Laravel event. The package includes listeners to send the appropriate MCP notification. ```php - use PhpMcp\Laravel\Server\Events\ToolsListChanged; - use PhpMcp\Laravel\Server\Events\ResourcesListChanged; - use PhpMcp\Laravel\Server\Events\PromptsListChanged; + use PhpMcp\Laravel\Events\ToolsListChanged; + use PhpMcp\Laravel\Events\ResourcesListChanged; + use PhpMcp\Laravel\Events\PromptsListChanged; ToolsListChanged::dispatch(); // ResourcesListChanged::dispatch(); @@ -313,9 +313,9 @@ If your available MCP elements or resource content change while the server is ru ``` * **Specific Resource Content Update:** - Dispatch the `PhpMcp\Laravel\Server\Events\ResourceUpdated` event with the URI of the changed resource. + Dispatch the `PhpMcp\Laravel\Events\ResourceUpdated` event with the URI of the changed resource. ```php - use PhpMcp\Laravel\Server\Events\ResourceUpdated; + use PhpMcp\Laravel\Events\ResourceUpdated; $resourceUri = 'file:///path/to/updated_file.txt'; // ... your logic that updates the resource ... From 7486ff16787ea5778c7e6fa63e0b39c4d5fa2dbb Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 9 Jun 2025 11:06:23 +0200 Subject: [PATCH 13/39] [docs] Remove config call from app/bootstrap.php --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ad3b32..e74372d 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ For Laravel 11+: // bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ - config('mcp.transports.http_integrated.route_prefix') . '/message', + 'mcp/message', // Adjust if you changed the route prefix ]); }) ``` From 15386d7626641e43c3060ea7264ec8ddb6d53076 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 9 Jun 2025 11:22:30 +0200 Subject: [PATCH 14/39] Do not defer ServiceProvider to boot routes --- src/McpServiceProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index ef3f2bd..82e1cbe 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -5,7 +5,6 @@ namespace PhpMcp\Laravel; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -21,7 +20,7 @@ use PhpMcp\Server\Registry; use PhpMcp\Server\Server; -class McpServiceProvider extends ServiceProvider implements DeferrableProvider +class McpServiceProvider extends ServiceProvider { public function register(): void { From 33cc1fa738ea764f73fd55a2d7705e96eef0fcc8 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 13 Jun 2025 07:58:44 +0100 Subject: [PATCH 15/39] fix: Correct client state handling in LaravelHttpTransport Removes in-memory active client tracking (`$activeClients` array and associated event listeners) from `LaravelHttpTransport`. This local tracking was unreliable due to Laravel's typical request-response lifecycle where the transport instance is re-initialized per interaction. The `ClientStateManager`, which uses a persistent cache, is already responsible for managing active client state and activity across requests. The `LaravelHttpTransport` now correctly relies solely on `ClientStateManager` for queuing messages (`sendToClientAsync`) and updates client activity via `ClientStateManager` when messages are received. --- src/Transports/LaravelHttpTransport.php | 28 +------------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/LaravelHttpTransport.php index 0fe7043..29ccf3f 100644 --- a/src/Transports/LaravelHttpTransport.php +++ b/src/Transports/LaravelHttpTransport.php @@ -7,13 +7,11 @@ use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; -use PhpMcp\Server\Exception\TransportException; use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\Promise\PromiseInterface; -use function React\Promise\reject; use function React\Promise\resolve; class LaravelHttpTransport implements ServerTransportInterface, LoggerAwareInterface @@ -24,23 +22,11 @@ class LaravelHttpTransport implements ServerTransportInterface, LoggerAwareInter protected ClientStateManager $clientStateManager; - /** @var array Tracks active client IDs managed by this transport */ - private array $activeClients = []; - public function __construct(ClientStateManager $clientStateManager) { $this->clientStateManager = $clientStateManager; $this->logger = new NullLogger; - $this->on('client_connected', function (string $clientId) { - $this->activeClients[$clientId] = true; - $this->clientStateManager->updateClientActivity($clientId); - }); - - $this->on('client_disconnected', function (string $clientId, string $reason) { - unset($this->activeClients[$clientId]); - }); - $this->on('message', function (string $message, string $clientId) { $this->clientStateManager->updateClientActivity($clientId); }); @@ -68,12 +54,6 @@ public function listen(): void */ public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface { - if (! isset($this->activeClients[$clientId])) { - $this->logger->warning('Attempted to send message to inactive or unknown client.', ['clientId' => $clientId]); - - return reject(new TransportException("Client '{$clientId}' is not actively managed by this transport.")); - } - $messagePayload = rtrim($rawFramedMessage, "\n"); if (empty($messagePayload)) { @@ -90,13 +70,7 @@ public function sendToClientAsync(string $clientId, string $rawFramedMessage): P */ public function close(): void { - $activeClientIds = array_keys($this->activeClients); - - foreach ($activeClientIds as $clientId) { - $this->emit('client_disconnected', [$clientId, 'Transport globally closed']); - $this->emit('close', ['Transport closed.']); - } - + $this->emit('close', ['Transport closed.']); $this->removeAllListeners(); } } From 451741bc50ed9482b92b5fac238c0506dd710225 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 13 Jun 2025 12:10:31 +0100 Subject: [PATCH 16/39] chore: Update dependencies and improve MCP notification handling - Bump `php-mcp/server` to version `^2.3` - Refactor `McpNotificationListener` to utilize the `Registry` for handling notifications, removing reliance on `ClientStateManager`. - Clean up unused methods and improve event handling logic in `McpServiceProvider` and `McpNotificationListener`. --- composer.json | 5 +- samples/basic/composer.json | 2 +- samples/basic/composer.lock | 119 +++++++++++----------- src/Listeners/McpNotificationListener.php | 52 ++-------- src/McpServiceProvider.php | 16 +-- 5 files changed, 79 insertions(+), 115 deletions(-) diff --git a/composer.json b/composer.json index 5d56a64..12851e3 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^2.2" + "php-mcp/server": "^2.3" }, "require-dev": { "laravel/pint": "^1.13", @@ -30,8 +30,7 @@ "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", - "phpunit/phpunit": "^10.0 || ^11.0", - "react/http": "^1.11" + "phpunit/phpunit": "^10.0 || ^11.0" }, "autoload": { "psr-4": { diff --git a/samples/basic/composer.json b/samples/basic/composer.json index 8b9368d..23dae59 100644 --- a/samples/basic/composer.json +++ b/samples/basic/composer.json @@ -12,7 +12,7 @@ "php": "^8.2", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", - "php-mcp/laravel": "*" + "php-mcp/laravel": "dev-main" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index 87bfb12..2e51e65 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0657b476192ef74ed23544ff24c8dac5", + "content-hash": "26187052f3195fd4e3e90385de4d0446", "packages": [ { "name": "brick/math", @@ -1207,16 +1207,16 @@ }, { "name": "laravel/framework", - "version": "v12.17.0", + "version": "v12.18.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "8729d084510480fdeec9b6ad198180147d4a7f06" + "reference": "7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/8729d084510480fdeec9b6ad198180147d4a7f06", - "reference": "8729d084510480fdeec9b6ad198180147d4a7f06", + "url": "https://api.github.com/repos/laravel/framework/zipball/7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d", + "reference": "7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d", "shasum": "" }, "require": { @@ -1418,7 +1418,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-03T14:04:18+00:00" + "time": "2025-06-10T14:48:34+00:00" }, { "name": "laravel/prompts", @@ -2262,16 +2262,16 @@ }, { "name": "nesbot/carbon", - "version": "3.9.1", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ced71f79398ece168e24f7f7710462f462310d4d" + "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d", - "reference": "ced71f79398ece168e24f7f7710462f462310d4d", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", + "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", "shasum": "" }, "require": { @@ -2279,9 +2279,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2289,14 +2289,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.57.2", + "friendsofphp/php-cs-fixer": "^3.75.0", "kylekatarnls/multi-tester": "^2.5.3", - "ondrejmirtes/better-reflection": "^6.25.0.4", "phpmd/phpmd": "^2.15.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.11.2", - "phpunit/phpunit": "^10.5.20", - "squizlabs/php_codesniffer": "^3.9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" }, "bin": [ "bin/carbon" @@ -2364,7 +2363,7 @@ "type": "tidelift" } ], - "time": "2025-05-01T19:51:51+00:00" + "time": "2025-06-12T10:24:28+00:00" }, { "name": "nette/schema", @@ -2855,12 +2854,12 @@ "dist": { "type": "path", "url": "../..", - "reference": "44b8217a32cb8031bf2ed8c9000e007a24c33cba" + "reference": "cdf7fbadc4830e0f01ffef363473e98f1ec3e8f4" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^2.2" + "php-mcp/server": "^2.3" }, "require-dev": { "laravel/pint": "^1.13", @@ -2868,10 +2867,8 @@ "orchestra/pest-plugin-testbench": "^2.1", "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", - "pestphp/pest-plugin-drift": "^2.6", "pestphp/pest-plugin-laravel": "^2.0", - "phpunit/phpunit": "^10.0 || ^11.0", - "react/http": "^1.11" + "phpunit/phpunit": "^10.0 || ^11.0" }, "type": "library", "extra": { @@ -2928,16 +2925,16 @@ }, { "name": "php-mcp/server", - "version": "2.2.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "9892dd32793a6dff324c5024d812645d10cdf786" + "reference": "686cac47af096907179ebf9ab38c9d5a75c5aa6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mcp/server/zipball/9892dd32793a6dff324c5024d812645d10cdf786", - "reference": "9892dd32793a6dff324c5024d812645d10cdf786", + "url": "https://api.github.com/repos/php-mcp/server/zipball/686cac47af096907179ebf9ab38c9d5a75c5aa6f", + "reference": "686cac47af096907179ebf9ab38c9d5a75c5aa6f", "shasum": "" }, "require": { @@ -2950,6 +2947,7 @@ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", "react/http": "^1.11", + "react/promise": "^3.0", "react/stream": "^1.4", "symfony/finder": "^6.4 || ^7.2" }, @@ -2990,9 +2988,9 @@ ], "support": { "issues": "https://github.com/php-mcp/server/issues", - "source": "https://github.com/php-mcp/server/tree/2.2.0" + "source": "https://github.com/php-mcp/server/tree/2.3.1" }, - "time": "2025-06-03T23:05:08+00:00" + "time": "2025-06-13T11:04:31+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -7250,16 +7248,16 @@ }, { "name": "filp/whoops", - "version": "2.18.1", + "version": "2.18.2", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26" + "reference": "89dabca1490bc77dbcab41c2b20968c7e44bf7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26", - "reference": "8fcc6a862f2e7b94eb4221fd0819ddba3d30ab26", + "url": "https://api.github.com/repos/filp/whoops/zipball/89dabca1490bc77dbcab41c2b20968c7e44bf7c3", + "reference": "89dabca1490bc77dbcab41c2b20968c7e44bf7c3", "shasum": "" }, "require": { @@ -7309,7 +7307,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.1" + "source": "https://github.com/filp/whoops/tree/2.18.2" }, "funding": [ { @@ -7317,7 +7315,7 @@ "type": "github" } ], - "time": "2025-06-03T18:56:14+00:00" + "time": "2025-06-11T20:42:19+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7432,16 +7430,16 @@ }, { "name": "laravel/pail", - "version": "v1.2.2", + "version": "v1.2.3", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2" + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/f31f4980f52be17c4667f3eafe034e6826787db2", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", "shasum": "" }, "require": { @@ -7461,7 +7459,7 @@ "orchestra/testbench-core": "^8.13|^9.0|^10.0", "pestphp/pest": "^2.20|^3.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, "type": "library", @@ -7497,6 +7495,7 @@ "description": "Easily delve into your Laravel application's log files directly from the command line.", "homepage": "https://github.com/laravel/pail", "keywords": [ + "dev", "laravel", "logs", "php", @@ -7506,7 +7505,7 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-01-28T15:15:15+00:00" + "time": "2025-06-05T13:55:57+00:00" }, { "name": "laravel/pint", @@ -7782,23 +7781,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.0", + "version": "v8.8.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8" + "reference": "44ccb82e3e21efb5446748d2a3c81a030ac22bd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/4cf9f3b47afff38b139fb79ce54fc71799022ce8", - "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/44ccb82e3e21efb5446748d2a3c81a030ac22bd5", + "reference": "44ccb82e3e21efb5446748d2a3c81a030ac22bd5", "shasum": "" }, "require": { - "filp/whoops": "^2.18.0", - "nunomaduro/termwind": "^2.3.0", + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", "php": "^8.2.0", - "symfony/console": "^7.2.5" + "symfony/console": "^7.3.0" }, "conflict": { "laravel/framework": "<11.44.2 || >=13.0.0", @@ -7806,15 +7805,15 @@ }, "require-dev": { "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.2", - "laravel/framework": "^11.44.2 || ^12.6", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^4.0.8", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.1", - "pestphp/pest": "^3.8.0", - "sebastian/environment": "^7.2.0 || ^8.0" + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", "extra": { @@ -7877,7 +7876,7 @@ "type": "patreon" } ], - "time": "2025-04-03T14:33:09+00:00" + "time": "2025-06-11T01:04:21+00:00" }, { "name": "pestphp/pest", @@ -9993,7 +9992,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "php-mcp/laravel": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Listeners/McpNotificationListener.php b/src/Listeners/McpNotificationListener.php index d3a9630..32e564a 100644 --- a/src/Listeners/McpNotificationListener.php +++ b/src/Listeners/McpNotificationListener.php @@ -3,62 +3,32 @@ namespace PhpMcp\Laravel\Listeners; use PhpMcp\Laravel\Events\McpNotificationEvent; +use PhpMcp\Laravel\Events\PromptsListChanged; use PhpMcp\Laravel\Events\ResourceUpdated; -use PhpMcp\Server\Server; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Laravel\Events\ResourcesListChanged; +use PhpMcp\Laravel\Events\ToolsListChanged; +use PhpMcp\Server\Registry; /** * Handles routing MCP notifications to the appropriate transport. */ class McpNotificationListener { - private ClientStateManager $clientStateManager; /** * Create a new event handler instance. */ - public function __construct( - private Server $server - ) { - $this->clientStateManager = $server->getClientStateManager(); - } + public function __construct(private Registry $registry) {} /** * Handle the event. */ public function handle(McpNotificationEvent $event): void { - if ($event instanceof ResourceUpdated) { - $this->handleResourceUpdated($event); - - return; - } - - $this->handleListChanged($event); - } - - /** - * Handle resource updated notifications. - */ - private function handleResourceUpdated(ResourceUpdated $event): void - { - $subscribers = $this->clientStateManager->getResourceSubscribers($event->uri); - - $message = json_encode($event->toNotification()->toArray()); - foreach ($subscribers as $clientId) { - $this->clientStateManager->queueMessage($clientId, $message); - } - } - - /** - * Handle list changed notifications (tools, prompts and resources) - */ - private function handleListChanged(McpNotificationEvent $event): void - { - $activeClients = $this->clientStateManager->getActiveClients(); - - $message = json_encode($event->toNotification()->toArray()); - foreach ($activeClients as $clientId) { - $this->clientStateManager->queueMessage($clientId, $message); - } + match (true) { + $event instanceof ResourceUpdated => $this->registry->notifyResourceUpdated($event->uri), + $event instanceof ToolsListChanged => $this->registry->notifyToolsListChanged(), + $event instanceof ResourcesListChanged => $this->registry->notifyResourcesListChanged(), + $event instanceof PromptsListChanged => $this->registry->notifyPromptsListChanged(), + }; } } diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index 82e1cbe..ec29cc5 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -54,7 +54,6 @@ public function boot(): void $this->bootRoutes(); $this->bootEvents(); $this->bootCommands(); - $this->bootEventListeners(); } protected function loadMcpDefinitions(): void @@ -95,14 +94,19 @@ protected function buildServer(): void $registrar->applyBlueprints($builder); $server = $builder->build(); + $registry = $server->getRegistry(); if (config('mcp.discovery.auto_discover', true)) { + $registry->disableNotifications(); + $server->discover( basePath: config('mcp.discovery.base_path', base_path()), scanDirs: config('mcp.discovery.directories', ['app/Mcp']), excludeDirs: config('mcp.discovery.exclude_dirs', []), saveToCache: config('mcp.discovery.save_to_cache', true) ); + + $registry->enableNotifications(); } return $server; @@ -162,14 +166,4 @@ protected function bootEvents(): void McpNotificationListener::class, ); } - - protected function bootEventListeners(): void - { - $server = $this->app->make(Server::class); - $registry = $server->getRegistry(); - - $registry->setToolsChangedNotifier(ToolsListChanged::dispatch(...)); - $registry->setResourcesChangedNotifier(ResourcesListChanged::dispatch(...)); - $registry->setPromptsChangedNotifier(PromptsListChanged::dispatch(...)); - } } From 5829e6050a33cd98614c095e94ba25b561a573fe Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 13 Jun 2025 12:13:42 +0100 Subject: [PATCH 17/39] refactor: Remove unused event notifier test --- tests/Feature/McpServiceProviderTest.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/Feature/McpServiceProviderTest.php b/tests/Feature/McpServiceProviderTest.php index 2ef8d77..0da15ab 100644 --- a/tests/Feature/McpServiceProviderTest.php +++ b/tests/Feature/McpServiceProviderTest.php @@ -95,23 +95,6 @@ public function test_auto_discovery_is_skipped_if_disabled() $this->assertNull($registry->findTool('stub_tool_one'), "Tool 'stub_tool_one' should not be found if auto-discovery is off."); } - public function test_event_notifiers_are_set_on_core_registry_and_dispatch_laravel_events() - { - Event::fake(); - - $server = $this->app->make('mcp.server'); - $registry = $server->getRegistry(); - - $newToolName = 'dynamic_tool_for_event_test'; - $this->assertNull($registry->findTool($newToolName)); - - $registry->registerTool( - new ToolDefinition(ManualTestHandler::class, 'handleTool', $newToolName, 'd', []) - ); - - Event::assertDispatched(ToolsListChanged::class); - } - public function test_http_integrated_routes_are_registered_if_enabled() { $this->assertTrue(Route::has('mcp.sse')); From d278c1cdc5fde4e00837c7972dbf1122127df7b3 Mon Sep 17 00:00:00 2001 From: Alexandre D'Eschambeault Date: Wed, 11 Jun 2025 11:58:24 -0400 Subject: [PATCH 18/39] docs: transport should be explicitly set to stdio for the tool to start When starting the tool is VSCode without providing a transport, it can't "Choose transport protocol for MCP server communication", so we need to explicitly define it. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e74372d..90fc00a 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,8 @@ Configure your MCP client to execute this command directly. For example, in Curs "command": "php", "args": [ "/full/path/to/your/laravel/project/artisan", - "mcp:serve" + "mcp:serve", + "--transport=stdio" ] } } From b2dc2db830aef298e8796a45a7dfa3a5febb1c86 Mon Sep 17 00:00:00 2001 From: CodeWithKyrian <48791154+CodeWithKyrian@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:22:24 +0000 Subject: [PATCH 19/39] Update CHANGELOG --- CHANGELOG.md | 105 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc66dc0..4653ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to `php-mcp/laravel` will be documented in this file. +## v2.1.0 - 2025-06-13 + +### What's Changed + +* Update README.md by @taylorotwell in https://github.com/php-mcp/laravel/pull/5 +* [docs] Fix publish config command for 2.x by @barryvdh in https://github.com/php-mcp/laravel/pull/7 +* [docs] Remove config call from app/bootstrap.php by @barryvdh in https://github.com/php-mcp/laravel/pull/8 +* Do not defer ServiceProvider to boot routes by @barryvdh in https://github.com/php-mcp/laravel/pull/9 +* Fix: Correct Client State Management in LaravelHttpTransport by @CodeWithKyrian in https://github.com/php-mcp/laravel/pull/17 +* chore: Update dependencies and improve MCP notification handling by @CodeWithKyrian in https://github.com/php-mcp/laravel/pull/18 +* docs: transport should be explicitly set to stdio for the tool to start by @xel1045 in https://github.com/php-mcp/laravel/pull/15 + +### New Contributors + +* @taylorotwell made their first contribution in https://github.com/php-mcp/laravel/pull/5 +* @barryvdh made their first contribution in https://github.com/php-mcp/laravel/pull/7 +* @xel1045 made their first contribution in https://github.com/php-mcp/laravel/pull/15 + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/2.0.0...2.1.0 + ## v2.0.0 - 2025-06-04 This release marks a **major overhaul**, bringing it into full alignment with `php-mcp/server` v2.1.0+ and introducing a significantly improved, more "Laravely" developer experience. @@ -9,47 +29,59 @@ This release marks a **major overhaul**, bringing it into full alignment with `p ### Added * **Fluent Manual Registration API:** - * Introduced the `Mcp` Facade (`PhpMcp\Laravel\Facades\Mcp`). - * Define Tools, Resources, Prompts, and Resource Templates fluently (e.g., `Mcp::tool(...)->description(...)`). - * Definitions are typically placed in `routes/mcp.php` (configurable). - * Handlers are resolved via Laravel's service container, allowing dependency injection. - + + * Introduced the `Mcp` Facade (`PhpMcp\Laravel\Facades\Mcp`). + * Define Tools, Resources, Prompts, and Resource Templates fluently (e.g., `Mcp::tool(...)->description(...)`). + * Definitions are typically placed in `routes/mcp.php` (configurable). + * Handlers are resolved via Laravel's service container, allowing dependency injection. + * **Dedicated HTTP Server Transport via `mcp:serve`:** - * The `php artisan mcp:serve --transport=http` command now launches a standalone, high-performance ReactPHP-based HTTP server using `\PhpMcp\Server\Transports\HttpServerTransport`. - * Configuration for this dedicated server is in `config/mcp.php` under `transports.http_dedicated`. - * CLI options (`--host`, `--port`, `--path-prefix`) can override config defaults. - + + * The `php artisan mcp:serve --transport=http` command now launches a standalone, high-performance ReactPHP-based HTTP server using `\PhpMcp\Server\Transports\HttpServerTransport`. + * Configuration for this dedicated server is in `config/mcp.php` under `transports.http_dedicated`. + * CLI options (`--host`, `--port`, `--path-prefix`) can override config defaults. + * **`LaravelHttpTransport` for Integrated HTTP:** - * New `PhpMcp\Laravel\Transports\LaravelHttpTransport` class implements `ServerTransportInterface` to bridge Laravel's HTTP request lifecycle with the core MCP `Protocol` handler. - + + * New `PhpMcp\Laravel\Transports\LaravelHttpTransport` class implements `ServerTransportInterface` to bridge Laravel's HTTP request lifecycle with the core MCP `Protocol` handler. + * **Configurable Auto-Discovery:** - * `config('mcp.discovery.auto_discover')` (default: `true`) now controls whether discovery runs automatically or not. You can set it to false in production.. - + + * `config('mcp.discovery.auto_discover')` (default: `true`) now controls whether discovery runs automatically or not. You can set it to false in production.. + * **Interactive Prompt for `mcp:serve`:** If `--transport` is not specified, the command now interactively prompts the user to choose between `stdio` and `http`. + ### Changed * **Core Server Integration:** Now uses `\PhpMcp\Server\Server::make()` (ServerBuilder) for all server instantiation, fully leveraging `php-mcp/server` v2.x architecture. + * **Namespace:** Base package namespace changed from `PhpMcp\Laravel\Server` to **`PhpMcp\Laravel`**. + * **Configuration (`config/mcp.php`):** - * Significantly restructured and updated to align with `ServerBuilder` options. - * Clearer separation of settings for `http_dedicated` vs. `http_integrated` transports. - * Simplified cache TTL (`cache.ttl`) and discovery (`discovery.save_to_cache_on_discover`) keys. - * Added `server.instructions` for the `initialize` MCP response. - * Added `discovery.exclude_dirs` and `discovery.definitions_file`. - + + * Significantly restructured and updated to align with `ServerBuilder` options. + * Clearer separation of settings for `http_dedicated` vs. `http_integrated` transports. + * Simplified cache TTL (`cache.ttl`) and discovery (`discovery.save_to_cache_on_discover`) keys. + * Added `server.instructions` for the `initialize` MCP response. + * Added `discovery.exclude_dirs` and `discovery.definitions_file`. + * **`McpServiceProvider`:** - * Completely rewritten to correctly build and configure the `\PhpMcp\Server\Server` instance using Laravel's services for logging, caching (with fallback to core `FileCache`), container, and event loop. - * Loads manual definitions from the configured `definitions_file` via `McpRegistrar`. - * Sets up core `Registry` notifiers to dispatch Laravel events for list changes. - + + * Completely rewritten to correctly build and configure the `\PhpMcp\Server\Server` instance using Laravel's services for logging, caching (with fallback to core `FileCache`), container, and event loop. + * Loads manual definitions from the configured `definitions_file` via `McpRegistrar`. + * Sets up core `Registry` notifiers to dispatch Laravel events for list changes. + * **`McpController` (Integrated HTTP):** More robustly handles the integrated server behavior, working with a custom `LaravelHttpTransport`. + * **Artisan Commands:** - * `mcp:discover`: Now directly calls `Server::discover()` with configured/CLI parameters. `force` option behavior clarified. - * `mcp:list`: Fetches elements from the live, fully configured `Registry` from the resolved `Server` instance. - * `mcp:serve`: Refactored to use core `StdioServerTransport` or `HttpServerTransport` directly. - + + * `mcp:discover`: Now directly calls `Server::discover()` with configured/CLI parameters. `force` option behavior clarified. + * `mcp:list`: Fetches elements from the live, fully configured `Registry` from the resolved `Server` instance. + * `mcp:serve`: Refactored to use core `StdioServerTransport` or `HttpServerTransport` directly. + * **Dependency:** Updated `php-mcp/server` to `^2.2.0` + ### Fixed @@ -66,7 +98,8 @@ This release marks a **major overhaul**, bringing it into full alignment with `p * **Configuration File:** The `config/mcp.php` file has been significantly restructured. You **must** republish and merge your customizations: ```bash php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" --force - + + ``` * **`mcp:serve` for HTTP:** The `--transport=http` option for `mcp:serve` now launches a *dedicated* ReactPHP-based server process. For serving MCP via your main Laravel application routes, ensure the `http_integrated` transport is enabled in `config/mcp.php` and your web server is configured appropriately. * **Event Handling:** If you were directly listening to internal events from the previous version, these may have changed. Rely on the documented Laravel events (`ToolsListChanged`, etc.). @@ -130,18 +163,22 @@ composer require php-mcp/laravel # 2. Publish the configuration file (optional but recommended) php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" + ``` ## Getting Started 1. **Define Elements:** Create PHP classes with methods annotated with `#[McpTool]`, `#[McpResource]`, etc., within directories specified in `config/mcp.php` (e.g., `app/Mcp`). Inject dependencies as needed. See [Defining MCP Elements](https://github.com/php-mcp/laravel/blob/main/README.md#defining-mcp-elements). + 2. **Discovery:** - * In development, discovery runs automatically when needed. - * In production, run `php artisan mcp:discover` during your deployment process. See [Automatic Discovery vs. Manual Discovery](https://github.com/php-mcp/laravel/blob/main/README.md#automatic-discovery-development-vs-manual-discovery-production). - + + * In development, discovery runs automatically when needed. + * In production, run `php artisan mcp:discover` during your deployment process. See [Automatic Discovery vs. Manual Discovery](https://github.com/php-mcp/laravel/blob/main/README.md#automatic-discovery-development-vs-manual-discovery-production). + 3. **Run the Server:** - * For **Stdio Transport:** Use `php artisan mcp:serve` and configure your client to execute this command (using the full path to `artisan`). - * For **HTTP+SSE Transport:** Ensure `transports.http.enabled` is true, run your Laravel app on a suitable web server (Nginx+FPM, Octane, etc. - **not** `php artisan serve`), exclude the MCP route from CSRF protection, and configure your client with the SSE URL (e.g., `http://your-app.test/mcp/sse`). See [Running the Server](https://github.com/php-mcp/laravel/blob/main/README.md#running-the-server) for critical details. - + + * For **Stdio Transport:** Use `php artisan mcp:serve` and configure your client to execute this command (using the full path to `artisan`). + * For **HTTP+SSE Transport:** Ensure `transports.http.enabled` is true, run your Laravel app on a suitable web server (Nginx+FPM, Octane, etc. - **not** `php artisan serve`), exclude the MCP route from CSRF protection, and configure your client with the SSE URL (e.g., `http://your-app.test/mcp/sse`). See [Running the Server](https://github.com/php-mcp/laravel/blob/main/README.md#running-the-server) for critical details. + ## Important Notes From 0ffc36f0274625c402c7b0e88cae1d3f2a624015 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 06:00:41 +0100 Subject: [PATCH 20/39] fix: remove output message after STDIO transport stops When MCP clients disconnect from STDIO transport, they close STDOUT/STDERR streams before the Laravel command completes. Attempting to output "MCP Server (STDIO) stopped." to closed streams causes fwrite() errors. --- src/Commands/ServeCommand.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index 12729e5..2e41613 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -89,8 +89,6 @@ private function handleStdioTransport(Server $server): int return Command::FAILURE; } - $this->info("MCP Server (STDIO) stopped."); - return Command::SUCCESS; } From 9dd32f0730529a8f56d674dfbbfc526e04b710db Mon Sep 17 00:00:00 2001 From: CodeWithKyrian <48791154+CodeWithKyrian@users.noreply.github.com> Date: Wed, 25 Jun 2025 05:04:55 +0000 Subject: [PATCH 21/39] Update CHANGELOG --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4653ea2..56f9614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `php-mcp/laravel` will be documented in this file. +## v2.1.1 - 2025-06-25 + +### What's Changed + +* Fix STDIO transport stream errors on client disconnect by @CodeWithKyrian in https://github.com/php-mcp/laravel/pull/23 + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/2.1.0...2.1.1 + ## v2.1.0 - 2025-06-13 ### What's Changed @@ -100,6 +108,7 @@ This release marks a **major overhaul**, bringing it into full alignment with `p php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" --force + ``` * **`mcp:serve` for HTTP:** The `--transport=http` option for `mcp:serve` now launches a *dedicated* ReactPHP-based server process. For serving MCP via your main Laravel application routes, ensure the `http_integrated` transport is enabled in `config/mcp.php` and your web server is configured appropriately. * **Event Handling:** If you were directly listening to internal events from the previous version, these may have changed. Rely on the documented Laravel events (`ToolsListChanged`, etc.). @@ -164,6 +173,7 @@ composer require php-mcp/laravel php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" --tag="mcp-config" + ``` ## Getting Started From ac7991ffe0a57fa7e18a37a3565e6f6541811e84 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 05:59:24 +0100 Subject: [PATCH 22/39] chore: Upgrade php-mcp/server to version ^3.0 and refactor transport handling - Updated `php-mcp/server` dependency to version `^3.0` in composer.json. - Refactored HTTP transport handling by removing the old `McpController` and introducing `SseTransportController` and `StreamableTransportController`. - Enhanced configuration options in `mcp.php` for dedicated and integrated HTTP transports, including support for JSON responses and event stores. - Updated routing to use a new web.php file for handling transport routes. - Improved session management and logging configurations in the MCP setup. - Adjusted command outputs for clarity when starting the MCP server. --- composer.json | 2 +- config/mcp.php | 22 + routes/mcp_http_integrated.php | 25 - routes/web.php | 22 + samples/basic/composer.lock | 138 ++++-- samples/basic/config/logging.php | 9 +- samples/basic/config/mcp.php | 12 +- src/Blueprints/ResourceBlueprint.php | 6 +- src/Blueprints/ResourceTemplateBlueprint.php | 6 +- src/Blueprints/ToolBlueprint.php | 9 + src/Commands/DiscoverCommand.php | 10 +- src/Commands/ListCommand.php | 62 +-- src/Commands/ServeCommand.php | 94 +++- src/Http/Controllers/McpController.php | 170 ------- .../Controllers/SseTransportController.php | 45 ++ .../StreamableTransportController.php | 73 +++ src/McpRegistrar.php | 2 +- src/McpServiceProvider.php | 38 +- src/Transports/LaravelHttpTransport.php | 188 +++++++- .../LaravelStreamableHttpTransport.php | 432 ++++++++++++++++++ tests/Feature/Commands/ServeCommandTest.php | 11 +- 21 files changed, 1045 insertions(+), 331 deletions(-) delete mode 100644 routes/mcp_http_integrated.php create mode 100644 routes/web.php delete mode 100644 src/Http/Controllers/McpController.php create mode 100644 src/Http/Controllers/SseTransportController.php create mode 100644 src/Http/Controllers/StreamableTransportController.php create mode 100644 src/Transports/LaravelStreamableHttpTransport.php diff --git a/composer.json b/composer.json index 12851e3..74a6ff3 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^2.3" + "php-mcp/server": "^3.0" }, "require-dev": { "laravel/pint": "^1.13", diff --git a/config/mcp.php b/config/mcp.php index d8952c5..4edd3c8 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -80,21 +80,43 @@ 'http_dedicated' => [ 'enabled' => (bool) env('MCP_HTTP_DEDICATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_DEDICATED_LEGACY', false), 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), 'ssl_context_options' => [], + 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), + 'event_store' => env('MCP_HTTP_DEDICATED_EVENT_STORE'), // FQCN or null ], 'http_integrated' => [ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), + 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), + 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), + 'json_response_timeout' => (int) env('MCP_HTTP_INTEGRATED_JSON_TIMEOUT', 30), + 'event_store' => env('MCP_HTTP_INTEGRATED_EVENT_STORE'), // FQCN or null ], ], + /* + |-------------------------------------------------------------------------- + | Session Management Configuration + |-------------------------------------------------------------------------- + | + | Configure how the MCP server manages client sessions. Sessions store + | client state, message queues, and subscriptions. + | + */ + 'session' => [ + 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'array' or 'cache' + 'ttl' => (int) env('MCP_SESSION_TTL', 3600), // Session lifetime in seconds + ], + /* |-------------------------------------------------------------------------- | Pagination Limit diff --git a/routes/mcp_http_integrated.php b/routes/mcp_http_integrated.php deleted file mode 100644 index 15a9811..0000000 --- a/routes/mcp_http_integrated.php +++ /dev/null @@ -1,25 +0,0 @@ -name('mcp.message'); - -Route::get('/sse', [McpController::class, 'handleSse']) - ->name('mcp.sse'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..ebf3b4b --- /dev/null +++ b/routes/web.php @@ -0,0 +1,22 @@ +name('mcp.sse'); + + Route::post('/message', [SseTransportController::class, 'handleMessage']) + ->name('mcp.message'); +} else { + Route::get('/', [StreamableTransportController::class, 'handleGet']) + ->name('mcp.streamable.get'); + + Route::post('/', [StreamableTransportController::class, 'handlePost']) + ->name('mcp.streamable.post'); + + Route::delete('/', [StreamableTransportController::class, 'handleDelete']) + ->name('mcp.streamable.delete'); +} diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index 2e51e65..e4c3f7b 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -1207,16 +1207,16 @@ }, { "name": "laravel/framework", - "version": "v12.18.0", + "version": "v12.19.3", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d" + "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d", - "reference": "7d264a0dad2bfc5c154240b38e8ce9b2c4cdd14d", + "url": "https://api.github.com/repos/laravel/framework/zipball/4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", + "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", "shasum": "" }, "require": { @@ -1418,7 +1418,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-10T14:48:34+00:00" + "time": "2025-06-18T12:56:23+00:00" }, { "name": "laravel/prompts", @@ -2262,16 +2262,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.0", + "version": "3.10.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9" + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", - "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", "shasum": "" }, "require": { @@ -2363,7 +2363,7 @@ "type": "tidelift" } ], - "time": "2025-06-12T10:24:28+00:00" + "time": "2025-06-21T15:19:35+00:00" }, { "name": "nette/schema", @@ -2854,12 +2854,12 @@ "dist": { "type": "path", "url": "../..", - "reference": "cdf7fbadc4830e0f01ffef363473e98f1ec3e8f4" + "reference": "807b5093ffe3345b02884439f06f5e2d6bc7ae84" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^2.3" + "php-mcp/server": "^3.0" }, "require-dev": { "laravel/pint": "^1.13", @@ -2923,26 +2923,67 @@ "relative": true } }, + { + "name": "php-mcp/schema", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-mcp/schema.git", + "reference": "de8a32e00a007b696a0fcc55cb813bd98f1ce42c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mcp/schema/zipball/de8a32e00a007b696a0fcc55cb813bd98f1ce42c", + "reference": "de8a32e00a007b696a0fcc55cb813bd98f1ce42c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpMcp\\Schema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "PHP Data Transfer Objects (DTOs) and Enums for the Model Context Protocol (MCP) schema.", + "support": { + "issues": "https://github.com/php-mcp/schema/issues", + "source": "https://github.com/php-mcp/schema/tree/1.0.1" + }, + "time": "2025-06-25T04:27:57+00:00" + }, { "name": "php-mcp/server", - "version": "2.3.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "686cac47af096907179ebf9ab38c9d5a75c5aa6f" + "reference": "96ff2cedbade121d3efb593276fbb7d090c5b6c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mcp/server/zipball/686cac47af096907179ebf9ab38c9d5a75c5aa6f", - "reference": "686cac47af096907179ebf9ab38c9d5a75c5aa6f", + "url": "https://api.github.com/repos/php-mcp/server/zipball/96ff2cedbade121d3efb593276fbb7d090c5b6c6", + "reference": "96ff2cedbade121d3efb593276fbb7d090c5b6c6", "shasum": "" }, "require": { "opis/json-schema": "^2.4", "php": ">=8.1", + "php-mcp/schema": "^1.0", "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", - "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", @@ -2956,6 +2997,7 @@ "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", "react/async": "^4.0", + "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { @@ -2988,9 +3030,9 @@ ], "support": { "issues": "https://github.com/php-mcp/server/issues", - "source": "https://github.com/php-mcp/server/tree/2.3.1" + "source": "https://github.com/php-mcp/server/tree/3.0.2" }, - "time": "2025-06-13T11:04:31+00:00" + "time": "2025-06-25T03:41:58+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -3703,16 +3745,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.8", + "version": "v0.12.9", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625" + "reference": "1b801844becfe648985372cb4b12ad6840245ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", "shasum": "" }, "require": { @@ -3776,9 +3818,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.8" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" }, - "time": "2025-03-16T03:05:19+00:00" + "time": "2025-06-23T02:35:06+00:00" }, { "name": "ralouphie/getallheaders", @@ -7248,16 +7290,16 @@ }, { "name": "filp/whoops", - "version": "2.18.2", + "version": "2.18.3", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "89dabca1490bc77dbcab41c2b20968c7e44bf7c3" + "reference": "59a123a3d459c5a23055802237cb317f609867e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/89dabca1490bc77dbcab41c2b20968c7e44bf7c3", - "reference": "89dabca1490bc77dbcab41c2b20968c7e44bf7c3", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", "shasum": "" }, "require": { @@ -7307,7 +7349,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.2" + "source": "https://github.com/filp/whoops/tree/2.18.3" }, "funding": [ { @@ -7315,7 +7357,7 @@ "type": "github" } ], - "time": "2025-06-11T20:42:19+00:00" + "time": "2025-06-16T00:02:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7781,16 +7823,16 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.1", + "version": "v8.8.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "44ccb82e3e21efb5446748d2a3c81a030ac22bd5" + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/44ccb82e3e21efb5446748d2a3c81a030ac22bd5", - "reference": "44ccb82e3e21efb5446748d2a3c81a030ac22bd5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", "shasum": "" }, "require": { @@ -7876,7 +7918,7 @@ "type": "patreon" } ], - "time": "2025-06-11T01:04:21+00:00" + "time": "2025-06-25T02:12:12+00:00" }, { "name": "pestphp/pest", @@ -8396,16 +8438,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.9", + "version": "11.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7" + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", "shasum": "" }, "require": { @@ -8462,15 +8504,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-02-25T13:26:39+00:00" + "time": "2025-06-18T08:56:18+00:00" }, { "name": "phpunit/php-file-iterator", diff --git a/samples/basic/config/logging.php b/samples/basic/config/logging.php index 1345f6f..e460385 100644 --- a/samples/basic/config/logging.php +++ b/samples/basic/config/logging.php @@ -89,7 +89,7 @@ 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], @@ -127,6 +127,13 @@ 'path' => storage_path('logs/laravel.log'), ], + 'mcp_logs' => [ + 'driver' => 'single', + 'path' => storage_path('logs/mcp.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + ], ]; diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index 768e914..6332b25 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -81,19 +81,27 @@ ], 'http_dedicated' => [ - 'enabled' => env('MCP_HTTP_DEDICATED_ENABLED', true), + 'enabled' => (bool) env('MCP_HTTP_DEDICATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_DEDICATED_LEGACY', false), 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), 'ssl_context_options' => [], + 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), + 'event_store' => env('MCP_HTTP_DEDICATED_EVENT_STORE'), // FQCN or null ], 'http_integrated' => [ - 'enabled' => env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), + 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), + 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), + 'json_response_timeout' => (int) env('MCP_HTTP_INTEGRATED_JSON_TIMEOUT', 30), + 'event_store' => env('MCP_HTTP_INTEGRATED_EVENT_STORE'), // FQCN or null ], ], diff --git a/src/Blueprints/ResourceBlueprint.php b/src/Blueprints/ResourceBlueprint.php index bb8e203..3501310 100644 --- a/src/Blueprints/ResourceBlueprint.php +++ b/src/Blueprints/ResourceBlueprint.php @@ -4,6 +4,8 @@ namespace PhpMcp\Laravel\Blueprints; +use PhpMcp\Schema\Annotations; + class ResourceBlueprint { public ?string $name = null; @@ -14,7 +16,7 @@ class ResourceBlueprint public ?int $size = null; - public array $annotations = []; + public ?Annotations $annotations = null; public function __construct( public string $uri, @@ -49,7 +51,7 @@ public function size(int $size): static return $this; } - public function annotations(array $annotations): static + public function annotations(Annotations $annotations): static { $this->annotations = $annotations; diff --git a/src/Blueprints/ResourceTemplateBlueprint.php b/src/Blueprints/ResourceTemplateBlueprint.php index 6f71ebd..14c53dd 100644 --- a/src/Blueprints/ResourceTemplateBlueprint.php +++ b/src/Blueprints/ResourceTemplateBlueprint.php @@ -4,6 +4,8 @@ namespace PhpMcp\Laravel\Blueprints; +use PhpMcp\Schema\Annotations; + class ResourceTemplateBlueprint { public ?string $name = null; @@ -12,7 +14,7 @@ class ResourceTemplateBlueprint public ?string $mimeType = null; - public array $annotations = []; + public ?Annotations $annotations = null; public function __construct( public string $uriTemplate, @@ -40,7 +42,7 @@ public function mimeType(string $mimeType): static return $this; } - public function annotations(array $annotations): static + public function annotations(Annotations $annotations): static { $this->annotations = $annotations; diff --git a/src/Blueprints/ToolBlueprint.php b/src/Blueprints/ToolBlueprint.php index c51c4aa..4d7b26d 100644 --- a/src/Blueprints/ToolBlueprint.php +++ b/src/Blueprints/ToolBlueprint.php @@ -4,10 +4,12 @@ namespace PhpMcp\Laravel\Blueprints; +use PhpMcp\Schema\ToolAnnotations; class ToolBlueprint { public ?string $description = null; + public ?ToolAnnotations $annotations = null; public function __construct( public array|string $handler, @@ -27,4 +29,11 @@ public function description(string $description): static return $this; } + + public function annotations(ToolAnnotations $annotations): static + { + $this->annotations = $annotations; + + return $this; + } } diff --git a/src/Commands/DiscoverCommand.php b/src/Commands/DiscoverCommand.php index 1da5112..af73565 100644 --- a/src/Commands/DiscoverCommand.php +++ b/src/Commands/DiscoverCommand.php @@ -54,10 +54,10 @@ public function handle(Server $server): int $registry = $server->getRegistry(); - $toolsCount = $registry->allTools()->count(); - $resourcesCount = $registry->allResources()->count(); - $templatesCount = $registry->allResourceTemplates()->count(); - $promptsCount = $registry->allPrompts()->count(); + $toolsCount = count($registry->getTools()); + $resourcesCount = count($registry->getResources()); + $templatesCount = count($registry->getResourceTemplates()); + $promptsCount = count($registry->getPrompts()); $this->info('Discovery complete.'); $this->table( @@ -70,7 +70,7 @@ public function handle(Server $server): int ] ); - if (! $noCache && $registry->discoveryRanOrCached()) { + if (! $noCache) { $this->info('MCP element definitions updated and cached.'); } diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index 00dbd85..1f0b7d8 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -7,11 +7,11 @@ use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\Str; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Server; +use PhpMcp\Schema\Tool; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\ResourceTemplate; class ListCommand extends Command { @@ -38,11 +38,11 @@ public function handle(Server $server): int { $registry = $server->getRegistry(); - if (! $registry->hasElements() && ! $registry->discoveryRanOrCached()) { - $this->comment('No MCP elements are manually registered, and discovery has not run (or cache is empty).'); - $this->comment('Run `php artisan mcp:discover` or ensure auto-discovery is enabled in dev.'); - } elseif (! $registry->hasElements() && $registry->discoveryRanOrCached()) { - $this->comment('Discovery/cache load ran, but no MCP elements were found.'); + if (! $registry->hasElements()) { + $this->comment('MCP Registry is empty.'); + $this->comment('Run `php artisan mcp:discover` to discover MCP elements.'); + + return Command::SUCCESS; } $type = $this->argument('type'); @@ -57,10 +57,10 @@ public function handle(Server $server): int } $elements = [ - 'tools' => new Collection($registry->allTools()), - 'resources' => new Collection($registry->allResources()), - 'prompts' => new Collection($registry->allPrompts()), - 'templates' => new Collection($registry->allResourceTemplates()), + 'tools' => new Collection($registry->getTools()), + 'resources' => new Collection($registry->getResources()), + 'prompts' => new Collection($registry->getPrompts()), + 'templates' => new Collection($registry->getResourceTemplates()), ]; if ($outputJson) { @@ -100,27 +100,27 @@ private function displayTable(string $type, Collection $collection): void $this->info(ucfirst($type) . ':'); $data = match ($type) { - 'tools' => $collection->map(fn(ToolDefinition $def) => [ - 'Name' => $def->getName(), - 'Description' => Str::limit($def->getDescription() ?? '-', 60), - 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), + 'tools' => $collection->map(fn(Tool $def) => [ + 'Name' => $def->name, + 'Description' => Str::limit($def->description ?? '-', 60), + // 'Handler' => $def->handler, ])->all(), - 'resources' => $collection->map(fn(ResourceDefinition $def) => [ - 'URI' => $def->getUri(), - 'Name' => $def->getName(), - 'MIME' => $def->getMimeType() ?? '-', - 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), + 'resources' => $collection->map(fn(Resource $def) => [ + 'URI' => $def->uri, + 'Name' => $def->name, + 'MIME' => $def->mimeType ?? '-', + // 'Handler' => $def->handler, ])->all(), - 'prompts' => $collection->map(fn(PromptDefinition $def) => [ - 'Name' => $def->getName(), - 'Description' => Str::limit($def->getDescription() ?? '-', 60), - 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), + 'prompts' => $collection->map(fn(Prompt $def) => [ + 'Name' => $def->name, + 'Description' => Str::limit($def->description ?? '-', 60), + // 'Handler' => $def->handler, ])->all(), - 'templates' => $collection->map(fn(ResourceTemplateDefinition $def) => [ - 'URI Template' => $def->getUriTemplate(), - 'Name' => $def->getName(), - 'MIME' => $def->getMimeType() ?? '-', - 'Handler' => $def->getClassName() . '::' . $def->getMethodName(), + 'templates' => $collection->map(fn(ResourceTemplate $def) => [ + 'URI Template' => $def->uriTemplate, + 'Name' => $def->name, + 'MIME' => $def->mimeType ?? '-', + // 'Handler' => $def->handler, ])->all(), default => [], }; diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index 2e41613..83ff721 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -6,8 +6,10 @@ use Illuminate\Console\Command; use PhpMcp\Server\Server; +use PhpMcp\Server\Contracts\EventStoreInterface; use PhpMcp\Server\Transports\HttpServerTransport; use PhpMcp\Server\Transports\StdioServerTransport; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; use function Laravel\Prompts\select; @@ -78,7 +80,10 @@ private function handleStdioTransport(Server $server): int return Command::FAILURE; } - $this->info('Starting MCP server with STDIO transport...'); + $this->info('Starting MCP server'); + $this->line(" \t- Transport: STDIO"); + $this->line(" \t- Communication: STDIN/STDOUT"); + $this->line(" \t- Mode: JSON-RPC over Standard I/O"); try { $transport = new StdioServerTransport; @@ -100,12 +105,25 @@ private function handleHttpTransport(Server $server): int return Command::FAILURE; } + $isLegacy = config('mcp.transports.http_dedicated.legacy', false); $host = $this->option('host') ?? config('mcp.transports.http_dedicated.host', '127.0.0.1'); $port = (int) ($this->option('port') ?? config('mcp.transports.http_dedicated.port', 8090)); - $pathPrefix = $this->option('path-prefix') ?? config('mcp.transports.http_dedicated.path_prefix', 'mcp_server'); - $sslContextOptions = config('mcp.transports.http_dedicated.ssl_context_options'); // For HTTPS + $pathPrefix = $this->option('path-prefix') ?? config('mcp.transports.http_dedicated.path_prefix', 'mcp'); + $sslContextOptions = config('mcp.transports.http_dedicated.ssl_context_options'); + + return $isLegacy + ? $this->handleSseHttpTransport($server, $host, $port, $pathPrefix, $sslContextOptions) + : $this->handleStreamableHttpTransport($server, $host, $port, $pathPrefix, $sslContextOptions); + } + + private function handleSseHttpTransport(Server $server, string $host, int $port, string $pathPrefix, ?array $sslContextOptions): int + { + $this->info("Starting MCP server on http://{$host}:{$port}"); + $this->line(" - Transport: Legacy HTTP"); + $this->line(" - SSE endpoint: http://{$host}:{$port}/{$pathPrefix}/sse"); + $this->line(" - Message endpoint: http://{$host}:{$port}/{$pathPrefix}/message"); + $this->line(" - Mode: Server-Sent Events"); - $this->info("Starting MCP server with dedicated HTTP transport on http://{$host}:{$port} (prefix: /{$pathPrefix})..."); $transport = new HttpServerTransport( host: $host, port: $port, @@ -116,16 +134,80 @@ private function handleHttpTransport(Server $server): int try { $server->listen($transport); } catch (\Exception $e) { - $this->error("Failed to start MCP server with dedicated HTTP transport: {$e->getMessage()}"); + $this->error("Failed to start MCP server with legacy HTTP transport: {$e->getMessage()}"); return Command::FAILURE; } - $this->info("MCP Server (HTTP) stopped."); + $this->info("MCP Server (Legacy HTTP) stopped."); return Command::SUCCESS; } + private function handleStreamableHttpTransport(Server $server, string $host, int $port, string $pathPrefix, ?array $sslContextOptions): int + { + $enableJsonResponse = config('mcp.transports.http_dedicated.enable_json_response', true); + $eventStore = $this->createEventStore(); + + $this->info("Starting MCP server on http://{$host}:{$port}"); + $this->line(" - Transport: Streamable HTTP"); + $this->line(" - MCP endpoint: http://{$host}:{$port}/{$pathPrefix}"); + $this->line(" - Mode: " . ($enableJsonResponse ? 'JSON' : 'SSE Streaming')); + + $transport = new StreamableHttpServerTransport( + host: $host, + port: $port, + mcpPath: $pathPrefix, + sslContext: $sslContextOptions, + enableJsonResponse: $enableJsonResponse, + eventStore: $eventStore + ); + + try { + $server->listen($transport); + } catch (\Exception $e) { + $this->error("Failed to start MCP server with streamable HTTP transport: {$e->getMessage()}"); + + return Command::FAILURE; + } + + $this->info("MCP Server (Streamable HTTP) stopped."); + + return Command::SUCCESS; + } + + /** + * Create event store instance from configuration + */ + private function createEventStore(): ?EventStoreInterface + { + $eventStoreFqcn = config('mcp.transports.http_dedicated.event_store'); + + if (!$eventStoreFqcn) { + return null; + } + + if (is_object($eventStoreFqcn) && $eventStoreFqcn instanceof EventStoreInterface) { + return $eventStoreFqcn; + } + + if (is_string($eventStoreFqcn) && class_exists($eventStoreFqcn)) { + $instance = app($eventStoreFqcn); + + if (!$instance instanceof EventStoreInterface) { + throw new \InvalidArgumentException( + "Event store class {$eventStoreFqcn} must implement EventStoreInterface" + ); + } + + return $instance; + } + + throw new \InvalidArgumentException( + "Invalid event store configuration: {$eventStoreFqcn}" + ); + } + private function handleInvalidTransport(string $transportOption): int { $this->error("Invalid transport specified: {$transportOption}. Use 'stdio' or 'http'."); diff --git a/src/Http/Controllers/McpController.php b/src/Http/Controllers/McpController.php deleted file mode 100644 index e63511f..0000000 --- a/src/Http/Controllers/McpController.php +++ /dev/null @@ -1,170 +0,0 @@ -clientStateManager = $server->getClientStateManager(); - - $server->listen($this->transport, false); - } - - /** - * Handle client message (HTTP POST endpoint). - */ - public function handleMessage(Request $request): Response - { - if (! $request->isJson()) { - Log::warning('MCP POST request with invalid Content-Type'); - - return response()->json([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32600, - 'message' => 'Invalid Request: Content-Type must be application/json', - ], - ], 400); - } - - $clientId = $request->query('clientId'); - - if (! $clientId || ! is_string($clientId)) { - Log::error('MCP: Missing or invalid clientId'); - - return response()->json([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32600, - 'message' => 'Invalid Request: Missing or invalid clientId query parameter', - ], - ], 400); - } - - // Confirm request body is not empty - $content = $request->getContent(); - if ($content === false || empty($content)) { - Log::warning('MCP POST request with empty body'); - - return response()->json([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32600, - 'message' => 'Invalid Request: Empty body', - ], - ], 400); - } - - $this->transport->emit('message', [$content, $clientId]); - - return response()->json([ - 'jsonrpc' => '2.0', - 'result' => null, - 'id' => 1, - ], 202); - } - - /** - * Handle SSE (GET endpoint). - */ - public function handleSse(Request $request): Response - { - $clientId = $request->hasSession() ? $request->session()->getId() : Str::uuid()->toString(); - - $this->transport->emit('client_connected', [$clientId]); - - $pollInterval = (int) config('mcp.transports.http_integrated.sse_poll_interval', 1); - if ($pollInterval < 1) { - $pollInterval = 1; - } - - return response()->stream(function () use ($clientId, $pollInterval) { - @set_time_limit(0); - - try { - $postEndpointUri = route('mcp.message', ['clientId' => $clientId], false); - - $this->sendSseEvent('endpoint', $postEndpointUri, "mcp-endpoint-{$clientId}"); - } catch (Throwable $e) { - Log::error('MCP: SSE stream loop terminated', ['client_id' => $clientId, 'reason' => $e->getMessage()]); - - return; - } - - while (true) { - if (connection_aborted()) { - break; - } - - $messages = $this->clientStateManager->getQueuedMessages($clientId); - foreach ($messages as $message) { - $this->sendSseEvent('message', rtrim($message, "\n")); - } - - static $keepAliveCounter = 0; - $keepAliveInterval = (int) round(15 / $pollInterval); - if (($keepAliveCounter++ % $keepAliveInterval) == 0) { - echo ": keep-alive\n\n"; - $this->flushOutput(); - } - - usleep($pollInterval * 1000000); - } - - $this->transport->emit('client_disconnected', [$clientId, 'Laravel SSE stream shutdown']); - $this->server->endListen($this->transport); - }, headers: [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - 'Access-Control-Allow-Origin' => '*', // TODO: Make this configurable - ]); - } - - private function sendSseEvent(string $event, string $data, ?string $id = null): void - { - if (connection_aborted()) { - return; - } - - echo "event: {$event}\n"; - if ($id !== null) { - echo "id: {$id}\n"; - } - - foreach (explode("\n", $data) as $line) { - echo "data: {$line}\n"; - } - - echo "\n"; - $this->flushOutput(); - } - - private function flushOutput(): void - { - if (function_exists('ob_flush')) { - @ob_flush(); - } - @flush(); - } -} diff --git a/src/Http/Controllers/SseTransportController.php b/src/Http/Controllers/SseTransportController.php new file mode 100644 index 0000000..ce3839d --- /dev/null +++ b/src/Http/Controllers/SseTransportController.php @@ -0,0 +1,45 @@ +transport = new LaravelHttpTransport($server->getSessionManager()); + $server->listen($this->transport, false); + } + + /** + * Handle client message (HTTP POST endpoint). + * Delegates to the transport for processing. + */ + public function handleMessage(Request $request): Response + { + return $this->transport->handleMessageRequest($request); + } + + /** + * Handle SSE (GET endpoint). + * Delegates to the transport for streaming. + */ + public function handleSse(Request $request): StreamedResponse + { + return $this->transport->handleSseRequest($request); + } +} diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php new file mode 100644 index 0000000..3ac7e2e --- /dev/null +++ b/src/Http/Controllers/StreamableTransportController.php @@ -0,0 +1,73 @@ +createEventStore(); + $sessionManager = $server->getSessionManager(); + + $this->transport = new LaravelStreamableHttpTransport($sessionManager, $eventStore); + $server->listen($this->transport, false); + } + + public function handleGet(Request $request): Response|StreamedResponse + { + return $this->transport->handleGetRequest($request); + } + + public function handlePost(Request $request): Response|StreamedResponse + { + return $this->transport->handlePostRequest($request); + } + + public function handleDelete(Request $request): Response + { + return $this->transport->handleDeleteRequest($request); + } + + /** + * Create event store instance from configuration + */ + private function createEventStore(): ?EventStoreInterface + { + $eventStoreFqcn = config('mcp.transports.http_integrated.event_store'); + + if (!$eventStoreFqcn) { + return null; + } + + if (is_object($eventStoreFqcn) && $eventStoreFqcn instanceof EventStoreInterface) { + return $eventStoreFqcn; + } + + if (is_string($eventStoreFqcn) && class_exists($eventStoreFqcn)) { + $instance = app($eventStoreFqcn); + + if (!$instance instanceof EventStoreInterface) { + throw new \InvalidArgumentException( + "Event store class {$eventStoreFqcn} must implement EventStoreInterface" + ); + } + + return $instance; + } + + throw new \InvalidArgumentException( + "Invalid event store configuration: {$eventStoreFqcn}" + ); + } +} diff --git a/src/McpRegistrar.php b/src/McpRegistrar.php index 812eabc..0935e0f 100644 --- a/src/McpRegistrar.php +++ b/src/McpRegistrar.php @@ -106,7 +106,7 @@ public function prompt(string|array ...$args): PromptBlueprint public function applyBlueprints(ServerBuilder $builder): void { foreach ($this->pendingTools as $pendingTool) { - $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description); + $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description, $pendingTool->annotations); } foreach ($this->pendingResources as $pendingResource) { diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index ec29cc5..bdf6446 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -15,10 +15,11 @@ use PhpMcp\Laravel\Events\ResourcesListChanged; use PhpMcp\Laravel\Events\ToolsListChanged; use PhpMcp\Laravel\Listeners\McpNotificationListener; -use PhpMcp\Laravel\Transports\LaravelHttpTransport; -use PhpMcp\Server\Model\Capabilities; + +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; +use PhpMcp\Server\Session\SessionManager; class McpServiceProvider extends ServiceProvider { @@ -42,7 +43,8 @@ public function provides(): array McpRegistrar::class, Server::class, Registry::class, - LaravelHttpTransport::class, + SessionManager::class, + ]; } @@ -71,24 +73,29 @@ protected function buildServer(): void $serverVersion = config('mcp.server.version', '1.0.0'); $logger = $app['log']->channel(config('mcp.logging.channel')); $cache = $app['cache']->store($app['config']->get('mcp.cache.store')); - $capabilities = Capabilities::forServer( - toolsEnabled: config('mcp.capabilities.tools.enabled', true), + $capabilities = ServerCapabilities::make( + tools: config('mcp.capabilities.tools.enabled', true), toolsListChanged: config('mcp.capabilities.tools.listChanged', true), - resourcesEnabled: config('mcp.capabilities.resources.enabled', true), + resources: config('mcp.capabilities.resources.enabled', true), resourcesSubscribe: config('mcp.capabilities.resources.subscribe', true), resourcesListChanged: config('mcp.capabilities.resources.listChanged', true), - promptsEnabled: config('mcp.capabilities.prompts.enabled', true), + prompts: config('mcp.capabilities.prompts.enabled', true), promptsListChanged: config('mcp.capabilities.prompts.listChanged', true), - loggingEnabled: config('mcp.capabilities.logging.enabled', true), - instructions: config('mcp.server.instructions') + logging: config('mcp.capabilities.logging.enabled', true), + experimental: null, ); + $sessionDriver = config('mcp.session.driver', 'cache'); + $sessionTtl = (int) config('mcp.session.ttl', 3600); + $builder = Server::make() ->withServerInfo($serverName, $serverVersion) ->withLogger($logger) ->withContainer($app) - ->withCache($cache, (int) config('mcp.cache.ttl', 3600)) - ->withCapabilities($capabilities); + ->withCache($cache) + ->withSession($sessionDriver, $sessionTtl) + ->withCapabilities($capabilities) + ->withPaginationLimit((int) config('mcp.pagination_limit', 50)); $registrar = $app->make(McpRegistrar::class); $registrar->applyBlueprints($builder); @@ -113,15 +120,10 @@ protected function buildServer(): void }); $this->app->singleton(Registry::class, fn($app) => $app->make(Server::class)->getRegistry()); + $this->app->singleton(SessionManager::class, fn($app) => $app->make(Server::class)->getSessionManager()); $this->app->alias(Server::class, 'mcp.server'); $this->app->alias(Registry::class, 'mcp.registry'); - - $this->app->singleton(LaravelHttpTransport::class, function (Application $app) { - $server = $app->make(Server::class); - - return new LaravelHttpTransport($server->getClientStateManager()); - }); } protected function bootConfig(): void @@ -143,7 +145,7 @@ protected function bootRoutes(): void 'prefix' => $routePrefix, 'middleware' => $middleware, ], function () { - $this->loadRoutesFrom(__DIR__ . '/../routes/mcp_http_integrated.php'); + $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); }); } } diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/LaravelHttpTransport.php index 29ccf3f..b6049d8 100644 --- a/src/Transports/LaravelHttpTransport.php +++ b/src/Transports/LaravelHttpTransport.php @@ -5,36 +5,41 @@ namespace PhpMcp\Laravel\Transports; use Evenement\EventEmitterTrait; -use PhpMcp\Server\Contracts\LoggerAwareInterface; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Server\Contracts\ServerTransportInterface; -use PhpMcp\Server\State\ClientStateManager; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Schema\JsonRpc\Message; use React\Promise\PromiseInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; use function React\Promise\resolve; -class LaravelHttpTransport implements ServerTransportInterface, LoggerAwareInterface +class LaravelHttpTransport implements ServerTransportInterface { use EventEmitterTrait; - protected LoggerInterface $logger; + protected SessionManager $sessionManager; - protected ClientStateManager $clientStateManager; - - public function __construct(ClientStateManager $clientStateManager) + public function __construct(SessionManager $sessionManager) { - $this->clientStateManager = $clientStateManager; - $this->logger = new NullLogger; + $this->sessionManager = $sessionManager; - $this->on('message', function (string $message, string $clientId) { - $this->clientStateManager->updateClientActivity($clientId); + $this->on('message', function (Message $message, string $sessionId) { + $session = $this->sessionManager->getSession($sessionId); + if ($session !== null) { + $session->save(); // This updates the session timestamp + } }); } - public function setLogger(LoggerInterface $logger): void + protected function generateId(): string { - $this->logger = $logger; + return bin2hex(random_bytes(16)); } /** @@ -48,23 +53,162 @@ public function listen(): void } /** - * Queues a message to be sent to the client via the ClientStateManager. - * The McpController's SSE loop will pick this up. - * The $rawFramedMessage is expected to be a complete JSON-RPC string (usually ending with \n, but we'll trim). + * Sends a message to a specific client session by queueing it in the SessionManager. + * The SSE streams will pick this up. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { - $messagePayload = rtrim($rawFramedMessage, "\n"); + $rawMessage = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (empty($messagePayload)) { + if (empty($rawMessage)) { return resolve(null); } - $this->clientStateManager->queueMessage($clientId, $messagePayload); + $this->sessionManager->queueMessage($sessionId, $rawMessage); return resolve(null); } + /** + * Handle incoming HTTP POST message requests + */ + public function handleMessageRequest(Request $request): Response + { + if (!$request->isJson()) { + Log::warning('Received POST request with invalid Content-Type'); + + $error = Error::forInvalidRequest('Content-Type must be application/json'); + + return response()->json($error, 415); + } + + $sessionId = $request->query('clientId'); + if (!$sessionId || !is_string($sessionId)) { + Log::error('Received POST request with missing or invalid sessionId'); + + $error = Error::forInvalidRequest('Missing or invalid clientId query parameter'); + + return response()->json($error, 400); + } + + $content = $request->getContent(); + if (empty($content)) { + Log::warning('Received POST request with empty body'); + + $error = Error::forInvalidRequest('Empty body'); + + return response()->json($error, 400); + } + + try { + $message = Parser::parse($content); + } catch (Throwable $e) { + Log::error('MCP: Failed to parse message', ['error' => $e->getMessage()]); + + $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage()); + + return response()->json($error, 400); + } + + $this->emit('message', [$message, $sessionId]); + + return response()->json([ + 'jsonrpc' => '2.0', + 'result' => null, + 'id' => 1, + ], 202); + } + + /** + * Handle SSE connection requests - moved from McpController + */ + public function handleSseRequest(Request $request): StreamedResponse + { + $sessionId = $this->generateId(); + + $this->emit('client_connected', [$sessionId]); + + $pollInterval = (int) config('mcp.transports.http_integrated.sse_poll_interval', 1); + if ($pollInterval < 1) { + $pollInterval = 1; + } + + return response()->stream(function () use ($sessionId, $pollInterval) { + @set_time_limit(0); + + try { + $postEndpointUri = route('mcp.message', ['clientId' => $sessionId], false); + + $this->sendSseEvent('endpoint', $postEndpointUri, "mcp-endpoint-{$sessionId}"); + } catch (Throwable $e) { + Log::error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]); + + return; + } + + while (true) { + if (connection_aborted()) { + break; + } + + $messages = $this->sessionManager->dequeueMessages($sessionId); + foreach ($messages as $message) { + $this->sendSseEvent('message', rtrim($message, "\n")); + } + + static $keepAliveCounter = 0; + $keepAliveInterval = (int) round(15 / $pollInterval); + if (($keepAliveCounter++ % $keepAliveInterval) == 0) { + echo ": keep-alive\n\n"; + $this->flushOutput(); + } + + usleep($pollInterval * 1000000); + } + + $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); + }, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + 'Access-Control-Allow-Origin' => config('mcp.transports.http_integrated.cors_origin', '*'), + ]); + } + + /** + * Send an SSE event + */ + private function sendSseEvent(string $event, string $data, ?string $id = null): void + { + if (connection_aborted()) { + return; + } + + echo "event: {$event}\n"; + if ($id !== null) { + echo "id: {$id}\n"; + } + + foreach (explode("\n", $data) as $line) { + echo "data: {$line}\n"; + } + + echo "\n"; + $this->flushOutput(); + } + + /** + * Flush output buffer + */ + private function flushOutput(): void + { + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + } + /** * 'Closes' the transport. */ diff --git a/src/Transports/LaravelStreamableHttpTransport.php b/src/Transports/LaravelStreamableHttpTransport.php new file mode 100644 index 0000000..89d1159 --- /dev/null +++ b/src/Transports/LaravelStreamableHttpTransport.php @@ -0,0 +1,432 @@ +on('message', function (Message $message, string $sessionId) { + $session = $this->sessionManager->getSession($sessionId); + if ($session !== null) { + $session->save(); + } + }); + } + + protected function generateId(): string + { + return bin2hex(random_bytes(16)); + } + + public function listen(): void + { + $this->emit('ready'); + } + + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface + { + $rawMessage = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if (empty($rawMessage)) { + return resolve(null); + } + + $eventId = null; + if ($this->eventStore && isset($context['type']) && in_array($context['type'], ['get_sse', 'post_sse'])) { + $streamKey = $context['type'] === 'get_sse' ? "get_stream_{$sessionId}" : $context['streamId'] ?? "post_stream_{$sessionId}"; + $eventId = $this->eventStore->storeEvent($streamKey, $rawMessage); + } + + $messageData = [ + 'id' => $eventId ?? $this->generateId(), + 'data' => $rawMessage, + 'context' => $context['type'] ?? 'get_sse', + 'timestamp' => time() + ]; + + $this->sessionManager->queueMessage($sessionId, json_encode($messageData)); + + return resolve(null); + } + + /** + * Handle incoming HTTP POST message requests + */ + public function handlePostRequest(Request $request): Response + { + $acceptHeader = $request->header('Accept', ''); + if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest('Not Acceptable: Client must accept application/json or text/event-stream'); + return response()->json($error, 406, $this->getCorsHeaders()); + } + + if (!$request->isJson()) { + $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json'); + return response()->json($error, 415, $this->getCorsHeaders()); + } + + $content = $request->getContent(); + if (empty($content)) { + Log::warning('Received POST request with empty body'); + $error = Error::forInvalidRequest('Empty request body'); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + try { + $message = Parser::parse($content); + } catch (Throwable $e) { + Log::error('Failed to parse MCP message from POST body', ['error' => $e->getMessage()]); + $error = Error::forParseError('Invalid JSON: ' . $e->getMessage()); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + $isInitializeRequest = ($message instanceof JsonRpcRequest && $message->method === 'initialize'); + $sessionId = null; + + if ($isInitializeRequest) { + if ($request->hasHeader('Mcp-Session-Id')) { + Log::warning('Client sent Mcp-Session-Id with InitializeRequest. Ignoring.', ['clientSentId' => $request->header('Mcp-Session-Id')]); + $error = Error::forInvalidRequest('Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.', $message->getId()); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + $sessionId = $this->generateId(); + $this->emit('client_connected', [$sessionId]); + } else { + $sessionId = $request->header('Mcp-Session-Id'); + + if (empty($sessionId)) { + Log::warning('POST request without Mcp-Session-Id'); + $error = Error::forInvalidRequest('Mcp-Session-Id header required for POST requests', $message->getId()); + return response()->json($error, 400, $this->getCorsHeaders()); + } + } + + $context = [ + 'is_initialize_request' => $isInitializeRequest, + ]; + + $nRequests = match (true) { + $message instanceof JsonRpcRequest => 1, + $message instanceof BatchRequest => $message->nRequests(), + default => 0, + }; + + if ($nRequests === 0) { + $context['type'] = 'post_202'; + $this->emit('message', [$message, $sessionId, $context]); + + return response()->json(JsonRpcResponse::make(1, []), 202, $this->getCorsHeaders()); + } + + $enableJsonResponse = config('mcp.transports.http_integrated.enable_json_response', true); + + return $enableJsonResponse + ? $this->handleJsonResponse($message, $sessionId, $context) + : $this->handleSseResponse($message, $sessionId, $nRequests, $context); + } + + /** + * Handle direct JSON response mode + */ + protected function handleJsonResponse(Message $message, string $sessionId, array $context): Response + { + try { + $context['type'] = 'post_json'; + $this->emit('message', [$message, $sessionId, $context]); + + $maxWaitTime = config('mcp.transports.http_integrated.json_response_timeout', 30); + $pollInterval = 0.1; // 100ms + $waitedTime = 0; + + while ($waitedTime < $maxWaitTime) { + $messages = $this->dequeueMessagesForContext($sessionId, 'post_json'); + + if (!empty($messages)) { + $responseMessage = $messages[0]; + $data = $responseMessage['data']; + + $headers = [ + 'Content-Type' => 'application/json', + ...$this->getCorsHeaders() + ]; + + if ($context['is_initialize_request'] ?? false) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + return response()->make($data, 200, $headers); + } + + usleep((int)($pollInterval * 1000000)); + $waitedTime += $pollInterval; + } + + $error = Error::forInternalError('Request timeout'); + return response()->json($error, 504, $this->getCorsHeaders()); + } catch (Throwable $e) { + Log::error('JSON response mode error', ['exception' => $e]); + $error = Error::forInternalError('Internal error'); + return response()->json($error, 500, $this->getCorsHeaders()); + } + } + + /** + * Handle SSE streaming response mode + */ + protected function handleSseResponse(Message $message, string $sessionId, int $nRequests, array $context): StreamedResponse + { + $streamId = $this->generateId(); + $context['type'] = 'post_sse'; + $context['streamId'] = $streamId; + $context['nRequests'] = $nRequests; + + $this->emit('message', [$message, $sessionId, $context]); + + return response()->stream(function () use ($sessionId, $nRequests, $streamId) { + $responsesSent = 0; + $maxWaitTime = 30; // 30 seconds timeout + $pollInterval = 0.1; // 100ms + $waitedTime = 0; + + while ($responsesSent < $nRequests && $waitedTime < $maxWaitTime) { + if (connection_aborted()) { + break; + } + + $messages = $this->dequeueMessagesForContext($sessionId, 'post_sse', $streamId); + + foreach ($messages as $messageData) { + $this->sendSseEvent($messageData['data'], $messageData['id']); + $responsesSent++; + + if ($responsesSent >= $nRequests) { + break; + } + } + + if ($responsesSent < $nRequests) { + usleep((int)($pollInterval * 1000000)); + $waitedTime += $pollInterval; + } + } + }, headers: array_merge([ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ], $this->getCorsHeaders())); + } + + /** + * Handle GET request with event replay support + */ + public function handleGetRequest(Request $request): StreamedResponse|Response + { + $acceptHeader = $request->header('Accept'); + if (!str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests."); + return response()->json($error, 406, $this->getCorsHeaders()); + } + + $sessionId = $request->header('Mcp-Session-Id'); + if (empty($sessionId)) { + Log::warning("GET request without Mcp-Session-Id."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for GET requests."); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + $lastEventId = $request->header('Last-Event-ID'); + + $pollInterval = (int) config('mcp.transports.http_integrated.sse_poll_interval', 1); + if ($pollInterval < 1) { + $pollInterval = 1; + } + + return response()->stream(function () use ($sessionId, $pollInterval, $lastEventId) { + @set_time_limit(0); + + if ($lastEventId && $this->eventStore) { + $this->replayEvents($lastEventId, $sessionId); + } + + while (true) { + if (connection_aborted()) { + break; + } + + $messages = $this->dequeueMessagesForContext($sessionId, 'get_sse'); + foreach ($messages as $messageData) { + $this->sendSseEvent(rtrim($messageData['data'], "\n"), $messageData['id']); + } + + static $keepAliveCounter = 0; + $keepAliveInterval = (int) round(15 / $pollInterval); + if (($keepAliveCounter++ % $keepAliveInterval) == 0) { + echo ": keep-alive\n\n"; + $this->flushOutput(); + } + + usleep($pollInterval * 1000000); + } + }, headers: array_merge([ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ], $this->getCorsHeaders())); + } + + /** + * Handle DELETE request for session termination + */ + public function handleDeleteRequest(Request $request): Response + { + $sessionId = $request->header('Mcp-Session-Id'); + if (empty($sessionId)) { + Log::warning("DELETE request without Mcp-Session-Id."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for DELETE requests."); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + $this->sessionManager->dequeueMessages($sessionId); + + $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']); + + return response()->noContent(204, $this->getCorsHeaders()); + } + + /** + * Dequeue messages for specific context, requeue others + */ + protected function dequeueMessagesForContext(string $sessionId, string $context, ?string $streamId = null): array + { + $allMessages = $this->sessionManager->dequeueMessages($sessionId); + $contextMessages = []; + $requeueMessages = []; + + foreach ($allMessages as $rawMessage) { + $messageData = json_decode($rawMessage, true); + + if ($messageData && isset($messageData['context'])) { + $matchesContext = $messageData['context'] === $context; + + if ($context === 'post_sse' && $streamId) { + $matchesContext = $matchesContext && isset($messageData['streamId']) && $messageData['streamId'] === $streamId; + } + + if ($matchesContext) { + $contextMessages[] = $messageData; + } else { + $requeueMessages[] = $rawMessage; + } + } + } + + foreach ($requeueMessages as $requeueMessage) { + $this->sessionManager->queueMessage($sessionId, $requeueMessage); + } + + return $contextMessages; + } + + /** + * Replay events from event store + */ + protected function replayEvents(string $lastEventId, string $sessionId): void + { + if (!$this->eventStore) { + return; + } + + try { + $streamKey = "get_stream_{$sessionId}"; + $this->eventStore->replayEventsAfter( + $lastEventId, + function (string $replayedEventId, string $json) { + Log::debug('Replaying event', ['replayedEventId' => $replayedEventId]); + $this->sendSseEvent($json, $replayedEventId); + } + ); + } catch (Throwable $e) { + Log::error('Error during event replay', ['sessionId' => $sessionId, 'exception' => $e]); + } + } + + /** + * Send an SSE event + */ + private function sendSseEvent(string $data, ?string $id = null): void + { + if (connection_aborted()) { + return; + } + + echo "event: message\n"; + if ($id !== null) { + echo "id: {$id}\n"; + } + + foreach (explode("\n", $data) as $line) { + echo "data: {$line}\n"; + } + + echo "\n"; + $this->flushOutput(); + } + + /** + * Flush output buffer + */ + private function flushOutput(): void + { + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + } + + /** + * Get CORS headers + */ + protected function getCorsHeaders(): array + { + return [ + 'Access-Control-Allow-Origin' => config('mcp.transports.http_integrated.cors_origin', '*'), + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', + ]; + } + + public function close(): void + { + $this->emit('close', ['Transport closed.']); + $this->removeAllListeners(); + } +} diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php index c572bf7..d8d4468 100644 --- a/tests/Feature/Commands/ServeCommandTest.php +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -44,7 +44,8 @@ public function test_serve_command_defaults_to_stdio_and_calls_server_listen() ); $this->artisan('mcp:serve --transport=stdio') - ->expectsOutputToContain('Starting MCP server with STDIO transport...') + ->expectsOutputToContain('Starting MCP server') + ->expectsOutputToContain('Transport: STDIO') ->assertSuccessful(); } @@ -58,7 +59,9 @@ public function test_serve_command_uses_http_transport_when_specified() ); $this->artisan('mcp:serve --transport=http --host=localhost --port=9091 --path-prefix=mcp_test_http') - ->expectsOutputToContain('Starting MCP server with dedicated HTTP transport on http://localhost:9091 (prefix: /mcp_test_http)...') + ->expectsOutputToContain('Starting MCP server on http://localhost:9091') + ->expectsOutputToContain('Transport: Streamable HTTP') + ->expectsOutputToContain('MCP endpoint: http://localhost:9091/mcp_test_http') ->assertSuccessful(); } @@ -86,7 +89,9 @@ public function test_serve_command_uses_http_transport_config_fallbacks() ); $this->artisan('mcp:serve --transport=http') // No CLI overrides - ->expectsOutputToContain('Starting MCP server with dedicated HTTP transport on http://0.0.0.0:8888 (prefix: /configured_prefix)...') + ->expectsOutputToContain('Starting MCP server on http://0.0.0.0:8888') + ->expectsOutputToContain('Transport: Streamable HTTP') + ->expectsOutputToContain('MCP endpoint: http://0.0.0.0:8888/configured_prefix') ->assertSuccessful(); } From 58d617354892f27f0b47e15fa13081f842df04be Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 14:10:56 +0100 Subject: [PATCH 23/39] feat: Enhance session management and transport handling - Added a lottery configuration for session garbage collection in `mcp.php`. - Updated `LaravelHttpTransport` to include session garbage collection on message requests. - Refactored `LaravelStreamableHttpTransport` to streamline message handling and improve context management. - Adjusted command output formatting for better readability when starting the MCP server. --- config/mcp.php | 1 + samples/basic/bootstrap/app.php | 6 +- samples/basic/config/mcp.php | 2 +- src/Commands/ServeCommand.php | 10 +- src/Transports/LaravelHttpTransport.php | 13 +- .../LaravelStreamableHttpTransport.php | 125 +++++++----------- 6 files changed, 71 insertions(+), 86 deletions(-) diff --git a/config/mcp.php b/config/mcp.php index 4edd3c8..7bae9d4 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -115,6 +115,7 @@ 'session' => [ 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'array' or 'cache' 'ttl' => (int) env('MCP_SESSION_TTL', 3600), // Session lifetime in seconds + 'lottery' => [2, 100], // 2% chance of garbage collection ], /* diff --git a/samples/basic/bootstrap/app.php b/samples/basic/bootstrap/app.php index 95c46c7..7a89dbd 100644 --- a/samples/basic/bootstrap/app.php +++ b/samples/basic/bootstrap/app.php @@ -6,13 +6,13 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__.'/../routes/web.php', - commands: __DIR__.'/../routes/console.php', + web: __DIR__ . '/../routes/web.php', + commands: __DIR__ . '/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - // $middleware->validateCsrfTokens(except: [ + 'mcp', 'mcp/*', ]); }) diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index 6332b25..ab8cb76 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -99,7 +99,7 @@ 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), - 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), + 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', false), 'json_response_timeout' => (int) env('MCP_HTTP_INTEGRATED_JSON_TIMEOUT', 30), 'event_store' => env('MCP_HTTP_INTEGRATED_EVENT_STORE'), // FQCN or null ], diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index 83ff721..77e3c35 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -81,9 +81,9 @@ private function handleStdioTransport(Server $server): int } $this->info('Starting MCP server'); - $this->line(" \t- Transport: STDIO"); - $this->line(" \t- Communication: STDIN/STDOUT"); - $this->line(" \t- Mode: JSON-RPC over Standard I/O"); + $this->line(" - Transport: STDIO"); + $this->line(" - Communication: STDIN/STDOUT"); + $this->line(" - Mode: JSON-RPC over Standard I/O"); try { $transport = new StdioServerTransport; @@ -139,8 +139,6 @@ private function handleSseHttpTransport(Server $server, string $host, int $port, return Command::FAILURE; } - $this->info("MCP Server (Legacy HTTP) stopped."); - return Command::SUCCESS; } @@ -171,8 +169,6 @@ private function handleStreamableHttpTransport(Server $server, string $host, int return Command::FAILURE; } - $this->info("MCP Server (Streamable HTTP) stopped."); - return Command::SUCCESS; } diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/LaravelHttpTransport.php index b6049d8..cd309d8 100644 --- a/src/Transports/LaravelHttpTransport.php +++ b/src/Transports/LaravelHttpTransport.php @@ -74,6 +74,8 @@ public function sendMessage(Message $message, string $sessionId, array $context */ public function handleMessageRequest(Request $request): Response { + $this->collectSessionGarbage(); + if (!$request->isJson()) { Log::warning('Received POST request with invalid Content-Type'); @@ -201,7 +203,7 @@ private function sendSseEvent(string $event, string $data, ?string $id = null): /** * Flush output buffer */ - private function flushOutput(): void + protected function flushOutput(): void { if (function_exists('ob_flush')) { @ob_flush(); @@ -209,6 +211,15 @@ private function flushOutput(): void @flush(); } + protected function collectSessionGarbage(): void + { + $lottery = config('mcp.session.lottery', [2, 100]); + + if (random_int(1, $lottery[1]) <= $lottery[0]) { + $this->sessionManager->gc(); + } + } + /** * 'Closes' the transport. */ diff --git a/src/Transports/LaravelStreamableHttpTransport.php b/src/Transports/LaravelStreamableHttpTransport.php index 89d1159..630f214 100644 --- a/src/Transports/LaravelStreamableHttpTransport.php +++ b/src/Transports/LaravelStreamableHttpTransport.php @@ -30,14 +30,7 @@ class LaravelStreamableHttpTransport implements ServerTransportInterface public function __construct( protected SessionManager $sessionManager, protected ?EventStoreInterface $eventStore = null - ) { - $this->on('message', function (Message $message, string $sessionId) { - $session = $this->sessionManager->getSession($sessionId); - if ($session !== null) { - $session->save(); - } - }); - } + ) {} protected function generateId(): string { @@ -59,14 +52,14 @@ public function sendMessage(Message $message, string $sessionId, array $context $eventId = null; if ($this->eventStore && isset($context['type']) && in_array($context['type'], ['get_sse', 'post_sse'])) { - $streamKey = $context['type'] === 'get_sse' ? "get_stream_{$sessionId}" : $context['streamId'] ?? "post_stream_{$sessionId}"; - $eventId = $this->eventStore->storeEvent($streamKey, $rawMessage); + $streamId = $context['streamId']; + $eventId = $this->eventStore->storeEvent($streamId, $rawMessage); } $messageData = [ 'id' => $eventId ?? $this->generateId(), 'data' => $rawMessage, - 'context' => $context['type'] ?? 'get_sse', + 'context' => $context, 'timestamp' => time() ]; @@ -161,35 +154,26 @@ protected function handleJsonResponse(Message $message, string $sessionId, array $context['type'] = 'post_json'; $this->emit('message', [$message, $sessionId, $context]); - $maxWaitTime = config('mcp.transports.http_integrated.json_response_timeout', 30); - $pollInterval = 0.1; // 100ms - $waitedTime = 0; - - while ($waitedTime < $maxWaitTime) { - $messages = $this->dequeueMessagesForContext($sessionId, 'post_json'); - - if (!empty($messages)) { - $responseMessage = $messages[0]; - $data = $responseMessage['data']; + $messages = $this->dequeueMessagesForContext($sessionId, 'post_json'); - $headers = [ - 'Content-Type' => 'application/json', - ...$this->getCorsHeaders() - ]; + if (empty($messages)) { + $error = Error::forInternalError('Internal error'); + return response()->json($error, 500, $this->getCorsHeaders()); + } - if ($context['is_initialize_request'] ?? false) { - $headers['Mcp-Session-Id'] = $sessionId; - } + $responseMessage = $messages[0]; + $data = $responseMessage['data']; - return response()->make($data, 200, $headers); - } + $headers = [ + 'Content-Type' => 'application/json', + ...$this->getCorsHeaders() + ]; - usleep((int)($pollInterval * 1000000)); - $waitedTime += $pollInterval; + if ($context['is_initialize_request'] ?? false) { + $headers['Mcp-Session-Id'] = $sessionId; } - $error = Error::forInternalError('Request timeout'); - return response()->json($error, 504, $this->getCorsHeaders()); + return response()->make($data, 200, $headers); } catch (Throwable $e) { Log::error('JSON response mode error', ['exception' => $e]); $error = Error::forInternalError('Internal error'); @@ -202,46 +186,29 @@ protected function handleJsonResponse(Message $message, string $sessionId, array */ protected function handleSseResponse(Message $message, string $sessionId, int $nRequests, array $context): StreamedResponse { - $streamId = $this->generateId(); - $context['type'] = 'post_sse'; - $context['streamId'] = $streamId; - $context['nRequests'] = $nRequests; - - $this->emit('message', [$message, $sessionId, $context]); - - return response()->stream(function () use ($sessionId, $nRequests, $streamId) { - $responsesSent = 0; - $maxWaitTime = 30; // 30 seconds timeout - $pollInterval = 0.1; // 100ms - $waitedTime = 0; + $headers = array_merge([ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ], $this->getCorsHeaders()); - while ($responsesSent < $nRequests && $waitedTime < $maxWaitTime) { - if (connection_aborted()) { - break; - } + if ($context['is_initialize_request'] ?? false) { + $headers['Mcp-Session-Id'] = $sessionId; + } - $messages = $this->dequeueMessagesForContext($sessionId, 'post_sse', $streamId); + return response()->stream(function () use ($sessionId, $nRequests, $message, $context) { + $streamId = $this->generateId(); + $context['type'] = 'post_sse'; + $context['streamId'] = $streamId; + $context['nRequests'] = $nRequests; - foreach ($messages as $messageData) { - $this->sendSseEvent($messageData['data'], $messageData['id']); - $responsesSent++; + $this->emit('message', [$message, $sessionId, $context]); - if ($responsesSent >= $nRequests) { - break; - } - } + $messages = $this->dequeueMessagesForContext($sessionId, 'post_sse', $streamId); - if ($responsesSent < $nRequests) { - usleep((int)($pollInterval * 1000000)); - $waitedTime += $pollInterval; - } - } - }, headers: array_merge([ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - ], $this->getCorsHeaders())); + $this->sendSseEvent($messages[0]['data'], $messages[0]['id']); + }, headers: $headers); } /** @@ -325,7 +292,7 @@ public function handleDeleteRequest(Request $request): Response /** * Dequeue messages for specific context, requeue others */ - protected function dequeueMessagesForContext(string $sessionId, string $context, ?string $streamId = null): array + protected function dequeueMessagesForContext(string $sessionId, string $type, ?string $streamId = null): array { $allMessages = $this->sessionManager->dequeueMessages($sessionId); $contextMessages = []; @@ -333,12 +300,13 @@ protected function dequeueMessagesForContext(string $sessionId, string $context, foreach ($allMessages as $rawMessage) { $messageData = json_decode($rawMessage, true); + $context = $messageData['context'] ?? []; - if ($messageData && isset($messageData['context'])) { - $matchesContext = $messageData['context'] === $context; + if ($messageData) { + $matchesContext = $context['type'] === $type; - if ($context === 'post_sse' && $streamId) { - $matchesContext = $matchesContext && isset($messageData['streamId']) && $messageData['streamId'] === $streamId; + if ($type === 'post_sse' && $streamId) { + $matchesContext = $matchesContext && isset($context['streamId']) && $context['streamId'] === $streamId; } if ($matchesContext) { @@ -412,6 +380,15 @@ private function flushOutput(): void @flush(); } + protected function collectSessionGarbage(): void + { + $lottery = config('mcp.session.lottery', [2, 100]); + + if (random_int(1, $lottery[1]) <= $lottery[0]) { + $this->sessionManager->gc(); + } + } + /** * Get CORS headers */ From dc9bf3a6248fe7cb2e538cbcd0a2597027e07861 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 15:49:03 +0100 Subject: [PATCH 24/39] feat: implement Laravel-native session handlers - Add FileSessionHandler with directory auto-creation and proper gc() - Add DatabaseSessionHandler with base64 encoding and session tracking - Update McpServiceProvider to construct handlers directly instead of using server builder - Add session configuration options for file path and database settings - Include database migration for mcp_sessions table - Support all Laravel session drivers: array, file, cache, database, redis, memcached, dynamodb --- config/mcp.php | 42 +++-- .../migrations/create_mcp_sessions_table.php | 28 +++ samples/basic/config/mcp.php | 79 +++++--- ...06_25_144611_create_mcp_sessions_table.php | 28 +++ src/McpServiceProvider.php | 66 ++++--- src/Session/DatabaseSessionHandler.php | 170 ++++++++++++++++++ src/Session/FileSessionHandler.php | 101 +++++++++++ 7 files changed, 451 insertions(+), 63 deletions(-) create mode 100644 database/migrations/create_mcp_sessions_table.php create mode 100644 samples/basic/database/migrations/2025_06_25_144611_create_mcp_sessions_table.php create mode 100644 src/Session/DatabaseSessionHandler.php create mode 100644 src/Session/FileSessionHandler.php diff --git a/config/mcp.php b/config/mcp.php index 7bae9d4..24ffc6f 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -53,14 +53,12 @@ | MCP Cache Configuration |-------------------------------------------------------------------------- | - | Configure how the MCP server caches discovered elements and transport - | state using Laravel's cache system. You can specify which store to use - | and how long items should be cached. + | Configure how the MCP server caches discovered elements using Laravel's cache system. + | You can specify which store to use and how long items should be cached. | */ 'cache' => [ 'store' => env('MCP_CACHE_STORE', config('cache.default')), - 'ttl' => env('MCP_CACHE_TTL', 3600), ], /* @@ -68,10 +66,12 @@ | MCP Transport Configuration |-------------------------------------------------------------------------- | - | Configure the available transports for MCP communication. Three types are - | supported: stdio for CLI clients, http_dedicated for a standalone server, - | and http_integrated for serving through Laravel's routing system. + | Configure the available transports for MCP communication. | + | Supported Transports: + | - `stdio`: for CLI clients. + | - `http_dedicated`: for a standalone server running on a process. + | - `http_integrated`: for serving through Laravel's routing system. */ 'transports' => [ 'stdio' => [ @@ -86,20 +86,19 @@ 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), 'ssl_context_options' => [], 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), - 'event_store' => env('MCP_HTTP_DEDICATED_EVENT_STORE'), // FQCN or null + 'event_store' => null, // FQCN or null ], 'http_integrated' => [ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), - 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), + 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'api')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), - 'json_response_timeout' => (int) env('MCP_HTTP_INTEGRATED_JSON_TIMEOUT', 30), - 'event_store' => env('MCP_HTTP_INTEGRATED_EVENT_STORE'), // FQCN or null + 'event_store' => null, // FQCN or null ], ], @@ -109,13 +108,26 @@ |-------------------------------------------------------------------------- | | Configure how the MCP server manages client sessions. Sessions store - | client state, message queues, and subscriptions. + | client state, message queues, and subscriptions. Supports Laravel's + | native session drivers for seamless integration. | */ 'session' => [ - 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'array' or 'cache' - 'ttl' => (int) env('MCP_SESSION_TTL', 3600), // Session lifetime in seconds - 'lottery' => [2, 100], // 2% chance of garbage collection + 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'file', 'cache', 'database', 'redis', 'memcached', 'dynamodb', 'array' + 'ttl' => (int) env('MCP_SESSION_TTL', 3600), + + // For cache-based drivers (redis, memcached, etc.) + 'store' => env('MCP_SESSION_CACHE_STORE', config('cache.default')), + + // For file driver + 'path' => env('MCP_SESSION_FILE_PATH', storage_path('framework/mcp_sessions')), + + // For database driver + 'connection' => env('MCP_SESSION_DB_CONNECTION', config('database.default')), + 'table' => env('MCP_SESSION_DB_TABLE', 'mcp_sessions'), + + // Session garbage collection probability. 2% chance that garbage collection will run on any given session operation. + 'lottery' => [2, 100], ], /* diff --git a/database/migrations/create_mcp_sessions_table.php b/database/migrations/create_mcp_sessions_table.php new file mode 100644 index 0000000..4054222 --- /dev/null +++ b/database/migrations/create_mcp_sessions_table.php @@ -0,0 +1,28 @@ +string('id')->primary(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mcp_sessions'); + } +}; diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index ab8cb76..24ffc6f 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -29,9 +29,7 @@ */ 'discovery' => [ 'base_path' => base_path(), - 'directories' => [ - env('MCP_DISCOVERY_PATH', 'app/Mcp'), - ], + 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_DIRECTORIES', 'app/Mcp'))), 'exclude_dirs' => [ 'vendor', 'tests', @@ -46,8 +44,8 @@ '.git', ], 'definitions_file' => base_path('routes/mcp.php'), - 'auto_discover' => env('MCP_AUTO_DISCOVER', true), - 'save_to_cache' => env('MCP_DISCOVERY_SAVE_TO_CACHE', true), + 'auto_discover' => (bool) env('MCP_AUTO_DISCOVER', true), + 'save_to_cache' => (bool) env('MCP_DISCOVERY_SAVE_TO_CACHE', true), ], /* @@ -55,14 +53,12 @@ | MCP Cache Configuration |-------------------------------------------------------------------------- | - | Configure how the MCP server caches discovered elements and transport - | state using Laravel's cache system. You can specify which store to use - | and how long items should be cached. + | Configure how the MCP server caches discovered elements using Laravel's cache system. + | You can specify which store to use and how long items should be cached. | */ 'cache' => [ 'store' => env('MCP_CACHE_STORE', config('cache.default')), - 'ttl' => env('MCP_CACHE_TTL', 3600), ], /* @@ -70,14 +66,16 @@ | MCP Transport Configuration |-------------------------------------------------------------------------- | - | Configure the available transports for MCP communication. Three types are - | supported: stdio for CLI clients, http_dedicated for a standalone server, - | and http_integrated for serving through Laravel's routing system. + | Configure the available transports for MCP communication. | + | Supported Transports: + | - `stdio`: for CLI clients. + | - `http_dedicated`: for a standalone server running on a process. + | - `http_integrated`: for serving through Laravel's routing system. */ 'transports' => [ 'stdio' => [ - 'enabled' => env('MCP_STDIO_ENABLED', true), + 'enabled' => (bool) env('MCP_STDIO_ENABLED', true), ], 'http_dedicated' => [ @@ -88,23 +86,50 @@ 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), 'ssl_context_options' => [], 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), - 'event_store' => env('MCP_HTTP_DEDICATED_EVENT_STORE'), // FQCN or null + 'event_store' => null, // FQCN or null ], 'http_integrated' => [ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), - 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'web')), + 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'api')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), - 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', false), - 'json_response_timeout' => (int) env('MCP_HTTP_INTEGRATED_JSON_TIMEOUT', 30), - 'event_store' => env('MCP_HTTP_INTEGRATED_EVENT_STORE'), // FQCN or null + 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), + 'event_store' => null, // FQCN or null ], ], + /* + |-------------------------------------------------------------------------- + | Session Management Configuration + |-------------------------------------------------------------------------- + | + | Configure how the MCP server manages client sessions. Sessions store + | client state, message queues, and subscriptions. Supports Laravel's + | native session drivers for seamless integration. + | + */ + 'session' => [ + 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'file', 'cache', 'database', 'redis', 'memcached', 'dynamodb', 'array' + 'ttl' => (int) env('MCP_SESSION_TTL', 3600), + + // For cache-based drivers (redis, memcached, etc.) + 'store' => env('MCP_SESSION_CACHE_STORE', config('cache.default')), + + // For file driver + 'path' => env('MCP_SESSION_FILE_PATH', storage_path('framework/mcp_sessions')), + + // For database driver + 'connection' => env('MCP_SESSION_DB_CONNECTION', config('database.default')), + 'table' => env('MCP_SESSION_DB_TABLE', 'mcp_sessions'), + + // Session garbage collection probability. 2% chance that garbage collection will run on any given session operation. + 'lottery' => [2, 100], + ], + /* |-------------------------------------------------------------------------- | Pagination Limit @@ -128,24 +153,24 @@ */ 'capabilities' => [ 'tools' => [ - 'enabled' => env('MCP_CAP_TOOLS_ENABLED', true), - 'listChanged' => env('MCP_CAP_TOOLS_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), + 'listChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), ], 'resources' => [ - 'enabled' => env('MCP_CAP_RESOURCES_ENABLED', true), - 'subscribe' => env('MCP_CAP_RESOURCES_SUBSCRIBE', true), - 'listChanged' => env('MCP_CAP_RESOURCES_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), + 'subscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), + 'listChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), ], 'prompts' => [ - 'enabled' => env('MCP_CAP_PROMPTS_ENABLED', true), - 'listChanged' => env('MCP_CAP_PROMPTS_LIST_CHANGED', true), + 'enabled' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), + 'listChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), ], 'logging' => [ - 'enabled' => env('MCP_CAP_LOGGING_ENABLED', true), - 'setLevel' => env('MCP_CAP_LOGGING_SET_LEVEL', false), + 'enabled' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), + 'setLevel' => (bool) env('MCP_CAP_LOGGING_SET_LEVEL', false), ], ], diff --git a/samples/basic/database/migrations/2025_06_25_144611_create_mcp_sessions_table.php b/samples/basic/database/migrations/2025_06_25_144611_create_mcp_sessions_table.php new file mode 100644 index 0000000..4054222 --- /dev/null +++ b/samples/basic/database/migrations/2025_06_25_144611_create_mcp_sessions_table.php @@ -0,0 +1,28 @@ +string('id')->primary(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mcp_sessions'); + } +}; diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index bdf6446..cc7ab73 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -15,11 +15,15 @@ use PhpMcp\Laravel\Events\ResourcesListChanged; use PhpMcp\Laravel\Events\ToolsListChanged; use PhpMcp\Laravel\Listeners\McpNotificationListener; - use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Contracts\SessionHandlerInterface; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\CacheSessionHandler; +use PhpMcp\Laravel\Session\DatabaseSessionHandler; +use PhpMcp\Laravel\Session\FileSessionHandler; class McpServiceProvider extends ServiceProvider { @@ -32,27 +36,11 @@ public function register(): void $this->app->alias(McpRegistrar::class, 'mcp.registrar'); } - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return [ - McpRegistrar::class, - Server::class, - Registry::class, - SessionManager::class, - - ]; - } - public function boot(): void { $this->loadMcpDefinitions(); $this->buildServer(); - $this->bootConfig(); + $this->bootPublishables(); $this->bootRoutes(); $this->bootEvents(); $this->bootCommands(); @@ -85,7 +73,7 @@ protected function buildServer(): void experimental: null, ); - $sessionDriver = config('mcp.session.driver', 'cache'); + $sessionHandler = $this->createSessionHandler($app); $sessionTtl = (int) config('mcp.session.ttl', 3600); $builder = Server::make() @@ -93,7 +81,7 @@ protected function buildServer(): void ->withLogger($logger) ->withContainer($app) ->withCache($cache) - ->withSession($sessionDriver, $sessionTtl) + ->withSessionHandler($sessionHandler, $sessionTtl) ->withCapabilities($capabilities) ->withPaginationLimit((int) config('mcp.pagination_limit', 50)); @@ -126,10 +114,46 @@ protected function buildServer(): void $this->app->alias(Registry::class, 'mcp.registry'); } - protected function bootConfig(): void + /** + * Create appropriate session handler based on configuration. + */ + private function createSessionHandler(Application $app): SessionHandlerInterface + { + $driver = config('mcp.session.driver', 'cache'); + $ttl = (int) config('mcp.session.ttl', 3600); + + return match ($driver) { + 'array' => new ArraySessionHandler($ttl), + + 'cache', 'redis', 'memcached', 'dynamodb' => new CacheSessionHandler( + $app['cache']->store(config('mcp.session.store', config('cache.default'))), + $ttl + ), + + 'file' => new FileSessionHandler( + $app['files'], + config('mcp.session.path', storage_path('framework/mcp_sessions')), + $ttl + ), + + 'database' => new DatabaseSessionHandler( + $app['db']->connection(config('mcp.session.connection')), + config('mcp.session.table', 'mcp_sessions'), + $ttl + ), + + default => throw new \InvalidArgumentException("Unsupported MCP session driver: {$driver}") + }; + } + + protected function bootPublishables(): void { if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mcp.php' => config_path('mcp.php')], 'mcp-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations/create_mcp_sessions_table.php' => database_path('migrations/' . date('Y_m_d_His', time()) . '_create_mcp_sessions_table.php'), + ], 'mcp-migrations'); } } diff --git a/src/Session/DatabaseSessionHandler.php b/src/Session/DatabaseSessionHandler.php new file mode 100644 index 0000000..af5122f --- /dev/null +++ b/src/Session/DatabaseSessionHandler.php @@ -0,0 +1,170 @@ +connection = $connection; + $this->table = $table; + $this->ttl = $ttl; + } + + /** + * {@inheritdoc} + */ + public function read(string $sessionId): string|false + { + $session = (object) $this->getQuery()->find($sessionId); + + if ($this->expired($session)) { + $this->exists = true; + return false; + } + + if (isset($session->payload)) { + $this->exists = true; + return base64_decode($session->payload); + } + + $this->exists = false; + return false; + } + + /** + * {@inheritdoc} + */ + public function write(string $sessionId, string $data): bool + { + $payload = $this->getDefaultPayload($data); + + if (!$this->exists) { + $this->read($sessionId); + } + + if ($this->exists) { + $this->performUpdate($sessionId, $payload); + } else { + $this->performInsert($sessionId, $payload); + } + + return $this->exists = true; + } + + /** + * {@inheritdoc} + */ + public function destroy(string $sessionId): bool + { + $this->getQuery()->where('id', $sessionId)->delete(); + return true; + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): array + { + // Get session IDs that will be deleted + $deletedSessions = $this->getQuery() + ->where('last_activity', '<=', $this->currentTime() - $maxLifetime) + ->pluck('id') + ->toArray(); + + // Delete the sessions + $this->getQuery() + ->where('last_activity', '<=', $this->currentTime() - $maxLifetime) + ->delete(); + + return $deletedSessions; + } + + /** + * Determine if the session is expired. + */ + protected function expired(object $session): bool + { + return isset($session->last_activity) && + $session->last_activity < Carbon::now()->subSeconds($this->ttl)->getTimestamp(); + } + + /** + * Perform an insert operation on the session ID. + */ + protected function performInsert(string $sessionId, array $payload): ?bool + { + try { + return $this->getQuery()->insert(Arr::set($payload, 'id', $sessionId)); + } catch (QueryException) { + $this->performUpdate($sessionId, $payload); + return null; + } + } + + /** + * Perform an update operation on the session ID. + */ + protected function performUpdate(string $sessionId, array $payload): int + { + return $this->getQuery()->where('id', $sessionId)->update($payload); + } + + /** + * Get the default payload for the session. + */ + protected function getDefaultPayload(string $data): array + { + return [ + 'payload' => base64_encode($data), + 'last_activity' => $this->currentTime(), + ]; + } + + /** + * Get the current UNIX timestamp. + */ + protected function currentTime(): int + { + return Carbon::now()->getTimestamp(); + } + + /** + * Get a fresh query builder instance for the table. + */ + protected function getQuery(): \Illuminate\Database\Query\Builder + { + return $this->connection->table($this->table)->useWritePdo(); + } +} diff --git a/src/Session/FileSessionHandler.php b/src/Session/FileSessionHandler.php new file mode 100644 index 0000000..397bddb --- /dev/null +++ b/src/Session/FileSessionHandler.php @@ -0,0 +1,101 @@ +isDirectory($path)) { + $files->makeDirectory($path, 0755, true); + } + + $this->files = $files; + $this->path = $path; + $this->ttl = $ttl; + } + + /** + * {@inheritdoc} + */ + public function read(string $sessionId): string|false + { + $path = $this->path . '/' . $sessionId; + + if ( + $this->files->isFile($path) && + $this->files->lastModified($path) >= Carbon::now()->subSeconds($this->ttl)->getTimestamp() + ) { + return $this->files->sharedGet($path); + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function write(string $sessionId, string $data): bool + { + $this->files->put($this->path . '/' . $sessionId, $data, true); + + return true; + } + + /** + * {@inheritdoc} + */ + public function destroy(string $sessionId): bool + { + $this->files->delete($this->path . '/' . $sessionId); + + return true; + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): array + { + $files = Finder::create() + ->in($this->path) + ->files() + ->ignoreDotFiles(true) + ->date('<= now - ' . $maxLifetime . ' seconds'); + + $deletedSessions = []; + + foreach ($files as $file) { + $sessionId = $file->getBasename(); + $this->files->delete($file->getRealPath()); + $deletedSessions[] = $sessionId; + } + + return $deletedSessions; + } +} From dd01d118891585bfe49ce0a7650aa07e90c6b136 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 18:49:46 +0100 Subject: [PATCH 25/39] refactor: rename the transports for consistency --- src/Http/Controllers/SseTransportController.php | 6 +++--- src/Http/Controllers/StreamableTransportController.php | 6 +++--- .../{LaravelHttpTransport.php => HttpServerTransport.php} | 2 +- ...eHttpTransport.php => StreamableHttpServerTransport.php} | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) rename src/Transports/{LaravelHttpTransport.php => HttpServerTransport.php} (99%) rename src/Transports/{LaravelStreamableHttpTransport.php => StreamableHttpServerTransport.php} (99%) diff --git a/src/Http/Controllers/SseTransportController.php b/src/Http/Controllers/SseTransportController.php index ce3839d..db2dead 100644 --- a/src/Http/Controllers/SseTransportController.php +++ b/src/Http/Controllers/SseTransportController.php @@ -5,14 +5,14 @@ namespace PhpMcp\Laravel\Http\Controllers; use Illuminate\Http\Request; -use PhpMcp\Laravel\Transports\LaravelHttpTransport; +use PhpMcp\Laravel\Transports\HttpServerTransport; use PhpMcp\Server\Server; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; class SseTransportController { - protected LaravelHttpTransport $transport; + protected HttpServerTransport $transport; /** * MCP Controller Constructor @@ -21,7 +21,7 @@ class SseTransportController */ public function __construct(Server $server) { - $this->transport = new LaravelHttpTransport($server->getSessionManager()); + $this->transport = new HttpServerTransport($server->getSessionManager()); $server->listen($this->transport, false); } diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php index 3ac7e2e..000f70f 100644 --- a/src/Http/Controllers/StreamableTransportController.php +++ b/src/Http/Controllers/StreamableTransportController.php @@ -5,7 +5,7 @@ namespace PhpMcp\Laravel\Http\Controllers; use Illuminate\Http\Request; -use PhpMcp\Laravel\Transports\LaravelStreamableHttpTransport; +use PhpMcp\Laravel\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Contracts\EventStoreInterface; use PhpMcp\Server\Server; use Symfony\Component\HttpFoundation\Response; @@ -13,14 +13,14 @@ class StreamableTransportController { - private LaravelStreamableHttpTransport $transport; + private StreamableHttpServerTransport $transport; public function __construct(Server $server) { $eventStore = $this->createEventStore(); $sessionManager = $server->getSessionManager(); - $this->transport = new LaravelStreamableHttpTransport($sessionManager, $eventStore); + $this->transport = new StreamableHttpServerTransport($sessionManager, $eventStore); $server->listen($this->transport, false); } diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/HttpServerTransport.php similarity index 99% rename from src/Transports/LaravelHttpTransport.php rename to src/Transports/HttpServerTransport.php index cd309d8..35d3323 100644 --- a/src/Transports/LaravelHttpTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -19,7 +19,7 @@ use function React\Promise\resolve; -class LaravelHttpTransport implements ServerTransportInterface +class HttpServerTransport implements ServerTransportInterface { use EventEmitterTrait; diff --git a/src/Transports/LaravelStreamableHttpTransport.php b/src/Transports/StreamableHttpServerTransport.php similarity index 99% rename from src/Transports/LaravelStreamableHttpTransport.php rename to src/Transports/StreamableHttpServerTransport.php index 630f214..618e7bf 100644 --- a/src/Transports/LaravelStreamableHttpTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -23,7 +23,7 @@ use function React\Promise\resolve; -class LaravelStreamableHttpTransport implements ServerTransportInterface +class StreamableHttpServerTransport implements ServerTransportInterface { use EventEmitterTrait; @@ -73,6 +73,8 @@ public function sendMessage(Message $message, string $sessionId, array $context */ public function handlePostRequest(Request $request): Response { + $this->collectSessionGarbage(); + $acceptHeader = $request->header('Accept', ''); if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { $error = Error::forInvalidRequest('Not Acceptable: Client must accept application/json or text/event-stream'); From 2f8ac08c06a3173a27b8fb078884fd4289927a02 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 19:21:19 +0100 Subject: [PATCH 26/39] fix: Close GET SSE Stream when session is deleted or has expired --- src/Transports/StreamableHttpServerTransport.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 618e7bf..c583453 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -250,18 +250,15 @@ public function handleGetRequest(Request $request): StreamedResponse|Response break; } + if (!$this->sessionManager->hasSession($sessionId)) { + break; + } + $messages = $this->dequeueMessagesForContext($sessionId, 'get_sse'); foreach ($messages as $messageData) { $this->sendSseEvent(rtrim($messageData['data'], "\n"), $messageData['id']); } - static $keepAliveCounter = 0; - $keepAliveInterval = (int) round(15 / $pollInterval); - if (($keepAliveCounter++ % $keepAliveInterval) == 0) { - echo ": keep-alive\n\n"; - $this->flushOutput(); - } - usleep($pollInterval * 1000000); } }, headers: array_merge([ From 58a2be6e964f7d93da8a298d338b20874a4b5097 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 25 Jun 2025 23:12:10 +0100 Subject: [PATCH 27/39] tests: Update tests to replace deprecated methods - Refactored tests to replace deprecated methods with updated ones for retrieving tools, resources, and prompts. - Enhanced assertions in tests to align with the new schema structure for registered elements. - Updated command tests to reflect changes in the MCP registry and output messages for better clarity. --- config/mcp.php | 5 +- .../Feature/Commands/DiscoverCommandTest.php | 9 +- tests/Feature/Commands/ListCommandTest.php | 52 ++++------- tests/Feature/Commands/ServeCommandTest.php | 9 +- tests/Feature/ManualRegistrationTest.php | 88 +++++++++---------- tests/Feature/McpServiceProviderTest.php | 40 ++++----- 6 files changed, 90 insertions(+), 113 deletions(-) diff --git a/config/mcp.php b/config/mcp.php index 24ffc6f..9e32f4c 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -72,6 +72,9 @@ | - `stdio`: for CLI clients. | - `http_dedicated`: for a standalone server running on a process. | - `http_integrated`: for serving through Laravel's routing system. + | + | The 'legacy' option is used to enable the deprecated HTTP+SSE transport. + | It is not recommended to use this option. */ 'transports' => [ 'stdio' => [ @@ -93,7 +96,7 @@ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), - 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'api')), + 'middleware' => ['api'], 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), diff --git a/tests/Feature/Commands/DiscoverCommandTest.php b/tests/Feature/Commands/DiscoverCommandTest.php index 1bad728..5b9621c 100644 --- a/tests/Feature/Commands/DiscoverCommandTest.php +++ b/tests/Feature/Commands/DiscoverCommandTest.php @@ -12,11 +12,10 @@ class DiscoverCommandTest extends TestCase public function test_discover_command_displays_correct_element_counts() { $registryMock = Mockery::mock(Registry::class); - $registryMock->shouldReceive('allTools->count')->andReturn(2); - $registryMock->shouldReceive('allResources->count')->andReturn(1); - $registryMock->shouldReceive('allResourceTemplates->count')->andReturn(0); - $registryMock->shouldReceive('allPrompts->count')->andReturn(3); - $registryMock->shouldReceive('discoveryRanOrCached')->andReturn(true); + $registryMock->shouldReceive('getTools')->andReturn(['tool1', 'tool2']); + $registryMock->shouldReceive('getResources')->andReturn(['resource1']); + $registryMock->shouldReceive('getResourceTemplates')->andReturn([]); + $registryMock->shouldReceive('getPrompts')->andReturn(['prompt1', 'prompt2', 'prompt3']); $serverMock = $this->mock(Server::class, function ($mock) use ($registryMock) { $mock->shouldReceive('discover')->once(); diff --git a/tests/Feature/Commands/ListCommandTest.php b/tests/Feature/Commands/ListCommandTest.php index 666655a..4290ffa 100644 --- a/tests/Feature/Commands/ListCommandTest.php +++ b/tests/Feature/Commands/ListCommandTest.php @@ -4,12 +4,10 @@ use PhpMcp\Laravel\Tests\Stubs\App\Mcp\ManualTestHandler; use PhpMcp\Laravel\Tests\TestCase; -use PhpMcp\Server\Definitions\ToolDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; -use PhpMcp\Server\Support\DocBlockParser; -use PhpMcp\Server\Support\SchemaGenerator; +use PhpMcp\Schema\Tool; +use PhpMcp\Schema\Resource; use ArrayObject; use Illuminate\Support\Facades\Artisan; use Psr\Log\NullLogger; @@ -24,29 +22,11 @@ protected function getEnvironmentSetUp($app) private function populateRegistry(Registry $registry) { - $logger = new NullLogger; - $docBlockParser = new DocBlockParser($logger); - $schemaGenerator = new SchemaGenerator($docBlockParser); - - $tool1 = ToolDefinition::fromReflection( - new \ReflectionMethod(ManualTestHandler::class, 'handleTool'), - 'list_tool_1', - 'Desc 1', - $docBlockParser, - $schemaGenerator - ); - $resource1 = ResourceDefinition::fromReflection( - new \ReflectionMethod(ManualTestHandler::class, 'handleResource'), - 'list_res_1', - 'Desc Res 1', - 'res://list/1', - 'text/plain', - null, - [], - $docBlockParser - ); - $registry->registerTool($tool1, true); - $registry->registerResource($resource1, true); + $tool1 = Tool::make('list_tool_1', ['type' => 'object'], 'Desc 1'); + $resource1 = Resource::make('res://list/1', 'list_res_1', 'Desc Res 1', 'text/plain'); + + $registry->registerTool($tool1, ManualTestHandler::class, 'handleTool', true); + $registry->registerResource($resource1, ManualTestHandler::class, 'handleResource', true); } public function test_list_command_shows_all_types_by_default() @@ -90,7 +70,7 @@ public function test_list_command_json_output_is_correct() $this->assertArrayHasKey('tools', $jsonData); $this->assertArrayHasKey('resources', $jsonData); $this->assertCount(1, $jsonData['tools']); - $this->assertEquals('list_tool_1', $jsonData['tools'][0]['toolName']); + $this->assertEquals('list_tool_1', $jsonData['tools'][0]['name']); $this->assertEquals('res://list/1', $jsonData['resources'][0]['uri']); } @@ -107,7 +87,8 @@ public function test_list_command_handles_empty_registry_for_type() public function test_list_command_warns_if_discovery_not_run_and_no_manual_elements() { $this->artisan('mcp:list') - ->expectsOutputToContain('No MCP elements are manually registered, and discovery has not run') + ->expectsOutputToContain('MCP Registry is empty.') + ->expectsOutputToContain('Run `php artisan mcp:discover` to discover MCP elements.') ->assertSuccessful(); } @@ -115,12 +96,10 @@ public function test_list_command_warns_if_discovery_ran_but_no_elements_found() { $registryMock = $this->mock(Registry::class); $registryMock->shouldReceive('hasElements')->andReturn(false); - $registryMock->shouldReceive('discoveryRanOrCached')->andReturn(true); // Key difference - $registryMock->shouldReceive('allTools')->andReturn(new ArrayObject()); - $registryMock->shouldReceive('allResources')->andReturn(new ArrayObject()); - $registryMock->shouldReceive('allPrompts')->andReturn(new ArrayObject()); - $registryMock->shouldReceive('allResourceTemplates')->andReturn(new ArrayObject()); - + $registryMock->shouldReceive('getTools')->andReturn([]); + $registryMock->shouldReceive('getResources')->andReturn([]); + $registryMock->shouldReceive('getPrompts')->andReturn([]); + $registryMock->shouldReceive('getResourceTemplates')->andReturn([]); $serverMock = $this->mock(Server::class, function ($mock) use ($registryMock) { $mock->shouldReceive('getRegistry')->andReturn($registryMock); @@ -128,7 +107,8 @@ public function test_list_command_warns_if_discovery_ran_but_no_elements_found() $this->app->instance(Server::class, $serverMock); $this->artisan('mcp:list') - ->expectsOutputToContain('Discovery/cache load ran, but no MCP elements were found.') + ->expectsOutputToContain('MCP Registry is empty.') + ->expectsOutputToContain('Run `php artisan mcp:discover` to discover MCP elements.') ->assertSuccessful(); } } diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php index d8d4468..97ed523 100644 --- a/tests/Feature/Commands/ServeCommandTest.php +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -5,6 +5,7 @@ use PhpMcp\Laravel\Tests\TestCase; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Transports\StdioServerTransport; use Mockery; use Orchestra\Testbench\Attributes\DefineEnvironment; @@ -55,7 +56,7 @@ public function test_serve_command_uses_http_transport_when_specified() $this->app->instance(Server::class, $serverMock); $serverMock->shouldReceive('listen')->once()->with( - Mockery::type(HttpServerTransport::class), + Mockery::type(StreamableHttpServerTransport::class), ); $this->artisan('mcp:serve --transport=http --host=localhost --port=9091 --path-prefix=mcp_test_http') @@ -78,13 +79,13 @@ public function test_serve_command_uses_http_transport_config_fallbacks() $hostProp->setAccessible(true); $portProp = $reflection->getProperty('port'); $portProp->setAccessible(true); - $prefixProp = $reflection->getProperty('mcpPathPrefix'); + $prefixProp = $reflection->getProperty('mcpPath'); $prefixProp->setAccessible(true); - return $transport instanceof HttpServerTransport && + return $transport instanceof StreamableHttpServerTransport && $hostProp->getValue($transport) === '0.0.0.0' && $portProp->getValue($transport) === 8888 && - $prefixProp->getValue($transport) === 'configured_prefix'; + $prefixProp->getValue($transport) === '/configured_prefix'; }), ); diff --git a/tests/Feature/ManualRegistrationTest.php b/tests/Feature/ManualRegistrationTest.php index c253f85..423d756 100644 --- a/tests/Feature/ManualRegistrationTest.php +++ b/tests/Feature/ManualRegistrationTest.php @@ -5,10 +5,10 @@ use PhpMcp\Laravel\Tests\Stubs\App\Mcp\ManualTestHandler; use PhpMcp\Laravel\Tests\Stubs\App\Mcp\ManualTestInvokableHandler; use PhpMcp\Laravel\Tests\TestCase; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Server\Elements\RegisteredTool; +use PhpMcp\Server\Elements\RegisteredResource; +use PhpMcp\Server\Elements\RegisteredResourceTemplate; +use PhpMcp\Server\Elements\RegisteredPrompt; class ManualRegistrationTest extends TestCase { @@ -26,15 +26,15 @@ public function test_can_manually_register_a_tool() $registry = $this->app->make('mcp.registry'); - $tool = $registry->findTool('manual_test_tool'); + $tool = $registry->getTool('manual_test_tool'); - $this->assertInstanceOf(ToolDefinition::class, $tool); - $this->assertEquals('manual_test_tool', $tool->getName()); - $this->assertEquals('A manually registered test tool.', $tool->getDescription()); - $this->assertEquals(ManualTestHandler::class, $tool->getClassName()); - $this->assertEquals('handleTool', $tool->getMethodName()); - $this->assertArrayHasKey('input', $tool->getInputSchema()['properties']); - $this->assertEquals('string', $tool->getInputSchema()['properties']['input']['type']); + $this->assertInstanceOf(RegisteredTool::class, $tool); + $this->assertEquals('manual_test_tool', $tool->schema->name); + $this->assertEquals('A manually registered test tool.', $tool->schema->description); + $this->assertEquals(ManualTestHandler::class, $tool->handlerClass); + $this->assertEquals('handleTool', $tool->handlerMethod); + $this->assertArrayHasKey('input', $tool->schema->inputSchema['properties']); + $this->assertEquals('string', $tool->schema->inputSchema['properties']['input']['type']); } public function test_can_manually_register_tool_using_handler_only() @@ -49,12 +49,12 @@ public function test_can_manually_register_tool_using_handler_only() $this->setMcpDefinitions($definitionsContent); $registry = $this->app->make('mcp.registry'); - $tool = $registry->findTool('handleTool'); + $tool = $registry->getTool('handleTool'); $this->assertNotNull($tool); - $this->assertEquals(ManualTestHandler::class, $tool->getClassName()); - $this->assertEquals('handleTool', $tool->getMethodName()); - $this->assertEquals('A sample tool handler.', $tool->getDescription()); + $this->assertEquals(ManualTestHandler::class, $tool->handlerClass); + $this->assertEquals('handleTool', $tool->handlerMethod); + $this->assertEquals('A sample tool handler.', $tool->schema->description); } public function test_can_manually_register_a_resource() @@ -63,26 +63,27 @@ public function test_can_manually_register_a_resource() name('manual_app_setting') ->mimeType('application/json') ->size(1024) - ->annotations(['category' => 'config']); + ->annotations(Annotations::make(priority:0.8)); PHP; $this->setMcpDefinitions($definitionsContent); $registry = $this->app->make('mcp.registry'); - $resource = $registry->findResourceByUri('manual://config/app-setting'); - - $this->assertInstanceOf(ResourceDefinition::class, $resource); - $this->assertEquals('manual_app_setting', $resource->getName()); - $this->assertEquals('A sample resource handler.', $resource->getDescription()); - $this->assertEquals('application/json', $resource->getMimeType()); - $this->assertEquals(1024, $resource->getSize()); - $this->assertEquals(['category' => 'config'], $resource->getAnnotations()); - $this->assertEquals(ManualTestHandler::class, $resource->getClassName()); - $this->assertEquals('handleResource', $resource->getMethodName()); + $resource = $registry->getResource('manual://config/app-setting'); + + $this->assertInstanceOf(RegisteredResource::class, $resource); + $this->assertEquals('manual_app_setting', $resource->schema->name); + $this->assertEquals('A sample resource handler.', $resource->schema->description); + $this->assertEquals('application/json', $resource->schema->mimeType); + $this->assertEquals(1024, $resource->schema->size); + $this->assertEquals(['priority' => 0.8], $resource->schema->annotations->toArray()); + $this->assertEquals(ManualTestHandler::class, $resource->handlerClass); + $this->assertEquals('handleResource', $resource->handlerMethod); } public function test_can_manually_register_a_prompt_with_invokable_class_handler() @@ -98,13 +99,13 @@ public function test_can_manually_register_a_prompt_with_invokable_class_handler $this->setMcpDefinitions($definitionsContent); $registry = $this->app->make('mcp.registry'); - $prompt = $registry->findPrompt('manual_invokable_prompt'); + $prompt = $registry->getPrompt('manual_invokable_prompt'); - $this->assertInstanceOf(PromptDefinition::class, $prompt); - $this->assertEquals('manual_invokable_prompt', $prompt->getName()); - $this->assertEquals('A prompt handled by an invokable class.', $prompt->getDescription()); - $this->assertEquals(ManualTestInvokableHandler::class, $prompt->getClassName()); - $this->assertEquals('__invoke', $prompt->getMethodName()); + $this->assertInstanceOf(RegisteredPrompt::class, $prompt); + $this->assertEquals('manual_invokable_prompt', $prompt->schema->name); + $this->assertEquals('A prompt handled by an invokable class.', $prompt->schema->description); + $this->assertEquals(ManualTestInvokableHandler::class, $prompt->handlerClass); + $this->assertEquals('__invoke', $prompt->handlerMethod); } public function test_can_manually_register_a_resource_template_via_facade() @@ -121,16 +122,15 @@ public function test_can_manually_register_a_resource_template_via_facade() $this->setMcpDefinitions($definitionsContent); $registry = $this->app->make('mcp.registry'); - $templateMatch = $registry->findResourceTemplateByUri('manual://item/123/details'); - - $this->assertNotNull($templateMatch); - $template = $templateMatch['definition']; - $this->assertInstanceOf(ResourceTemplateDefinition::class, $template); - $this->assertEquals('manual://item/{itemId}/details', $template->getUriTemplate()); - $this->assertEquals('manual_item_details_template', $template->getName()); - $this->assertEquals('A sample resource template handler.', $template->getDescription()); - $this->assertEquals('application/vnd.api+json', $template->getMimeType()); - $this->assertEquals(ManualTestHandler::class, $template->getClassName()); - $this->assertEquals('handleTemplate', $template->getMethodName()); + $template = $registry->getResource('manual://item/123/details'); + + $this->assertNotNull($template); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $template); + $this->assertEquals('manual://item/{itemId}/details', $template->schema->uriTemplate); + $this->assertEquals('manual_item_details_template', $template->schema->name); + $this->assertEquals('A sample resource template handler.', $template->schema->description); + $this->assertEquals('application/vnd.api+json', $template->schema->mimeType); + $this->assertEquals(ManualTestHandler::class, $template->handlerClass); + $this->assertEquals('handleTemplate', $template->handlerMethod); } } diff --git a/tests/Feature/McpServiceProviderTest.php b/tests/Feature/McpServiceProviderTest.php index 0da15ab..68580d8 100644 --- a/tests/Feature/McpServiceProviderTest.php +++ b/tests/Feature/McpServiceProviderTest.php @@ -3,23 +3,18 @@ namespace PhpMcp\Laravel\Tests\Feature; use Illuminate\Contracts\Container\Container; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\Attributes\DefineEnvironment; use PhpMcp\Laravel\McpServiceProvider; -use PhpMcp\Laravel\Events\ToolsListChanged; use PhpMcp\Laravel\McpRegistrar; -use PhpMcp\Laravel\Tests\Stubs\App\Mcp\ManualTestHandler; use PhpMcp\Laravel\Tests\TestCase; -use PhpMcp\Laravel\Transports\LaravelHttpTransport; -use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Laravel\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Server\Session\SessionManager; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use React\EventLoop\LoopInterface; class McpServiceProviderTest extends TestCase { @@ -27,7 +22,6 @@ protected function useTestServerConfig($app) { $app['config']->set('mcp.server.name', 'My Awesome MCP Test Server'); $app['config']->set('mcp.server.version', 'v2.test'); - $app['config']->set('mcp.server.instructions', 'Test instructions from config.'); $app['config']->set('mcp.cache.ttl', 7200); } @@ -55,13 +49,12 @@ public function test_provider_is_registered_and_boots_core_server_and_components $this->assertInstanceOf(Registry::class, $server1->getRegistry()); $this->assertInstanceOf(Protocol::class, $server1->getProtocol()); - $this->assertInstanceOf(ClientStateManager::class, $server1->getClientStateManager()); + $this->assertInstanceOf(SessionManager::class, $server1->getSessionManager()); $this->assertInstanceOf(McpRegistrar::class, $this->app->make('mcp.registrar')); - $this->assertInstanceOf(LaravelHttpTransport::class, $this->app->make(LaravelHttpTransport::class)); + $this->assertInstanceOf(StreamableHttpServerTransport::class, $this->app->make(StreamableHttpServerTransport::class)); $configVO = $server1->getConfiguration(); $this->assertInstanceOf(LoggerInterface::class, $configVO->logger); - $this->assertInstanceOf(LoopInterface::class, $configVO->loop); $this->assertInstanceOf(CacheInterface::class, $configVO->cache); $this->assertInstanceOf(Container::class, $configVO->container); } @@ -72,18 +65,17 @@ public function test_configuration_values_are_correctly_applied_to_server() $server = $this->app->make('mcp.server'); $configVO = $server->getConfiguration(); - $this->assertEquals('My Awesome MCP Test Server', $configVO->serverName); - $this->assertEquals('v2.test', $configVO->serverVersion); - $this->assertEquals('Test instructions from config.', $configVO->capabilities->instructions); - $this->assertEquals(7200, $configVO->definitionCacheTtl); - $this->assertTrue($configVO->capabilities->promptsEnabled); + $this->assertEquals('My Awesome MCP Test Server', $configVO->serverInfo->name); + $this->assertEquals('v2.test', $configVO->serverInfo->version); + $this->assertEquals(50, $configVO->paginationLimit); + $this->assertTrue($configVO->capabilities->prompts->listChanged ?? true); } public function test_auto_discovery_is_triggered_when_enabled() { $server = $this->app->make('mcp.server'); $registry = $server->getRegistry(); - $this->assertNotNull($registry->findTool('stub_tool_one'), "Discovered tool 'stub_tool_one' not found in registry."); + $this->assertNotNull($registry->getTool('stub_tool_one'), "Discovered tool 'stub_tool_one' not found in registry."); } #[DefineEnvironment('disableAutoDiscovery')] @@ -92,20 +84,22 @@ public function test_auto_discovery_is_skipped_if_disabled() $server = $this->app->make('mcp.server'); $registry = $server->getRegistry(); - $this->assertNull($registry->findTool('stub_tool_one'), "Tool 'stub_tool_one' should not be found if auto-discovery is off."); + $this->assertNull($registry->getTool('stub_tool_one'), "Tool 'stub_tool_one' should not be found if auto-discovery is off."); } public function test_http_integrated_routes_are_registered_if_enabled() { - $this->assertTrue(Route::has('mcp.sse')); - $this->assertTrue(Route::has('mcp.message')); - $this->assertStringContainsString('/mcp/sse', route('mcp.sse')); + $this->assertTrue(Route::has('mcp.streamable.get')); + $this->assertTrue(Route::has('mcp.streamable.post')); + $this->assertTrue(Route::has('mcp.streamable.delete')); + $this->assertStringContainsString('/mcp', route('mcp.streamable.get')); } #[DefineEnvironment('disableHttpIntegratedRoutes')] public function test_http_integrated_routes_are_not_registered_if_disabled() { - $this->assertFalse(Route::has('mcp.sse')); - $this->assertFalse(Route::has('mcp.message')); + $this->assertFalse(Route::has('mcp.streamable.get')); + $this->assertFalse(Route::has('mcp.streamable.post')); + $this->assertFalse(Route::has('mcp.streamable.delete')); } } From 9b04ccbe6fa97d0ae2dd930defd4cea524065c66 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 26 Jun 2025 00:00:34 +0100 Subject: [PATCH 28/39] refactor: add completion capability and simplify capabilities config - Updated the capabilities structure in `mcp.php` to streamline the configuration by removing nested arrays for tools, resources, prompts, and logging. - Enhanced the `McpServiceProvider` to reflect the new configuration structure, ensuring compatibility with the updated capabilities. - Added support for completions and clarified the experimental capabilities in the configuration. --- composer.json | 4 ++-- config/mcp.php | 41 ++++++++++++++++++++-------------- samples/basic/composer.lock | 44 ++++++++++++++++++------------------- src/McpServiceProvider.php | 22 ++++++++++--------- 4 files changed, 60 insertions(+), 51 deletions(-) diff --git a/composer.json b/composer.json index 74a6ff3..78cea8c 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^3.0" + "php-mcp/server": "^3.1" }, "require-dev": { "laravel/pint": "^1.13", @@ -62,4 +62,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/config/mcp.php b/config/mcp.php index 9e32f4c..3fa876c 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -153,28 +153,35 @@ | support for tools, resources, prompts, and their related functionality like | subscriptions and change notifications. | + | The following capabilities are supported: + | - tools - Whether the server offers tools. + | - toolsListChanged - Whether the server supports sending a notification when the list of tools changes. + | - resources - Whether the server offers resources. + | - resourcesSubscribe - Whether the server supports resource subscriptions. + | - resourcesListChanged - Whether the server supports sending a notification when the list of resources changes. + | - prompts - Whether the server offers prompts. + | - promptsListChanged - Whether the server supports sending a notification when the list of prompts changes. + | - logging - Whether the server supports sending log messages to the client. + | - completions - Whether the server supports argument autocompletion suggestions. + | - experimental - Experimental, non-standard capabilities that the server supports. + | */ 'capabilities' => [ - 'tools' => [ - 'enabled' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), - 'listChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), - ], + 'tools' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), + 'toolsListChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), - 'resources' => [ - 'enabled' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), - 'subscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), - 'listChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), - ], + 'resources' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), + 'resourcesSubscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), + 'resourcesListChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), - 'prompts' => [ - 'enabled' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), - 'listChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), - ], + 'prompts' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), + 'promptsListChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), - 'logging' => [ - 'enabled' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), - 'setLevel' => (bool) env('MCP_CAP_LOGGING_SET_LEVEL', false), - ], + 'logging' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), + + 'completions' => (bool) env('MCP_CAP_COMPLETIONS_ENABLED', true), + + 'experimental' => null, ], /* diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index e4c3f7b..094bb16 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -1797,16 +1797,16 @@ }, { "name": "league/flysystem", - "version": "3.29.1", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", "shasum": "" }, "require": { @@ -1830,13 +1830,13 @@ "composer/semver": "^3.0", "ext-fileinfo": "*", "ext-ftp": "*", - "ext-mongodb": "^1.3", + "ext-mongodb": "^1.3|^2", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "guzzlehttp/psr7": "^2.6", "microsoft/azure-storage-blob": "^1.1", - "mongodb/mongodb": "^1.2", + "mongodb/mongodb": "^1.2|^2", "phpseclib/phpseclib": "^3.0.36", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5.11|^10.0", @@ -1874,22 +1874,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" }, - "time": "2024-10-08T08:58:34+00:00" + "time": "2025-06-25T13:29:59+00:00" }, { "name": "league/flysystem-local", - "version": "3.29.0", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", "shasum": "" }, "require": { @@ -1923,9 +1923,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" }, - "time": "2024-08-09T21:24:39+00:00" + "time": "2025-05-21T10:34:19+00:00" }, { "name": "league/mime-type-detection", @@ -2854,12 +2854,12 @@ "dist": { "type": "path", "url": "../..", - "reference": "807b5093ffe3345b02884439f06f5e2d6bc7ae84" + "reference": "571d03d87225587b1799d6f58880b4d805cdbaef" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^3.0" + "php-mcp/server": "^3.1" }, "require-dev": { "laravel/pint": "^1.13", @@ -2965,16 +2965,16 @@ }, { "name": "php-mcp/server", - "version": "3.0.2", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "96ff2cedbade121d3efb593276fbb7d090c5b6c6" + "reference": "caa5686076a4707239a0af902f97722bc9689a89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mcp/server/zipball/96ff2cedbade121d3efb593276fbb7d090c5b6c6", - "reference": "96ff2cedbade121d3efb593276fbb7d090c5b6c6", + "url": "https://api.github.com/repos/php-mcp/server/zipball/caa5686076a4707239a0af902f97722bc9689a89", + "reference": "caa5686076a4707239a0af902f97722bc9689a89", "shasum": "" }, "require": { @@ -3030,9 +3030,9 @@ ], "support": { "issues": "https://github.com/php-mcp/server/issues", - "source": "https://github.com/php-mcp/server/tree/3.0.2" + "source": "https://github.com/php-mcp/server/tree/3.1.0" }, - "time": "2025-06-25T03:41:58+00:00" + "time": "2025-06-25T22:55:35+00:00" }, { "name": "phpdocumentor/reflection-common", diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index cc7ab73..eb8118e 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -62,15 +62,16 @@ protected function buildServer(): void $logger = $app['log']->channel(config('mcp.logging.channel')); $cache = $app['cache']->store($app['config']->get('mcp.cache.store')); $capabilities = ServerCapabilities::make( - tools: config('mcp.capabilities.tools.enabled', true), - toolsListChanged: config('mcp.capabilities.tools.listChanged', true), - resources: config('mcp.capabilities.resources.enabled', true), - resourcesSubscribe: config('mcp.capabilities.resources.subscribe', true), - resourcesListChanged: config('mcp.capabilities.resources.listChanged', true), - prompts: config('mcp.capabilities.prompts.enabled', true), - promptsListChanged: config('mcp.capabilities.prompts.listChanged', true), - logging: config('mcp.capabilities.logging.enabled', true), - experimental: null, + tools: (bool) config('mcp.capabilities.tools', true), + toolsListChanged: (bool) config('mcp.capabilities.toolsListChanged', true), + resources: (bool) config('mcp.capabilities.resources', true), + resourcesSubscribe: (bool) config('mcp.capabilities.resourcesSubscribe', true), + resourcesListChanged: (bool) config('mcp.capabilities.resourcesListChanged', true), + prompts: (bool) config('mcp.capabilities.prompts', true), + promptsListChanged: (bool) config('mcp.capabilities.promptsListChanged', true), + logging: (bool) config('mcp.capabilities.logging', true), + completions: (bool) config('mcp.capabilities.completions', true), + experimental: config('mcp.capabilities.experimental', null), ); $sessionHandler = $this->createSessionHandler($app); @@ -83,7 +84,8 @@ protected function buildServer(): void ->withCache($cache) ->withSessionHandler($sessionHandler, $sessionTtl) ->withCapabilities($capabilities) - ->withPaginationLimit((int) config('mcp.pagination_limit', 50)); + ->withPaginationLimit((int) config('mcp.pagination_limit', 50)) + ->withInstructions(config('mcp.server.instructions')); $registrar = $app->make(McpRegistrar::class); $registrar->applyBlueprints($builder); From 8f2745dfc592c652255132a436f25cbe15c07d41 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 26 Jun 2025 01:22:16 +0100 Subject: [PATCH 29/39] docs: Update README with latest features and capabilities. --- README.md | 987 +++++++++++++++++++++++++++++++++++++++----------- composer.json | 11 +- 2 files changed, 782 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 90fc00a..85ec5d4 100644 --- a/README.md +++ b/README.md @@ -1,337 +1,896 @@ -# PHP MCP Server for Laravel +# Laravel MCP Server SDK [![Latest Version on Packagist](https://img.shields.io/packagist/v/php-mcp/laravel.svg?style=flat-square)](https://packagist.org/packages/php-mcp/laravel) [![Total Downloads](https://img.shields.io/packagist/dt/php-mcp/laravel.svg?style=flat-square)](https://packagist.org/packages/php-mcp/laravel) [![License](https://img.shields.io/packagist/l/php-mcp/laravel.svg?style=flat-square)](LICENSE) -**Seamlessly integrate the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) into your Laravel applications.** +**A comprehensive Laravel SDK for building [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) servers with enterprise-grade features and Laravel-native integrations.** -This package is a Laravel compatible wrapper for the powerful [`php-mcp/server`](https://github.com/php-mcp/server) library. It allows you to effortlessly expose parts of your Laravel application as MCP **Tools**, **Resources**, and **Prompts**, enabling standardized communication with AI assistants like Anthropic's Claude, Cursor IDE, and others. +This SDK provides a Laravel-optimized wrapper for the powerful [`php-mcp/server`](https://github.com/php-mcp/server) library, enabling you to expose your Laravel application's functionality as standardized MCP **Tools**, **Resources**, **Prompts**, and **Resource Templates** for AI assistants like Anthropic's Claude, Cursor IDE, OpenAI's ChatGPT, and others. -**Key Features:** +## Key Features -* **Effortless Integration:** Designed from the ground up for Laravel, leveraging its service container, configuration, caching, logging, and Artisan console. -* **Fluent Element Definition:** Define MCP elements programmatically with a clean, Laravely API using the `Mcp` Facade (e.g., `Mcp::tool(...)->description(...)`). -* **Attribute-Based Discovery:** Alternatively, use PHP 8 attributes (`#[McpTool]`, etc.) on your classes and methods, then run a simple Artisan command to discover and cache them. -* **Flexible Transports:** - * **Integrated HTTP+SSE:** Serve MCP requests directly through your Laravel application's routes, ideal for many setups. - * **Dedicated HTTP+SSE Server:** Launch a high-performance, standalone ReactPHP-based HTTP server via an Artisan command for demanding scenarios. - * **STDIO:** Run an MCP server over standard input/output, perfect for CLI-driven clients. -* **Robust Configuration:** Manage all aspects of your MCP server via the `config/mcp.php` file. -* **Artisan Commands:** Includes commands for serving, discovering elements, and listing registered components. -* **Event-Driven Updates:** Integrates with Laravel's event system to notify clients of dynamic changes to your MCP elements. +- **Laravel-Native Integration**: Deep integration with Laravel's service container, configuration, caching, logging, sessions, and Artisan console +- **Fluent Element Definition**: Define MCP elements with an elegant, Laravel-style API using the `Mcp` facade +- **Attribute-Based Discovery**: Use PHP 8 attributes (`#[McpTool]`, `#[McpResource]`, etc.) with automatic discovery and caching +- **Advanced Session Management**: Laravel-native session handlers (file, database, cache, redis) with automatic garbage collection +- **Flexible Transport Options**: + - **Integrated HTTP**: Serve through Laravel routes with middleware support + - **Dedicated HTTP Server**: High-performance standalone ReactPHP server + - **STDIO**: Command-line interface for direct client integration +- **Streamable Transport**: Enhanced HTTP transport with resumability and event sourcing +- **Artisan Commands**: Commands for serving, discovery, and element management +- **Full Test Coverage**: Comprehensive test suite ensuring reliability -This package utilizes `php-mcp/server` v2.1.0+ which supports the `2024-11-05` version of the Model Context Protocol. +This package supports the **2025-03-26** version of the Model Context Protocol. ## Requirements -* PHP >= 8.1 -* Laravel >= 10.0 -* [`php-mcp/server`](https://github.com/php-mcp/server) ^2.1.0 (automatically installed) +- **PHP** >= 8.1 +- **Laravel** >= 10.0 +- **Extensions**: `json`, `mbstring`, `pcre` (typically enabled by default) ## Installation -1. **Require the Package:** - ```bash - composer require php-mcp/laravel - ``` +Install the package via Composer: -2. **Publish Configuration:** - ```bash - php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" - ``` - -## Configuration - -All MCP server settings are managed in `config/mcp.php`. Here are the key sections: - -### Server Information -* **`server`**: Basic server identity settings - * `name`: Your MCP server's name (default: 'Laravel MCP') - * `version`: Server version number - * `instructions`: Optional initialization instructions for clients - -### Discovery Settings -* **`discovery`**: Controls how MCP elements are discovered - * `base_path`: Root directory for scanning (defaults to `base_path()`) - * `directories`: Paths to scan for MCP attributes (default: `['app/Mcp']`) - * `exclude_dirs`: Directories to skip during scans (e.g., 'vendor', 'tests', etc.) - * `definitions_file`: Path to manual element definitions (default: `routes/mcp.php`) - * `auto_discover`: Enable automatic discovery in development (default: `true`) - * `save_to_cache`: Cache discovery results (default: `true`) - -### Transport Configuration -* **`transports`**: Available communication methods - * **`stdio`**: CLI-based transport - * `enabled`: Enable the `mcp:serve` command with `stdio` option. - * **`http_dedicated`**: Standalone HTTP server - * `enabled`: Enable the `mcp:serve` command with `http` option. - * `host`, `port`, `path_prefix` settings - * **`http_integrated`**: Laravel route-based server - * `enabled`: Serve through Laravel routes - * `route_prefix`: URL prefix (default: 'mcp') - * `middleware`: Applied middleware (default: 'web') - -### Cache & Performance -* **`cache`**: Caching configuration - * `store`: Laravel cache store to use - * `ttl`: Cache lifetime in seconds -* **`pagination_limit`**: Maximum items returned in list operations - -### Feature Control -* **`capabilities`**: Toggle MCP features - * Enable/disable tools, resources, prompts - * Control subscriptions and change notifications -* **`logging`**: Server logging configuration - * `channel`: Laravel log channel - * `level`: Default log level - -Review the published `config/mcp.php` file for detailed documentation of all available options and their descriptions. - -## Defining MCP Elements - -PHP MCP Laravel provides two approaches to define your MCP elements: manual registration using a fluent API or attribute-based discovery. - -### Manual Registration - -The recommended approach is using the fluent `Mcp` facade to manually register your elements in `routes/mcp.php` (this path can be changed in config/mcp.php via the discovery.definitions_file key). +```bash +composer require php-mcp/laravel +``` -```php -Mcp::tool([MyHandlers::class, 'calculateSum']); +Publish the configuration file: -Mcp::resource( 'status://app/health', [MyHandlers::class, 'getAppStatus']); +```bash +php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-config" +``` -Mcp::prompt(MyInvokableTool::class); +For database session storage, publish the migration: -Mcp::resourceTemplate('user://{id}/data', [MyHandlers::class, 'getUserData']); +```bash +php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" --tag="mcp-migrations" +php artisan migrate ``` -The facade provides several registration methods, each with optional fluent configuration methods: - -#### Tools (`Mcp::tool()`) +## Configuration -Defines an action or function the MCP client can invoke. Register a tool by providing either: -- Just the handler: `Mcp::tool(MyTool::class)` -- Name and handler: `Mcp::tool('my_tool', [MyClass::class, 'method'])` +All MCP server settings are managed through `config/mcp.php`, which contains comprehensive documentation for each option. The configuration covers server identity, capabilities, discovery settings, session management, transport options, caching, and logging. All settings support environment variables for easy deployment management. -Available configuration methods: -- `name()`: Override the inferred name -- `description()`: Set a custom description +Key configuration areas include: +- **Server Info**: Name, version, and basic identity +- **Capabilities**: Control which MCP features are enabled (tools, resources, prompts, etc.) +- **Discovery**: How elements are found and cached from your codebase +- **Session Management**: Multiple storage backends (file, database, cache, redis) with automatic garbage collection +- **Transports**: STDIO, integrated HTTP, and dedicated HTTP server options +- **Performance**: Caching strategies and pagination limits -#### Resources (`Mcp::resource()`) +Review the published `config/mcp.php` file for detailed documentation of all available options and their environment variable overrides. -Defines a specific, static piece of content or data identified by a URI. Register a resource by providing: -- `$uri` (`required`): The unique URI for this resource instance (e.g., `config://app/settings`). -- `$handler`: The handler that will return the resource's content. +## Defining MCP Elements -Available configuration methods: -- `name(string $name): self`: Sets a human-readable name. Inferred if omitted. -- `description(string $description): self`: Sets a description. Inferred if omitted. -- `mimeType(string $mimeType): self`: Specifies the resource's MIME type. Can sometimes be inferred from the handler's return type or content. -- `size(?int $size): self`: Specifies the resource size in bytes, if known. -- `annotations(array $annotations): self`: Adds MCP-standard annotations (e.g., ['audience' => ['user']]). +Laravel MCP provides two powerful approaches for defining MCP elements: **Manual Registration** (using the fluent `Mcp` facade) and **Attribute-Based Discovery** (using PHP 8 attributes). Both can be combined, with manual registrations taking precedence. -#### Resource Template (`Mcp::resourceTemplate()`) +### Element Types -Defines a handler for resource URIs that contain variable parts, allowing dynamic resource instance generation. Register a resource template by providing: -- `$uriTemplate` (`required`): The URI template string (`RFC 6570`), e.g., `user://{userId}/profile`. -- `$handler`: The handler method. Its parameters must match the variables in the `$uriTemplate`. +- **Tools**: Executable functions/actions (e.g., `calculate`, `send_email`, `query_database`) +- **Resources**: Static content/data accessible via URI (e.g., `config://settings`, `file://readme.txt`) +- **Resource Templates**: Dynamic resources with URI patterns (e.g., `user://{id}/profile`) +- **Prompts**: Conversation starters/templates (e.g., `summarize`, `translate`) -Available configuration methods: -- `name(string $name): self`: Sets a human-readable name for the template type. -- `description(string $description): self`: Sets a description for the template. -- `mimeType(string $mimeType): self`: Sets a default MIME type for resources resolved by this template. -- `annotations(array $annotations): self`: Adds MCP-standard annotations. +### 1. Manual Registration -#### Prompts (`Mcp::prompt()`) +Define your MCP elements using the elegant `Mcp` facade in `routes/mcp.php`: -Defines a generator for MCP prompt messages, often used to construct conversations for an LLM. Register a prompt by providing just the handler, or the name and handler. -- `$name` (`optional`): The MCP prompt name. Inferred if omitted. -- `$handler`: The handler method. Its parameters become the prompt's input arguments. +```php +name('add_numbers') + ->description('Add two numbers together'); + +// Register an invokable class as a tool +Mcp::tool(EmailService::class) + ->description('Send emails to users'); + +// Register a resource with metadata +Mcp::resource('config://app/settings', [UserService::class, 'getAppSettings']) + ->name('app_settings') + ->description('Application configuration settings') + ->mimeType('application/json') + ->size(1024); + +// Register a resource template for dynamic content +Mcp::resourceTemplate('user://{userId}/profile', [UserService::class, 'getUserProfile']) + ->name('user_profile') + ->description('Get user profile by ID') + ->mimeType('application/json'); + +// Register a prompt generator +Mcp::prompt([PromptService::class, 'generateWelcome']) + ->name('welcome_user') + ->description('Generate a personalized welcome message'); +``` +**Available Fluent Methods:** -The package automatically resolves handlers through Laravel's service container, allowing you to inject dependencies through constructor injection. Each registration method accepts either an invokable class or a `[class, method]` array. +**For All Elements:** +- `name(string $name)`: Override the inferred name +- `description(string $description)`: Set a custom description -The fluent methods like `description()`, `name()`, and `mimeType()` are optional. When omitted, the package intelligently infers these values from your handler's method signatures, return types, and DocBlocks. Use these methods only when you need to override the automatically generated metadata. +**For Resources:** +- `mimeType(string $mimeType)`: Specify content type +- `size(int $size)`: Set content size in bytes +- `annotations(array|Annotations $annotations)`: Add MCP annotations -Manually registered elements are always available regardless of cache status and take precedence over discovered elements with the same identifier. +**Handler Formats:** +- `[ClassName::class, 'methodName']` - Class method +- `InvokableClass::class` - Invokable class with `__invoke()` method -### Attribute-Based Discovery +### 2. Attribute-Based Discovery -As an alternative, you can use PHP 8 attributes to mark your methods or invokable classes as MCP elements. That way, you don't have to manually register them in the definitions file: +Alternatively, you can use PHP 8 attributes to mark your methods or classes as MCP elements, in which case, you don't have to register them in them `routes/mcp.php`: ```php -namespace App\Mcp; + 123, + 'email' => $email, + 'role' => $role, + 'created_at' => now()->toISOString(), + ]; } - - #[McpResource(uri: 'status://server/health', mimeType: 'application/json')] - public function getServerHealth(): array + + /** + * Get application configuration. + */ + #[McpResource( + uri: 'config://app/settings', + mimeType: 'application/json' + )] + public function getAppSettings(): array { - return ['status' => 'healthy', 'uptime' => 123]; + return [ + 'theme' => config('app.theme', 'light'), + 'timezone' => config('app.timezone'), + 'features' => config('app.features', []), + ]; + } + + /** + * Get user profile by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId): array + { + return [ + 'id' => $userId, + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'profile' => [ + 'bio' => 'Software developer', + 'location' => 'New York', + ], + ]; + } + + /** + * Generate a welcome message prompt. + */ + #[McpPrompt(name: 'welcome_user')] + public function generateWelcome(string $username, string $role = 'user'): array + { + return [ + [ + 'role' => 'user', + 'content' => "Create a personalized welcome message for {$username} with role {$role}. Be warm and professional." + ] + ]; } } ``` -When `auto_discover` enabled in your config, these elements are automatically discovered when needed. For production or to manually trigger discovery, run: +**Discovery Process:** + +Elements marked with attributes are automatically discovered when: +- `auto_discover` is enabled in configuration (default: `true`) +- You run `php artisan mcp:discover` manually ```bash +# Discover and cache MCP elements php artisan mcp:discover + +# Force re-discovery (ignores cache) +php artisan mcp:discover --force + +# Discover without saving to cache +php artisan mcp:discover --no-cache ``` -This command scans the configured directories, registers the discovered elements, and caches the results for improved performance. Use the `--no-cache` flag to skip caching or `--force` to perform a fresh scan regardless of cache status. +### Element Precedence -See the [`php-mcp/server` documentation](https://github.com/php-mcp/server?tab=readme-ov-file#attribute-details--return-formatting) for detailed information on attribute parameters and return value formatting. +- **Manual registrations** always override discovered elements with the same identifier +- **Discovered elements** are cached for performance +- **Cache** is automatically invalidated on fresh discovery runs ## Running the MCP Server -PHP MCP Laravel offers three transport options to serve your MCP elements. +Laravel MCP offers three transport options, each optimized for different deployment scenarios: + +### 1. STDIO Transport -### Integrated HTTP+SSE via Laravel Routes +**Best for:** Direct client execution, Cursor IDE, command-line tools + +```bash +php artisan mcp:serve --transport=stdio +``` -The most convenient option for getting started is serving MCP directly through your Laravel application's routes: +**Client Configuration (Cursor IDE):** + +```json +{ + "mcpServers": { + "my-laravel-app": { + "command": "php", + "args": [ + "/absolute/path/to/your/laravel/project/artisan", + "mcp:serve", + "--transport=stdio" + ] + } + } +} +``` + +> ⚠️ **Important**: When using STDIO transport, never write to `STDOUT` in your handlers (use Laravel's logger or `STDERR` for debugging). `STDOUT` is reserved for JSON-RPC communication. + +### 2. Integrated HTTP Transport + +**Best for:** Development, applications with existing web servers, quick setup + +The integrated transport serves MCP through your Laravel application's routes: ```php -// Client connects to: http://your-app.test/mcp/sse -// No additional processes needed +// Routes are automatically registered at: +// GET /mcp - Streamable connection endpoint +// POST /mcp - Message sending endpoint +// DELETE /mcp - Session termination endpoint + +// Legacy mode (if enabled): +// GET /mcp/sse - Server-Sent Events endpoint +// POST /mcp/message - Message sending endpoint ``` -**Configuration**: -- Ensure `mcp.transports.http_integrated.enabled` is `true` in your config -- The package registers routes at `/mcp/sse` (GET) and `/mcp/message` (POST) by default -- You can customize the prefix, middleware, and domain in `config/mcp.php` +**CSRF Protection Configuration:** -**CSRF Protection**: You must exclude the MCP message endpoint from CSRF verification: +Add the MCP routes to your CSRF exclusions: -For Laravel 11+: +**Laravel 11+:** ```php // bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ - 'mcp/message', // Adjust if you changed the route prefix + 'mcp', // For streamable transport (default) + 'mcp/*', // For legacy transport (if enabled) ]); }) ``` -For Laravel 10 and below: +**Laravel 10 and below:** ```php // app/Http/Middleware/VerifyCsrfToken.php protected $except = [ - 'mcp/message', // Adjust if you changed the route prefix + 'mcp', // For streamable transport (default) + 'mcp/*', // For legacy transport (if enabled) ]; ``` -**Server Environment Considerations**: -Standard synchronous servers like PHP's built-in server or basic PHP-FPM setups can struggle with SSE connections. For eg, a single PHP-FPM worker will be tied up for each active SSE connection. For production, consider using Laravel Octane with Swoole/RoadRunner or properly configured Nginx with sufficient PHP-FPM workers. +**Configuration Options:** -### Dedicated HTTP+SSE Server (Recommended) +```php +'http_integrated' => [ + 'enabled' => true, + 'route_prefix' => 'mcp', // URL prefix + 'middleware' => ['api'], // Applied middleware + 'domain' => 'api.example.com', // Optional domain + 'legacy' => false, // Use legacy SSE transport instead +], +``` -For production environments or high-traffic applications, the dedicated HTTP server provides better performance and isolation: +**Client Configuration:** + +```json +{ + "mcpServers": { + "my-laravel-app": { + "url": "https://your-app.test/mcp" + } + } +} +``` + +**Server Environment Considerations:** + +Standard synchronous servers struggle with persistent SSE connections, as each active connection ties up a worker process. This affects both development and production environments. + +**For Development:** +- **PHP's built-in server** (`php artisan serve`) won't work - the SSE stream locks the single process +- **Laravel Herd** (recommended for local development) +- **Properly configured Nginx** with multiple PHP-FPM workers +- **Laravel Octane** with Swoole/RoadRunner for async handling +- **Dedicated HTTP server** (`php artisan mcp:serve --transport=http`) + +**For Production:** +- **Dedicated HTTP server** (strongly recommended) +- **Laravel Octane** with Swoole/RoadRunner +- **Properly configured Nginx** with sufficient PHP-FPM workers + +### 3. Dedicated HTTP Server (Recommended for Production) + +**Best for:** Production environments, high-traffic applications, multiple concurrent clients + +Launch a standalone ReactPHP-based HTTP server: ```bash +# Start dedicated server php artisan mcp:serve --transport=http + +# With custom configuration +php artisan mcp:serve --transport=http \ + --host=0.0.0.0 \ + --port=8091 \ + --path-prefix=mcp_api +``` + +**Configuration Options:** + +```php +'http_dedicated' => [ + 'enabled' => true, + 'host' => '127.0.0.1', // Bind address + 'port' => 8090, // Port number + 'path_prefix' => 'mcp', // URL path prefix + 'legacy' => false, // Use legacy transport + 'enable_json_response' => false, // JSON mode vs SSE streaming + 'event_store' => null, // Event store for resumability + 'ssl_context_options' => [], // SSL configuration +], ``` -This launches a standalone ReactPHP-based HTTP server specifically for MCP traffic: +**Transport Modes:** -**Configuration**: -- Ensure `mcp.transports.http_dedicated.enabled` is `true` in your config -- Default server listens on `127.0.0.1:8090` with path prefix `/mcp` -- Configure through any of these methods: - - Environment variables: `MCP_HTTP_DEDICATED_HOST`, `MCP_HTTP_DEDICATED_PORT`, `MCP_HTTP_DEDICATED_PATH_PREFIX` - - Edit values directly in `config/mcp.php` - - Override at runtime: `--host=0.0.0.0 --port=8091 --path-prefix=custom_mcp` +- **Streamable Mode** (`legacy: false`): Enhanced transport with resumability and event sourcing +- **Legacy Mode** (`legacy: true`): Deprecated HTTP+SSE transport. -This is a blocking, long-running process that should be managed with Supervisor, systemd, or Docker in production environments. +**JSON Response Mode:** -### STDIO Transport for Direct Client Integration +```php +'enable_json_response' => true, // Returns immediate JSON responses +'enable_json_response' => false, // Uses SSE streaming (default) +``` -Ideal for Cursor IDE and other MCP clients that directly launch server processes: +- **JSON Mode**: Returns immediate responses, best for fast-executing tools +- **SSE Mode**: Streams responses, ideal for long-running operations + +**Production Deployment:** + +This creates a long-running process that should be managed with: + +- **Supervisor** (recommended) +- **systemd** +- **Docker** containers +- **Process managers** + +Example Supervisor configuration: + +```ini +[program:laravel-mcp] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/laravel/artisan mcp:serve --transport=http +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/log/laravel-mcp.log +``` + +For comprehensive production deployment guides, see the [php-mcp/server documentation](https://github.com/php-mcp/server#-production-deployment). + +## Artisan Commands + +Laravel MCP includes several Artisan commands for managing your MCP server: + +### Discovery Command + +Discover and cache MCP elements from your codebase: ```bash +# Discover elements and update cache +php artisan mcp:discover + +# Force re-discovery (ignore existing cache) +php artisan mcp:discover --force + +# Discover without updating cache +php artisan mcp:discover --no-cache +``` + +**Output Example:** +``` +Starting MCP element discovery... +Discovery complete. + +┌─────────────────────┬───────┐ +│ Element Type │ Count │ +├─────────────────────┼───────┤ +│ Tools │ 5 │ +│ Resources │ 3 │ +│ Resource Templates │ 2 │ +│ Prompts │ 1 │ +└─────────────────────┴───────┘ + +MCP element definitions updated and cached. +``` + +### List Command + +View registered MCP elements: + +```bash +# List all elements +php artisan mcp:list + +# List specific type +php artisan mcp:list tools +php artisan mcp:list resources +php artisan mcp:list prompts +php artisan mcp:list templates + +# JSON output +php artisan mcp:list --json +``` + +**Output Example:** +``` +Tools: +┌─────────────────┬──────────────────────────────────────────────┐ +│ Name │ Description │ +├─────────────────┼──────────────────────────────────────────────┤ +│ add_numbers │ Add two numbers together │ +│ send_email │ Send email to specified recipient │ +│ create_user │ Create a new user account with validation │ +└─────────────────┴──────────────────────────────────────────────┘ + +Resources: +┌─────────────────────────┬───────────────────┬─────────────────────┐ +│ URI │ Name │ MIME │ +├─────────────────────────┼───────────────────┼─────────────────────┤ +│ config://app/settings │ app_settings │ application/json │ +│ file://readme.txt │ readme_file │ text/plain │ +└─────────────────────────┴───────────────────┴─────────────────────┘ +``` + +### Serve Command + +Start the MCP server with various transport options: + +```bash +# Interactive mode (prompts for transport selection) php artisan mcp:serve -# or explicitly: + +# STDIO transport php artisan mcp:serve --transport=stdio + +# HTTP transport with defaults +php artisan mcp:serve --transport=http + +# HTTP transport with custom settings +php artisan mcp:serve --transport=http \ + --host=0.0.0.0 \ + --port=8091 \ + --path-prefix=api/mcp ``` -**Client Configuration**: -Configure your MCP client to execute this command directly. For example, in Cursor: +**Command Options:** +- `--transport`: Choose transport type (`stdio` or `http`) +- `--host`: Host address for HTTP transport +- `--port`: Port number for HTTP transport +- `--path-prefix`: URL path prefix for HTTP transport -```json -// .cursor/mcp.json +## Dynamic Updates & Events + +Laravel MCP integrates with Laravel's event system to provide real-time updates to connected clients: + +### List Change Events + +Notify clients when your available elements change: + +```php +use PhpMcp\Laravel\Events\{ToolsListChanged, ResourcesListChanged, PromptsListChanged}; + +// Notify clients that available tools have changed +ToolsListChanged::dispatch(); + +// Notify about resource list changes +ResourcesListChanged::dispatch(); + +// Notify about prompt list changes +PromptsListChanged::dispatch(); +``` + +### Resource Update Events + +Notify clients when specific resource content changes: + +```php +use PhpMcp\Laravel\Events\ResourceUpdated; + +// Update a file and notify subscribers +file_put_contents('/path/to/config.json', json_encode($newConfig)); +ResourceUpdated::dispatch('file:///path/to/config.json'); + +// Update database content and notify +User::find(123)->update(['status' => 'active']); +ResourceUpdated::dispatch('user://123/profile'); +``` + +## Advanced Features + +### Schema Validation + +The server automatically generates JSON schemas for tool parameters from PHP type hints and docblocks. You can enhance this with the `#[Schema]` attribute for advanced validation: + +```php +use PhpMcp\Server\Attributes\Schema; + +class PostService { - "mcpServers": { - "my-laravel-stdio": { - "command": "php", - "args": [ - "/full/path/to/your/laravel/project/artisan", - "mcp:serve", - "--transport=stdio" - ] + public function createPost( + #[Schema(minLength: 5, maxLength: 200)] + string $title, + + #[Schema(minLength: 10)] + string $content, + + #[Schema(enum: ['draft', 'published', 'archived'])] + string $status = 'draft', + + #[Schema(type: 'array', items: ['type' => 'string'])] + array $tags = [] + ): array { + return Post::create([ + 'title' => $title, + 'content' => $content, + 'status' => $status, + 'tags' => $tags, + ])->toArray(); + } +} +``` + +**Schema Features:** +- **Automatic inference** from PHP type hints and docblocks +- **Parameter-level validation** using `#[Schema]` attributes +- **Support for** string constraints, numeric ranges, enums, arrays, and objects +- **Works with both** manual registration and attribute-based discovery + +For comprehensive schema documentation and advanced features, see the [php-mcp/server Schema documentation](https://github.com/php-mcp/server#-schema-generation-and-validation). + +### Completion Providers + +Provide auto-completion suggestions for resource template variables and prompt arguments to help users discover available options: + +```php +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Attributes\CompletionProvider; + +class UserIdCompletionProvider implements CompletionProviderInterface +{ + public function getCompletions(string $currentValue, SessionInterface $session): array + { + return User::where('username', 'like', $currentValue . '%') + ->limit(10) + ->pluck('username') + ->toArray(); + } +} + +class UserService +{ + public function getUserData( + #[CompletionProvider(UserIdCompletionProvider::class)] + string $userId + ): array { + return User::where('username', $userId)->first()->toArray(); + } +} +``` + +**Completion Features:** +- **Auto-completion** for resource template variables and prompt arguments +- **Laravel integration** - use Eloquent models, collections, etc. +- **Session-aware** - completions can vary based on user session +- **Real-time filtering** based on user input + +For detailed completion provider documentation, see the [php-mcp/server Completion documentation](https://github.com/php-mcp/server#completion-providers). + +### Dependency Injection + +Your MCP handlers automatically benefit from Laravel's service container: + +```php +class OrderService +{ + public function __construct( + private PaymentGateway $gateway, + private NotificationService $notifications, + private LoggerInterface $logger + ) {} + + #[McpTool(name: 'process_order')] + public function processOrder(array $orderData): array + { + $this->logger->info('Processing order', $orderData); + + $payment = $this->gateway->charge($orderData['amount']); + + if ($payment->successful()) { + $this->notifications->sendOrderConfirmation($orderData['email']); + return ['status' => 'success', 'order_id' => $payment->id]; } + + throw new \Exception('Payment failed: ' . $payment->error); } } ``` -**Important**: When using STDIO transport, your handler code must not write to STDOUT using echo, print, or similar functions. Use Laravel's logger or STDERR for any debugging output. -## Listing Registered Elements +### Exception Handling -To see which MCP elements your server has registered (both manual and discovered/cached): +Tool handlers can throw exceptions that are automatically converted to proper JSON-RPC error responses: -```bash -php artisan mcp:list -# Specific types: -php artisan mcp:list tools -php artisan mcp:list resources -# JSON output: -php artisan mcp:list --json +```php +#[McpTool(name: 'get_user')] +public function getUser(int $userId): array +{ + $user = User::find($userId); + + if (!$user) { + throw new \InvalidArgumentException("User with ID {$userId} not found"); + } + + if (!$user->isActive()) { + throw new \RuntimeException("User account is deactivated"); + } + + return $user->toArray(); +} +``` + +### Logging and Debugging + +Configure comprehensive logging for your MCP server: + +```php +// config/mcp.php +'logging' => [ + 'channel' => 'mcp', // Use dedicated log channel + 'level' => 'debug', // Set appropriate log level +], +``` + +Create a dedicated log channel in `config/logging.php`: + +```php +'channels' => [ + 'mcp' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/mcp.log'), + 'level' => env('MCP_LOG_LEVEL', 'info'), + 'days' => 14, + ], +], ``` -## Dynamic Updates (Events) +## Migration Guide + +### From v2.x to v3.x -If your available MCP elements or resource content change while the server is running, you can notify connected clients (most relevant for HTTP transports). +**Configuration Changes:** -* **List Changes (Tools, Resources, Prompts):** - Dispatch the corresponding Laravel event. The package includes listeners to send the appropriate MCP notification. - ```php - use PhpMcp\Laravel\Events\ToolsListChanged; - use PhpMcp\Laravel\Events\ResourcesListChanged; - use PhpMcp\Laravel\Events\PromptsListChanged; +```php +// Old structure +'capabilities' => [ + 'tools' => ['enabled' => true, 'listChanged' => true], + 'resources' => ['enabled' => true, 'subscribe' => true], +], + +// New structure +'capabilities' => [ + 'tools' => true, + 'toolsListChanged' => true, + 'resources' => true, + 'resourcesSubscribe' => true, +], +``` - ToolsListChanged::dispatch(); - // ResourcesListChanged::dispatch(); - // PromptsListChanged::dispatch(); - ``` +**Session Configuration:** -* **Specific Resource Content Update:** - Dispatch the `PhpMcp\Laravel\Events\ResourceUpdated` event with the URI of the changed resource. - ```php - use PhpMcp\Laravel\Events\ResourceUpdated; +```php +// Old: Basic configuration +'session' => [ + 'driver' => 'cache', + 'ttl' => 3600, +], + +// New: Enhanced configuration +'session' => [ + 'driver' => 'cache', + 'ttl' => 3600, + 'store' => config('cache.default'), + 'lottery' => [2, 100], +], +``` - $resourceUri = 'file:///path/to/updated_file.txt'; - // ... your logic that updates the resource ... - ResourceUpdated::dispatch($resourceUri); - ``` - The `McpNotificationListener` will handle sending the `notifications/resource/updated` MCP notification to clients subscribed to that URI. +**Transport Updates:** -## Testing +- Default transport changed from sse to streamable +- New CSRF exclusion pattern: `mcp` instead of `mcp/*` +- Enhanced session management with automatic garbage collection -For your application tests, you can mock the `Mcp` facade or specific MCP handlers as needed. When testing MCP functionality itself, consider integration tests that make HTTP requests to your integrated MCP endpoints (if used) or command tests for Artisan commands. +**Breaking Changes:** + +- Removed deprecated methods in favor of new registry API +- Updated element registration to use new schema format +- Changed configuration structure for better organization + +## Examples & Use Cases + +### E-commerce Integration + +```php +class EcommerceService +{ + #[McpTool(name: 'get_product_info')] + public function getProductInfo(int $productId): array + { + return Product::with(['category', 'reviews']) + ->findOrFail($productId) + ->toArray(); + } + + #[McpTool(name: 'search_products')] + public function searchProducts( + string $query, + ?string $category = null, + int $limit = 10 + ): array { + return Product::search($query) + ->when($category, fn($q) => $q->where('category', $category)) + ->limit($limit) + ->get() + ->toArray(); + } + + #[McpResource(uri: 'config://store/settings', mimeType: 'application/json')] + public function getStoreSettings(): array + { + return [ + 'currency' => config('store.currency'), + 'tax_rate' => config('store.tax_rate'), + 'shipping_zones' => config('store.shipping_zones'), + ]; + } +} +``` + +### Content Management + +```php +class ContentService +{ + #[McpResourceTemplate(uriTemplate: 'post://{slug}', mimeType: 'text/markdown')] + public function getPostContent(string $slug): string + { + return Post::where('slug', $slug) + ->firstOrFail() + ->markdown_content; + } + + #[McpPrompt(name: 'content_summary')] + public function generateContentSummary(string $postSlug, int $maxWords = 50): array + { + $post = Post::where('slug', $postSlug)->firstOrFail(); + + return [ + [ + 'role' => 'user', + 'content' => "Summarize this blog post in {$maxWords} words or less:\n\n{$post->content}" + ] + ]; + } +} +``` + +### API Integration + +```php +class ApiService +{ + #[McpTool(name: 'send_notification')] + public function sendNotification( + #[Schema(format: 'email')] + string $email, + + string $subject, + string $message + ): array { + $response = Http::post('https://api.emailservice.com/send', [ + 'to' => $email, + 'subject' => $subject, + 'body' => $message, + ]); + + if ($response->failed()) { + throw new \RuntimeException('Failed to send notification: ' . $response->body()); + } + + return $response->json(); + } +} +``` ## Contributing -Please see [CONTRIBUTING.md](CONTRIBUTING.md) in the main [`php-mcp/server`](https://github.com/php-mcp/server) repository for general contribution guidelines. For issues or PRs specific to this Laravel package, please use this repository's issue tracker. +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/php-mcp/laravel.git +cd laravel + +# Install dependencies +composer install + +# Run tests +./vendor/bin/pest + +# Check code style +./vendor/bin/pint +``` ## License -The MIT License (MIT). See [LICENSE](LICENSE). +The MIT License (MIT). See [LICENSE](LICENSE) for details. + +## Acknowledgments + +- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification +- Powered by [`php-mcp/server`](https://github.com/php-mcp/server) for core MCP functionality +- Leverages [Laravel](https://laravel.com/) framework features for seamless integration +- Uses [ReactPHP](https://reactphp.org/) for high-performance async operations diff --git a/composer.json b/composer.json index 78cea8c..b109835 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,20 @@ { "name": "php-mcp/laravel", - "description": "The official Laravel integration for the PHP MCP Server package.", + "description": "Laravel SDK for building Model Context Protocol (MCP) servers - Seamlessly integrate MCP tools, resources, and prompts into Laravel applications", "keywords": [ "laravel", "mcp", "model-context-protocol", "ai", "llm", - "tools" + "tools", + "laravel mcp", + "laravel mcp sdk", + "laravel mcp server", + "laravel mcp tools", + "laravel mcp resources", + "laravel mcp prompts", + "laravel model context protocol" ], "homepage": "https://github.com/php-mcp/laravel", "license": "MIT", From 5b0a66ed9aba330a5b273f231a8296aa360df1d3 Mon Sep 17 00:00:00 2001 From: CodeWithKyrian <48791154+CodeWithKyrian@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:36:32 +0000 Subject: [PATCH 30/39] Update CHANGELOG --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f9614..c0e28f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to `php-mcp/laravel` will be documented in this file. +## v3.0.0 - 2025-06-26 + +### Major Changes + +- **Upgraded to php-mcp/server ^3.0** with latest MCP protocol support(`2025-03-26`) +- **Laravel-native session management** with multiple storage backends (file, database, cache, redis) +- **Streamable HTTP transport** with resumability and better connection handling +- **Simplified configuration** structure for capabilities and session management + +### Improvements + +- Enhanced transport layer with better error handling +- Automatic session garbage collection +- Updated documentation with migration guide + +### Fixes + +- Fixed SSE stream handling for expired sessions +- Improved transport initialization +- Better memory management for long-running servers + +### Breaking Changes + +- Requires `php-mcp/server ^3.0` +- Updated configuration structure (see migration guide in `README`) +- Transport class names changed for consistency + +### Installation + +```bash +composer require php-mcp/laravel:^3.0 +php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" + +``` +**Full Changelog**: https://github.com/php-mcp/laravel/compare/2.1.1...3.0.0 + ## v2.1.1 - 2025-06-25 ### What's Changed @@ -109,6 +145,7 @@ This release marks a **major overhaul**, bringing it into full alignment with `p + ``` * **`mcp:serve` for HTTP:** The `--transport=http` option for `mcp:serve` now launches a *dedicated* ReactPHP-based server process. For serving MCP via your main Laravel application routes, ensure the `http_integrated` transport is enabled in `config/mcp.php` and your web server is configured appropriately. * **Event Handling:** If you were directly listening to internal events from the previous version, these may have changed. Rely on the documented Laravel events (`ToolsListChanged`, etc.). @@ -174,6 +211,7 @@ php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" + ``` ## Getting Started From 374d12934af53055b342ba6aa1c1897dab4e44b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Turan=20Karatu=C4=9F?= Date: Tue, 1 Jul 2025 15:33:21 +0300 Subject: [PATCH 31/39] chore: Update PHPUnit version constraint in composer.json to support version 12.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b109835..0dbf8fd 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", - "phpunit/phpunit": "^10.0 || ^11.0" + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" }, "autoload": { "psr-4": { From 0d9e744ecb0121aef538385d34bf8229a4e9e934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Turan=20Karatu=C4=9F?= Date: Tue, 1 Jul 2025 15:33:32 +0300 Subject: [PATCH 32/39] refactor: Update handler assertions in ManualRegistrationTest - Changed assertions in ManualRegistrationTest to verify handler as an array containing the handler class and method instead of separate properties. - Updated tests to reflect the new structure for handler registration, ensuring consistency across tool, resource, and prompt registrations. --- tests/Feature/ManualRegistrationTest.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/Feature/ManualRegistrationTest.php b/tests/Feature/ManualRegistrationTest.php index 423d756..72bb15d 100644 --- a/tests/Feature/ManualRegistrationTest.php +++ b/tests/Feature/ManualRegistrationTest.php @@ -31,8 +31,7 @@ public function test_can_manually_register_a_tool() $this->assertInstanceOf(RegisteredTool::class, $tool); $this->assertEquals('manual_test_tool', $tool->schema->name); $this->assertEquals('A manually registered test tool.', $tool->schema->description); - $this->assertEquals(ManualTestHandler::class, $tool->handlerClass); - $this->assertEquals('handleTool', $tool->handlerMethod); + $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); $this->assertArrayHasKey('input', $tool->schema->inputSchema['properties']); $this->assertEquals('string', $tool->schema->inputSchema['properties']['input']['type']); } @@ -52,8 +51,7 @@ public function test_can_manually_register_tool_using_handler_only() $tool = $registry->getTool('handleTool'); $this->assertNotNull($tool); - $this->assertEquals(ManualTestHandler::class, $tool->handlerClass); - $this->assertEquals('handleTool', $tool->handlerMethod); + $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); $this->assertEquals('A sample tool handler.', $tool->schema->description); } @@ -82,8 +80,7 @@ public function test_can_manually_register_a_resource() $this->assertEquals('application/json', $resource->schema->mimeType); $this->assertEquals(1024, $resource->schema->size); $this->assertEquals(['priority' => 0.8], $resource->schema->annotations->toArray()); - $this->assertEquals(ManualTestHandler::class, $resource->handlerClass); - $this->assertEquals('handleResource', $resource->handlerMethod); + $this->assertEquals([ManualTestHandler::class, 'handleResource'], $resource->handler); } public function test_can_manually_register_a_prompt_with_invokable_class_handler() @@ -104,8 +101,7 @@ public function test_can_manually_register_a_prompt_with_invokable_class_handler $this->assertInstanceOf(RegisteredPrompt::class, $prompt); $this->assertEquals('manual_invokable_prompt', $prompt->schema->name); $this->assertEquals('A prompt handled by an invokable class.', $prompt->schema->description); - $this->assertEquals(ManualTestInvokableHandler::class, $prompt->handlerClass); - $this->assertEquals('__invoke', $prompt->handlerMethod); + $this->assertEquals(ManualTestInvokableHandler::class, $prompt->handler); } public function test_can_manually_register_a_resource_template_via_facade() @@ -130,7 +126,6 @@ public function test_can_manually_register_a_resource_template_via_facade() $this->assertEquals('manual_item_details_template', $template->schema->name); $this->assertEquals('A sample resource template handler.', $template->schema->description); $this->assertEquals('application/vnd.api+json', $template->schema->mimeType); - $this->assertEquals(ManualTestHandler::class, $template->handlerClass); - $this->assertEquals('handleTemplate', $template->handlerMethod); + $this->assertEquals([ManualTestHandler::class, 'handleTemplate'], $template->handler); } } From 18b21c6d3094955573c17942d9d149378110db7a Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 1 Jul 2025 15:06:07 +0100 Subject: [PATCH 33/39] feat: add closure handler support and custom input schema for tools - Bump php-mcp/server dependency to ^3.2 - Add support for callable/closure handlers in all MCP element types (tools, resources, resource templates, prompts) - Introduce inputSchema() method for tools to define custom JSON validation schemas Breaking: None - all existing v3.0 code remains compatible --- README.md | 120 ++++++++++++++++++- composer.json | 2 +- src/Blueprints/PromptBlueprint.php | 5 +- src/Blueprints/ResourceBlueprint.php | 6 +- src/Blueprints/ResourceTemplateBlueprint.php | 6 +- src/Blueprints/ToolBlueprint.php | 14 ++- src/Facades/Mcp.php | 8 +- src/McpRegistrar.php | 18 +-- tests/Feature/ManualRegistrationTest.php | 102 ++++++++++++++++ 9 files changed, 262 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 85ec5d4..50353ad 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ Mcp::tool([CalculatorService::class, 'add']) Mcp::tool(EmailService::class) ->description('Send emails to users'); +// Register a closure as a tool with custom input schema +Mcp::tool(function(float $x, float $y): float { + return $x * $y; +}) + ->name('multiply') + ->description('Multiply two numbers') + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'number', 'description' => 'First number'], + 'y' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['x', 'y'], + ]); + // Register a resource with metadata Mcp::resource('config://app/settings', [UserService::class, 'getAppSettings']) ->name('app_settings') @@ -102,16 +117,47 @@ Mcp::resource('config://app/settings', [UserService::class, 'getAppSettings']) ->mimeType('application/json') ->size(1024); +// Register a closure as a resource +Mcp::resource('system://time', function(): string { + return now()->toISOString(); +}) + ->name('current_time') + ->description('Get current server time') + ->mimeType('text/plain'); + // Register a resource template for dynamic content Mcp::resourceTemplate('user://{userId}/profile', [UserService::class, 'getUserProfile']) ->name('user_profile') ->description('Get user profile by ID') ->mimeType('application/json'); +// Register a closure as a resource template +Mcp::resourceTemplate('file://{path}', function(string $path): string { + if (!file_exists($path) || !is_readable($path)) { + throw new \InvalidArgumentException("File not found or not readable: {$path}"); + } + return file_get_contents($path); +}) + ->name('file_reader') + ->description('Read file contents by path') + ->mimeType('text/plain'); + // Register a prompt generator Mcp::prompt([PromptService::class, 'generateWelcome']) ->name('welcome_user') ->description('Generate a personalized welcome message'); + +// Register a closure as a prompt +Mcp::prompt(function(string $topic, string $tone = 'professional'): array { + return [ + [ + 'role' => 'user', + 'content' => "Write a {$tone} summary about {$topic}. Make it informative and engaging." + ] + ]; +}) + ->name('topic_summary') + ->description('Generate topic summary prompts'); ``` **Available Fluent Methods:** @@ -120,14 +166,23 @@ Mcp::prompt([PromptService::class, 'generateWelcome']) - `name(string $name)`: Override the inferred name - `description(string $description)`: Set a custom description +**For Tools:** +- `annotations(ToolAnnotations $annotations)`: Add MCP tool annotations +- `inputSchema(array $schema)`: Define custom JSON schema for parameters + **For Resources:** - `mimeType(string $mimeType)`: Specify content type - `size(int $size)`: Set content size in bytes -- `annotations(array|Annotations $annotations)`: Add MCP annotations +- `annotations(Annotations $annotations)`: Add MCP annotations + +**For Resource Templates:** +- `mimeType(string $mimeType)`: Specify content type +- `annotations(Annotations $annotations)`: Add MCP annotations **Handler Formats:** - `[ClassName::class, 'methodName']` - Class method - `InvokableClass::class` - Invokable class with `__invoke()` method +- `function(...) { ... }` - Callables (v3.2+) ### 2. Attribute-Based Discovery @@ -717,6 +772,69 @@ Create a dedicated log channel in `config/logging.php`: ## Migration Guide +### From v3.0 to v3.1 + +**New Handler Types:** + +Laravel MCP v3.1 introduces support for closure handlers, expanding beyond just class methods and invokable classes: + +```php +// v3.0 and earlier - Class-based handlers only +Mcp::tool([CalculatorService::class, 'add']) + ->name('add_numbers'); + +Mcp::tool(EmailService::class) // Invokable class + ->name('send_email'); + +// v3.1+ - Now supports closures +Mcp::tool(function(float $x, float $y): float { + return $x * $y; +}) + ->name('multiply') + ->description('Multiply two numbers'); + +Mcp::resource('system://time', function(): string { + return now()->toISOString(); +}) + ->name('current_time'); +``` + +**Input Schema Support:** + +Tools can now define custom JSON schemas for parameter validation: + +```php +// v3.1+ - Custom input schema +Mcp::tool([CalculatorService::class, 'calculate']) + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'operation' => [ + 'type' => 'string', + 'enum' => ['add', 'subtract', 'multiply', 'divide'] + ], + 'numbers' => [ + 'type' => 'array', + 'items' => ['type' => 'number'], + 'minItems' => 2 + ] + ], + 'required' => ['operation', 'numbers'] + ]); +``` + +**Enhanced Blueprint Methods:** + +New fluent methods available on blueprints: + +```php +->inputSchema(array $schema) // Define custom parameter schema +``` + +**No Breaking Changes:** + +All existing v3.0 code continues to work without modification. The new features are additive enhancements. + ### From v2.x to v3.x **Configuration Changes:** diff --git a/composer.json b/composer.json index 0dbf8fd..ef52894 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^3.1" + "php-mcp/server": "^3.2" }, "require-dev": { "laravel/pint": "^1.13", diff --git a/src/Blueprints/PromptBlueprint.php b/src/Blueprints/PromptBlueprint.php index d097a09..06b91ee 100644 --- a/src/Blueprints/PromptBlueprint.php +++ b/src/Blueprints/PromptBlueprint.php @@ -8,8 +8,11 @@ class PromptBlueprint { public ?string $description = null; + /** + * @param string|array|callable $handler + */ public function __construct( - public array|string $handler, + public mixed $handler, public ?string $name = null ) {} diff --git a/src/Blueprints/ResourceBlueprint.php b/src/Blueprints/ResourceBlueprint.php index 3501310..0e98b9e 100644 --- a/src/Blueprints/ResourceBlueprint.php +++ b/src/Blueprints/ResourceBlueprint.php @@ -4,6 +4,7 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\Annotations; class ResourceBlueprint @@ -18,9 +19,12 @@ class ResourceBlueprint public ?Annotations $annotations = null; + /** + * @param string|array|callable $handler + */ public function __construct( public string $uri, - public array|string $handler, + public mixed $handler, ) {} public function name(string $name): static diff --git a/src/Blueprints/ResourceTemplateBlueprint.php b/src/Blueprints/ResourceTemplateBlueprint.php index 14c53dd..6a50c39 100644 --- a/src/Blueprints/ResourceTemplateBlueprint.php +++ b/src/Blueprints/ResourceTemplateBlueprint.php @@ -4,6 +4,7 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\Annotations; class ResourceTemplateBlueprint @@ -16,9 +17,12 @@ class ResourceTemplateBlueprint public ?Annotations $annotations = null; + /** + * @param string|array|callable $handler + */ public function __construct( public string $uriTemplate, - public array|string $handler, + public mixed $handler, ) {} public function name(string $name): static diff --git a/src/Blueprints/ToolBlueprint.php b/src/Blueprints/ToolBlueprint.php index 4d7b26d..56b8223 100644 --- a/src/Blueprints/ToolBlueprint.php +++ b/src/Blueprints/ToolBlueprint.php @@ -4,15 +4,20 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\ToolAnnotations; class ToolBlueprint { public ?string $description = null; public ?ToolAnnotations $annotations = null; + public ?array $inputSchema = null; + /** + * @param string|array|callable $handler + */ public function __construct( - public array|string $handler, + public mixed $handler, public ?string $name = null ) {} @@ -36,4 +41,11 @@ public function annotations(ToolAnnotations $annotations): static return $this; } + + public function inputSchema(array $inputSchema): static + { + $this->inputSchema = $inputSchema; + + return $this; + } } diff --git a/src/Facades/Mcp.php b/src/Facades/Mcp.php index cd571ed..ee8e509 100644 --- a/src/Facades/Mcp.php +++ b/src/Facades/Mcp.php @@ -11,10 +11,10 @@ use PhpMcp\Laravel\Blueprints\ToolBlueprint; /** - * @method static ToolBlueprint tool(string|array $handlerOrName, array|string|null $handler = null) - * @method static ResourceBlueprint resource(string $uri, array|string $handler) - * @method static ResourceTemplateBlueprint resourceTemplate(string $uriTemplate, array|string $handler) - * @method static PromptBlueprint prompt(string|array $handlerOrName, array|string|null $handler = null) + * @method static ToolBlueprint tool(string|callable|array $handlerOrName, callable|array|string|null $handler = null) + * @method static ResourceBlueprint resource(string $uri, callable|array|string $handler) + * @method static ResourceTemplateBlueprint resourceTemplate(string $uriTemplate, callable|array|string $handler) + * @method static PromptBlueprint prompt(string|callable|array $handlerOrName, callable|array|string|null $handler = null) * * @see \PhpMcp\Laravel\McpRegistrar */ diff --git a/src/McpRegistrar.php b/src/McpRegistrar.php index 0935e0f..7fff3fa 100644 --- a/src/McpRegistrar.php +++ b/src/McpRegistrar.php @@ -34,14 +34,14 @@ public function __construct() {} * Mcp::tool('tool_name', $handler) * Mcp::tool($handler) // Name will be inferred */ - public function tool(string|array ...$args): ToolBlueprint + public function tool(string|callable|array ...$args): ToolBlueprint { $name = null; $handler = null; - if (count($args) === 1 && (is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { + if (count($args) === 1 && (is_callable($args[0]) || is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { $handler = $args[0]; - } elseif (count($args) === 2 && is_string($args[0]) && (is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { + } elseif (count($args) === 2 && is_string($args[0]) && (is_callable($args[1]) || is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { $name = $args[0]; $handler = $args[1]; } else { @@ -57,7 +57,7 @@ public function tool(string|array ...$args): ToolBlueprint /** * Register a new resource. */ - public function resource(string $uri, array|string $handler): ResourceBlueprint + public function resource(string $uri, callable|array|string $handler): ResourceBlueprint { $pendingResource = new ResourceBlueprint($uri, $handler); $this->pendingResources[] = $pendingResource; @@ -68,7 +68,7 @@ public function resource(string $uri, array|string $handler): ResourceBlueprint /** * Register a new resource template. */ - public function resourceTemplate(string $uriTemplate, array|string $handler): ResourceTemplateBlueprint + public function resourceTemplate(string $uriTemplate, callable|array|string $handler): ResourceTemplateBlueprint { $pendingResourceTemplate = new ResourceTemplateBlueprint($uriTemplate, $handler); $this->pendingResourceTemplates[] = $pendingResourceTemplate; @@ -83,14 +83,14 @@ public function resourceTemplate(string $uriTemplate, array|string $handler): Re * Mcp::prompt('prompt_name', $handler) * Mcp::prompt($handler) // Name will be inferred */ - public function prompt(string|array ...$args): PromptBlueprint + public function prompt(string|callable|array ...$args): PromptBlueprint { $name = null; $handler = null; - if (count($args) === 1 && (is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { + if (count($args) === 1 && (is_callable($args[0]) || is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { $handler = $args[0]; - } elseif (count($args) === 2 && is_string($args[0]) && (is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { + } elseif (count($args) === 2 && is_string($args[0]) && (is_callable($args[1]) || is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { $name = $args[0]; $handler = $args[1]; } else { @@ -106,7 +106,7 @@ public function prompt(string|array ...$args): PromptBlueprint public function applyBlueprints(ServerBuilder $builder): void { foreach ($this->pendingTools as $pendingTool) { - $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description, $pendingTool->annotations); + $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description, $pendingTool->annotations, $pendingTool->inputSchema); } foreach ($this->pendingResources as $pendingResource) { diff --git a/tests/Feature/ManualRegistrationTest.php b/tests/Feature/ManualRegistrationTest.php index 72bb15d..7f569bb 100644 --- a/tests/Feature/ManualRegistrationTest.php +++ b/tests/Feature/ManualRegistrationTest.php @@ -32,6 +32,7 @@ public function test_can_manually_register_a_tool() $this->assertEquals('manual_test_tool', $tool->schema->name); $this->assertEquals('A manually registered test tool.', $tool->schema->description); $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); + $this->assertTrue($tool->isManual); $this->assertArrayHasKey('input', $tool->schema->inputSchema['properties']); $this->assertEquals('string', $tool->schema->inputSchema['properties']['input']['type']); } @@ -52,6 +53,7 @@ public function test_can_manually_register_tool_using_handler_only() $this->assertNotNull($tool); $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); + $this->assertTrue($tool->isManual); $this->assertEquals('A sample tool handler.', $tool->schema->description); } @@ -81,6 +83,7 @@ public function test_can_manually_register_a_resource() $this->assertEquals(1024, $resource->schema->size); $this->assertEquals(['priority' => 0.8], $resource->schema->annotations->toArray()); $this->assertEquals([ManualTestHandler::class, 'handleResource'], $resource->handler); + $this->assertTrue($resource->isManual); } public function test_can_manually_register_a_prompt_with_invokable_class_handler() @@ -102,6 +105,7 @@ public function test_can_manually_register_a_prompt_with_invokable_class_handler $this->assertEquals('manual_invokable_prompt', $prompt->schema->name); $this->assertEquals('A prompt handled by an invokable class.', $prompt->schema->description); $this->assertEquals(ManualTestInvokableHandler::class, $prompt->handler); + $this->assertTrue($prompt->isManual); } public function test_can_manually_register_a_resource_template_via_facade() @@ -127,5 +131,103 @@ public function test_can_manually_register_a_resource_template_via_facade() $this->assertEquals('A sample resource template handler.', $template->schema->description); $this->assertEquals('application/vnd.api+json', $template->schema->mimeType); $this->assertEquals([ManualTestHandler::class, 'handleTemplate'], $template->handler); + $this->assertTrue($template->isManual); + } + + public function test_can_manually_register_closure_handlers_and_custom_input_schema() + { + $definitionsContent = <<<'PHP' + name('multiply') + ->description('Multiply two numbers') + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'number', 'description' => 'First number'], + 'y' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['x', 'y'], + ]); + + // Test closure resource + Mcp::resource('system://time', function(): string { + return now()->toISOString(); + }) + ->name('current_time') + ->description('Get current server time') + ->mimeType('text/plain'); + + // Test closure resource template + Mcp::resourceTemplate('calculation://{operation}', function(string $operation): string { + return "Result of {$operation}"; + }) + ->name('calculator') + ->description('Perform calculations') + ->mimeType('text/plain'); + + // Test closure prompt + Mcp::prompt(function(string $topic): array { + return [ + [ + 'role' => 'user', + 'content' => "Write about {$topic}", + ] + ]; + }) + ->name('write_about') + ->description('Generate writing prompts'); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + + // Test closure tool + $tool = $registry->getTool('multiply'); + $this->assertInstanceOf(RegisteredTool::class, $tool); + $this->assertEquals('multiply', $tool->schema->name); + $this->assertEquals('Multiply two numbers', $tool->schema->description); + $this->assertInstanceOf(\Closure::class, $tool->handler); + $this->assertTrue($tool->isManual); + + // Test custom input schema + $schema = $tool->schema->inputSchema; + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('x', $schema['properties']); + $this->assertArrayHasKey('y', $schema['properties']); + $this->assertEquals('number', $schema['properties']['x']['type']); + $this->assertEquals('number', $schema['properties']['y']['type']); + $this->assertEquals(['x', 'y'], $schema['required']); + + // Test closure resource + $resource = $registry->getResource('system://time'); + $this->assertInstanceOf(RegisteredResource::class, $resource); + $this->assertEquals('current_time', $resource->schema->name); + $this->assertEquals('Get current server time', $resource->schema->description); + $this->assertEquals('text/plain', $resource->schema->mimeType); + $this->assertInstanceOf(\Closure::class, $resource->handler); + $this->assertTrue($resource->isManual); + + // Test closure resource template + $template = $registry->getResource('calculation://add'); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $template); + $this->assertEquals('calculator', $template->schema->name); + $this->assertEquals('Perform calculations', $template->schema->description); + $this->assertEquals('text/plain', $template->schema->mimeType); + $this->assertInstanceOf(\Closure::class, $template->handler); + $this->assertTrue($template->isManual); + + // Test closure prompt + $prompt = $registry->getPrompt('write_about'); + $this->assertInstanceOf(RegisteredPrompt::class, $prompt); + $this->assertEquals('write_about', $prompt->schema->name); + $this->assertEquals('Generate writing prompts', $prompt->schema->description); + $this->assertInstanceOf(\Closure::class, $prompt->handler); + $this->assertTrue($prompt->isManual); } } From 0b8029522e2196154c8c0923b8331016a24c12a1 Mon Sep 17 00:00:00 2001 From: WSL PC Sergio Date: Wed, 9 Jul 2025 10:49:31 +0200 Subject: [PATCH 34/39] Fix: correct argument order in response()->stream for legacy SSE mode --- src/Transports/HttpServerTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index 35d3323..f51db35 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -169,7 +169,7 @@ public function handleSseRequest(Request $request): StreamedResponse } $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); - }, [ + }, headers: [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', From 2d9ee19dfdb38b16f3b9bb48cc812c3150501719 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 13 Jul 2025 01:00:32 +0100 Subject: [PATCH 35/39] fix: redirect ServeCommand output to STDERR for stdio transport Console output methods sometimes write to STDOUT, which interferes with JSON-RPC communication in stdio mode. This redirects startup messages to STDERR when available. --- src/Commands/ServeCommand.php | 13 +++++++++---- tests/Feature/Commands/ServeCommandTest.php | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index 77e3c35..da1dbab 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -10,6 +10,7 @@ use PhpMcp\Server\Transports\HttpServerTransport; use PhpMcp\Server\Transports\StdioServerTransport; use PhpMcp\Server\Transports\StreamableHttpServerTransport; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use function Laravel\Prompts\select; @@ -80,10 +81,14 @@ private function handleStdioTransport(Server $server): int return Command::FAILURE; } - $this->info('Starting MCP server'); - $this->line(" - Transport: STDIO"); - $this->line(" - Communication: STDIN/STDOUT"); - $this->line(" - Mode: JSON-RPC over Standard I/O"); + $output = $this->output->getOutput(); + + if ($output instanceof ConsoleOutputInterface) { + $output->getErrorOutput()->writeln("Starting MCP server"); + $output->getErrorOutput()->writeln(" - Transport: STDIO"); + $output->getErrorOutput()->writeln(" - Communication: STDIN/STDOUT"); + $output->getErrorOutput()->writeln(" - Mode: JSON-RPC over Standard I/O"); + } try { $transport = new StdioServerTransport; diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php index 97ed523..270054f 100644 --- a/tests/Feature/Commands/ServeCommandTest.php +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -45,8 +45,8 @@ public function test_serve_command_defaults_to_stdio_and_calls_server_listen() ); $this->artisan('mcp:serve --transport=stdio') - ->expectsOutputToContain('Starting MCP server') - ->expectsOutputToContain('Transport: STDIO') + ->doesntExpectOutputToContain('Starting MCP server') + ->doesntExpectOutputToContain('Transport: STDIO') ->assertSuccessful(); } From 008bebba495f3bfad7efc1cd06862328c60a0ca8 Mon Sep 17 00:00:00 2001 From: CodeWithKyrian <48791154+CodeWithKyrian@users.noreply.github.com> Date: Sun, 13 Jul 2025 00:06:01 +0000 Subject: [PATCH 36/39] Update CHANGELOG --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e28f5..3b690bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `php-mcp/laravel` will be documented in this file. +## v3.1.0 - 2025-07-13 + +### What's Changed + +* Add PHPUnit 12 support and fix test compatibility issues by @tkaratug in https://github.com/php-mcp/laravel/pull/26 +* feat: Add Closure Handler Support and Custom Input Schema for Tools by @CodeWithKyrian in https://github.com/php-mcp/laravel/pull/27 +* Fix: correct argument order in response()->stream for legacy SSE mode by @sergioalborada in https://github.com/php-mcp/laravel/pull/31 +* Fix ServeCommand output interfering with stdio JSON-RPC communication by @CodeWithKyrian in https://github.com/php-mcp/laravel/pull/34 + +### New Contributors + +* @tkaratug made their first contribution in https://github.com/php-mcp/laravel/pull/26 +* @sergioalborada made their first contribution in https://github.com/php-mcp/laravel/pull/31 + +**Full Changelog**: https://github.com/php-mcp/laravel/compare/3.0.0...3.1.0 + ## v3.0.0 - 2025-06-26 ### Major Changes @@ -35,6 +51,7 @@ All notable changes to `php-mcp/laravel` will be documented in this file. composer require php-mcp/laravel:^3.0 php artisan vendor:publish --provider="PhpMcp\Laravel\McpServiceProvider" + ``` **Full Changelog**: https://github.com/php-mcp/laravel/compare/2.1.1...3.0.0 @@ -146,6 +163,7 @@ This release marks a **major overhaul**, bringing it into full alignment with `p + ``` * **`mcp:serve` for HTTP:** The `--transport=http` option for `mcp:serve` now launches a *dedicated* ReactPHP-based server process. For serving MCP via your main Laravel application routes, ensure the `http_integrated` transport is enabled in `config/mcp.php` and your web server is configured appropriately. * **Event Handling:** If you were directly listening to internal events from the previous version, these may have changed. Rely on the documented Laravel events (`ToolsListChanged`, etc.). @@ -212,6 +230,7 @@ php artisan vendor:publish --provider="PhpMcp\Laravel\Server\McpServiceProvider" + ``` ## Getting Started From 7c90b8d0fca0917a9d41edaca1a0bf2afd548a98 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Mon, 14 Jul 2025 21:27:14 +0100 Subject: [PATCH 37/39] feat: add stateless mode support for HTTP transports Adds stateless configuration option for both dedicated and integrated HTTP transports. Requires server package 3.3+ for stateless StreamableHttpServerTransport support. --- composer.json | 2 +- config/mcp.php | 2 + samples/basic/composer.lock | 228 ++++++++++-------- samples/basic/config/mcp.php | 2 + src/Commands/ServeCommand.php | 2 + .../StreamableTransportController.php | 3 +- .../StreamableHttpServerTransport.php | 57 +++-- 7 files changed, 171 insertions(+), 125 deletions(-) diff --git a/composer.json b/composer.json index ef52894..47d8ecd 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^3.2" + "php-mcp/server": "^3.3" }, "require-dev": { "laravel/pint": "^1.13", diff --git a/config/mcp.php b/config/mcp.php index 3fa876c..5822ddf 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -87,6 +87,7 @@ 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_DEDICATED_STATELESS', false), 'ssl_context_options' => [], 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), 'event_store' => null, // FQCN or null @@ -96,6 +97,7 @@ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', false), 'middleware' => ['api'], 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index 094bb16..ab604f2 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -1207,16 +1207,16 @@ }, { "name": "laravel/framework", - "version": "v12.19.3", + "version": "v12.20.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262" + "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", - "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", + "url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff", + "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff", "shasum": "" }, "require": { @@ -1418,20 +1418,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-18T12:56:23+00:00" + "time": "2025-07-08T15:02:21+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.5", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { @@ -1475,9 +1475,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.5" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2025-02-11T13:34:40+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "laravel/serializable-closure", @@ -2854,12 +2854,12 @@ "dist": { "type": "path", "url": "../..", - "reference": "571d03d87225587b1799d6f58880b4d805cdbaef" + "reference": "6cec2a728f6aed155a5bb4518e9241fbe3fa4d17" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^3.1" + "php-mcp/server": "^3.3" }, "require-dev": { "laravel/pint": "^1.13", @@ -2868,7 +2868,7 @@ "orchestra/testbench": "^8.0 || ^9.0", "pestphp/pest": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", - "phpunit/phpunit": "^10.0 || ^11.0" + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" }, "type": "library", "extra": { @@ -2909,11 +2909,18 @@ "role": "Developer" } ], - "description": "The official Laravel integration for the PHP MCP Server package.", + "description": "Laravel SDK for building Model Context Protocol (MCP) servers - Seamlessly integrate MCP tools, resources, and prompts into Laravel applications", "homepage": "https://github.com/php-mcp/laravel", "keywords": [ "ai", "laravel", + "laravel mcp", + "laravel mcp prompts", + "laravel mcp resources", + "laravel mcp sdk", + "laravel mcp server", + "laravel mcp tools", + "laravel model context protocol", "llm", "mcp", "model-context-protocol", @@ -2965,16 +2972,16 @@ }, { "name": "php-mcp/server", - "version": "3.1.0", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "caa5686076a4707239a0af902f97722bc9689a89" + "reference": "37b40d5e91f0600442677ddd226e5a22d5661ee1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mcp/server/zipball/caa5686076a4707239a0af902f97722bc9689a89", - "reference": "caa5686076a4707239a0af902f97722bc9689a89", + "url": "https://api.github.com/repos/php-mcp/server/zipball/37b40d5e91f0600442677ddd226e5a22d5661ee1", + "reference": "37b40d5e91f0600442677ddd226e5a22d5661ee1", "shasum": "" }, "require": { @@ -3001,7 +3008,7 @@ "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { - "react/http": "Required for using the ReactPHP HTTP transport handler (^1.11 recommended)." + "ext-pcntl": "For signal handling support when using StdioServerTransport with StreamSelectLoop" }, "type": "library", "autoload": { @@ -3019,20 +3026,25 @@ "email": "koshnawaza@gmail.com" } ], - "description": "Core PHP implementation for the Model Context Protocol (MCP) server", + "description": "PHP SDK for building Model Context Protocol (MCP) servers - Create MCP tools, resources, and prompts", "keywords": [ "Model Context Protocol", - "ai", - "llm", "mcp", "php", + "php mcp", + "php mcp prompts", + "php mcp resources", + "php mcp sdk", + "php mcp server", + "php mcp tools", + "php model context protocol", "server" ], "support": { "issues": "https://github.com/php-mcp/server/issues", - "source": "https://github.com/php-mcp/server/tree/3.1.0" + "source": "https://github.com/php-mcp/server/tree/3.3.0" }, - "time": "2025-06-25T22:55:35+00:00" + "time": "2025-07-12T22:19:39+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -3286,16 +3298,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -3327,9 +3339,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "psr/clock", @@ -3944,21 +3956,20 @@ }, { "name": "ramsey/uuid", - "version": "4.8.1", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28" + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", - "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", "shasum": "" }, "require": { "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", - "ext-json": "*", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4017,9 +4028,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.8.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.0" }, - "time": "2025-06-01T06:28:46+00:00" + "time": "2025-06-25T14:20:11+00:00" }, { "name": "react/cache", @@ -4639,16 +4650,16 @@ }, { "name": "symfony/console", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", - "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", + "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", "shasum": "" }, "require": { @@ -4713,7 +4724,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.0" + "source": "https://github.com/symfony/console/tree/v7.3.1" }, "funding": [ { @@ -4729,7 +4740,7 @@ "type": "tidelift" } ], - "time": "2025-05-24T10:34:04+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/css-selector", @@ -4865,16 +4876,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "cf68d225bc43629de4ff54778029aee6dc191b83" + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/cf68d225bc43629de4ff54778029aee6dc191b83", - "reference": "cf68d225bc43629de4ff54778029aee6dc191b83", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", "shasum": "" }, "require": { @@ -4922,7 +4933,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.0" + "source": "https://github.com/symfony/error-handler/tree/v7.3.1" }, "funding": [ { @@ -4938,7 +4949,7 @@ "type": "tidelift" } ], - "time": "2025-05-29T07:19:49+00:00" + "time": "2025-06-13T07:48:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -5162,16 +5173,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "4236baf01609667d53b20371486228231eb135fd" + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/4236baf01609667d53b20371486228231eb135fd", - "reference": "4236baf01609667d53b20371486228231eb135fd", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", "shasum": "" }, "require": { @@ -5221,7 +5232,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" }, "funding": [ { @@ -5237,20 +5248,20 @@ "type": "tidelift" } ], - "time": "2025-05-12T14:48:23+00:00" + "time": "2025-06-23T15:07:14+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f" + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ac7b8e163e8c83dce3abcc055a502d4486051a9f", - "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", "shasum": "" }, "require": { @@ -5335,7 +5346,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" }, "funding": [ { @@ -5351,20 +5362,20 @@ "type": "tidelift" } ], - "time": "2025-05-29T07:47:32+00:00" + "time": "2025-06-28T08:24:55+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "0f375bbbde96ae8c78e4aa3e63aabd486e33364c" + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/0f375bbbde96ae8c78e4aa3e63aabd486e33364c", - "reference": "0f375bbbde96ae8c78e4aa3e63aabd486e33364c", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", "shasum": "" }, "require": { @@ -5415,7 +5426,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.0" + "source": "https://github.com/symfony/mailer/tree/v7.3.1" }, "funding": [ { @@ -5431,7 +5442,7 @@ "type": "tidelift" } ], - "time": "2025-04-04T09:51:09+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/mime", @@ -6468,16 +6479,16 @@ }, { "name": "symfony/translation", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "4aba29076a29a3aa667e09b791e5f868973a8667" + "reference": "241d5ac4910d256660238a7ecf250deba4c73063" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/4aba29076a29a3aa667e09b791e5f868973a8667", - "reference": "4aba29076a29a3aa667e09b791e5f868973a8667", + "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063", "shasum": "" }, "require": { @@ -6544,7 +6555,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.0" + "source": "https://github.com/symfony/translation/tree/v7.3.1" }, "funding": [ { @@ -6560,7 +6571,7 @@ "type": "tidelift" } ], - "time": "2025-05-29T07:19:49+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/translation-contracts", @@ -6642,16 +6653,16 @@ }, { "name": "symfony/uid", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3" + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7beeb2b885cd584cd01e126c5777206ae4c3c6a3", - "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", "shasum": "" }, "require": { @@ -6696,7 +6707,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.3.0" + "source": "https://github.com/symfony/uid/tree/v7.3.1" }, "funding": [ { @@ -6712,20 +6723,20 @@ "type": "tidelift" } ], - "time": "2025-05-24T14:28:13+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e" + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/548f6760c54197b1084e1e5c71f6d9d523f2f78e", - "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", "shasum": "" }, "require": { @@ -6780,7 +6791,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" }, "funding": [ { @@ -6796,7 +6807,7 @@ "type": "tidelift" } ], - "time": "2025-04-27T18:39:23+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7551,16 +7562,16 @@ }, { "name": "laravel/pint", - "version": "v1.22.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", - "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -7571,10 +7582,10 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.7", - "larastan/larastan": "^3.4.0", - "laravel-zero/framework": "^11.36.1", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" @@ -7584,6 +7595,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -7613,7 +7627,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-05-08T08:38:12+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "laravel/sail", @@ -7763,16 +7777,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -7811,7 +7825,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -7819,7 +7833,7 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nunomaduro/collision", @@ -9864,16 +9878,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "cea40a48279d58dc3efee8112634cb90141156c2" + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/cea40a48279d58dc3efee8112634cb90141156c2", - "reference": "cea40a48279d58dc3efee8112634cb90141156c2", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", "shasum": "" }, "require": { @@ -9916,7 +9930,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.0" + "source": "https://github.com/symfony/yaml/tree/v7.3.1" }, "funding": [ { @@ -9932,7 +9946,7 @@ "type": "tidelift" } ], - "time": "2025-04-04T10:10:33+00:00" + "time": "2025-06-03T06:57:57+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index 24ffc6f..e950e70 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -84,6 +84,7 @@ 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_DEDICATED_STATELESS', false), 'ssl_context_options' => [], 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), 'event_store' => null, // FQCN or null @@ -93,6 +94,7 @@ 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', true), 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'api')), 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), diff --git a/src/Commands/ServeCommand.php b/src/Commands/ServeCommand.php index da1dbab..b84bc28 100644 --- a/src/Commands/ServeCommand.php +++ b/src/Commands/ServeCommand.php @@ -151,6 +151,7 @@ private function handleStreamableHttpTransport(Server $server, string $host, int { $enableJsonResponse = config('mcp.transports.http_dedicated.enable_json_response', true); $eventStore = $this->createEventStore(); + $stateless = config('mcp.transports.http_dedicated.stateless', false); $this->info("Starting MCP server on http://{$host}:{$port}"); $this->line(" - Transport: Streamable HTTP"); @@ -163,6 +164,7 @@ private function handleStreamableHttpTransport(Server $server, string $host, int mcpPath: $pathPrefix, sslContext: $sslContextOptions, enableJsonResponse: $enableJsonResponse, + stateless: $stateless, eventStore: $eventStore ); diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php index 000f70f..becc601 100644 --- a/src/Http/Controllers/StreamableTransportController.php +++ b/src/Http/Controllers/StreamableTransportController.php @@ -19,8 +19,9 @@ public function __construct(Server $server) { $eventStore = $this->createEventStore(); $sessionManager = $server->getSessionManager(); + $stateless = config('mcp.transports.http_integrated.stateless', false); - $this->transport = new StreamableHttpServerTransport($sessionManager, $eventStore); + $this->transport = new StreamableHttpServerTransport($sessionManager, $eventStore, $stateless); $server->listen($this->transport, false); } diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index c583453..f07a32d 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -29,7 +29,8 @@ class StreamableHttpServerTransport implements ServerTransportInterface public function __construct( protected SessionManager $sessionManager, - protected ?EventStoreInterface $eventStore = null + protected ?EventStoreInterface $eventStore = null, + protected bool $stateless = false ) {} protected function generateId(): string @@ -104,27 +105,33 @@ public function handlePostRequest(Request $request): Response $isInitializeRequest = ($message instanceof JsonRpcRequest && $message->method === 'initialize'); $sessionId = null; - if ($isInitializeRequest) { - if ($request->hasHeader('Mcp-Session-Id')) { - Log::warning('Client sent Mcp-Session-Id with InitializeRequest. Ignoring.', ['clientSentId' => $request->header('Mcp-Session-Id')]); - $error = Error::forInvalidRequest('Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.', $message->getId()); - return response()->json($error, 400, $this->getCorsHeaders()); - } - + if ($this->stateless) { $sessionId = $this->generateId(); $this->emit('client_connected', [$sessionId]); } else { - $sessionId = $request->header('Mcp-Session-Id'); + if ($isInitializeRequest) { + if ($request->hasHeader('Mcp-Session-Id')) { + Log::warning('Client sent Mcp-Session-Id with InitializeRequest. Ignoring.', ['clientSentId' => $request->header('Mcp-Session-Id')]); + $error = Error::forInvalidRequest('Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.', $message->getId()); + return response()->json($error, 400, $this->getCorsHeaders()); + } + + $sessionId = $this->generateId(); + $this->emit('client_connected', [$sessionId]); + } else { + $sessionId = $request->header('Mcp-Session-Id'); - if (empty($sessionId)) { - Log::warning('POST request without Mcp-Session-Id'); - $error = Error::forInvalidRequest('Mcp-Session-Id header required for POST requests', $message->getId()); - return response()->json($error, 400, $this->getCorsHeaders()); + if (empty($sessionId)) { + Log::warning('POST request without Mcp-Session-Id'); + $error = Error::forInvalidRequest('Mcp-Session-Id header required for POST requests', $message->getId()); + return response()->json($error, 400, $this->getCorsHeaders()); + } } } $context = [ 'is_initialize_request' => $isInitializeRequest, + 'stateless' => $this->stateless, ]; $nRequests = match (true) { @@ -171,10 +178,14 @@ protected function handleJsonResponse(Message $message, string $sessionId, array ...$this->getCorsHeaders() ]; - if ($context['is_initialize_request'] ?? false) { + if ($context['is_initialize_request'] ?? false && !$this->stateless) { $headers['Mcp-Session-Id'] = $sessionId; } + if ($this->stateless) { + $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']); + } + return response()->make($data, 200, $headers); } catch (Throwable $e) { Log::error('JSON response mode error', ['exception' => $e]); @@ -195,7 +206,7 @@ protected function handleSseResponse(Message $message, string $sessionId, int $n 'X-Accel-Buffering' => 'no', ], $this->getCorsHeaders()); - if ($context['is_initialize_request'] ?? false) { + if ($context['is_initialize_request'] ?? false && !$this->stateless) { $headers['Mcp-Session-Id'] = $sessionId; } @@ -210,6 +221,10 @@ protected function handleSseResponse(Message $message, string $sessionId, int $n $messages = $this->dequeueMessagesForContext($sessionId, 'post_sse', $streamId); $this->sendSseEvent($messages[0]['data'], $messages[0]['id']); + + if ($this->stateless) { + $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']); + } }, headers: $headers); } @@ -218,6 +233,12 @@ protected function handleSseResponse(Message $message, string $sessionId, int $n */ public function handleGetRequest(Request $request): StreamedResponse|Response { + if ($this->stateless) { + return new Response('SSE not available in stateless mode', 405, [ + 'Allow' => 'POST, DELETE, OPTIONS' + ]); + } + $acceptHeader = $request->header('Accept'); if (!str_contains($acceptHeader, 'text/event-stream')) { $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests."); @@ -274,6 +295,10 @@ public function handleGetRequest(Request $request): StreamedResponse|Response */ public function handleDeleteRequest(Request $request): Response { + if ($this->stateless) { + return response()->noContent(headers: $this->getCorsHeaders()); + } + $sessionId = $request->header('Mcp-Session-Id'); if (empty($sessionId)) { Log::warning("DELETE request without Mcp-Session-Id."); @@ -285,7 +310,7 @@ public function handleDeleteRequest(Request $request): Response $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']); - return response()->noContent(204, $this->getCorsHeaders()); + return response()->noContent(headers: $this->getCorsHeaders()); } /** From 69ddc3a9670d655487cab0689057c84ecfa624b5 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 19 Aug 2025 17:16:18 +0100 Subject: [PATCH 38/39] chore: use a more explicit install command in docs to force latest version [skip ci] --- README.md | 2 +- samples/basic/app/Models/User.php | 3 +- samples/basic/composer.json | 1 + samples/basic/composer.lock | 68 +++++++++++++- samples/basic/config/mcp.php | 2 +- samples/basic/config/sanctum.php | 84 ++++++++++++++++++ ...17_create_personal_access_tokens_table.php | 33 +++++++ samples/basic/dump.rdb | Bin 0 -> 1151 bytes samples/basic/routes/api.php | 8 ++ samples/basic/routes/mcp.php | 5 ++ 10 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 samples/basic/config/sanctum.php create mode 100644 samples/basic/database/migrations/2025_08_08_121817_create_personal_access_tokens_table.php create mode 100644 samples/basic/dump.rdb create mode 100644 samples/basic/routes/api.php diff --git a/README.md b/README.md index 50353ad..739cfeb 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This package supports the **2025-03-26** version of the Model Context Protocol. Install the package via Composer: ```bash -composer require php-mcp/laravel +composer require php-mcp/laravel:^3.0 -W ``` Publish the configuration file: diff --git a/samples/basic/app/Models/User.php b/samples/basic/app/Models/User.php index 749c7b7..a6ab88e 100644 --- a/samples/basic/app/Models/User.php +++ b/samples/basic/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasApiTokens; /** * The attributes that are mass assignable. diff --git a/samples/basic/composer.json b/samples/basic/composer.json index 23dae59..36f722c 100644 --- a/samples/basic/composer.json +++ b/samples/basic/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "php-mcp/laravel": "dev-main" }, diff --git a/samples/basic/composer.lock b/samples/basic/composer.lock index ab604f2..9e1170e 100644 --- a/samples/basic/composer.lock +++ b/samples/basic/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "26187052f3195fd4e3e90385de4d0446", + "content-hash": "56f664a818eb3d1df9d4165ed6c99428", "packages": [ { "name": "brick/math", @@ -1479,6 +1479,70 @@ }, "time": "2025-07-07T14:17:42+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-07-09T19:45:24+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.4", @@ -2854,7 +2918,7 @@ "dist": { "type": "path", "url": "../..", - "reference": "6cec2a728f6aed155a5bb4518e9241fbe3fa4d17" + "reference": "840aa668be8ea858e78f038abb2a563981f04f8c" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", diff --git a/samples/basic/config/mcp.php b/samples/basic/config/mcp.php index e950e70..f8e9fc9 100644 --- a/samples/basic/config/mcp.php +++ b/samples/basic/config/mcp.php @@ -95,7 +95,7 @@ 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', true), - 'middleware' => explode(',', env('MCP_HTTP_INTEGRATED_MIDDLEWARE', 'api')), + 'middleware' => ['api', 'auth:sanctum'], 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), diff --git a/samples/basic/config/sanctum.php b/samples/basic/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/samples/basic/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/samples/basic/database/migrations/2025_08_08_121817_create_personal_access_tokens_table.php b/samples/basic/database/migrations/2025_08_08_121817_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/samples/basic/database/migrations/2025_08_08_121817_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/samples/basic/dump.rdb b/samples/basic/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..772f46d0bac1a76929be44396028fa48b5265e1e GIT binary patch literal 1151 zcma)+&1(}u7>8%`u}P~HTNM?p8MhWhvzk;9yP`~}#kQanD|qlDne0xQp}RA2W;Xqz zAo#7|xgIPQPgbZP)N@FW-bC=)zd>(lv2L4Gq}t+j*!MT@`^@w1_1??Qt)MTtCQDB`c^FwH5V9_0!x;u@?6waZHbR)!f&8pccoxVCJzT74Q=* z>*3b@f|RLERkp|lo#4#!uvA3IWD)=n0PWm)qDi2ZyhW9{I5l z+!0+}$&7w}Kdl87oqxq~Mim#o$AL~0AjqPC}(mp)0cU9c+$vY7c-j}mVtf6jih07#z+8&Gq@+R zjt%Y&-}8x&I&_B=u{ zV{i89SMk_Tkrq%A-Hny7AWZc;@Iv@(`b;;v6cR+DcGR39U8@pbnBt54Uyg5 z>#olIf85E2xBzaBbp!pglHrmpa+SrZ`t03IaizVrZtL!|o4>DB`941jB99xKpEN+nUj(3bG`20rI#}VgKR|Zlz{YlwWIel-4UCq-Vq__E V$e50;lr=fE`t;^%d6B=({Q|h9pi}?= literal 0 HcmV?d00001 diff --git a/samples/basic/routes/api.php b/samples/basic/routes/api.php new file mode 100644 index 0000000..ccc387f --- /dev/null +++ b/samples/basic/routes/api.php @@ -0,0 +1,8 @@ +user(); +})->middleware('auth:sanctum'); diff --git a/samples/basic/routes/mcp.php b/samples/basic/routes/mcp.php index 3d8d775..fc88880 100644 --- a/samples/basic/routes/mcp.php +++ b/samples/basic/routes/mcp.php @@ -5,6 +5,7 @@ use App\Mcp\GetArticleContent; use App\Mcp\GetAppVersion; use PhpMcp\Laravel\Facades\Mcp; +use Illuminate\Support\Facades\Auth; Mcp::tool('welcome_message', GenerateWelcomeMessage::class); @@ -12,6 +13,10 @@ ->name('laravel_app_version') ->mimeType('text/plain'); +Mcp::tool('get_me', function () { + return Auth::user(); +}); + Mcp::resourceTemplate('content://articles/{articleId}', GetArticleContent::class) ->name('article_content') ->mimeType('application/json'); From 2075008ad5cfbb86dec080be0b51d663d2fc4651 Mon Sep 17 00:00:00 2001 From: arifulhoque7 Date: Thu, 11 Sep 2025 22:32:31 +0600 Subject: [PATCH 39/39] fix: Add authentication support for Dedicated HTTP configuration --- .gitignore | 130 +++--- config/mcp.php | 404 +++++++++--------- examples/AuthenticatedTools.php | 309 ++++++++++++++ samples/basic/routes/mcp.php | 19 +- samples/basic/vite.config.js | 4 + src/Facades/McpAuth.php | 102 +++++ .../Controllers/SseTransportController.php | 95 ++-- .../StreamableTransportController.php | 155 +++---- .../McpAuthenticationMiddleware.php | 174 ++++++++ src/McpServiceProvider.php | 399 ++++++++--------- src/Support/McpContext.php | 176 ++++++++ tests/Feature/AuthenticationTest.php | 201 +++++++++ 12 files changed, 1584 insertions(+), 584 deletions(-) create mode 100644 examples/AuthenticatedTools.php create mode 100644 src/Facades/McpAuth.php create mode 100644 src/Http/Middleware/McpAuthenticationMiddleware.php create mode 100644 src/Support/McpContext.php create mode 100644 tests/Feature/AuthenticationTest.php diff --git a/.gitignore b/.gitignore index 0e52713..9b8f58c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,65 @@ -# Composer dependencies -/vendor/ -/composer.lock - -# PHPUnit -.phpunit.result.cache - -# PHP CS Fixer -/.php-cs-fixer.cache -/.php-cs-fixer.php - -# Editor directories and files -/.idea -/.vscode -*.sublime-project -*.sublime-workspace - -# Operating system files -.DS_Store -Thumbs.db - -# Local environment files -/.env -/.env.backup -/.env.local - -# PHP CodeSniffer -/.phpcs.xml -/.phpcs.xml.dist -/phpcs.xml -/phpcs.xml.dist - -# PHPStan -/phpstan.neon -/phpstan.neon.dist - -# Local development tools -/.php_cs -/.php_cs.cache -/.php_cs.dist -/_ide_helper.php -/.php-cs-fixer.php - -# Build artifacts -/build/ -/coverage/ - -# PHPUnit coverage reports -/clover.xml -/coverage.xml -/coverage/ - -# Laravel generated files -bootstrap/cache/ -.phpunit.result.cache - -# Local Composer dependencies -composer.phar - -workbench -playground - - -# Log files -*.log \ No newline at end of file +# Composer dependencies +/vendor/ +/composer.lock + +# PHPUnit +.phpunit.result.cache + +# PHP CS Fixer +/.php-cs-fixer.cache +/.php-cs-fixer.php + +# Editor directories and files +/.idea +/.vscode +*.sublime-project +*.sublime-workspace + +# Operating system files +.DS_Store +Thumbs.db + +# Local environment files +/.env.backup +/.env.local + +# PHP CodeSniffer +/.phpcs.xml +/.phpcs.xml.dist +/phpcs.xml +/phpcs.xml.dist + +# PHPStan +/phpstan.neon +/phpstan.neon.dist + +# Local development tools +/.php_cs +/.php_cs.cache +/.php_cs.dist +/_ide_helper.php +/.php-cs-fixer.php + +# Build artifacts +/build/ +/coverage/ + +# PHPUnit coverage reports +/clover.xml +/coverage.xml +/coverage/ + +# Laravel generated files +bootstrap/cache/ +.phpunit.result.cache + +# Local Composer dependencies +composer.phar + +workbench +playground + + +# Log files +*.log +node_modules diff --git a/config/mcp.php b/config/mcp.php index 5822ddf..2a7dffa 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -1,202 +1,202 @@ - [ - 'name' => env('MCP_SERVER_NAME', 'Laravel MCP'), - 'version' => env('MCP_SERVER_VERSION', '1.0.0'), - 'instructions' => env('MCP_SERVER_INSTRUCTIONS'), - ], - - /* - |-------------------------------------------------------------------------- - | MCP Discovery Configuration - |-------------------------------------------------------------------------- - | - | These options control how the MCP server discovers and registers tools, - | resources and prompts in your application. You can configure which - | directories to scan, what to exclude, and how discovery behaves. - | - */ - 'discovery' => [ - 'base_path' => base_path(), - 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_DIRECTORIES', 'app/Mcp'))), - 'exclude_dirs' => [ - 'vendor', - 'tests', - 'storage', - 'public', - 'resources', - 'bootstrap', - 'config', - 'database', - 'routes', - 'node_modules', - '.git', - ], - 'definitions_file' => base_path('routes/mcp.php'), - 'auto_discover' => (bool) env('MCP_AUTO_DISCOVER', true), - 'save_to_cache' => (bool) env('MCP_DISCOVERY_SAVE_TO_CACHE', true), - ], - - /* - |-------------------------------------------------------------------------- - | MCP Cache Configuration - |-------------------------------------------------------------------------- - | - | Configure how the MCP server caches discovered elements using Laravel's cache system. - | You can specify which store to use and how long items should be cached. - | - */ - 'cache' => [ - 'store' => env('MCP_CACHE_STORE', config('cache.default')), - ], - - /* - |-------------------------------------------------------------------------- - | MCP Transport Configuration - |-------------------------------------------------------------------------- - | - | Configure the available transports for MCP communication. - | - | Supported Transports: - | - `stdio`: for CLI clients. - | - `http_dedicated`: for a standalone server running on a process. - | - `http_integrated`: for serving through Laravel's routing system. - | - | The 'legacy' option is used to enable the deprecated HTTP+SSE transport. - | It is not recommended to use this option. - */ - 'transports' => [ - 'stdio' => [ - 'enabled' => (bool) env('MCP_STDIO_ENABLED', true), - ], - - 'http_dedicated' => [ - 'enabled' => (bool) env('MCP_HTTP_DEDICATED_ENABLED', true), - 'legacy' => (bool) env('MCP_HTTP_DEDICATED_LEGACY', false), - 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), - 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), - 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), - 'stateless' => (bool) env('MCP_HTTP_DEDICATED_STATELESS', false), - 'ssl_context_options' => [], - 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), - 'event_store' => null, // FQCN or null - ], - - 'http_integrated' => [ - 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), - 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), - 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), - 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', false), - 'middleware' => ['api'], - 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), - 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), - 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), - 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), - 'event_store' => null, // FQCN or null - ], - ], - - /* - |-------------------------------------------------------------------------- - | Session Management Configuration - |-------------------------------------------------------------------------- - | - | Configure how the MCP server manages client sessions. Sessions store - | client state, message queues, and subscriptions. Supports Laravel's - | native session drivers for seamless integration. - | - */ - 'session' => [ - 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'file', 'cache', 'database', 'redis', 'memcached', 'dynamodb', 'array' - 'ttl' => (int) env('MCP_SESSION_TTL', 3600), - - // For cache-based drivers (redis, memcached, etc.) - 'store' => env('MCP_SESSION_CACHE_STORE', config('cache.default')), - - // For file driver - 'path' => env('MCP_SESSION_FILE_PATH', storage_path('framework/mcp_sessions')), - - // For database driver - 'connection' => env('MCP_SESSION_DB_CONNECTION', config('database.default')), - 'table' => env('MCP_SESSION_DB_TABLE', 'mcp_sessions'), - - // Session garbage collection probability. 2% chance that garbage collection will run on any given session operation. - 'lottery' => [2, 100], - ], - - /* - |-------------------------------------------------------------------------- - | Pagination Limit - |-------------------------------------------------------------------------- - | - | This value determines the maximum number of items that will be returned - | by list methods in the MCP server. - | - */ - 'pagination_limit' => env('MCP_PAGINATION_LIMIT', 50), - - /* - |-------------------------------------------------------------------------- - | MCP Capabilities Configuration - |-------------------------------------------------------------------------- - | - | Define which MCP features are enabled in your server instance. This includes - | support for tools, resources, prompts, and their related functionality like - | subscriptions and change notifications. - | - | The following capabilities are supported: - | - tools - Whether the server offers tools. - | - toolsListChanged - Whether the server supports sending a notification when the list of tools changes. - | - resources - Whether the server offers resources. - | - resourcesSubscribe - Whether the server supports resource subscriptions. - | - resourcesListChanged - Whether the server supports sending a notification when the list of resources changes. - | - prompts - Whether the server offers prompts. - | - promptsListChanged - Whether the server supports sending a notification when the list of prompts changes. - | - logging - Whether the server supports sending log messages to the client. - | - completions - Whether the server supports argument autocompletion suggestions. - | - experimental - Experimental, non-standard capabilities that the server supports. - | - */ - 'capabilities' => [ - 'tools' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), - 'toolsListChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), - - 'resources' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), - 'resourcesSubscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), - 'resourcesListChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), - - 'prompts' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), - 'promptsListChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), - - 'logging' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), - - 'completions' => (bool) env('MCP_CAP_COMPLETIONS_ENABLED', true), - - 'experimental' => null, - ], - - /* - |-------------------------------------------------------------------------- - | Logging Configuration - |-------------------------------------------------------------------------- - | - | Configure how the MCP server handles logging. You can specify which Laravel - | log channel to use and set the default log level. - | - */ - 'logging' => [ - 'channel' => env('MCP_LOG_CHANNEL', config('logging.default')), - 'level' => env('MCP_LOG_LEVEL', 'info'), - ], -]; + [ + 'name' => env('MCP_SERVER_NAME', 'Laravel MCP'), + 'version' => env('MCP_SERVER_VERSION', '1.0.0'), + 'instructions' => env('MCP_SERVER_INSTRUCTIONS'), + ], + + /* + |-------------------------------------------------------------------------- + | MCP Discovery Configuration + |-------------------------------------------------------------------------- + | + | These options control how the MCP server discovers and registers tools, + | resources and prompts in your application. You can configure which + | directories to scan, what to exclude, and how discovery behaves. + | + */ + 'discovery' => [ + 'base_path' => base_path(), + 'directories' => array_filter(explode(',', env('MCP_DISCOVERY_DIRECTORIES', 'app/Mcp'))), + 'exclude_dirs' => [ + 'vendor', + 'tests', + 'storage', + 'public', + 'resources', + 'bootstrap', + 'config', + 'database', + 'routes', + 'node_modules', + '.git', + ], + 'definitions_file' => base_path('routes/mcp.php'), + 'auto_discover' => (bool) env('MCP_AUTO_DISCOVER', true), + 'save_to_cache' => (bool) env('MCP_DISCOVERY_SAVE_TO_CACHE', true), + ], + + /* + |-------------------------------------------------------------------------- + | MCP Cache Configuration + |-------------------------------------------------------------------------- + | + | Configure how the MCP server caches discovered elements using Laravel's cache system. + | You can specify which store to use and how long items should be cached. + | + */ + 'cache' => [ + 'store' => env('MCP_CACHE_STORE', config('cache.default')), + ], + + /* + |-------------------------------------------------------------------------- + | MCP Transport Configuration + |-------------------------------------------------------------------------- + | + | Configure the available transports for MCP communication. + | + | Supported Transports: + | - `stdio`: for CLI clients. + | - `http_dedicated`: for a standalone server running on a process. + | - `http_integrated`: for serving through Laravel's routing system. + | + | The 'legacy' option is used to enable the deprecated HTTP+SSE transport. + | It is not recommended to use this option. + */ + 'transports' => [ + 'stdio' => [ + 'enabled' => (bool) env('MCP_STDIO_ENABLED', true), + ], + + 'http_dedicated' => [ + 'enabled' => (bool) env('MCP_HTTP_DEDICATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_DEDICATED_LEGACY', false), + 'host' => env('MCP_HTTP_DEDICATED_HOST', '127.0.0.1'), + 'port' => (int) env('MCP_HTTP_DEDICATED_PORT', 8090), + 'path_prefix' => env('MCP_HTTP_DEDICATED_PATH_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_DEDICATED_STATELESS', false), + 'ssl_context_options' => [], + 'enable_json_response' => (bool) env('MCP_HTTP_DEDICATED_JSON_RESPONSE', true), + 'event_store' => null, // FQCN or null + ], + + 'http_integrated' => [ + 'enabled' => (bool) env('MCP_HTTP_INTEGRATED_ENABLED', true), + 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), + 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), + 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', false), + 'middleware' => ['api', 'auth:sanctum'], + 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), + 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), + 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), + 'enable_json_response' => (bool) env('MCP_HTTP_INTEGRATED_JSON_RESPONSE', true), + 'event_store' => null, // FQCN or null + ], + ], + + /* + |-------------------------------------------------------------------------- + | Session Management Configuration + |-------------------------------------------------------------------------- + | + | Configure how the MCP server manages client sessions. Sessions store + | client state, message queues, and subscriptions. Supports Laravel's + | native session drivers for seamless integration. + | + */ + 'session' => [ + 'driver' => env('MCP_SESSION_DRIVER', 'cache'), // 'file', 'cache', 'database', 'redis', 'memcached', 'dynamodb', 'array' + 'ttl' => (int) env('MCP_SESSION_TTL', 3600), + + // For cache-based drivers (redis, memcached, etc.) + 'store' => env('MCP_SESSION_CACHE_STORE', config('cache.default')), + + // For file driver + 'path' => env('MCP_SESSION_FILE_PATH', storage_path('framework/mcp_sessions')), + + // For database driver + 'connection' => env('MCP_SESSION_DB_CONNECTION', config('database.default')), + 'table' => env('MCP_SESSION_DB_TABLE', 'mcp_sessions'), + + // Session garbage collection probability. 2% chance that garbage collection will run on any given session operation. + 'lottery' => [2, 100], + ], + + /* + |-------------------------------------------------------------------------- + | Pagination Limit + |-------------------------------------------------------------------------- + | + | This value determines the maximum number of items that will be returned + | by list methods in the MCP server. + | + */ + 'pagination_limit' => env('MCP_PAGINATION_LIMIT', 50), + + /* + |-------------------------------------------------------------------------- + | MCP Capabilities Configuration + |-------------------------------------------------------------------------- + | + | Define which MCP features are enabled in your server instance. This includes + | support for tools, resources, prompts, and their related functionality like + | subscriptions and change notifications. + | + | The following capabilities are supported: + | - tools - Whether the server offers tools. + | - toolsListChanged - Whether the server supports sending a notification when the list of tools changes. + | - resources - Whether the server offers resources. + | - resourcesSubscribe - Whether the server supports resource subscriptions. + | - resourcesListChanged - Whether the server supports sending a notification when the list of resources changes. + | - prompts - Whether the server offers prompts. + | - promptsListChanged - Whether the server supports sending a notification when the list of prompts changes. + | - logging - Whether the server supports sending log messages to the client. + | - completions - Whether the server supports argument autocompletion suggestions. + | - experimental - Experimental, non-standard capabilities that the server supports. + | + */ + 'capabilities' => [ + 'tools' => (bool) env('MCP_CAP_TOOLS_ENABLED', true), + 'toolsListChanged' => (bool) env('MCP_CAP_TOOLS_LIST_CHANGED', true), + + 'resources' => (bool) env('MCP_CAP_RESOURCES_ENABLED', true), + 'resourcesSubscribe' => (bool) env('MCP_CAP_RESOURCES_SUBSCRIBE', true), + 'resourcesListChanged' => (bool) env('MCP_CAP_RESOURCES_LIST_CHANGED', true), + + 'prompts' => (bool) env('MCP_CAP_PROMPTS_ENABLED', true), + 'promptsListChanged' => (bool) env('MCP_CAP_PROMPTS_LIST_CHANGED', true), + + 'logging' => (bool) env('MCP_CAP_LOGGING_ENABLED', true), + + 'completions' => (bool) env('MCP_CAP_COMPLETIONS_ENABLED', true), + + 'experimental' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Logging Configuration + |-------------------------------------------------------------------------- + | + | Configure how the MCP server handles logging. You can specify which Laravel + | log channel to use and set the default log level. + | + */ + 'logging' => [ + 'channel' => env('MCP_LOG_CHANNEL', config('logging.default')), + 'level' => env('MCP_LOG_LEVEL', 'info'), + ], +]; diff --git a/examples/AuthenticatedTools.php b/examples/AuthenticatedTools.php new file mode 100644 index 0000000..9aff11e --- /dev/null +++ b/examples/AuthenticatedTools.php @@ -0,0 +1,309 @@ + 'Authentication required', + 'message' => 'Please include a valid Authorization header with Bearer token', + ]; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->created_at, + ]; +}); + +/** + * Authentication debugging tool + * Useful for troubleshooting authentication issues + */ +Mcp::tool('debug_auth_context', function () { + return [ + 'mcp_auth' => [ + 'authenticated' => McpAuth::check(), + 'user_id' => McpAuth::user()?->id, + 'user_name' => McpAuth::user()?->name, + 'guard' => McpAuth::guard(), + 'has_token' => McpAuth::token() !== null, + 'token_type' => McpAuth::token() ? get_class(McpAuth::token()) : null, + ], + 'laravel_auth' => [ + 'authenticated' => Auth::check(), + 'user_id' => Auth::user()?->id, + 'user_name' => Auth::user()?->name, + 'default_guard' => Auth::getDefaultDriver(), + ], + 'request_headers' => [ + 'authorization' => McpAuth::header('Authorization') ? 'Present' : 'Missing', + 'session_id' => McpAuth::header('Mcp-Session-Id'), + 'all_headers' => array_keys(McpAuth::headers()), + ], + ]; +}); + +/** + * Protected action requiring authentication + * Demonstrates error handling for unauthenticated requests + */ +Mcp::tool('protected_action', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + throw new \Exception('Authentication required to perform this action'); + } + + // Perform some protected operation + return [ + 'success' => true, + 'message' => "Protected action performed successfully by {$user->name}", + 'timestamp' => now()->toISOString(), + 'user_id' => $user->id, + ]; +}); + +/** + * Sanctum token-specific operations + * Shows how to work with Sanctum tokens and abilities + */ +Mcp::tool('check_token_abilities', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + $token = McpAuth::token(); + + if (!$token) { + return [ + 'user' => $user->name, + 'auth_method' => 'session_based', + 'message' => 'No token found, likely using session-based authentication', + ]; + } + + $abilities = []; + if (method_exists($token, 'abilities')) { + $abilities = $token->abilities; + } + + return [ + 'user' => $user->name, + 'token_name' => $token->name ?? 'Unknown', + 'token_abilities' => $abilities, + 'can_admin' => method_exists($token, 'can') ? $token->can('admin') : false, + 'can_read' => method_exists($token, 'can') ? $token->can('read') : false, + 'can_write' => method_exists($token, 'can') ? $token->can('write') : false, + 'token_created' => $token->created_at ?? null, + 'token_last_used' => $token->last_used_at ?? null, + ]; +}); + +/** + * User-specific resource access + * Shows how to filter data based on authenticated user + */ +Mcp::tool('get_user_posts', function (int $limit = 10) { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + // Assuming you have a Post model with user relationship + // This is just a demonstration - adjust based on your models + try { + $posts = $user->posts() + ->latest() + ->limit($limit) + ->get() + ->map(function ($post) { + return [ + 'id' => $post->id, + 'title' => $post->title, + 'excerpt' => substr($post->content, 0, 100) . '...', + 'created_at' => $post->created_at, + 'updated_at' => $post->updated_at, + ]; + }); + + return [ + 'user' => $user->name, + 'posts_count' => $posts->count(), + 'posts' => $posts, + ]; + } catch (\Exception $e) { + return [ + 'user' => $user->name, + 'message' => 'Posts feature not available in this demo', + 'error' => $e->getMessage(), + ]; + } +}); + +/** + * Multi-guard authentication example + * Shows how to handle different authentication guards + */ +Mcp::tool('check_multi_guard_auth', function () { + $results = []; + + // Check MCP context first + if (McpAuth::check()) { + $results['mcp'] = [ + 'authenticated' => true, + 'user' => McpAuth::user()->name, + 'guard' => McpAuth::guard(), + ]; + } + + // Check different Laravel guards + $guards = ['web', 'api', 'sanctum']; + + foreach ($guards as $guard) { + try { + if (Auth::guard($guard)->check()) { + $results[$guard] = [ + 'authenticated' => true, + 'user' => Auth::guard($guard)->user()->name, + ]; + } else { + $results[$guard] = ['authenticated' => false]; + } + } catch (\Exception $e) { + $results[$guard] = [ + 'authenticated' => false, + 'error' => $e->getMessage(), + ]; + } + } + + return $results; +}); + +/** + * Permission-based access control + * Demonstrates how to implement permission checks + */ +Mcp::tool('admin_only_action', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + // Check if user has admin role (adjust based on your role system) + $isAdmin = false; + + // Example permission checks - adjust based on your implementation + if (method_exists($user, 'hasRole')) { + $isAdmin = $user->hasRole('admin'); + } elseif (method_exists($user, 'can')) { + $isAdmin = $user->can('admin-access'); + } elseif (isset($user->role)) { + $isAdmin = $user->role === 'admin'; + } + + // Also check token abilities for Sanctum + $token = McpAuth::token(); + if ($token && method_exists($token, 'can')) { + $isAdmin = $isAdmin && $token->can('admin'); + } + + if (!$isAdmin) { + return [ + 'error' => 'Insufficient permissions', + 'message' => 'This action requires administrator privileges', + 'user' => $user->name, + ]; + } + + return [ + 'success' => true, + 'message' => 'Admin action performed successfully', + 'user' => $user->name, + 'timestamp' => now()->toISOString(), + ]; +}); + +/** + * Resource with authentication context + * Shows how to use authentication in resources + */ +Mcp::resource('user://profile', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return json_encode(['error' => 'Authentication required']); + } + + return json_encode([ + 'profile' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ], + 'auth_context' => [ + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'authenticated_via' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ], + ]); +})->name('user_profile')->mimeType('application/json'); + +/** + * Dynamic resource template with user context + * Shows parameterized resources with authentication + */ +Mcp::resourceTemplate('user://data/{dataType}', function (string $dataType) { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return json_encode(['error' => 'Authentication required']); + } + + $data = match ($dataType) { + 'basic' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'contact' => [ + 'email' => $user->email, + 'email_verified' => $user->email_verified_at !== null, + ], + 'timestamps' => [ + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ], + default => ['error' => "Unknown data type: {$dataType}"], + }; + + return json_encode([ + 'user_id' => $user->id, + 'data_type' => $dataType, + 'data' => $data, + 'retrieved_at' => now()->toISOString(), + ]); +})->name('user_data')->mimeType('application/json'); diff --git a/samples/basic/routes/mcp.php b/samples/basic/routes/mcp.php index fc88880..832f3bb 100644 --- a/samples/basic/routes/mcp.php +++ b/samples/basic/routes/mcp.php @@ -5,6 +5,7 @@ use App\Mcp\GetArticleContent; use App\Mcp\GetAppVersion; use PhpMcp\Laravel\Facades\Mcp; +use PhpMcp\Laravel\Facades\McpAuth; use Illuminate\Support\Facades\Auth; Mcp::tool('welcome_message', GenerateWelcomeMessage::class); @@ -14,7 +15,23 @@ ->mimeType('text/plain'); Mcp::tool('get_me', function () { - return Auth::user(); + // Try MCP context first (for dedicated HTTP), fallback to Laravel Auth + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return [ + 'error' => 'No authenticated user found', + 'context' => 'Make sure to include Authorization header with Bearer token', + 'mcp_context' => McpAuth::check() ? 'MCP context available' : 'No MCP context', + 'auth_context' => Auth::check() ? 'Laravel auth available' : 'No Laravel auth', + ]; + } + + return [ + 'user' => $user, + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'auth_method' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ]; }); Mcp::resourceTemplate('content://articles/{articleId}', GetArticleContent::class) diff --git a/samples/basic/vite.config.js b/samples/basic/vite.config.js index 29fbfe9..360bfc9 100644 --- a/samples/basic/vite.config.js +++ b/samples/basic/vite.config.js @@ -1,6 +1,9 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import tailwindcss from '@tailwindcss/vite'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); export default defineConfig({ plugins: [ @@ -11,3 +14,4 @@ export default defineConfig({ tailwindcss(), ], }); + diff --git a/src/Facades/McpAuth.php b/src/Facades/McpAuth.php new file mode 100644 index 0000000..b8f1e83 --- /dev/null +++ b/src/Facades/McpAuth.php @@ -0,0 +1,102 @@ +transport = new HttpServerTransport($server->getSessionManager()); - $server->listen($this->transport, false); - } - - /** - * Handle client message (HTTP POST endpoint). - * Delegates to the transport for processing. - */ - public function handleMessage(Request $request): Response - { - return $this->transport->handleMessageRequest($request); - } - - /** - * Handle SSE (GET endpoint). - * Delegates to the transport for streaming. - */ - public function handleSse(Request $request): StreamedResponse - { - return $this->transport->handleSseRequest($request); - } -} +transport = new HttpServerTransport($server->getSessionManager()); + $server->listen($this->transport, false); + } + + /** + * Handle client message (HTTP POST endpoint). + * Delegates to the transport for processing. + */ + public function handleMessage(Request $request): Response + { + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleMessageRequest($request); + }); + } + + /** + * Handle SSE (GET endpoint). + * Delegates to the transport for streaming. + */ + public function handleSse(Request $request): StreamedResponse + { + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleSseRequest($request); + }); + } +} diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php index becc601..882db18 100644 --- a/src/Http/Controllers/StreamableTransportController.php +++ b/src/Http/Controllers/StreamableTransportController.php @@ -1,74 +1,81 @@ -createEventStore(); - $sessionManager = $server->getSessionManager(); - $stateless = config('mcp.transports.http_integrated.stateless', false); - - $this->transport = new StreamableHttpServerTransport($sessionManager, $eventStore, $stateless); - $server->listen($this->transport, false); - } - - public function handleGet(Request $request): Response|StreamedResponse - { - return $this->transport->handleGetRequest($request); - } - - public function handlePost(Request $request): Response|StreamedResponse - { - return $this->transport->handlePostRequest($request); - } - - public function handleDelete(Request $request): Response - { - return $this->transport->handleDeleteRequest($request); - } - - /** - * Create event store instance from configuration - */ - private function createEventStore(): ?EventStoreInterface - { - $eventStoreFqcn = config('mcp.transports.http_integrated.event_store'); - - if (!$eventStoreFqcn) { - return null; - } - - if (is_object($eventStoreFqcn) && $eventStoreFqcn instanceof EventStoreInterface) { - return $eventStoreFqcn; - } - - if (is_string($eventStoreFqcn) && class_exists($eventStoreFqcn)) { - $instance = app($eventStoreFqcn); - - if (!$instance instanceof EventStoreInterface) { - throw new \InvalidArgumentException( - "Event store class {$eventStoreFqcn} must implement EventStoreInterface" - ); - } - - return $instance; - } - - throw new \InvalidArgumentException( - "Invalid event store configuration: {$eventStoreFqcn}" - ); - } -} +createEventStore(); + $sessionManager = $server->getSessionManager(); + $stateless = config('mcp.transports.http_integrated.stateless', false); + + $this->transport = new StreamableHttpServerTransport($sessionManager, $eventStore, $stateless); + $server->listen($this->transport, false); + } + + public function handleGet(Request $request): Response|StreamedResponse + { + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleGetRequest($request); + }); + } + + public function handlePost(Request $request): Response|StreamedResponse + { + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handlePostRequest($request); + }); + } + + public function handleDelete(Request $request): Response + { + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleDeleteRequest($request); + }); + } + + /** + * Create event store instance from configuration + */ + private function createEventStore(): ?EventStoreInterface + { + $eventStoreFqcn = config('mcp.transports.http_integrated.event_store'); + + if (!$eventStoreFqcn) { + return null; + } + + if (is_object($eventStoreFqcn) && $eventStoreFqcn instanceof EventStoreInterface) { + return $eventStoreFqcn; + } + + if (is_string($eventStoreFqcn) && class_exists($eventStoreFqcn)) { + $instance = app($eventStoreFqcn); + + if (!$instance instanceof EventStoreInterface) { + throw new \InvalidArgumentException( + "Event store class {$eventStoreFqcn} must implement EventStoreInterface" + ); + } + + return $instance; + } + + throw new \InvalidArgumentException( + "Invalid event store configuration: {$eventStoreFqcn}" + ); + } +} diff --git a/src/Http/Middleware/McpAuthenticationMiddleware.php b/src/Http/Middleware/McpAuthenticationMiddleware.php new file mode 100644 index 0000000..fc6cde7 --- /dev/null +++ b/src/Http/Middleware/McpAuthenticationMiddleware.php @@ -0,0 +1,174 @@ +extractAuthContext($request); + + // Store in MCP context for later access by tools + McpContext::setAuthContext($authContext); + + // Log for debugging purposes + Log::debug('MCP Authentication context set', [ + 'has_user' => !empty($authContext['user']), + 'auth_guard' => $authContext['guard'] ?? null, + 'session_id' => $request->header('Mcp-Session-Id'), + ]); + + $response = $next($request); + + // Clear the context after request processing + McpContext::clearAuthContext(); + + return $response; + } + + /** + * Extract authentication context from the request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function extractAuthContext(Request $request): array + { + $context = [ + 'request_headers' => $this->getRelevantHeaders($request), + 'user' => null, + 'guard' => null, + 'token' => null, + ]; + + // Try different authentication guards + foreach ($this->getAuthGuards() as $guard) { + if (Auth::guard($guard)->check()) { + $user = Auth::guard($guard)->user(); + $context['user'] = $user; + $context['guard'] = $guard; + + // For Sanctum, also store the token + if ($guard === 'sanctum' && method_exists($user, 'currentAccessToken')) { + $context['token'] = $user->currentAccessToken(); + } + + break; + } + } + + // If no authenticated user found, try to authenticate using Bearer token + if (!$context['user'] && $request->bearerToken()) { + $context = $this->attemptTokenAuthentication($request, $context); + } + + return $context; + } + + /** + * Get relevant headers for authentication context. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function getRelevantHeaders(Request $request): array + { + $headers = []; + + $relevantHeaders = [ + 'Authorization', + 'X-API-KEY', + 'X-Auth-Token', + 'Cookie', + 'Mcp-Session-Id', + ]; + + foreach ($relevantHeaders as $header) { + if ($request->hasHeader($header)) { + $headers[$header] = $request->header($header); + } + } + + return $headers; + } + + /** + * Get the authentication guards to try. + * + * @return array + */ + protected function getAuthGuards(): array + { + return [ + 'sanctum', + 'api', + 'web', + config('auth.defaults.guard'), + ]; + } + + /** + * Attempt to authenticate using a bearer token. + * + * @param \Illuminate\Http\Request $request + * @param array $context + * @return array + */ + protected function attemptTokenAuthentication(Request $request, array $context): array + { + $token = $request->bearerToken(); + + if (!$token) { + return $context; + } + + // Try Sanctum token authentication + if (class_exists(\Laravel\Sanctum\PersonalAccessToken::class)) { + try { + $accessToken = \Laravel\Sanctum\PersonalAccessToken::findToken($token); + + if ($accessToken && $accessToken->can('*')) { + $user = $accessToken->tokenable; + + if ($user) { + $context['user'] = $user; + $context['guard'] = 'sanctum'; + $context['token'] = $accessToken; + + // Set the authenticated user for this request + Auth::guard('sanctum')->setUser($user); + } + } + } catch (\Exception $e) { + Log::warning('Failed to authenticate with Sanctum token', [ + 'error' => $e->getMessage(), + 'token_prefix' => substr($token, 0, 10) . '...' + ]); + } + } + + return $context; + } +} diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index eb8118e..969e814 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -1,197 +1,202 @@ -mergeConfigFrom(__DIR__ . '/../config/mcp.php', 'mcp'); - - $this->app->singleton(McpRegistrar::class, fn() => new McpRegistrar()); - - $this->app->alias(McpRegistrar::class, 'mcp.registrar'); - } - - public function boot(): void - { - $this->loadMcpDefinitions(); - $this->buildServer(); - $this->bootPublishables(); - $this->bootRoutes(); - $this->bootEvents(); - $this->bootCommands(); - } - - protected function loadMcpDefinitions(): void - { - $definitionsPath = config('mcp.discovery.definitions_file', base_path('routes/mcp.php')); - if ($definitionsPath && file_exists($definitionsPath)) { - require $definitionsPath; - } - } - - protected function buildServer(): void - { - $this->app->singleton(Server::class, function (Application $app) { - $serverName = config('mcp.server.name', config('app.name', 'Laravel') . ' MCP Server'); - $serverVersion = config('mcp.server.version', '1.0.0'); - $logger = $app['log']->channel(config('mcp.logging.channel')); - $cache = $app['cache']->store($app['config']->get('mcp.cache.store')); - $capabilities = ServerCapabilities::make( - tools: (bool) config('mcp.capabilities.tools', true), - toolsListChanged: (bool) config('mcp.capabilities.toolsListChanged', true), - resources: (bool) config('mcp.capabilities.resources', true), - resourcesSubscribe: (bool) config('mcp.capabilities.resourcesSubscribe', true), - resourcesListChanged: (bool) config('mcp.capabilities.resourcesListChanged', true), - prompts: (bool) config('mcp.capabilities.prompts', true), - promptsListChanged: (bool) config('mcp.capabilities.promptsListChanged', true), - logging: (bool) config('mcp.capabilities.logging', true), - completions: (bool) config('mcp.capabilities.completions', true), - experimental: config('mcp.capabilities.experimental', null), - ); - - $sessionHandler = $this->createSessionHandler($app); - $sessionTtl = (int) config('mcp.session.ttl', 3600); - - $builder = Server::make() - ->withServerInfo($serverName, $serverVersion) - ->withLogger($logger) - ->withContainer($app) - ->withCache($cache) - ->withSessionHandler($sessionHandler, $sessionTtl) - ->withCapabilities($capabilities) - ->withPaginationLimit((int) config('mcp.pagination_limit', 50)) - ->withInstructions(config('mcp.server.instructions')); - - $registrar = $app->make(McpRegistrar::class); - $registrar->applyBlueprints($builder); - - $server = $builder->build(); - $registry = $server->getRegistry(); - - if (config('mcp.discovery.auto_discover', true)) { - $registry->disableNotifications(); - - $server->discover( - basePath: config('mcp.discovery.base_path', base_path()), - scanDirs: config('mcp.discovery.directories', ['app/Mcp']), - excludeDirs: config('mcp.discovery.exclude_dirs', []), - saveToCache: config('mcp.discovery.save_to_cache', true) - ); - - $registry->enableNotifications(); - } - - return $server; - }); - - $this->app->singleton(Registry::class, fn($app) => $app->make(Server::class)->getRegistry()); - $this->app->singleton(SessionManager::class, fn($app) => $app->make(Server::class)->getSessionManager()); - - $this->app->alias(Server::class, 'mcp.server'); - $this->app->alias(Registry::class, 'mcp.registry'); - } - - /** - * Create appropriate session handler based on configuration. - */ - private function createSessionHandler(Application $app): SessionHandlerInterface - { - $driver = config('mcp.session.driver', 'cache'); - $ttl = (int) config('mcp.session.ttl', 3600); - - return match ($driver) { - 'array' => new ArraySessionHandler($ttl), - - 'cache', 'redis', 'memcached', 'dynamodb' => new CacheSessionHandler( - $app['cache']->store(config('mcp.session.store', config('cache.default'))), - $ttl - ), - - 'file' => new FileSessionHandler( - $app['files'], - config('mcp.session.path', storage_path('framework/mcp_sessions')), - $ttl - ), - - 'database' => new DatabaseSessionHandler( - $app['db']->connection(config('mcp.session.connection')), - config('mcp.session.table', 'mcp_sessions'), - $ttl - ), - - default => throw new \InvalidArgumentException("Unsupported MCP session driver: {$driver}") - }; - } - - protected function bootPublishables(): void - { - if ($this->app->runningInConsole()) { - $this->publishes([__DIR__ . '/../config/mcp.php' => config_path('mcp.php')], 'mcp-config'); - - $this->publishes([ - __DIR__ . '/../database/migrations/create_mcp_sessions_table.php' => database_path('migrations/' . date('Y_m_d_His', time()) . '_create_mcp_sessions_table.php'), - ], 'mcp-migrations'); - } - } - - protected function bootRoutes(): void - { - if (config('mcp.transports.http_integrated.enabled', true)) { - $routePrefix = config('mcp.transports.http_integrated.route_prefix', 'mcp'); - $middleware = config('mcp.transports.http_integrated.middleware', ['web']); - $domain = config('mcp.transports.http_integrated.domain'); - - Route::group([ - 'domain' => $domain, - 'prefix' => $routePrefix, - 'middleware' => $middleware, - ], function () { - $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); - }); - } - } - - protected function bootCommands(): void - { - if ($this->app->runningInConsole()) { - $this->commands([ - ServeCommand::class, - DiscoverCommand::class, - ListCommand::class, - ]); - } - } - - protected function bootEvents(): void - { - Event::listen( - [ToolsListChanged::class, ResourcesListChanged::class, PromptsListChanged::class], - McpNotificationListener::class, - ); - } -} +mergeConfigFrom(__DIR__ . '/../config/mcp.php', 'mcp'); + + $this->app->singleton(McpRegistrar::class, fn() => new McpRegistrar()); + $this->app->singleton(McpContext::class); + $this->app->singleton(McpAuthenticationMiddleware::class); + + $this->app->alias(McpRegistrar::class, 'mcp.registrar'); + $this->app->alias(McpContext::class, 'mcp.context'); + } + + public function boot(): void + { + $this->loadMcpDefinitions(); + $this->buildServer(); + $this->bootPublishables(); + $this->bootRoutes(); + $this->bootEvents(); + $this->bootCommands(); + } + + protected function loadMcpDefinitions(): void + { + $definitionsPath = config('mcp.discovery.definitions_file', base_path('routes/mcp.php')); + if ($definitionsPath && file_exists($definitionsPath)) { + require $definitionsPath; + } + } + + protected function buildServer(): void + { + $this->app->singleton(Server::class, function (Application $app) { + $serverName = config('mcp.server.name', config('app.name', 'Laravel') . ' MCP Server'); + $serverVersion = config('mcp.server.version', '1.0.0'); + $logger = $app['log']->channel(config('mcp.logging.channel')); + $cache = $app['cache']->store($app['config']->get('mcp.cache.store')); + $capabilities = ServerCapabilities::make( + tools: (bool) config('mcp.capabilities.tools', true), + toolsListChanged: (bool) config('mcp.capabilities.toolsListChanged', true), + resources: (bool) config('mcp.capabilities.resources', true), + resourcesSubscribe: (bool) config('mcp.capabilities.resourcesSubscribe', true), + resourcesListChanged: (bool) config('mcp.capabilities.resourcesListChanged', true), + prompts: (bool) config('mcp.capabilities.prompts', true), + promptsListChanged: (bool) config('mcp.capabilities.promptsListChanged', true), + logging: (bool) config('mcp.capabilities.logging', true), + completions: (bool) config('mcp.capabilities.completions', true), + experimental: config('mcp.capabilities.experimental', null), + ); + + $sessionHandler = $this->createSessionHandler($app); + $sessionTtl = (int) config('mcp.session.ttl', 3600); + + $builder = Server::make() + ->withServerInfo($serverName, $serverVersion) + ->withLogger($logger) + ->withContainer($app) + ->withCache($cache) + ->withSessionHandler($sessionHandler, $sessionTtl) + ->withCapabilities($capabilities) + ->withPaginationLimit((int) config('mcp.pagination_limit', 50)) + ->withInstructions(config('mcp.server.instructions')); + + $registrar = $app->make(McpRegistrar::class); + $registrar->applyBlueprints($builder); + + $server = $builder->build(); + $registry = $server->getRegistry(); + + if (config('mcp.discovery.auto_discover', true)) { + $registry->disableNotifications(); + + $server->discover( + basePath: config('mcp.discovery.base_path', base_path()), + scanDirs: config('mcp.discovery.directories', ['app/Mcp']), + excludeDirs: config('mcp.discovery.exclude_dirs', []), + saveToCache: config('mcp.discovery.save_to_cache', true) + ); + + $registry->enableNotifications(); + } + + return $server; + }); + + $this->app->singleton(Registry::class, fn($app) => $app->make(Server::class)->getRegistry()); + $this->app->singleton(SessionManager::class, fn($app) => $app->make(Server::class)->getSessionManager()); + + $this->app->alias(Server::class, 'mcp.server'); + $this->app->alias(Registry::class, 'mcp.registry'); + } + + /** + * Create appropriate session handler based on configuration. + */ + private function createSessionHandler(Application $app): SessionHandlerInterface + { + $driver = config('mcp.session.driver', 'cache'); + $ttl = (int) config('mcp.session.ttl', 3600); + + return match ($driver) { + 'array' => new ArraySessionHandler($ttl), + + 'cache', 'redis', 'memcached', 'dynamodb' => new CacheSessionHandler( + $app['cache']->store(config('mcp.session.store', config('cache.default'))), + $ttl + ), + + 'file' => new FileSessionHandler( + $app['files'], + config('mcp.session.path', storage_path('framework/mcp_sessions')), + $ttl + ), + + 'database' => new DatabaseSessionHandler( + $app['db']->connection(config('mcp.session.connection')), + config('mcp.session.table', 'mcp_sessions'), + $ttl + ), + + default => throw new \InvalidArgumentException("Unsupported MCP session driver: {$driver}") + }; + } + + protected function bootPublishables(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([__DIR__ . '/../config/mcp.php' => config_path('mcp.php')], 'mcp-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations/create_mcp_sessions_table.php' => database_path('migrations/' . date('Y_m_d_His', time()) . '_create_mcp_sessions_table.php'), + ], 'mcp-migrations'); + } + } + + protected function bootRoutes(): void + { + if (config('mcp.transports.http_integrated.enabled', true)) { + $routePrefix = config('mcp.transports.http_integrated.route_prefix', 'mcp'); + $middleware = config('mcp.transports.http_integrated.middleware', ['web']); + $domain = config('mcp.transports.http_integrated.domain'); + + Route::group([ + 'domain' => $domain, + 'prefix' => $routePrefix, + 'middleware' => $middleware, + ], function () { + $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); + }); + } + } + + protected function bootCommands(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + ServeCommand::class, + DiscoverCommand::class, + ListCommand::class, + ]); + } + } + + protected function bootEvents(): void + { + Event::listen( + [ToolsListChanged::class, ResourcesListChanged::class, PromptsListChanged::class], + McpNotificationListener::class, + ); + } +} diff --git a/src/Support/McpContext.php b/src/Support/McpContext.php new file mode 100644 index 0000000..0158811 --- /dev/null +++ b/src/Support/McpContext.php @@ -0,0 +1,176 @@ + + */ + public static function all(): array + { + return [ + 'auth' => static::$authContext, + 'request' => static::$requestContext, + ]; + } +} diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..d78f02b --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,201 @@ + 1, + 'name' => 'Test User', + 'email' => 'test@example.com', + ]; + + $authContext = [ + 'user' => $user, + 'guard' => 'sanctum', + 'token' => 'test-token', + 'request_headers' => [ + 'Authorization' => 'Bearer test-token', + ], + ]; + + McpContext::setAuthContext($authContext); + + $this->assertEquals($user, McpContext::user()); + $this->assertTrue(McpContext::check()); + $this->assertEquals('sanctum', McpContext::guard()); + $this->assertEquals('test-token', McpContext::token()); + $this->assertEquals('Bearer test-token', McpContext::header('Authorization')); + } + + /** @test */ + public function it_returns_null_when_no_auth_context(): void + { + $this->assertNull(McpContext::user()); + $this->assertFalse(McpContext::check()); + $this->assertNull(McpContext::guard()); + $this->assertNull(McpContext::token()); + $this->assertEmpty(McpContext::headers()); + } + + /** @test */ + public function it_can_clear_auth_context(): void + { + $user = (object) ['id' => 1, 'name' => 'Test User']; + + McpContext::setAuthContext([ + 'user' => $user, + 'guard' => 'web', + 'request_headers' => [], + ]); + + $this->assertTrue(McpContext::check()); + + McpContext::clearAuthContext(); + + $this->assertFalse(McpContext::check()); + $this->assertNull(McpContext::user()); + } + + /** @test */ + public function mcp_auth_facade_works(): void + { + $user = (object) [ + 'id' => 1, + 'name' => 'Facade User', + ]; + + McpContext::setAuthContext([ + 'user' => $user, + 'guard' => 'api', + 'token' => 'facade-token', + 'request_headers' => [ + 'Authorization' => 'Bearer facade-token', + 'X-Custom-Header' => 'custom-value', + ], + ]); + + $this->assertEquals($user, McpAuth::user()); + $this->assertTrue(McpAuth::check()); + $this->assertEquals('api', McpAuth::guard()); + $this->assertEquals('facade-token', McpAuth::token()); + $this->assertEquals('Bearer facade-token', McpAuth::header('Authorization')); + $this->assertEquals('custom-value', McpAuth::header('X-Custom-Header')); + $this->assertNull(McpAuth::header('Non-Existent-Header')); + $this->assertEquals('default', McpAuth::header('Non-Existent-Header', 'default')); + } + + /** @test */ + public function it_handles_mixed_authentication_scenarios(): void + { + // Simulate Laravel's Auth system having a user + $laravelUser = (object) ['id' => 1, 'name' => 'Laravel User']; + Auth::shouldReceive('user')->andReturn($laravelUser); + Auth::shouldReceive('check')->andReturn(true); + Auth::shouldReceive('getDefaultDriver')->andReturn('web'); + + // But MCP context is empty + $this->assertNull(McpAuth::user()); + $this->assertFalse(McpAuth::check()); + + // Tool should fallback to Laravel auth + $result = $this->simulateGetMeTool(); + + $this->assertEquals($laravelUser, $result['user']); + $this->assertEquals('laravel_auth', $result['auth_method']); + $this->assertEquals('web', $result['guard']); + } + + /** @test */ + public function it_prefers_mcp_context_over_laravel_auth(): void + { + // Set up both authentication contexts + $laravelUser = (object) ['id' => 1, 'name' => 'Laravel User']; + $mcpUser = (object) ['id' => 2, 'name' => 'MCP User']; + + Auth::shouldReceive('user')->andReturn($laravelUser); + Auth::shouldReceive('check')->andReturn(true); + Auth::shouldReceive('getDefaultDriver')->andReturn('web'); + + McpContext::setAuthContext([ + 'user' => $mcpUser, + 'guard' => 'sanctum', + 'token' => 'mcp-token', + 'request_headers' => [], + ]); + + // Tool should prefer MCP context + $result = $this->simulateGetMeTool(); + + $this->assertEquals($mcpUser, $result['user']); + $this->assertEquals('mcp_context', $result['auth_method']); + $this->assertEquals('sanctum', $result['guard']); + } + + /** @test */ + public function it_handles_unauthenticated_scenarios(): void + { + Auth::shouldReceive('user')->andReturn(null); + Auth::shouldReceive('check')->andReturn(false); + + $result = $this->simulateGetMeTool(); + + $this->assertArrayHasKey('error', $result); + $this->assertEquals('No authenticated user found', $result['error']); + $this->assertArrayHasKey('context', $result); + $this->assertArrayHasKey('mcp_context', $result); + $this->assertArrayHasKey('auth_context', $result); + } + + /** + * Simulate the get_me tool from the sample routes/mcp.php + */ + protected function simulateGetMeTool(): array + { + // Try MCP context first (for dedicated HTTP), fallback to Laravel Auth + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return [ + 'error' => 'No authenticated user found', + 'context' => 'Make sure to include Authorization header with Bearer token', + 'mcp_context' => McpAuth::check() ? 'MCP context available' : 'No MCP context', + 'auth_context' => Auth::check() ? 'Laravel auth available' : 'No Laravel auth', + ]; + } + + return [ + 'user' => $user, + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'auth_method' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ]; + } +}