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 12851e3..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", @@ -21,7 +28,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^2.3" + "php-mcp/server": "^3.1" }, "require-dev": { "laravel/pint": "^1.13", @@ -62,4 +69,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/config/mcp.php b/config/mcp.php index d8952c5..3fa876c 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,15 @@ | 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. + | + | The 'legacy' option is used to enable the deprecated HTTP+SSE transport. + | It is not recommended to use this option. */ 'transports' => [ 'stdio' => [ @@ -80,21 +83,56 @@ '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' => 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' => ['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 @@ -115,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/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/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/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/composer.lock b/samples/basic/composer.lock index 2e51e65..094bb16 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", @@ -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", @@ -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": "571d03d87225587b1799d6f58880b4d805cdbaef" }, "require": { "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", "php": "^8.1", - "php-mcp/server": "^2.3" + "php-mcp/server": "^3.1" }, "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.1.0", "source": { "type": "git", "url": "https://github.com/php-mcp/server.git", - "reference": "686cac47af096907179ebf9ab38c9d5a75c5aa6f" + "reference": "caa5686076a4707239a0af902f97722bc9689a89" }, "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/caa5686076a4707239a0af902f97722bc9689a89", + "reference": "caa5686076a4707239a0af902f97722bc9689a89", "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.1.0" }, - "time": "2025-06-13T11:04:31+00:00" + "time": "2025-06-25T22:55:35+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..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,33 +66,70 @@ | 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' => [ - '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' => null, // 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')), + '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), + '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 @@ -120,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/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..77e3c35 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(" - Transport: STDIO"); + $this->line(" - Communication: STDIN/STDOUT"); + $this->line(" - 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,76 @@ 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."); + 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; + } 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..db2dead --- /dev/null +++ b/src/Http/Controllers/SseTransportController.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php new file mode 100644 index 0000000..000f70f --- /dev/null +++ b/src/Http/Controllers/StreamableTransportController.php @@ -0,0 +1,73 @@ +createEventStore(); + $sessionManager = $server->getSessionManager(); + + $this->transport = new StreamableHttpServerTransport($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..eb8118e 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -15,10 +15,15 @@ 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; +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 { @@ -31,26 +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, - LaravelHttpTransport::class, - ]; - } - public function boot(): void { $this->loadMcpDefinitions(); $this->buildServer(); - $this->bootConfig(); + $this->bootPublishables(); $this->bootRoutes(); $this->bootEvents(); $this->bootCommands(); @@ -71,24 +61,31 @@ 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), - 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') + $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, (int) config('mcp.cache.ttl', 3600)) - ->withCapabilities($capabilities); + ->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); @@ -113,21 +110,52 @@ 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()); - }); + /** + * 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 bootConfig(): void + 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'); } } @@ -143,7 +171,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/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; + } +} diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php new file mode 100644 index 0000000..35d3323 --- /dev/null +++ b/src/Transports/HttpServerTransport.php @@ -0,0 +1,231 @@ +sessionManager = $sessionManager; + + $this->on('message', function (Message $message, string $sessionId) { + $session = $this->sessionManager->getSession($sessionId); + if ($session !== null) { + $session->save(); // This updates the session timestamp + } + }); + } + + protected function generateId(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * 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'); + } + + /** + * Sends a message to a specific client session by queueing it in the SessionManager. + * The SSE streams will pick this up. + */ + 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); + } + + $this->sessionManager->queueMessage($sessionId, $rawMessage); + + return resolve(null); + } + + /** + * Handle incoming HTTP POST message requests + */ + public function handleMessageRequest(Request $request): Response + { + $this->collectSessionGarbage(); + + 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 + */ + protected function flushOutput(): void + { + if (function_exists('ob_flush')) { + @ob_flush(); + } + @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. + */ + public function close(): void + { + $this->emit('close', ['Transport closed.']); + $this->removeAllListeners(); + } +} diff --git a/src/Transports/LaravelHttpTransport.php b/src/Transports/LaravelHttpTransport.php deleted file mode 100644 index 29ccf3f..0000000 --- a/src/Transports/LaravelHttpTransport.php +++ /dev/null @@ -1,76 +0,0 @@ -clientStateManager = $clientStateManager; - $this->logger = new NullLogger; - - $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 - { - $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 - { - $this->emit('close', ['Transport closed.']); - $this->removeAllListeners(); - } -} diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php new file mode 100644 index 0000000..c583453 --- /dev/null +++ b/src/Transports/StreamableHttpServerTransport.php @@ -0,0 +1,408 @@ +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'])) { + $streamId = $context['streamId']; + $eventId = $this->eventStore->storeEvent($streamId, $rawMessage); + } + + $messageData = [ + 'id' => $eventId ?? $this->generateId(), + 'data' => $rawMessage, + 'context' => $context, + 'timestamp' => time() + ]; + + $this->sessionManager->queueMessage($sessionId, json_encode($messageData)); + + return resolve(null); + } + + /** + * Handle incoming HTTP POST message requests + */ + 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'); + 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]); + + $messages = $this->dequeueMessagesForContext($sessionId, 'post_json'); + + if (empty($messages)) { + $error = Error::forInternalError('Internal error'); + return response()->json($error, 500, $this->getCorsHeaders()); + } + + $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); + } 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 + { + $headers = array_merge([ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ], $this->getCorsHeaders()); + + if ($context['is_initialize_request'] ?? false) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + return response()->stream(function () use ($sessionId, $nRequests, $message, $context) { + $streamId = $this->generateId(); + $context['type'] = 'post_sse'; + $context['streamId'] = $streamId; + $context['nRequests'] = $nRequests; + + $this->emit('message', [$message, $sessionId, $context]); + + $messages = $this->dequeueMessagesForContext($sessionId, 'post_sse', $streamId); + + $this->sendSseEvent($messages[0]['data'], $messages[0]['id']); + }, headers: $headers); + } + + /** + * 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; + } + + if (!$this->sessionManager->hasSession($sessionId)) { + break; + } + + $messages = $this->dequeueMessagesForContext($sessionId, 'get_sse'); + foreach ($messages as $messageData) { + $this->sendSseEvent(rtrim($messageData['data'], "\n"), $messageData['id']); + } + + 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 $type, ?string $streamId = null): array + { + $allMessages = $this->sessionManager->dequeueMessages($sessionId); + $contextMessages = []; + $requeueMessages = []; + + foreach ($allMessages as $rawMessage) { + $messageData = json_decode($rawMessage, true); + $context = $messageData['context'] ?? []; + + if ($messageData) { + $matchesContext = $context['type'] === $type; + + if ($type === 'post_sse' && $streamId) { + $matchesContext = $matchesContext && isset($context['streamId']) && $context['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(); + } + + 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 + */ + 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/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 c572bf7..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; @@ -44,7 +45,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(); } @@ -54,11 +56,13 @@ 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') - ->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(); } @@ -75,18 +79,20 @@ 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'; }), ); $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(); } 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')); } }