├── .windsurfrules ├── AGENTS.md ├── CLAUDE.md ├── LICENSE.md ├── README.es.md ├── README.ko.md ├── README.md ├── README.pl.md ├── README.pt-BR.md ├── README.ru.md ├── README.zh-CN.md ├── README.zh-TW.md ├── composer.json ├── config └── mcp-server.php ├── scripts ├── release.sh ├── requirements.txt ├── test-setup.sh └── translate_readme.py └── src ├── Console └── Commands │ ├── MakeMcpPromptCommand.php │ ├── MakeMcpResourceCommand.php │ ├── MakeMcpResourceTemplateCommand.php │ ├── MakeMcpToolCommand.php │ ├── MigrateToolsCommand.php │ └── TestMcpToolCommand.php ├── Data ├── ProcessMessageData.php ├── Requests │ ├── InitializeData.php │ ├── NotificationData.php │ └── RequestData.php └── Resources │ ├── InitializeResource.php │ └── JsonRpc │ ├── JsonRpcErrorResource.php │ └── JsonRpcResultResource.php ├── Enums └── ProcessMessageType.php ├── Exceptions ├── Enums │ └── JsonRpcErrorCode.php └── JsonRpcErrorException.php ├── Facades └── LaravelMcpServer.php ├── Http └── Controllers │ ├── MessageController.php │ ├── SseController.php │ └── StreamableHttpController.php ├── LaravelMcpServerServiceProvider.php ├── Protocol ├── Handlers │ ├── NotificationHandler.php │ └── RequestHandler.php └── MCPProtocol.php ├── Providers ├── SseServiceProvider.php └── StreamableHttpServiceProvider.php ├── Server ├── MCPServer.php ├── Request │ ├── InitializeHandler.php │ ├── PingHandler.php │ ├── PromptsGetHandler.php │ ├── PromptsListHandler.php │ ├── ResourcesListHandler.php │ ├── ResourcesReadHandler.php │ ├── ResourcesTemplatesListHandler.php │ ├── ToolsCallHandler.php │ └── ToolsListHandler.php └── ServerCapabilities.php ├── Services ├── PromptService │ ├── Examples │ │ └── WelcomePrompt.php │ ├── Prompt.php │ └── PromptRepository.php ├── ResourceService │ ├── Examples │ │ ├── LogFileResource.php │ │ ├── LogFileTemplate.php │ │ ├── UserListResource.php │ │ └── UserResourceTemplate.php │ ├── Resource.php │ ├── ResourceRepository.php │ └── ResourceTemplate.php ├── SseAdapterFactory.php └── ToolService │ ├── Examples │ ├── HelloWorldTool.php │ └── VersionCheckTool.php │ ├── ToolInterface.php │ └── ToolRepository.php ├── Transports ├── SseAdapters │ ├── RedisAdapter.php │ └── SseAdapterInterface.php ├── SseTransport.php ├── StreamableHttpTransport.php └── TransportInterface.php ├── Utils ├── DataUtil.php ├── StringUtil.php └── UriTemplateUtil.php └── stubs ├── prompt.stub ├── resource.stub ├── resource_template.stub └── tool.stub /.windsurfrules: -------------------------------------------------------------------------------- 1 | # Laravel MCP Server Development Rules 2 | 3 | ## Project Overview 4 | This is a Laravel package for implementing Model Context Protocol (MCP) servers with Streamable HTTP transport and legacy SSE support. Focus on secure, enterprise-ready MCP implementations. 5 | 6 | ## Technical Specification 7 | - Support Laravel 10, 11, 12 8 | - Support PHP 8.2+ 9 | - Namespace: `OPGG\LaravelMcpServer\` 10 | 11 | ## Common Commands 12 | 13 | ### Testing and Quality 14 | - Run tests: `vendor/bin/pest` 15 | - Code formatting: `vendor/bin/pint` 16 | - Static analysis: `vendor/bin/phpstan analyse` 17 | 18 | ### MCP Tool Development 19 | - Create tool: `php artisan make:mcp-tool ToolName` 20 | - Test tool: `php artisan mcp:test-tool ToolName` 21 | - List tools: `php artisan mcp:test-tool --list` 22 | - Test with JSON: `php artisan mcp:test-tool ToolName --input='{"param":"value"}'` 23 | 24 | ### Configuration 25 | - Publish config: `php artisan vendor:publish --provider="OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider"` 26 | 27 | ### Development Server 28 | **CRITICAL**: Never use `php artisan serve` with SSE provider - use Laravel Octane: 29 | ```bash 30 | composer require laravel/octane 31 | php artisan octane:install --server=frankenphp 32 | php artisan octane:start 33 | ``` 34 | 35 | ## Laravel Package Development Guidelines 36 | 37 | ### Code Style 38 | - Add `use` statements for all Facade classes to call them more concisely 39 | - Use Facade classes or original Laravel classes instead of helper functions 40 | - Create new class and refactor if file exceeds 300 lines 41 | - Use `env()` instead of `Config` facade in config files (`/config/mcp-server.php`) 42 | 43 | ### Type Annotations 44 | - When specifying nullable types, use `string|null` instead of `?string` 45 | - Always place `null` at the end of union types 46 | - Add type hints and return types to all methods for better IDE support 47 | 48 | ### MCP Implementation Standards 49 | - All tools must implement `ToolInterface` 50 | - Register tools in `config/mcp-server.php` 51 | - Use JSON-RPC 2.0 message format strictly 52 | - Support both Streamable HTTP and SSE transports 53 | - Implement proper error handling with `JsonRpcErrorException` 54 | 55 | ## Architecture Overview 56 | 57 | ### Core Components 58 | - **MCPServer** (`src/Server/MCPServer.php`): Main orchestrator for server lifecycle 59 | - **MCPProtocol** (`src/Protocol/MCPProtocol.php`): JSON-RPC 2.0 message processing 60 | - **Transport Layer**: Abstracted communication (Streamable HTTP/SSE) 61 | - **Handler Pattern**: RequestHandler/NotificationHandler for processing 62 | - **Repository Pattern**: ToolRepository for tool management 63 | 64 | ### Key Handlers 65 | - **InitializeHandler**: Client-server handshake and capability negotiation 66 | - **ToolsListHandler**: Returns available MCP tools to clients 67 | - **ToolsCallHandler**: Executes specific tool calls with parameters 68 | - **PingHandler**: Health check endpoint 69 | 70 | ### Configuration 71 | - Primary config: `config/mcp-server.php` 72 | - Environment variables: `MCP_SERVER_ENABLED` 73 | - Default transport: `streamable_http` (recommended) 74 | - Legacy SSE with Redis pub/sub adapter 75 | 76 | ### Endpoints 77 | - **Streamable HTTP**: `GET/POST /{default_path}` (default: `/mcp`) 78 | - **SSE (legacy)**: `GET /{default_path}/sse`, `POST /{default_path}/message` 79 | 80 | ## File Organization 81 | 82 | ### Tool Development 83 | - Create in `app/MCP/Tools/` (via make command) 84 | - Use `src/stubs/tool.stub` template 85 | - Examples in `src/Services/ToolService/Examples/` 86 | - Interface: `src/Services/ToolService/ToolInterface.php` 87 | 88 | ### Transport Layer 89 | - `src/Transports/StreamableHttpTransport.php`: HTTP transport 90 | - `src/Transports/SseTransport.php`: SSE transport 91 | - `src/Transports/SseAdapters/RedisAdapter.php`: Redis pub/sub 92 | 93 | ## Development Guidelines 94 | 95 | ### When Creating Tools 96 | 1. Use `php artisan make:mcp-tool ToolName` 97 | 2. Implement all ToolInterface methods 98 | 3. Define proper input schema validation 99 | 4. Test with `php artisan mcp:test-tool ToolName` 100 | 5. Register in config before production 101 | 102 | ### Error Handling 103 | - Use `JsonRpcErrorException` for MCP errors 104 | - Implement proper error codes from `JsonRpcErrorCode` enum 105 | - Provide meaningful error messages 106 | - Log errors appropriately 107 | 108 | ## Documentation Standards 109 | 110 | ### PHPDoc Requirements 111 | - Document all public methods and classes with PHPDoc annotations 112 | - Include `@param`, `@return`, and `@throws` tags for all methods 113 | - Document configuration options with sample values and explanations 114 | - Use descriptive variable and method names for self-documentation 115 | - Add inline comments for complex logic explaining the "why" not just the "what" 116 | 117 | ### Documentation Maintenance 118 | - Keep documentation up-to-date when changing functionality 119 | - Document breaking changes prominently in CHANGELOG.md and README.md 120 | - Include version compatibility information in all documentation 121 | - Document expected environment variables and their purposes 122 | - Document how the package integrates with Laravel's existing features 123 | 124 | ## MCP Protocol References 125 | - https://modelcontextprotocol.io/docs/concepts/architecture 126 | - https://modelcontextprotocol.io/docs/concepts/tools 127 | - https://modelcontextprotocol.io/docs/concepts/transports 128 | 129 | ## Don't Do 130 | - Don't use `php artisan serve` with SSE provider 131 | - Don't hardcode configuration values 132 | - Don't skip input validation in tools 133 | - Don't commit sensitive data (API keys, secrets) 134 | - Don't break JSON-RPC 2.0 message format 135 | - Don't modify core MCP protocol handlers without careful consideration 136 | - Don't use `?string` syntax - use `string|null` instead 137 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | ## Rules 2 | 3 | - Read CLAUDE.md for code generation instructions. 4 | 5 | ## Final Test Guide 6 | 7 | To verify the complete MCP workflow after you implement new features, use the following script: 8 | 9 | 1. Run `./scripts/test-setup.sh` from the project root 10 | 2. Navigate to the created directory (`laravel-mcp-test`) and run `./run-test.sh` 11 | - The server will start and execute example tools. 12 | - You need to wait more than 30 seconds, then it will setup properly so that you can test the MCP server. 13 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Common Commands 6 | 7 | ### Testing and Quality Assurance 8 | - **Run tests**: `vendor/bin/pest` 9 | - **Run tests with coverage**: `vendor/bin/pest --coverage` 10 | - **Code formatting**: `vendor/bin/pint` 11 | - **Static analysis**: `vendor/bin/phpstan analyse` 12 | 13 | ### MCP Tool Development 14 | - **Create new MCP tool**: `php artisan make:mcp-tool ToolName` 15 | - **Test specific tool**: `php artisan mcp:test-tool ToolName` 16 | - **List all tools**: `php artisan mcp:test-tool --list` 17 | - **Test tool with JSON input**: `php artisan mcp:test-tool ToolName --input='{"param":"value"}'` 18 | 19 | ### Configuration Publishing 20 | - **Publish config file**: `php artisan vendor:publish --provider="OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider"` 21 | 22 | ### Development Server (IMPORTANT) 23 | **WARNING**: `php artisan serve` CANNOT be used with this package when you use SSE driver. 24 | 25 | **Use Laravel Octane instead**: 26 | ```bash 27 | composer require laravel/octane 28 | php artisan octane:install --server=frankenphp 29 | php artisan octane:start 30 | ``` 31 | 32 | ## Architecture Overview 33 | 34 | ### Core Components 35 | 36 | **MCPServer (`src/Server/MCPServer.php`)**: Main orchestrator that manages the MCP server lifecycle, initialization, and request routing. Handles client capabilities negotiation and registers request/notification handlers. 37 | 38 | **MCPProtocol (`src/Protocol/MCPProtocol.php`)**: Protocol implementation that handles JSON-RPC 2.0 message processing. Routes requests to appropriate handlers and manages communication with transport layer. 39 | 40 | **Transport Layer**: Abstracted transport system supporting multiple providers: 41 | - **Streamable HTTP** (recommended): Standard HTTP requests, works on all platforms 42 | - **SSE (legacy)**: Server-Sent Events with pub/sub architecture using Redis adapter 43 | 44 | ### Request Handling Flow 45 | 46 | 1. Transport receives JSON-RPC 2.0 messages 47 | 2. MCPProtocol validates and routes messages 48 | 3. Registered handlers (RequestHandler/NotificationHandler) process requests 49 | 4. Results are sent back through the transport layer 50 | 51 | ### Key Handlers 52 | - **InitializeHandler**: Handles client-server handshake and capability negotiation 53 | - **ToolsListHandler**: Returns available MCP tools to clients 54 | - **ToolsCallHandler**: Executes specific tool calls with parameters 55 | - **ResourcesListHandler**: Returns available resources (static + template-generated) 56 | - **ResourcesTemplatesListHandler**: Returns resource template definitions 57 | - **ResourcesReadHandler**: Reads resource content by URI 58 | - **PingHandler**: Health check endpoint 59 | 60 | ### Tool System 61 | Tools implement `ToolInterface` and are registered in `config/mcp-server.php`. Each tool defines: 62 | - Input schema for parameter validation 63 | - Execution logic 64 | - Output formatting 65 | 66 | ### Resource System 67 | Resources expose data to LLMs and are registered in `config/mcp-server.php`. Two types: 68 | - **Static Resources**: Concrete resources with fixed URIs 69 | - **Resource Templates**: Dynamic resources using URI templates (RFC 6570) 70 | 71 | Resource Templates support: 72 | - URI pattern matching with variables (e.g., `database://users/{id}`) 73 | - Optional `list()` method for dynamic resource discovery 74 | - Parameter extraction for `read()` method implementation 75 | 76 | ### Configuration 77 | Primary config: `config/mcp-server.php` 78 | - Server info (name, version) 79 | - Transport provider selection 80 | - Tool registration 81 | - SSE adapter settings (Redis connection, TTL) 82 | - Route middlewares 83 | 84 | ### Environment Variables 85 | - `MCP_SERVER_ENABLED`: Enable/disable server 86 | 87 | ### Endpoints 88 | - **Streamable HTTP**: `GET/POST /{default_path}` (default: `/mcp`) 89 | - **SSE (legacy)**: `GET /{default_path}/sse`, `POST /{default_path}/message` 90 | 91 | ### Key Files for Tool Development 92 | - Tool interface: `src/Services/ToolService/ToolInterface.php` 93 | - Tool repository: `src/Services/ToolService/ToolRepository.php` 94 | - Example tools: `src/Services/ToolService/Examples/` 95 | - Tool stub template: `src/stubs/tool.stub` 96 | 97 | ### Key Files for Resource Development 98 | - Resource base class: `src/Services/ResourceService/Resource.php` 99 | - ResourceTemplate base class: `src/Services/ResourceService/ResourceTemplate.php` 100 | - Resource repository: `src/Services/ResourceService/ResourceRepository.php` 101 | - URI template utility: `src/Utils/UriTemplateUtil.php` 102 | - Example resources: `src/Services/ResourceService/Examples/` 103 | - Resource stub templates: `src/stubs/resource.stub`, `src/stubs/resource_template.stub` 104 | 105 | ## Package Development Notes 106 | 107 | ### Project Structure 108 | This is a Laravel package distributed via Composer. Key structural elements: 109 | - **Source code**: All functionality in `src/` directory 110 | - **Configuration**: Published config file at `config/mcp-server.php` 111 | - **Service Provider**: Auto-registered via Laravel package discovery 112 | - **Testing**: Uses Pest testing framework with Orchestra Testbench 113 | - **Quality Tools**: PHPStan (level 5), Laravel Pint for formatting 114 | 115 | ### Breaking Changes & Migration 116 | v1.1.0 introduced breaking changes to `ToolInterface`: 117 | - Method renames (e.g., `getName()` → `name()`) 118 | - New required `messageType()` method 119 | - Use `php artisan mcp:migrate-tools` for automated migration 120 | 121 | ### Environment Requirements 122 | - PHP >=8.2 123 | - Laravel >=10.x 124 | - Redis (for SSE legacy transport only) 125 | - Laravel Octane with FrankenPHP (recommended for SSE, required for development server) 126 | 127 | ## Final action after AI work 128 | 129 | ### Updating README.md 130 | - When you add new feature, add it to README.md 131 | - Never translate README.md. 132 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) OP.GG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opgginc/laravel-mcp-server", 3 | "description": "This is my package laravel-mcp-server", 4 | "keywords": [ 5 | "opgginc", 6 | "laravel", 7 | "laravel-mcp-server" 8 | ], 9 | "homepage": "https://github.com/opgginc/laravel-mcp-server", 10 | "license": "MIT", 11 | "require": { 12 | "php": "^8.2", 13 | "spatie/laravel-package-tools": "^1.16", 14 | "illuminate/contracts": "^10.0||^11.0||^12.0" 15 | }, 16 | "require-dev": { 17 | "laravel/pint": "^1.14", 18 | "nunomaduro/collision": "^8.1.1||^7.10.0", 19 | "larastan/larastan": "^2.9||^3.0", 20 | "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", 21 | "pestphp/pest": "^3.0", 22 | "pestphp/pest-plugin-arch": "^3.0", 23 | "pestphp/pest-plugin-laravel": "^3.0", 24 | "phpstan/extension-installer": "^1.3||^2.0", 25 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 26 | "phpstan/phpstan-phpunit": "^1.3||^2.0", 27 | "spatie/laravel-ray": "^1.41" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "OPGG\\LaravelMcpServer\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "OPGG\\LaravelMcpServer\\Tests\\": "tests/", 37 | "Workbench\\App\\": "workbench/app/" 38 | } 39 | }, 40 | "scripts": { 41 | "post-autoload-dump": "@composer run prepare", 42 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 43 | "analyse": "vendor/bin/phpstan analyse", 44 | "test": "vendor/bin/pest", 45 | "test-coverage": "vendor/bin/pest --coverage", 46 | "format": "vendor/bin/pint" 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "pestphp/pest-plugin": true, 52 | "phpstan/extension-installer": true 53 | } 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "OPGG\\LaravelMcpServer\\LaravelMcpServerServiceProvider" 59 | ], 60 | "aliases": { 61 | "LaravelMcpServer": "OPGG\\LaravelMcpServer\\Facades\\LaravelMcpServer" 62 | } 63 | } 64 | }, 65 | "minimum-stability": "dev", 66 | "prefer-stable": true 67 | } 68 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Laravel MCP Server Release Script 4 | # This script updates the version, commits changes, creates a tag, and pushes to the repository 5 | # Usage: ./scripts/release.sh [version] 6 | # Example: ./scripts/release.sh 1.2.0 7 | 8 | set -e # Exit on any error 9 | 10 | # Colors for output 11 | RED='\033[0;31m' 12 | GREEN='\033[0;32m' 13 | YELLOW='\033[1;33m' 14 | BLUE='\033[0;34m' 15 | NC='\033[0m' # No Color 16 | 17 | # Helper functions 18 | print_step() { 19 | printf "${BLUE}==>${NC} ${1}\n" 20 | } 21 | 22 | print_success() { 23 | printf "${GREEN}✓${NC} ${1}\n" 24 | } 25 | 26 | print_warning() { 27 | printf "${YELLOW}⚠${NC} ${1}\n" 28 | } 29 | 30 | print_error() { 31 | printf "${RED}✗${NC} ${1}\n" 32 | } 33 | 34 | # Check if version is provided 35 | if [ -z "$1" ]; then 36 | print_error "Version number is required" 37 | echo "Usage: $0 " 38 | echo "Example: $0 1.2.0" 39 | exit 1 40 | fi 41 | 42 | VERSION=$1 43 | 44 | # Validate version format (basic check for semantic versioning) 45 | if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.\-]+)?(\+[a-zA-Z0-9\.\-]+)?$ ]]; then 46 | print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.0, 1.2.0-beta.1)" 47 | exit 1 48 | fi 49 | 50 | # Check if we're in a git repository 51 | if ! git rev-parse --git-dir > /dev/null 2>&1; then 52 | print_error "Not in a git repository" 53 | exit 1 54 | fi 55 | 56 | # Check for uncommitted changes 57 | if ! git diff-index --quiet HEAD --; then 58 | print_error "You have uncommitted changes. Please commit or stash them first." 59 | git status --short 60 | exit 1 61 | fi 62 | 63 | # Check if we're on a release branch (master or main) 64 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 65 | if [[ "$CURRENT_BRANCH" != "master" && "$CURRENT_BRANCH" != "main" ]]; then 66 | print_warning "You are on branch '$CURRENT_BRANCH'. Releases are typically done from 'master' or 'main'." 67 | read -p "Do you want to continue? (y/N) " -n 1 -r 68 | echo 69 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 70 | print_error "Release cancelled" 71 | exit 1 72 | fi 73 | fi 74 | 75 | # Check if tag already exists 76 | if git rev-parse "$VERSION" >/dev/null 2>&1; then 77 | print_error "Tag $VERSION already exists" 78 | exit 1 79 | fi 80 | 81 | # Pull latest changes 82 | print_step "Pulling latest changes..." 83 | git pull origin "$CURRENT_BRANCH" 84 | print_success "Repository updated" 85 | 86 | # Update composer.json version if it contains a version field 87 | print_step "Checking composer.json for version field..." 88 | if grep -q '"version"' composer.json; then 89 | print_step "Updating version in composer.json to $VERSION..." 90 | # Use different sed syntax for macOS vs Linux 91 | if [[ "$OSTYPE" == "darwin"* ]]; then 92 | sed -i '' "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" composer.json 93 | else 94 | sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" composer.json 95 | fi 96 | print_success "composer.json updated" 97 | else 98 | print_warning "No version field found in composer.json, skipping update" 99 | fi 100 | 101 | # Check if there are changes to commit 102 | if ! git diff-index --quiet HEAD --; then 103 | # Stage composer.json 104 | print_step "Staging composer.json..." 105 | git add composer.json 106 | 107 | # Commit the version update 108 | print_step "Committing version update..." 109 | git commit -m "chore: bump version to $VERSION" 110 | print_success "Version update committed" 111 | else 112 | print_warning "No changes to commit" 113 | fi 114 | 115 | # Create annotated tag 116 | print_step "Creating tag $VERSION..." 117 | git tag -a "$VERSION" -m "Release version $VERSION" 118 | print_success "Tag $VERSION created" 119 | 120 | # Push commits and tag 121 | print_step "Pushing changes to remote..." 122 | git push origin "$CURRENT_BRANCH" 123 | print_success "Changes pushed to $CURRENT_BRANCH" 124 | 125 | print_step "Pushing tag to remote..." 126 | git push origin "$VERSION" 127 | print_success "Tag $VERSION pushed" 128 | 129 | # Summary 130 | echo "" 131 | print_success "Release $VERSION completed successfully!" 132 | echo "" 133 | echo "Summary:" 134 | echo " - Version: $VERSION" 135 | echo " - Branch: $CURRENT_BRANCH" 136 | echo " - Tag: $VERSION" 137 | echo "" 138 | echo "Next steps:" 139 | echo " 1. Check the GitHub releases page" 140 | echo " 2. Create release notes if needed" 141 | echo " 3. Notify users about the new release" 142 | echo "" 143 | echo "To publish to Packagist (if not auto-synced):" 144 | echo " - Visit https://packagist.org/packages/opgginc/laravel-mcp-server" 145 | echo " - Click 'Update' to sync the new version" -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | anthropic>=0.25.0 2 | aiofiles>=23.0.0 3 | python-dotenv>=1.0.0 -------------------------------------------------------------------------------- /scripts/translate_readme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | README Translation Script using Claude API 4 | 5 | This script translates README.md into multiple languages using Claude API 6 | with parallel processing for efficiency. 7 | 8 | Usage: 9 | python scripts/translate_readme.py 10 | 11 | Requirements: 12 | pip install -r scripts/requirements.txt 13 | 14 | Environment Variables: 15 | ANTHROPIC_API_KEY: Your Claude API key (can be set in .env file) 16 | """ 17 | 18 | import asyncio 19 | import os 20 | import sys 21 | from pathlib import Path 22 | from typing import Dict, List 23 | 24 | try: 25 | import anthropic 26 | import aiofiles 27 | from dotenv import load_dotenv 28 | except ImportError as e: 29 | print(f"Error: Required packages not found. Install with: pip install -r scripts/requirements.txt") 30 | print(f"Missing: {e}") 31 | sys.exit(1) 32 | 33 | # Language configurations 34 | LANGUAGES = { 35 | "es": { 36 | "name": "Español", 37 | "filename": "README.es.md", 38 | "locale": "Spanish (Spain)" 39 | }, 40 | "pt-BR": { 41 | "name": "Português do Brasil", 42 | "filename": "README.pt-BR.md", 43 | "locale": "Brazilian Portuguese" 44 | }, 45 | "ko": { 46 | "name": "한국어", 47 | "filename": "README.ko.md", 48 | "locale": "Korean" 49 | }, 50 | "ru": { 51 | "name": "Русский", 52 | "filename": "README.ru.md", 53 | "locale": "Russian" 54 | }, 55 | "zh-CN": { 56 | "name": "简体中文", 57 | "filename": "README.zh-CN.md", 58 | "locale": "Simplified Chinese (China)" 59 | }, 60 | "zh-TW": { 61 | "name": "繁體中文", 62 | "filename": "README.zh-TW.md", 63 | "locale": "Traditional Chinese (Taiwan)" 64 | }, 65 | "pl": { 66 | "name": "Polski", 67 | "filename": "README.pl.md", 68 | "locale": "Polish" 69 | } 70 | } 71 | 72 | SYSTEM_PROMPT = """You are a native {target_language} professional technical documentation writer specializing in software development documentation. You are an expert at translating Laravel package documentation while maintaining technical accuracy and natural language flow. 73 | 74 | CRITICAL REQUIREMENTS: 75 | 1. **Preserve ALL technical elements exactly**: 76 | - Code blocks, commands, file paths, URLs 77 | - Package names, class names, method names 78 | - Configuration keys, environment variables 79 | - All markdown formatting and structure 80 | 81 | 2. **Technical accuracy**: 82 | - Keep Laravel/PHP terminology consistent 83 | - Maintain proper technical context 84 | - Preserve all code examples unchanged 85 | - Use the English words if the term is very popular and seems like natural for {target_language} software engineers 86 | 87 | 3. **Quality translation**: 88 | - Use natural, fluent and very local {target_language} 89 | - Adapt for {target_language} technical documentation style 90 | - Maintain opensource geek tone throughout 91 | - Use local idioms and expressions 92 | 93 | 4. **DO NOT translate**: 94 | - Code snippets and commands 95 | - URLs and links 96 | - Package/class/method names 97 | - Configuration file contents 98 | - Environment variable names 99 | - File paths and directory names 100 | 101 | 5. **Structure preservation**: 102 | - Keep exact same markdown hierarchy 103 | - Preserve all headers, lists, tables 104 | - Maintain all badges and links 105 | - Keep language selector links unchanged""" 106 | 107 | USER_PROMPT = """Please translate this Laravel package README from English to {target_language}. 108 | 109 | 110 | {content} 111 | 112 | 113 | Return ONLY the translated content without any additional commentary or explanation.""" 114 | 115 | class ReadmeTranslator: 116 | def __init__(self): 117 | self.client = anthropic.Anthropic() 118 | self.project_root = Path(__file__).parent.parent 119 | self.readme_path = self.project_root / "README.md" 120 | 121 | async def read_readme(self) -> str: 122 | """Read the source README.md file.""" 123 | async with aiofiles.open(self.readme_path, 'r', encoding='utf-8') as f: 124 | return await f.read() 125 | 126 | async def translate_to_language(self, content: str, lang_code: str, lang_config: Dict) -> str: 127 | """Translate content to a specific language using Claude.""" 128 | print(f"🌐 Translating to {lang_config['name']}...") 129 | 130 | try: 131 | # Run the synchronous API call in a thread pool to make it non-blocking 132 | loop = asyncio.get_event_loop() 133 | 134 | # Add timeout and increased token limit 135 | message = await asyncio.wait_for( 136 | loop.run_in_executor( 137 | None, 138 | lambda: self.client.messages.create( 139 | model="claude-sonnet-4-20250514", 140 | max_tokens=16000, # Increased token limit 141 | temperature=0.3, # Reduced temperature for more consistent output 142 | system=SYSTEM_PROMPT.format(target_language=lang_config['locale']), 143 | messages=[{ 144 | "role": "user", 145 | "content": USER_PROMPT.format( 146 | target_language=lang_config['locale'], 147 | content=content 148 | ) 149 | }] 150 | ) 151 | ), 152 | timeout=300.0 153 | ) 154 | 155 | # Extract text content properly, handling different content types 156 | translated_content = "" 157 | for content_block in message.content: 158 | if hasattr(content_block, 'text'): 159 | translated_content += content_block.text 160 | elif hasattr(content_block, 'type') and content_block.type == 'text': 161 | translated_content += content_block.text 162 | print(f"✅ {lang_config['name']} translation completed ({len(translated_content)} chars)") 163 | return translated_content 164 | 165 | except asyncio.TimeoutError: 166 | print(f"⏰ Timeout translating to {lang_config['name']} - trying with retry...") 167 | raise 168 | except anthropic.APIError as e: 169 | print(f"🔑 API Error translating to {lang_config['name']}: {e}") 170 | raise 171 | except Exception as e: 172 | print(f"❌ Unexpected error translating to {lang_config['name']}: {e}") 173 | print(f" Error type: {type(e).__name__}") 174 | raise 175 | 176 | async def save_translation(self, content: str, filename: str) -> None: 177 | """Save translated content to file.""" 178 | output_path = self.project_root / filename 179 | async with aiofiles.open(output_path, 'w', encoding='utf-8') as f: 180 | await f.write(content) 181 | print(f"💾 Saved {filename}") 182 | 183 | async def translate_language(self, content: str, lang_code: str, lang_config: Dict) -> None: 184 | """Translate and save a single language with retry logic.""" 185 | max_retries = 3 186 | for attempt in range(max_retries): 187 | try: 188 | translated_content = await self.translate_to_language(content, lang_code, lang_config) 189 | await self.save_translation(translated_content, lang_config['filename']) 190 | return # Success, exit retry loop 191 | except (asyncio.TimeoutError, anthropic.APIError) as e: 192 | if attempt < max_retries - 1: 193 | wait_time = (attempt + 1) * 10 # 10, 20, 30 seconds 194 | print(f"🔄 Retry {attempt + 1}/{max_retries} for {lang_config['name']} in {wait_time}s...") 195 | await asyncio.sleep(wait_time) 196 | else: 197 | print(f"❌ All retries failed for {lang_config['name']}: {e}") 198 | except Exception as e: 199 | print(f"❌ Failed to process {lang_config['name']}: {e}") 200 | break # Don't retry for other types of errors 201 | 202 | async def translate_all(self, languages: List[str] = None) -> None: 203 | """Translate README to all specified languages in parallel.""" 204 | # Read source content 205 | print("📖 Reading README.md...") 206 | content = await self.read_readme() 207 | 208 | # Filter languages if specified 209 | target_languages = languages or list(LANGUAGES.keys()) 210 | tasks = [] 211 | 212 | print(f"🚀 Starting parallel translation for {len(target_languages)} languages...") 213 | 214 | # Create translation tasks 215 | for lang_code in target_languages: 216 | if lang_code in LANGUAGES: 217 | task = self.translate_language(content, lang_code, LANGUAGES[lang_code]) 218 | tasks.append(task) 219 | else: 220 | print(f"⚠️ Unknown language code: {lang_code}") 221 | 222 | # Execute all translations in parallel 223 | await asyncio.gather(*tasks, return_exceptions=True) 224 | print("🎉 All translations completed!") 225 | 226 | def check_api_key(): 227 | """Check if Anthropic API key is available.""" 228 | # Load environment variables from .env file 229 | load_dotenv() 230 | 231 | api_key = os.getenv('ANTHROPIC_API_KEY') 232 | if not api_key: 233 | print("❌ Error: ANTHROPIC_API_KEY environment variable not set") 234 | print("Please set your Claude API key in .env file or environment:") 235 | print("ANTHROPIC_API_KEY=your-api-key-here") 236 | sys.exit(1) 237 | return api_key 238 | 239 | async def main(): 240 | """Main entry point.""" 241 | # Check for API key 242 | check_api_key() 243 | 244 | # Parse command line arguments 245 | target_languages = sys.argv[1:] if len(sys.argv) > 1 else None 246 | 247 | if target_languages: 248 | print(f"🎯 Translating to specific languages: {', '.join(target_languages)}") 249 | else: 250 | print("🌍 Translating to all supported languages") 251 | 252 | # Create translator and run 253 | translator = ReadmeTranslator() 254 | await translator.translate_all(target_languages) 255 | 256 | if __name__ == "__main__": 257 | try: 258 | asyncio.run(main()) 259 | except KeyboardInterrupt: 260 | print("\n⚠️ Translation interrupted by user") 261 | except Exception as e: 262 | print(f"❌ Translation failed: {e}") 263 | sys.exit(1) 264 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeMcpPromptCommand.php: -------------------------------------------------------------------------------- 1 | getClassName(); 23 | $path = $this->getPath($className); 24 | 25 | if ($this->files->exists($path)) { 26 | $this->error("❌ MCP prompt {$className} already exists!"); 27 | 28 | return 1; 29 | } 30 | 31 | $this->makeDirectory($path); 32 | $stub = $this->files->get(__DIR__.'/../../stubs/prompt.stub'); 33 | $stub = str_replace(['{{ className }}', '{{ namespace }}'], [$className, 'App\\MCP\\Prompts'], $stub); 34 | $this->files->put($path, $stub); 35 | $this->info("✅ Created: {$path}"); 36 | 37 | return 0; 38 | } 39 | 40 | protected function getClassName(): string 41 | { 42 | $name = preg_replace('/[\s\-_]+/', ' ', trim($this->argument('name'))); 43 | $name = Str::studly($name); 44 | if (! Str::endsWith($name, 'Prompt')) { 45 | $name .= 'Prompt'; 46 | } 47 | 48 | return $name; 49 | } 50 | 51 | protected function getPath(string $className): string 52 | { 53 | return app_path("MCP/Prompts/{$className}.php"); 54 | } 55 | 56 | protected function makeDirectory(string $path): void 57 | { 58 | $dir = dirname($path); 59 | if (! $this->files->isDirectory($dir)) { 60 | $this->files->makeDirectory($dir, 0755, true, true); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeMcpResourceCommand.php: -------------------------------------------------------------------------------- 1 | getClassName(); 23 | $path = $this->getPath($className); 24 | 25 | if ($this->files->exists($path)) { 26 | $this->error("❌ MCP resource {$className} already exists!"); 27 | 28 | return 1; 29 | } 30 | 31 | $this->makeDirectory($path); 32 | $stub = $this->files->get(__DIR__.'/../../stubs/resource.stub'); 33 | $stub = str_replace(['{{ className }}', '{{ namespace }}'], [$className, 'App\\MCP\\Resources'], $stub); 34 | $this->files->put($path, $stub); 35 | $this->info("✅ Created: {$path}"); 36 | 37 | return 0; 38 | } 39 | 40 | protected function getClassName(): string 41 | { 42 | $name = preg_replace('/[\s\-_]+/', ' ', trim($this->argument('name'))); 43 | $name = Str::studly($name); 44 | if (! Str::endsWith($name, 'Resource')) { 45 | $name .= 'Resource'; 46 | } 47 | 48 | return $name; 49 | } 50 | 51 | protected function getPath(string $className): string 52 | { 53 | return app_path("MCP/Resources/{$className}.php"); 54 | } 55 | 56 | protected function makeDirectory(string $path): void 57 | { 58 | $dir = dirname($path); 59 | if (! $this->files->isDirectory($dir)) { 60 | $this->files->makeDirectory($dir, 0755, true, true); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeMcpResourceTemplateCommand.php: -------------------------------------------------------------------------------- 1 | getClassName(); 23 | $path = $this->getPath($className); 24 | 25 | if ($this->files->exists($path)) { 26 | $this->error("❌ MCP resource template {$className} already exists!"); 27 | 28 | return 1; 29 | } 30 | 31 | $this->makeDirectory($path); 32 | $stub = $this->files->get(__DIR__.'/../../stubs/resource_template.stub'); 33 | $stub = str_replace(['{{ className }}', '{{ namespace }}'], [$className, 'App\\MCP\\ResourceTemplates'], $stub); 34 | $this->files->put($path, $stub); 35 | $this->info("✅ Created: {$path}"); 36 | 37 | return 0; 38 | } 39 | 40 | protected function getClassName(): string 41 | { 42 | $name = preg_replace('/[\s\-_]+/', ' ', trim($this->argument('name'))); 43 | $name = Str::studly($name); 44 | if (! Str::endsWith($name, 'Template')) { 45 | $name .= 'Template'; 46 | } 47 | 48 | return $name; 49 | } 50 | 51 | protected function getPath(string $className): string 52 | { 53 | return app_path("MCP/ResourceTemplates/{$className}.php"); 54 | } 55 | 56 | protected function makeDirectory(string $path): void 57 | { 58 | $dir = dirname($path); 59 | if (! $this->files->isDirectory($dir)) { 60 | $this->files->makeDirectory($dir, 0755, true, true); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeMcpToolCommand.php: -------------------------------------------------------------------------------- 1 | files = $files; 42 | } 43 | 44 | /** 45 | * Execute the console command. 46 | * 47 | * @return int 48 | */ 49 | public function handle() 50 | { 51 | $className = $this->getClassName(); 52 | $path = $this->getPath($className); 53 | 54 | // Check if file already exists 55 | if ($this->files->exists($path)) { 56 | $this->error("❌ MCP tool {$className} already exists!"); 57 | 58 | return 1; 59 | } 60 | 61 | // Create directories if they don't exist 62 | $this->makeDirectory($path); 63 | 64 | // Generate the file using stub 65 | $this->files->put($path, $this->buildClass($className)); 66 | 67 | $this->info("✅ Created: {$path}"); 68 | 69 | $fullClassName = "\\App\\MCP\\Tools\\{$className}"; 70 | 71 | // Ask if they want to automatically register the tool 72 | if ($this->confirm('🤖 Would you like to automatically register this tool in config/mcp-server.php?', true)) { 73 | $this->registerToolInConfig($fullClassName); 74 | } else { 75 | $this->info("☑️ Don't forget to register your tool in config/mcp-server.php:"); 76 | $this->comment(' // config/mcp-server.php'); 77 | $this->comment(" 'tools' => ["); 78 | $this->comment(' // other tools...'); 79 | $this->comment(" {$fullClassName}::class,"); 80 | $this->comment(' ],'); 81 | } 82 | 83 | // Display testing instructions 84 | $this->newLine(); 85 | $this->info('You can now test your tool with the following command:'); 86 | $this->comment(' php artisan mcp:test-tool '.$className); 87 | $this->info('Or view all available tools:'); 88 | $this->comment(' php artisan mcp:test-tool --list'); 89 | 90 | return 0; 91 | } 92 | 93 | /** 94 | * Get the class name from the command argument. 95 | * 96 | * @return string 97 | */ 98 | protected function getClassName() 99 | { 100 | $name = $this->argument('name'); 101 | 102 | // Clean up the input: remove multiple spaces, hyphens, underscores 103 | // and handle mixed case input 104 | $name = preg_replace('/[\s\-_]+/', ' ', trim($name)); 105 | 106 | // Convert to StudlyCase 107 | $name = Str::studly($name); 108 | 109 | // Ensure the class name ends with "Tool" if not already 110 | if (! Str::endsWith($name, 'Tool')) { 111 | $name .= 'Tool'; 112 | } 113 | 114 | return $name; 115 | } 116 | 117 | /** 118 | * Get the destination file path. 119 | * 120 | * @return string 121 | */ 122 | protected function getPath(string $className) 123 | { 124 | // Create the file in the app/MCP/Tools directory 125 | return app_path("MCP/Tools/{$className}.php"); 126 | } 127 | 128 | /** 129 | * Build the directory for the class if necessary. 130 | * 131 | * @param string $path 132 | * @return string 133 | */ 134 | protected function makeDirectory($path) 135 | { 136 | $directory = dirname($path); 137 | 138 | if (! $this->files->isDirectory($directory)) { 139 | $this->files->makeDirectory($directory, 0755, true, true); 140 | } 141 | 142 | return $directory; 143 | } 144 | 145 | /** 146 | * Build the class with the given name. 147 | * 148 | * @return string 149 | */ 150 | protected function buildClass(string $className) 151 | { 152 | $stub = $this->files->get($this->getStubPath()); 153 | 154 | // Generate a kebab-case tool name without the 'Tool' suffix 155 | $toolName = Str::kebab(preg_replace('/Tool$/', '', $className)); 156 | 157 | // Ensure tool name doesn't have unwanted characters 158 | $toolName = preg_replace('/[^a-z0-9\-]/', '', $toolName); 159 | 160 | // Ensure no consecutive hyphens 161 | $toolName = preg_replace('/\-+/', '-', $toolName); 162 | 163 | // Ensure it starts with a letter 164 | if (! preg_match('/^[a-z]/', $toolName)) { 165 | $toolName = 'tool-'.$toolName; 166 | } 167 | 168 | return $this->replaceStubPlaceholders($stub, $className, $toolName); 169 | } 170 | 171 | /** 172 | * Get the stub file path. 173 | * 174 | * @return string 175 | */ 176 | protected function getStubPath() 177 | { 178 | return __DIR__.'/../../stubs/tool.stub'; 179 | } 180 | 181 | /** 182 | * Replace the stub placeholders with actual values. 183 | * 184 | * @return string 185 | */ 186 | protected function replaceStubPlaceholders(string $stub, string $className, string $toolName) 187 | { 188 | return str_replace( 189 | ['{{ className }}', '{{ namespace }}', '{{ toolName }}'], 190 | [$className, 'App\\MCP\\Tools', $toolName], 191 | $stub 192 | ); 193 | } 194 | 195 | /** 196 | * Register the tool in the MCP server configuration file. 197 | * 198 | * @param string $toolClassName Fully qualified class name of the tool 199 | * @return bool Whether registration was successful 200 | */ 201 | protected function registerToolInConfig(string $toolClassName): bool 202 | { 203 | $configPath = config_path('mcp-server.php'); 204 | 205 | if (! file_exists($configPath)) { 206 | $this->error("❌ Config file not found: {$configPath}"); 207 | 208 | return false; 209 | } 210 | 211 | $content = file_get_contents($configPath); 212 | 213 | // Find the tools array in the config file 214 | if (! preg_match('/[\'"]tools[\'"]\s*=>\s*\[(.*?)\s*\],/s', $content, $matches)) { 215 | $this->error('❌ Could not locate tools array in config file.'); 216 | 217 | return false; 218 | } 219 | 220 | $toolsArrayContent = $matches[1]; 221 | $fullEntry = "\n {$toolClassName}::class,"; 222 | 223 | // Check if the tool is already registered 224 | if (strpos($toolsArrayContent, $toolClassName) !== false) { 225 | $this->info('✅ Tool is already registered in config file.'); 226 | 227 | return true; 228 | } 229 | 230 | // Add the new tool to the tools array 231 | $newToolsArrayContent = $toolsArrayContent.$fullEntry; 232 | $newContent = str_replace($toolsArrayContent, $newToolsArrayContent, $content); 233 | 234 | // Write the updated content back to the config file 235 | if (file_put_contents($configPath, $newContent)) { 236 | $this->info('✅ Tool registered successfully in config/mcp-server.php'); 237 | 238 | return true; 239 | } else { 240 | $this->error('❌ Failed to update config file. Please manually register the tool.'); 241 | 242 | return false; 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/Console/Commands/MigrateToolsCommand.php: -------------------------------------------------------------------------------- 1 | argument('path') ?? app_path('MCP/Tools'); 33 | 34 | if (! File::isDirectory($toolsPath)) { 35 | $this->error("The specified path `{$toolsPath}` is not a directory or does not exist."); 36 | 37 | return self::FAILURE; 38 | } 39 | 40 | $this->info("Starting migration scan for tools in: {$toolsPath}"); 41 | $this->info('This tool supports migration from v1.0.x, v1.1.x, and v1.2.x to v1.3.0'); 42 | 43 | $finder = new Finder; 44 | $finder->files()->in($toolsPath)->name('*.php'); 45 | 46 | if (! $finder->hasResults()) { 47 | $this->info('No PHP files found in the specified path.'); 48 | 49 | return self::SUCCESS; 50 | } 51 | 52 | $potentialCandidates = 0; 53 | $createBackups = null; 54 | 55 | foreach ($finder as $file) { 56 | $content = $file->getContents(); 57 | 58 | // Check for tools that need migration 59 | $filePath = $file->getRealPath(); 60 | $toolVersion = $this->detectToolVersion($content); 61 | $needsMigration = $toolVersion !== null && $toolVersion !== '1.3.0'; 62 | 63 | if ($needsMigration) { 64 | $this->line("Found {$toolVersion} tool requiring migration to 1.3.0: {$filePath}"); 65 | $potentialCandidates++; 66 | 67 | // Ask about backup creation only once 68 | if ($createBackups === null && ! $this->option('no-backup')) { 69 | $createBackups = $this->confirm( 70 | 'Do you want to create backup files before migration? (Recommended)', 71 | true // Default to yes 72 | ); 73 | 74 | if ($createBackups) { 75 | $this->info('Backup files will be created with .backup extension.'); 76 | } else { 77 | $this->warn('No backup files will be created. Migration will modify files directly.'); 78 | } 79 | } elseif ($this->option('no-backup')) { 80 | $createBackups = false; 81 | } 82 | 83 | $backupFilePath = $filePath.'.backup'; 84 | 85 | // Check if backup already exists when backups are enabled 86 | if ($createBackups && File::exists($backupFilePath)) { 87 | $this->warn("Backup for '{$filePath}' already exists at '{$backupFilePath}'. Skipping migration for this file."); 88 | 89 | continue; // Skip to the next file 90 | } 91 | 92 | try { 93 | // Create backup if requested 94 | if ($createBackups) { 95 | if (File::copy($filePath, $backupFilePath)) { 96 | $this->info("Backed up '{$filePath}' to '{$backupFilePath}'."); 97 | } else { 98 | $this->error("Failed to create backup for '{$filePath}'. Skipping migration for this file."); 99 | 100 | continue; 101 | } 102 | } 103 | 104 | // Proceed with migration 105 | $originalContent = File::get($filePath); 106 | 107 | // Apply migration strategy based on detected version 108 | $this->info("Performing migration from {$toolVersion} to 1.3.0..."); 109 | $modifiedContent = $this->applyMigrationStrategy($toolVersion, $originalContent); 110 | 111 | if ($modifiedContent !== $originalContent) { 112 | if (File::put($filePath, $modifiedContent)) { 113 | $this->info("Successfully migrated '{$filePath}'."); 114 | } else { 115 | $this->error("Failed to write changes to '{$filePath}'.".($createBackups ? ' You can restore from backup if needed.' : '')); 116 | } 117 | } else { 118 | $this->info("No changes were necessary for '{$filePath}' during migration content generation (this might indicate an issue or already migrated parts)."); 119 | } 120 | 121 | } catch (\Exception $e) { 122 | $this->error("Error migrating '{$filePath}': ".$e->getMessage().'. Skipping migration for this file.'); 123 | 124 | continue; 125 | } 126 | 127 | } 128 | } 129 | 130 | if ($potentialCandidates > 0) { 131 | $this->info("Scan complete. Processed {$potentialCandidates} potential candidates."); 132 | } else { 133 | $this->info('Scan complete. No files seem to require migration based on initial checks.'); 134 | } 135 | 136 | return self::SUCCESS; 137 | } 138 | 139 | /** 140 | * Detect the version of a tool based on its content 141 | */ 142 | private function detectToolVersion(string $content): ?string 143 | { 144 | $isToolInterface = str_contains($content, 'implements ToolInterface') || 145 | str_contains($content, 'use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;'); 146 | 147 | if (! $isToolInterface) { 148 | return null; // Not a tool 149 | } 150 | 151 | // Check for v1.0.x style methods (old method names) 152 | $hasV1Methods = str_contains($content, 'public function getName(): string') || 153 | str_contains($content, 'public function getDescription(): string') || 154 | str_contains($content, 'public function getInputSchema(): array') || 155 | str_contains($content, 'public function getAnnotations(): array'); 156 | 157 | if ($hasV1Methods) { 158 | return '1.0.x'; 159 | } 160 | 161 | // Check for isStreaming() method (v1.3.0 style) 162 | $hasIsStreaming = str_contains($content, 'public function isStreaming(): bool'); 163 | 164 | if ($hasIsStreaming) { 165 | return '1.3.0'; // Already migrated 166 | } 167 | 168 | // Check for messageType() method without isStreaming() (v1.1.x/v1.2.x tools) 169 | $hasMessageType = str_contains($content, 'public function messageType(): ProcessMessageType'); 170 | 171 | if ($hasMessageType) { 172 | return '1.1.x'; // Could be 1.1.x or 1.2.x, needs isStreaming() method 173 | } 174 | 175 | return null; // Unknown or not a proper tool 176 | } 177 | 178 | /** 179 | * Apply the appropriate migration strategy 180 | */ 181 | private function applyMigrationStrategy(string $fromVersion, string $content): string 182 | { 183 | return match ($fromVersion) { 184 | '1.0.x' => $this->migrateFromV1_0($content), 185 | '1.1.x' => $this->migrateFromV1_1($content), 186 | default => $content, 187 | }; 188 | } 189 | 190 | /** 191 | * Migrate v1.0.x tools to v1.3.0 (rename methods only, no isStreaming since v1.0.x defaulted to HTTP) 192 | */ 193 | private function migrateFromV1_0(string $content): string 194 | { 195 | $modifiedContent = $content; 196 | 197 | // Rename methods only - v1.0.x tools defaulted to HTTP so no isStreaming() needed 198 | $replacements = [ 199 | 'public function getName(): string' => 'public function name(): string', 200 | 'public function getDescription(): string' => 'public function description(): string', 201 | 'public function getInputSchema(): array' => 'public function inputSchema(): array', 202 | 'public function getAnnotations(): array' => 'public function annotations(): array', 203 | ]; 204 | 205 | foreach ($replacements as $old => $new) { 206 | $modifiedContent = str_replace($old, $new, $modifiedContent); 207 | } 208 | 209 | return $modifiedContent; 210 | } 211 | 212 | /** 213 | * Migrate v1.1.x/v1.2.x tools to v1.3.0 (remove messageType and conditionally add isStreaming) 214 | */ 215 | private function migrateFromV1_1(string $content): string 216 | { 217 | $modifiedContent = $content; 218 | 219 | // Find messageType method and determine if it's SSE or HTTP 220 | if (preg_match('/(public function messageType\(\): ProcessMessageType\s*\{[^}]*\})/s', $content, $matches)) { 221 | $messageTypeMethod = $matches[1]; 222 | 223 | // Check if the messageType returns SSE 224 | $isSSE = str_contains($messageTypeMethod, 'ProcessMessageType::SSE'); 225 | 226 | if ($isSSE) { 227 | // For SSE tools: Replace messageType with isStreaming() returning true 228 | $isStreamingMethod = ' public function isStreaming(): bool'.PHP_EOL. 229 | ' {'.PHP_EOL. 230 | ' return true;'.PHP_EOL. 231 | ' }'; 232 | $modifiedContent = str_replace($messageTypeMethod, $isStreamingMethod, $modifiedContent); 233 | } else { 234 | // For HTTP tools: Just remove the messageType method completely 235 | $modifiedContent = str_replace($messageTypeMethod, '', $modifiedContent); 236 | 237 | // Clean up any extra newlines left behind 238 | $modifiedContent = preg_replace('/\n\s*\n\s*\n/', "\n\n", $modifiedContent); 239 | } 240 | 241 | // Remove the ProcessMessageType import if it's no longer needed 242 | if (! $isSSE && ! str_contains($modifiedContent, 'ProcessMessageType::')) { 243 | $modifiedContent = preg_replace('/use OPGG\\\\LaravelMcpServer\\\\Enums\\\\ProcessMessageType;\s*\n/', '', $modifiedContent); 244 | } 245 | } 246 | 247 | return $modifiedContent; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Data/ProcessMessageData.php: -------------------------------------------------------------------------------- 1 | messageType = $messageType; 19 | $this->resource = $resource; 20 | } 21 | 22 | public function toArray(): array 23 | { 24 | if ($this->resource instanceof JsonRpcResultResource || $this->resource instanceof JsonRpcErrorResource) { 25 | return $this->resource->toResponse(); 26 | } 27 | 28 | return $this->resource; 29 | } 30 | 31 | public function isSSEMessage(): bool 32 | { 33 | if (Config::get('mcp-server.server_provider') === 'sse') { 34 | return true; 35 | } 36 | 37 | return $this->messageType === ProcessMessageType::SSE; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Data/Requests/InitializeData.php: -------------------------------------------------------------------------------- 1 | version = $version; 17 | $this->capabilities = $capabilities; 18 | } 19 | 20 | public static function fromArray(array $data): self 21 | { 22 | return new self( 23 | $data['version'] ?? '1.0', 24 | $data['capabilities'] ?? [] 25 | ); 26 | } 27 | 28 | public function toArray(): array 29 | { 30 | return [ 31 | 'version' => $this->version, 32 | 'capabilities' => $this->capabilities, 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Data/Requests/NotificationData.php: -------------------------------------------------------------------------------- 1 | |null 27 | */ 28 | public ?array $params; 29 | 30 | /** 31 | * Constructor for NotificationData. 32 | * 33 | * @param string $method The notification method name. 34 | * @param string $jsonRpc The JSON-RPC version (should be "2.0"). 35 | * @param array|null $params The notification parameters. 36 | */ 37 | public function __construct(string $method, string $jsonRpc, ?array $params) 38 | { 39 | $this->method = $method; 40 | $this->jsonRpc = $jsonRpc; 41 | $this->params = $params; 42 | } 43 | 44 | /** 45 | * Creates a NotificationData object from an array. 46 | * 47 | * @param array $data The source data array, typically from a decoded JSON request. 48 | * @return self Returns an instance of NotificationData. 49 | */ 50 | public static function fromArray(array $data): self 51 | { 52 | return new self( 53 | method: $data['method'], 54 | jsonRpc: $data['jsonrpc'], 55 | params: $data['params'] ?? null 56 | ); 57 | } 58 | 59 | /** 60 | * Converts the NotificationData object back into an array format suitable for JSON encoding. 61 | * 62 | * @return array Returns an array representation of the notification. 63 | */ 64 | public function toArray(): array 65 | { 66 | $result = [ 67 | 'jsonrpc' => $this->jsonRpc, 68 | 'method' => $this->method, 69 | ]; 70 | 71 | if ($this->params !== null) { 72 | $result['params'] = $this->params; 73 | } 74 | 75 | return $result; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Data/Requests/RequestData.php: -------------------------------------------------------------------------------- 1 | $params The parameters to be used during the invocation of the method. 15 | */ 16 | class RequestData 17 | { 18 | /** @var string The name of the method to be invoked. */ 19 | public string $method; 20 | 21 | /** @var string The JSON-RPC version string. */ 22 | public string $jsonRpc; 23 | 24 | /** @var int An identifier established by the Client. */ 25 | public int $id; 26 | 27 | /** @var array The parameters for the method invocation. */ 28 | public array $params; 29 | 30 | /** 31 | * Constructor for RequestData. 32 | * 33 | * @param string $method The method name. 34 | * @param string $jsonRpc The JSON-RPC version. 35 | * @param int $id The request identifier. 36 | * @param array $params The request parameters. 37 | */ 38 | public function __construct(string $method, string $jsonRpc, int $id, array $params) 39 | { 40 | $this->method = $method; 41 | $this->jsonRpc = $jsonRpc; 42 | $this->id = $id; 43 | $this->params = $params; 44 | } 45 | 46 | /** 47 | * Creates a RequestData instance from an array. 48 | * 49 | * @param array $data The data array, typically from a decoded JSON request. 50 | * @return self A new instance of RequestData. 51 | */ 52 | public static function fromArray(array $data): self 53 | { 54 | return new self( 55 | method: $data['method'], 56 | jsonRpc: $data['jsonrpc'], 57 | id: $data['id'], 58 | params: $data['params'] ?? [] 59 | ); 60 | } 61 | 62 | /** 63 | * Converts the RequestData instance to an array. 64 | * 65 | * @return array The array representation of the request data. 66 | */ 67 | public function toArray(): array 68 | { 69 | return [ 70 | 'jsonrpc' => $this->jsonRpc, 71 | 'id' => $this->id, 72 | 'method' => $this->method, 73 | 'params' => $this->params, 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Data/Resources/InitializeResource.php: -------------------------------------------------------------------------------- 1 | serverInfo = [ 44 | 'name' => $name, 45 | 'version' => $version, 46 | ]; 47 | $this->capabilities = $capabilities; 48 | $this->protocolVersion = $protocolVersion; 49 | } 50 | 51 | /** 52 | * Creates an InitializeResource instance from an array. 53 | * Useful for hydrating the object from serialized data. 54 | * 55 | * @param array $data The data array, expected to contain 'name', 'version', and 'capabilities'. 56 | * @return self A new instance of InitializeResource. 57 | */ 58 | public static function fromArray(array $data): self 59 | { 60 | return new self( 61 | $data['name'] ?? 'unknown', 62 | $data['version'] ?? '1.0', 63 | $data['capabilities'] ?? [] 64 | ); 65 | } 66 | 67 | /** 68 | * Converts the InitializeResource instance to an array. 69 | * Suitable for serialization or sending as part of an API response. 70 | * 71 | * @return array An associative array representing the resource. 72 | */ 73 | public function toArray(): array 74 | { 75 | return [ 76 | 'protocolVersion' => $this->protocolVersion, 77 | 'capabilities' => $this->capabilities, 78 | 'serverInfo' => $this->serverInfo, 79 | ]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Data/Resources/JsonRpc/JsonRpcErrorResource.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 35 | $this->id = $id; 36 | } 37 | 38 | /** 39 | * Converts the error resource into a JSON-RPC compliant array format. 40 | * 41 | * @return array{jsonrpc: string, error: array{code: int, message: string, data?: mixed}, id?: string|int|null} The JSON-RPC error response array. 42 | */ 43 | public function toResponse(): array 44 | { 45 | $injectId = []; 46 | if ($this->id) { 47 | $injectId['id'] = $this->id; 48 | } 49 | 50 | return [ 51 | 'jsonrpc' => '2.0', 52 | ...$injectId, 53 | 'error' => $this->exception->toArray(), 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Data/Resources/JsonRpc/JsonRpcResultResource.php: -------------------------------------------------------------------------------- 1 | result = $result; 34 | $this->id = $id; 35 | } 36 | 37 | /** 38 | * Formats the data into a JSON-RPC 2.0 compliant response array. 39 | * 40 | * @return array{jsonrpc: string, id: string|int, result: array|stdClass} The JSON-RPC response array. 41 | */ 42 | public function toResponse(): array 43 | { 44 | return [ 45 | 'jsonrpc' => '2.0', 46 | 'id' => $this->id, 47 | 'result' => $this->result, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Enums/ProcessMessageType.php: -------------------------------------------------------------------------------- 1 | value); 38 | $this->jsonRpcErrorCode = $code; 39 | $this->errorData = $data; 40 | } 41 | 42 | /** 43 | * Get the additional data associated with the error. 44 | * 45 | * @return mixed|null The error data, or null if not set. 46 | */ 47 | public function getErrorData() 48 | { 49 | return $this->errorData; 50 | } 51 | 52 | /** 53 | * Get the JSON-RPC error code. 54 | * 55 | * @return int The integer value of the JSON-RPC error code. 56 | */ 57 | public function getJsonRpcErrorCode(): int 58 | { 59 | return $this->jsonRpcErrorCode->value; 60 | } 61 | 62 | /** 63 | * Convert the exception to a JSON-RPC error object array. 64 | * 65 | * @return array{code: int, message: string, data?: mixed} The error object representation. 66 | */ 67 | public function toArray(): array 68 | { 69 | $error = [ 70 | 'code' => $this->jsonRpcErrorCode->value, 71 | 'message' => $this->message, 72 | ]; 73 | 74 | if ($this->errorData !== null) { 75 | $error['data'] = $this->errorData; 76 | } 77 | 78 | return $error; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Facades/LaravelMcpServer.php: -------------------------------------------------------------------------------- 1 | input('sessionId'); 13 | 14 | $messageJson = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR); 15 | 16 | $server = app(MCPServer::class); 17 | $server->requestMessage(clientId: $sessionId, message: $messageJson); 18 | 19 | return response()->json(['success' => true]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Http/Controllers/SseController.php: -------------------------------------------------------------------------------- 1 | $server->connect(), headers: [ 19 | 'Content-Type' => 'text/event-stream', 20 | 'Cache-Control' => 'no-cache', 21 | 'Connection' => 'keep-alive', 22 | 'X-Accel-Buffering' => 'no', 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Controllers/StreamableHttpController.php: -------------------------------------------------------------------------------- 1 | headers->get('mcp-session-id'); 20 | $lastEventId = $request->headers->get('last-event-id'); 21 | 22 | // todo:: SSE connection configuration restricted 23 | 24 | return response()->json([ 25 | 'jsonrpc' => '2.0', 26 | 'error' => 'Method Not Allowed', 27 | ], 405); 28 | } 29 | 30 | public function postHandle(Request $request) 31 | { 32 | $server = app(MCPServer::class); 33 | 34 | $mcpSessionId = $request->headers->get('mcp-session-id'); 35 | if (! $mcpSessionId) { 36 | $mcpSessionId = Str::uuid()->toString(); 37 | } 38 | 39 | $messageJson = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR); 40 | $processMessageData = $server->requestMessage(clientId: $mcpSessionId, message: $messageJson); 41 | 42 | if (in_array($processMessageData->messageType, [ProcessMessageType::HTTP]) 43 | && ($processMessageData->resource instanceof JsonRpcResultResource || $processMessageData->resource instanceof JsonRpcErrorResource)) { 44 | return response()->json($processMessageData->resource->toResponse()); 45 | } 46 | 47 | return response()->json([ 48 | 'jsonrpc' => '2.0', 49 | 'error' => 'Bad Request: invalid session ID or method.', 50 | ], 400); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/LaravelMcpServerServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-mcp-server') 33 | ->hasConfigFile('mcp-server') 34 | ->hasCommands([ 35 | MakeMcpToolCommand::class, 36 | MakeMcpResourceCommand::class, 37 | MakeMcpResourceTemplateCommand::class, 38 | MakeMcpPromptCommand::class, 39 | TestMcpToolCommand::class, 40 | MigrateToolsCommand::class, 41 | ]); 42 | } 43 | 44 | public function register(): void 45 | { 46 | parent::register(); 47 | 48 | $provider = match (Config::get('mcp-server.server_provider')) { 49 | 'streamable_http' => StreamableHttpServiceProvider::class, 50 | default => SseServiceProvider::class, 51 | }; 52 | 53 | $this->app->register($provider); 54 | } 55 | 56 | public function boot(): void 57 | { 58 | parent::boot(); 59 | 60 | $this->registerRoutes(); 61 | } 62 | 63 | /** 64 | * Register the routes for the MCP Server 65 | */ 66 | protected function registerRoutes(): void 67 | { 68 | // Skip route registration if the server is disabled 69 | if (! Config::get('mcp-server.enabled', true)) { 70 | return; 71 | } 72 | 73 | // Skip route registration if MCPServer instance doesn't exist 74 | if (! app()->has(MCPServer::class)) { 75 | return; 76 | } 77 | 78 | $path = Config::get('mcp-server.default_path'); 79 | $middlewares = Config::get('mcp-server.middlewares', []); 80 | $domain = Config::get('mcp-server.domain'); 81 | $provider = Config::get('mcp-server.server_provider'); 82 | 83 | // Handle multiple domains support 84 | $domains = $this->normalizeDomains($domain); 85 | 86 | // Register routes for each domain 87 | foreach ($domains as $domainName) { 88 | $this->registerRoutesForDomain($domainName, $path, $middlewares, $provider); 89 | } 90 | } 91 | 92 | /** 93 | * Normalize domain configuration to array format 94 | * 95 | * @param null|string|array $domain 96 | */ 97 | protected function normalizeDomains($domain): array 98 | { 99 | if ($domain === null) { 100 | return [null]; // No domain restriction 101 | } 102 | 103 | if (is_string($domain)) { 104 | return [$domain]; // Single domain 105 | } 106 | 107 | if (is_array($domain)) { 108 | return $domain; // Multiple domains 109 | } 110 | 111 | // Invalid configuration, default to no restriction 112 | return [null]; 113 | } 114 | 115 | /** 116 | * Register routes for a specific domain 117 | */ 118 | protected function registerRoutesForDomain(?string $domain, string $path, array $middlewares, string $provider): void 119 | { 120 | // Build route configuration 121 | $router = Route::middleware($middlewares); 122 | 123 | // Apply domain restriction if specified 124 | if ($domain !== null) { 125 | $router = $router->domain($domain); 126 | } 127 | 128 | // Register provider-specific routes 129 | switch ($provider) { 130 | case 'sse': 131 | $router->get("{$path}/sse", [SseController::class, 'handle']); 132 | $router->post("{$path}/message", [MessageController::class, 'handle']); 133 | break; 134 | 135 | case 'streamable_http': 136 | $router->get($path, [StreamableHttpController::class, 'getHandle']); 137 | $router->post($path, [StreamableHttpController::class, 'postHandle']); 138 | break; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Protocol/Handlers/NotificationHandler.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 49 | } 50 | 51 | /** 52 | * @throws Exception 53 | */ 54 | public function connect(): void 55 | { 56 | $this->transport->start(); 57 | 58 | while ($this->transport->isConnected()) { 59 | foreach ($this->transport->receive() as $message) { 60 | if ($message === null) { 61 | continue; 62 | } 63 | 64 | $this->send(message: $message); 65 | } 66 | 67 | usleep(10000); // 10ms 68 | } 69 | 70 | $this->disconnect(); 71 | } 72 | 73 | public function send(string|array $message): void 74 | { 75 | $this->transport->send(message: $message); 76 | } 77 | 78 | public function disconnect(): void 79 | { 80 | $this->transport->close(); 81 | } 82 | 83 | public function registerRequestHandler(RequestHandler $handler): void 84 | { 85 | if (is_string($handler->getHandleMethod())) { 86 | $this->requestHandlers[$handler->getHandleMethod()] = $handler; 87 | } 88 | if (is_array($handler->getHandleMethod())) { 89 | foreach ($handler->getHandleMethod() as $method) { 90 | $this->requestHandlers[$method] = $handler; 91 | } 92 | } 93 | } 94 | 95 | public function registerNotificationHandler(NotificationHandler $handler): void 96 | { 97 | if (is_string($handler->getHandleMethod())) { 98 | $this->notificationHandlers[$handler->getHandleMethod()] = $handler; 99 | } 100 | if (is_array($handler->getHandleMethod())) { 101 | foreach ($handler->getHandleMethod() as $method) { 102 | $this->notificationHandlers[$method] = $handler; 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * @throws JsonRpcErrorException 109 | * @throws Exception 110 | */ 111 | public function handleMessage(string $clientId, array $message): ProcessMessageData 112 | { 113 | $messageId = $message['id'] ?? null; 114 | try { 115 | if (! isset($message['jsonrpc']) || $message['jsonrpc'] !== '2.0') { 116 | throw new JsonRpcErrorException(message: 'Invalid Request: Not a valid JSON-RPC 2.0 message', code: JsonRpcErrorCode::INVALID_REQUEST); 117 | } 118 | 119 | $requestData = DataUtil::makeRequestData(message: $message); 120 | if ($requestData instanceof RequestData) { 121 | return $this->processRequestData(clientId: $clientId, requestData: $requestData); 122 | } 123 | if ($requestData instanceof NotificationData) { 124 | return $this->processNotification(clientId: $clientId, notificationData: $requestData); 125 | } 126 | 127 | throw new JsonRpcErrorException(message: 'Invalid Request: Message format not recognized', code: JsonRpcErrorCode::INVALID_REQUEST); 128 | } catch (JsonRpcErrorException $e) { 129 | $jsonErrorResource = new JsonRpcErrorResource(exception: $e, id: $messageId); 130 | $this->sendSSEMessage(clientId: $clientId, message: $jsonErrorResource); 131 | 132 | return new ProcessMessageData(messageType: ProcessMessageType::HTTP, resource: $jsonErrorResource); 133 | } catch (Exception $e) { 134 | $jsonErrorResource = new JsonRpcErrorResource( 135 | exception: new JsonRpcErrorException(message: 'INTERNAL_ERROR', code: JsonRpcErrorCode::INTERNAL_ERROR), 136 | id: $messageId 137 | ); 138 | $this->sendSSEMessage(clientId: $clientId, message: $jsonErrorResource); 139 | 140 | return new ProcessMessageData(messageType: ProcessMessageType::HTTP, resource: $jsonErrorResource); 141 | } 142 | } 143 | 144 | /** 145 | * Handles incoming request messages. 146 | * Finds a matching request handler and executes it. 147 | * Sends the result or an error back to the client. 148 | * 149 | * @param string $clientId The identifier of the client sending the request. 150 | * @param RequestData $requestData The parsed request data object. 151 | * 152 | * @throws Exception 153 | */ 154 | private function processRequestData(string $clientId, RequestData $requestData): ProcessMessageData 155 | { 156 | $method = $requestData->method; 157 | $handler = $this->requestHandlers[$method] ?? null; 158 | if ($handler) { 159 | $result = $handler->execute(method: $requestData->method, params: $requestData->params); 160 | $messageType = $handler->getMessageType($requestData->params); 161 | 162 | $resultResource = new JsonRpcResultResource(id: $requestData->id, result: $result); 163 | $processMessageData = new ProcessMessageData(messageType: $messageType, resource: $resultResource); 164 | 165 | if ($processMessageData->isSSEMessage()) { 166 | $this->sendSSEMessage(clientId: $clientId, message: $resultResource); 167 | } 168 | 169 | return $processMessageData; 170 | } 171 | 172 | throw new JsonRpcErrorException("Method not found: {$requestData->method}", JsonRpcErrorCode::METHOD_NOT_FOUND); 173 | } 174 | 175 | /** 176 | * @throws Exception 177 | */ 178 | private function sendSSEMessage(string $clientId, array|JsonRpcResultResource|JsonRpcErrorResource $message): void 179 | { 180 | if ($message instanceof JsonRpcResultResource || $message instanceof JsonRpcErrorResource) { 181 | $this->transport->pushMessage(clientId: $clientId, message: $message->toResponse()); 182 | 183 | return; 184 | } 185 | 186 | $this->transport->pushMessage(clientId: $clientId, message: $message); 187 | } 188 | 189 | /** 190 | * Handles incoming notification messages. 191 | * Finds a matching notification handler and executes it. 192 | * Does not send a response back to the client for notifications. 193 | * 194 | * @param string $clientId The identifier of the client sending the notification. 195 | * @param NotificationData $notificationData The parsed notification data object. 196 | */ 197 | private function processNotification(string $clientId, NotificationData $notificationData): ProcessMessageData 198 | { 199 | // todo:: processNotification currently not implemented 200 | $method = $notificationData->method; 201 | $handler = $this->requestHandlers[$method] ?? null; 202 | if ($handler) { 203 | $result = $handler->execute(method: $notificationData->method, params: $notificationData->params); 204 | $messageType = $handler->getMessageType($notificationData->params); 205 | 206 | $resultResource = new JsonRpcResultResource(id: Str::uuid()->toString(), result: $result); 207 | $processMessageData = new ProcessMessageData(messageType: $messageType, resource: $resultResource); 208 | 209 | if ($processMessageData->isSSEMessage()) { 210 | $this->sendSSEMessage(clientId: $clientId, message: $resultResource); 211 | } 212 | 213 | return $processMessageData; 214 | } 215 | 216 | throw new JsonRpcErrorException("Method not found: {$notificationData->method}", JsonRpcErrorCode::METHOD_NOT_FOUND); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Providers/SseServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(ToolRepository::class, function ($app) { 27 | $toolRepository = new ToolRepository($app); 28 | 29 | $tools = Config::get('mcp-server.tools', []); 30 | $toolRepository->registerMany($tools); 31 | 32 | return $toolRepository; 33 | }); 34 | 35 | $this->app->singleton(ResourceRepository::class, function ($app) { 36 | $repo = new ResourceRepository($app); 37 | $repo->registerResources(Config::get('mcp-server.resources', [])); 38 | $repo->registerResourceTemplates(Config::get('mcp-server.resource_templates', [])); 39 | 40 | return $repo; 41 | }); 42 | 43 | $this->app->singleton(PromptRepository::class, function ($app) { 44 | $repo = new PromptRepository($app); 45 | $repo->registerPrompts(Config::get('mcp-server.prompts', [])); 46 | 47 | return $repo; 48 | }); 49 | 50 | $this->app->singleton(MCPServer::class, function ($app) { 51 | $transport = new SseTransport; 52 | 53 | $adapterType = Config::get('mcp-server.sse_adapter', 'redis'); 54 | $adapterFactory = new SseAdapterFactory(adapterType: $adapterType); 55 | $adapter = $adapterFactory->createAdapter(); 56 | 57 | $transport->setAdapter($adapter); 58 | 59 | $protocol = new MCPProtocol($transport); 60 | 61 | $serverInfo = Config::get('mcp-server.server'); 62 | 63 | $capabilities = new ServerCapabilities; 64 | 65 | $toolRepository = app(ToolRepository::class); 66 | $capabilities->withTools(['schemas' => $toolRepository->getToolSchemas()]); 67 | $resourceRepository = app(ResourceRepository::class); 68 | $capabilities->withResources(['schemas' => [ 69 | 'resources' => $resourceRepository->getResourceSchemas(), 70 | 'resourceTemplates' => $resourceRepository->getTemplateSchemas(), 71 | ]]); 72 | $promptRepository = app(PromptRepository::class); 73 | $capabilities->withPrompts(['schemas' => [ 74 | 'prompts' => $promptRepository->getPromptSchemas(), 75 | ]]); 76 | 77 | return MCPServer::create(protocol: $protocol, name: $serverInfo['name'], version: $serverInfo['version'], capabilities: $capabilities) 78 | ->registerToolRepository(toolRepository: $toolRepository) 79 | ->registerResourceRepository(repository: $resourceRepository) 80 | ->registerPromptRepository(repository: $promptRepository); 81 | }); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Providers/StreamableHttpServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(ToolRepository::class, function ($app) { 28 | $toolRepository = new ToolRepository($app); 29 | 30 | $tools = Config::get('mcp-server.tools', []); 31 | $toolRepository->registerMany($tools); 32 | 33 | return $toolRepository; 34 | }); 35 | 36 | $this->app->singleton(ResourceRepository::class, function ($app) { 37 | $repo = new ResourceRepository($app); 38 | $repo->registerResources(Config::get('mcp-server.resources', [])); 39 | $repo->registerResourceTemplates(Config::get('mcp-server.resource_templates', [])); 40 | 41 | return $repo; 42 | }); 43 | 44 | $this->app->singleton(PromptRepository::class, function ($app) { 45 | $repo = new PromptRepository($app); 46 | $repo->registerPrompts(Config::get('mcp-server.prompts', [])); 47 | 48 | return $repo; 49 | }); 50 | 51 | $this->app->singleton(MCPServer::class, function ($app) { 52 | $transport = new StreamableHttpTransport; 53 | 54 | $protocol = new MCPProtocol($transport); 55 | 56 | $serverInfo = Config::get('mcp-server.server'); 57 | 58 | $capabilities = new ServerCapabilities; 59 | 60 | $toolRepository = app(ToolRepository::class); 61 | $capabilities->withTools(['schemas' => $toolRepository->getToolSchemas()]); 62 | $resourceRepository = app(ResourceRepository::class); 63 | $capabilities->withResources(['schemas' => [ 64 | 'resources' => $resourceRepository->getResourceSchemas(), 65 | 'resourceTemplates' => $resourceRepository->getTemplateSchemas(), 66 | ]]); 67 | $promptRepository = app(PromptRepository::class); 68 | $capabilities->withPrompts(['schemas' => [ 69 | 'prompts' => $promptRepository->getPromptSchemas(), 70 | ]]); 71 | 72 | return MCPServer::create(protocol: $protocol, name: $serverInfo['name'], version: $serverInfo['version'], capabilities: $capabilities) 73 | ->registerToolRepository(toolRepository: $toolRepository) 74 | ->registerResourceRepository(repository: $resourceRepository) 75 | ->registerPromptRepository(repository: $promptRepository); 76 | }); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Server/MCPServer.php: -------------------------------------------------------------------------------- 1 | |null 64 | */ 65 | private ?array $clientCapabilities = null; 66 | 67 | /** 68 | * Creates a new MCPServer instance. 69 | * 70 | * Initializes the server with the communication protocol, server information, 71 | * and capabilities. Registers the mandatory 'initialize' request handler. 72 | * 73 | * @param MCPProtocol $protocol The protocol handler instance (e.g., for JSON-RPC over SSE). 74 | * @param array{name: string, version: string} $serverInfo Associative array containing the server's name and version. 75 | * @param ServerCapabilities|null $capabilities Optional server capabilities configuration. If null, default capabilities are used. 76 | */ 77 | public function __construct(MCPProtocol $protocol, array $serverInfo, ?ServerCapabilities $capabilities = null) 78 | { 79 | $this->protocol = $protocol; 80 | $this->serverInfo = $serverInfo; 81 | $this->capabilities = $capabilities ?? new ServerCapabilities; 82 | 83 | // Register the handler for the mandatory 'initialize' method. 84 | $this->registerRequestHandler(new InitializeHandler($this)); 85 | 86 | // Initialize Default Handlers 87 | $this->registerRequestHandler(new PingHandler); 88 | } 89 | 90 | /** 91 | * Registers a request handler with the protocol layer. 92 | * Request handlers process incoming method calls from the client. 93 | * 94 | * @param RequestHandler $handler The request handler instance to register. 95 | */ 96 | public function registerRequestHandler(RequestHandler $handler): void 97 | { 98 | $this->protocol->registerRequestHandler($handler); 99 | } 100 | 101 | /** 102 | * Static factory method to create a new MCPServer instance with simplified parameters. 103 | * 104 | * @param MCPProtocol $protocol The protocol handler instance. 105 | * @param string $name The server name. 106 | * @param string $version The server version. 107 | * @param ServerCapabilities|null $capabilities Optional server capabilities configuration. 108 | * @return self A new MCPServer instance. 109 | */ 110 | public static function create( 111 | MCPProtocol $protocol, 112 | string $name, 113 | string $version, 114 | ?ServerCapabilities $capabilities = null 115 | ): self { 116 | return new self($protocol, [ 117 | 'name' => $name, 118 | 'version' => $version, 119 | ], $capabilities); 120 | } 121 | 122 | /** 123 | * Registers the necessary request handlers for MCP Tools functionality. 124 | * This typically includes handlers for 'tools/list' and 'tools/call'. 125 | * 126 | * @param ToolRepository $toolRepository The repository containing available tools. 127 | * @return self The current MCPServer instance for method chaining. 128 | */ 129 | public function registerToolRepository(ToolRepository $toolRepository): self 130 | { 131 | $this->registerRequestHandler(new ToolsListHandler($toolRepository)); 132 | $this->registerRequestHandler(new ToolsCallHandler($toolRepository)); 133 | 134 | return $this; 135 | } 136 | 137 | /** 138 | * Registers request handlers required for MCP Resources. 139 | */ 140 | public function registerResourceRepository(ResourceRepository $repository): self 141 | { 142 | $this->registerRequestHandler(new ResourcesListHandler($repository)); 143 | $this->registerRequestHandler(new ResourcesReadHandler($repository)); 144 | $this->registerRequestHandler(new ResourcesTemplatesListHandler($repository)); 145 | 146 | return $this; 147 | } 148 | 149 | /** 150 | * Registers request handlers for MCP Prompts. 151 | */ 152 | public function registerPromptRepository(PromptRepository $repository): self 153 | { 154 | $this->registerRequestHandler(new PromptsListHandler($repository)); 155 | $this->registerRequestHandler(new PromptsGetHandler($repository)); 156 | 157 | return $this; 158 | } 159 | 160 | /** 161 | * Initiates the connection process via the protocol handler. 162 | * Depending on the transport (e.g., SSE), this might start listening for client connections. 163 | */ 164 | public function connect(): void 165 | { 166 | $this->protocol->connect(); 167 | } 168 | 169 | /** 170 | * Initiates the disconnection process via the protocol handler. 171 | */ 172 | public function disconnect(): void 173 | { 174 | $this->protocol->disconnect(); 175 | } 176 | 177 | /** 178 | * Registers a notification handler with the protocol layer. 179 | * Notification handlers process incoming notifications from the client (requests without an ID). 180 | * 181 | * @param NotificationHandler $handler The notification handler instance to register. 182 | */ 183 | public function registerNotificationHandler(NotificationHandler $handler): void 184 | { 185 | $this->protocol->registerNotificationHandler($handler); 186 | } 187 | 188 | /** 189 | * Handles the 'initialize' request from the client. 190 | * Stores client capabilities, checks protocol version, and marks the server as initialized. 191 | * Throws an error if the server is already initialized. 192 | * 193 | * @param InitializeData $data The data object containing initialization parameters from the client. 194 | * @return InitializeResource A resource object containing the server's initialization response. 195 | * 196 | * @throws JsonRpcErrorException If the server has already been initialized (JSON-RPC error code -32600). 197 | */ 198 | public function initialize(InitializeData $data): InitializeResource 199 | { 200 | if ($this->initialized) { 201 | throw new JsonRpcErrorException(message: 'Server already initialized', code: JsonRpcErrorCode::INVALID_REQUEST); 202 | } 203 | 204 | $this->initialized = true; 205 | 206 | $this->clientCapabilities = $data->capabilities; 207 | $protocolVersion = $data->protocolVersion ?? MCPProtocol::PROTOCOL_VERSION; 208 | 209 | $initializeResource = new InitializeResource( 210 | $this->serverInfo['name'], 211 | $this->serverInfo['version'], 212 | $this->capabilities->toArray(), 213 | $protocolVersion 214 | ); 215 | 216 | return $initializeResource; 217 | } 218 | 219 | /** 220 | * Forwards a request message to a specific client via the protocol handler. 221 | * Used for server-initiated requests to the client (if supported by the protocol/transport). 222 | * 223 | * @param string $clientId The identifier of the target client. 224 | * @param array $message The request message payload (following JSON-RPC structure). 225 | */ 226 | public function requestMessage(string $clientId, array $message): ProcessMessageData 227 | { 228 | return $this->protocol->handleMessage(clientId: $clientId, message: $message); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Server/Request/InitializeHandler.php: -------------------------------------------------------------------------------- 1 | server = $server; 23 | } 24 | 25 | /** 26 | * @throws JsonRpcErrorException 27 | */ 28 | public function execute(string $method, ?array $params = null): array 29 | { 30 | $data = InitializeData::fromArray(data: $params); 31 | $result = $this->server->initialize(data: $data); 32 | 33 | return $result->toArray(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Server/Request/PingHandler.php: -------------------------------------------------------------------------------- 1 | repository->render($name, $arguments); 35 | } catch (\InvalidArgumentException $e) { 36 | throw new JsonRpcErrorException(message: $e->getMessage(), code: JsonRpcErrorCode::INVALID_PARAMS); 37 | } 38 | 39 | if ($content === null) { 40 | throw new JsonRpcErrorException(message: 'Prompt not found', code: JsonRpcErrorCode::INVALID_PARAMS); 41 | } 42 | 43 | return $content; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Server/Request/PromptsListHandler.php: -------------------------------------------------------------------------------- 1 | $this->repository->getPromptSchemas(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Server/Request/ResourcesListHandler.php: -------------------------------------------------------------------------------- 1 | $this->repository->getResourceSchemas(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Server/Request/ResourcesReadHandler.php: -------------------------------------------------------------------------------- 1 | repository->readResource($uri); 30 | if ($content === null) { 31 | throw new JsonRpcErrorException(message: 'Resource not found', code: JsonRpcErrorCode::INVALID_PARAMS); 32 | } 33 | 34 | return [ 35 | 'contents' => [$content], 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Server/Request/ResourcesTemplatesListHandler.php: -------------------------------------------------------------------------------- 1 | $this->repository->getTemplateSchemas(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Server/Request/ToolsCallHandler.php: -------------------------------------------------------------------------------- 1 | toolRepository = $toolRepository; 23 | } 24 | 25 | public function getMessageType(?array $params = null): ProcessMessageType 26 | { 27 | $name = $params['name'] ?? null; 28 | if ($name === null) { 29 | throw new JsonRpcErrorException(message: 'Tool name is required', code: JsonRpcErrorCode::INVALID_REQUEST); 30 | } 31 | 32 | $tool = $this->toolRepository->getTool($name); 33 | if (! $tool) { 34 | throw new JsonRpcErrorException(message: "Tool '{$name}' not found", code: JsonRpcErrorCode::METHOD_NOT_FOUND); 35 | } 36 | 37 | // Check for new isStreaming() method first (v1.3.0+) 38 | if (method_exists($tool, 'isStreaming')) { 39 | return $tool->isStreaming() ? ProcessMessageType::SSE : ProcessMessageType::HTTP; 40 | } 41 | 42 | // Fallback to legacy messageType() method for backward compatibility 43 | if (method_exists($tool, 'messageType')) { 44 | return $tool->messageType(); 45 | } 46 | 47 | // Default to HTTP if neither method exists 48 | return ProcessMessageType::HTTP; 49 | } 50 | 51 | public function execute(string $method, ?array $params = null): array 52 | { 53 | $name = $params['name'] ?? null; 54 | if ($name === null) { 55 | throw new JsonRpcErrorException(message: 'Tool name is required', code: JsonRpcErrorCode::INVALID_REQUEST); 56 | } 57 | 58 | $tool = $this->toolRepository->getTool($name); 59 | if (! $tool) { 60 | throw new JsonRpcErrorException(message: "Tool '{$name}' not found", code: JsonRpcErrorCode::METHOD_NOT_FOUND); 61 | } 62 | 63 | $arguments = $params['arguments'] ?? []; 64 | $result = $tool->execute($arguments); 65 | 66 | if ($method === 'tools/call') { 67 | return [ 68 | 'content' => [ 69 | [ 70 | 'type' => 'text', 71 | 'text' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE), 72 | ], 73 | ], 74 | ]; 75 | } else { 76 | return [ 77 | 'result' => $result, 78 | ]; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Server/Request/ToolsListHandler.php: -------------------------------------------------------------------------------- 1 | toolRepository = $toolRepository; 21 | } 22 | 23 | public function execute(string $method, ?array $params = null): array 24 | { 25 | return [ 26 | 'tools' => $this->toolRepository->getToolSchemas(), 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Server/ServerCapabilities.php: -------------------------------------------------------------------------------- 1 | supportsTools = true; 64 | $this->toolsConfig = $config; 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Enables the resources capability for the server. 71 | */ 72 | public function withResources(?array $config = []): self 73 | { 74 | $this->supportsResources = true; 75 | $this->resourcesConfig = $config; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Enables the prompts capability for the server. 82 | */ 83 | public function withPrompts(?array $config = []): self 84 | { 85 | $this->supportsPrompts = true; 86 | $this->promptsConfig = $config; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Converts the server capabilities configuration into an array format suitable for JSON serialization. 93 | * Only includes capabilities that are actively enabled. 94 | * 95 | * @return array An associative array representing the enabled server capabilities. 96 | * For tools, if enabled but no config is set, it defaults to an empty JSON object. 97 | */ 98 | public function toArray(): array 99 | { 100 | $capabilities = []; 101 | 102 | if ($this->supportsTools) { 103 | // Use an empty stdClass to ensure JSON serialization as {} instead of [] for empty arrays. 104 | $capabilities['tools'] = $this->toolsConfig ?? new stdClass; 105 | } 106 | 107 | if ($this->supportsResources) { 108 | $capabilities['resources'] = $this->resourcesConfig ?? new stdClass; 109 | } 110 | 111 | if ($this->supportsPrompts) { 112 | $capabilities['prompts'] = $this->promptsConfig ?? new stdClass; 113 | } 114 | 115 | return $capabilities; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Services/PromptService/Examples/WelcomePrompt.php: -------------------------------------------------------------------------------- 1 | 'username', 16 | 'description' => 'The name of the user to welcome', 17 | 'required' => true, 18 | ], 19 | [ 20 | 'name' => 'role', 21 | 'description' => 'The role of the user (optional)', 22 | 'required' => false, 23 | ], 24 | ]; 25 | 26 | public string $text = 'Welcome, {username}! You are logged in as {role}.'; 27 | } 28 | -------------------------------------------------------------------------------- /src/Services/PromptService/Prompt.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public array $arguments = []; 26 | 27 | /** 28 | * The prompt text. Can include placeholder variables like {name}. 29 | */ 30 | public string $text; 31 | 32 | /** 33 | * Attempt to match the given identifier against this prompt's name 34 | * template. If it matches, extracted variables are returned via the 35 | * provided array reference. 36 | */ 37 | public function matches(string $identifier, array &$variables = []): bool 38 | { 39 | $regex = '/^'.preg_quote($this->name, '/').'$/'; 40 | $regex = str_replace('\\{', '(?P<', $regex); 41 | $regex = str_replace('\\}', '>[^\/]+)', $regex); 42 | 43 | if (preg_match($regex, $identifier, $matches)) { 44 | $variables = array_merge($variables, array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY)); 45 | 46 | return true; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | public function toArray(): array 53 | { 54 | $data = [ 55 | 'name' => $this->name, 56 | ]; 57 | 58 | if ($this->description !== null) { 59 | $data['description'] = $this->description; 60 | } 61 | 62 | if (! empty($this->arguments)) { 63 | $data['arguments'] = $this->arguments; 64 | } 65 | 66 | return $data; 67 | } 68 | 69 | /** 70 | * Render the prompt text using provided arguments. 71 | * 72 | * @param array $arguments 73 | */ 74 | public function render(array $arguments = []): array 75 | { 76 | $this->validateArguments($arguments); 77 | 78 | $rendered = $this->text; 79 | foreach ($arguments as $key => $value) { 80 | $rendered = str_replace('{'.$key.'}', $value, $rendered); 81 | } 82 | 83 | $response = [ 84 | 'messages' => [ 85 | [ 86 | 'role' => 'user', 87 | 'content' => [ 88 | 'type' => 'text', 89 | 'text' => $rendered, 90 | ], 91 | ], 92 | ], 93 | ]; 94 | 95 | if ($this->description !== null) { 96 | $response['description'] = $this->description; 97 | } 98 | 99 | return $response; 100 | } 101 | 102 | /** 103 | * Validate that all required arguments are provided. 104 | * 105 | * @param array $providedArguments 106 | * 107 | * @throws \InvalidArgumentException 108 | */ 109 | protected function validateArguments(array $providedArguments): void 110 | { 111 | foreach ($this->arguments as $argument) { 112 | $argName = $argument['name']; 113 | $isRequired = $argument['required'] ?? false; 114 | 115 | if ($isRequired && (! isset($providedArguments[$argName]) || trim($providedArguments[$argName]) === '')) { 116 | throw new \InvalidArgumentException("Required argument '{$argName}' is missing or empty"); 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Services/PromptService/PromptRepository.php: -------------------------------------------------------------------------------- 1 | */ 11 | protected array $prompts = []; 12 | 13 | protected Container $container; 14 | 15 | public function __construct(?Container $container = null) 16 | { 17 | $this->container = $container ?? Container::getInstance(); 18 | } 19 | 20 | /** 21 | * @param Prompt[] $prompts 22 | */ 23 | public function registerPrompts(array $prompts): self 24 | { 25 | foreach ($prompts as $prompt) { 26 | $this->registerPrompt($prompt); 27 | } 28 | 29 | return $this; 30 | } 31 | 32 | public function registerPrompt(Prompt|string $prompt): self 33 | { 34 | if (is_string($prompt)) { 35 | $prompt = $this->container->make($prompt); 36 | } 37 | 38 | if (! $prompt instanceof Prompt) { 39 | throw new InvalidArgumentException('Prompt must extend '.Prompt::class); 40 | } 41 | 42 | $this->prompts[$prompt->name] = $prompt; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @return array> 49 | */ 50 | public function getPromptSchemas(): array 51 | { 52 | return array_values(array_map(fn (Prompt $p) => $p->toArray(), $this->prompts)); 53 | } 54 | 55 | public function render(string $name, array $arguments = []): ?array 56 | { 57 | if (isset($this->prompts[$name])) { 58 | return $this->prompts[$name]->render($arguments); 59 | } 60 | 61 | foreach ($this->prompts as $prompt) { 62 | if ($prompt->matches($name, $arguments)) { 63 | return $prompt->render($arguments); 64 | } 65 | } 66 | 67 | return null; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Services/ResourceService/Examples/LogFileResource.php: -------------------------------------------------------------------------------- 1 | $this->uri, 21 | 'mimeType' => $this->mimeType, 22 | 'text' => $text, 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Services/ResourceService/Examples/LogFileTemplate.php: -------------------------------------------------------------------------------- 1 | '2024-01-01']) 22 | * @return array Resource content with uri, mimeType, and text 23 | */ 24 | public function read(string $uri, array $params): array 25 | { 26 | $date = $params['date'] ?? 'unknown'; 27 | 28 | // In a real implementation, you would read the actual log file 29 | // For this example, we'll return mock log data 30 | $logContent = "Log entries for {$date}\n"; 31 | $logContent .= "[{$date} 10:00:00] INFO: Application started\n"; 32 | $logContent .= "[{$date} 10:05:00] INFO: Processing requests\n"; 33 | $logContent .= "[{$date} 10:10:00] WARNING: High memory usage detected\n"; 34 | $logContent .= "[{$date} 10:15:00] INFO: Memory usage normalized\n"; 35 | 36 | return [ 37 | 'uri' => $uri, 38 | 'mimeType' => $this->mimeType, 39 | 'text' => $logContent, 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Services/ResourceService/Examples/UserListResource.php: -------------------------------------------------------------------------------- 1 | get(); 36 | // 37 | // For this example, we'll return mock data: 38 | $users = [ 39 | ['id' => 1, 'name' => 'User 1', 'email' => 'user1@example.com'], 40 | ['id' => 2, 'name' => 'User 2', 'email' => 'user2@example.com'], 41 | ['id' => 3, 'name' => 'User 3', 'email' => 'user3@example.com'], 42 | ]; 43 | 44 | $response = [ 45 | 'total' => count($users), 46 | 'users' => $users, 47 | 'template' => 'database://users/{id}', 48 | 'description' => 'Use the template URI to get individual user details', 49 | ]; 50 | 51 | return [ 52 | 'uri' => $this->uri, 53 | 'mimeType' => $this->mimeType, 54 | 'text' => json_encode($response, JSON_PRETTY_PRINT), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Services/ResourceService/Examples/UserResourceTemplate.php: -------------------------------------------------------------------------------- 1 | '123'] as parameters 15 | */ 16 | class UserResourceTemplate extends ResourceTemplate 17 | { 18 | public string $uriTemplate = 'database://users/{id}'; 19 | 20 | public string $name = 'User by ID'; 21 | 22 | public ?string $description = 'Access individual user details by user ID'; 23 | 24 | public ?string $mimeType = 'application/json'; 25 | 26 | /** 27 | * List all available user resources. 28 | * 29 | * This method returns a list of concrete user resources that can be accessed 30 | * through this template. In a real implementation, you would query your database 31 | * to get all available users. 32 | * 33 | * @return array Array of user resource definitions 34 | */ 35 | public function list(): ?array 36 | { 37 | // In a real implementation, you would query your database: 38 | // $users = User::select(['id', 'name'])->get(); 39 | // 40 | // For this example, we'll return mock data: 41 | $users = [ 42 | ['id' => 1, 'name' => 'Alice'], 43 | ['id' => 2, 'name' => 'Bob'], 44 | ['id' => 3, 'name' => 'Charlie'], 45 | ]; 46 | 47 | $resources = []; 48 | foreach ($users as $user) { 49 | $resources[] = [ 50 | 'uri' => "database://users/{$user['id']}", 51 | 'name' => "User: {$user['name']}", 52 | 'description' => "Profile data for user {$user['name']} (ID: {$user['id']})", 53 | 'mimeType' => $this->mimeType, 54 | ]; 55 | } 56 | 57 | return $resources; 58 | } 59 | 60 | /** 61 | * Read user data for the specified user ID. 62 | * 63 | * In a real implementation, this would: 64 | * 1. Extract the user ID from the parameters 65 | * 2. Query the database to fetch user details 66 | * 3. Return the user data as JSON 67 | * 68 | * @param string $uri The full URI being requested (e.g., "database://users/123") 69 | * @param array $params Extracted parameters (e.g., ['id' => '123']) 70 | * @return array Resource content with uri, mimeType, and text/blob 71 | */ 72 | public function read(string $uri, array $params): array 73 | { 74 | $userId = $params['id'] ?? null; 75 | 76 | if ($userId === null) { 77 | return [ 78 | 'uri' => $uri, 79 | 'mimeType' => 'application/json', 80 | 'text' => json_encode(['error' => 'Missing user ID'], JSON_PRETTY_PRINT), 81 | ]; 82 | } 83 | 84 | // In a real implementation, you would query your database here: 85 | // $user = User::find($userId); 86 | // 87 | // For this example, we'll return mock data: 88 | $userData = [ 89 | 'id' => (int) $userId, 90 | 'name' => "User {$userId}", 91 | 'email' => "user{$userId}@example.com", 92 | 'created_at' => '2024-01-01T00:00:00Z', 93 | 'profile' => [ 94 | 'bio' => "This is the bio for user {$userId}", 95 | 'location' => 'Example City', 96 | ], 97 | ]; 98 | 99 | return [ 100 | 'uri' => $uri, 101 | 'mimeType' => $this->mimeType, 102 | 'text' => json_encode($userData, JSON_PRETTY_PRINT), 103 | ]; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Services/ResourceService/Resource.php: -------------------------------------------------------------------------------- 1 | $this->uri, 43 | 'name' => $this->name, 44 | 'description' => $this->description, 45 | 'mimeType' => $this->mimeType, 46 | 'size' => $this->size, 47 | ], static fn ($v) => $v !== null); 48 | } 49 | 50 | /** 51 | * Read the content of the resource. Implementations should return an 52 | * associative array containing the URI, optional mimeType and one of 53 | * 'text' (for UTF-8 text) or 'blob' (for base64 encoded binary data). 54 | */ 55 | abstract public function read(): array; 56 | } 57 | -------------------------------------------------------------------------------- /src/Services/ResourceService/ResourceRepository.php: -------------------------------------------------------------------------------- 1 | */ 11 | protected array $resources = []; 12 | 13 | /** @var ResourceTemplate[] */ 14 | protected array $templates = []; 15 | 16 | protected Container $container; 17 | 18 | public function __construct(?Container $container = null) 19 | { 20 | $this->container = $container ?? Container::getInstance(); 21 | } 22 | 23 | /** 24 | * @param resource[] $resources 25 | */ 26 | public function registerResources(array $resources): self 27 | { 28 | foreach ($resources as $resource) { 29 | $this->registerResource($resource); 30 | } 31 | 32 | return $this; 33 | } 34 | 35 | public function registerResource(Resource|string $resource): self 36 | { 37 | if (is_string($resource)) { 38 | $resource = $this->container->make($resource); 39 | } 40 | 41 | if (! $resource instanceof Resource) { 42 | throw new InvalidArgumentException('Resource must extend '.Resource::class); 43 | } 44 | 45 | $this->resources[$resource->uri] = $resource; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * @param ResourceTemplate[] $templates 52 | */ 53 | public function registerResourceTemplates(array $templates): self 54 | { 55 | foreach ($templates as $template) { 56 | $this->registerResourceTemplate($template); 57 | } 58 | 59 | return $this; 60 | } 61 | 62 | public function registerResourceTemplate(ResourceTemplate|string $template): self 63 | { 64 | if (is_string($template)) { 65 | $template = $this->container->make($template); 66 | } 67 | 68 | if (! $template instanceof ResourceTemplate) { 69 | throw new InvalidArgumentException('Template must extend '.ResourceTemplate::class); 70 | } 71 | 72 | $this->templates[] = $template; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Get all available resources including static resources and template-generated resources. 79 | * 80 | * @return array> 81 | */ 82 | public function getResourceSchemas(): array 83 | { 84 | $staticResources = array_values(array_map(fn (Resource $r) => $r->toArray(), $this->resources)); 85 | 86 | $templateResources = []; 87 | foreach ($this->templates as $template) { 88 | $listedResources = $template->list(); 89 | if ($listedResources !== null) { 90 | foreach ($listedResources as $resource) { 91 | $templateResources[] = $resource; 92 | } 93 | } 94 | } 95 | 96 | return array_merge($staticResources, $templateResources); 97 | } 98 | 99 | /** 100 | * @return array> 101 | */ 102 | public function getTemplateSchemas(): array 103 | { 104 | return array_values(array_map(fn (ResourceTemplate $t) => $t->toArray(), $this->templates)); 105 | } 106 | 107 | /** 108 | * Read resource content by URI. 109 | * 110 | * This method first attempts to find a static resource with an exact URI match. 111 | * If no static resource is found, it tries to match the URI against registered 112 | * resource templates and returns the dynamically generated content. 113 | * 114 | * @param string $uri The resource URI to read (e.g., "database://users/123") 115 | * @return array|null Resource content array with 'uri', 'mimeType', and 'text'/'blob', or null if not found 116 | */ 117 | public function readResource(string $uri): ?array 118 | { 119 | // First, try to find a static resource with exact URI match 120 | $resource = $this->resources[$uri] ?? null; 121 | if ($resource !== null) { 122 | return $resource->read(); 123 | } 124 | 125 | // If no static resource found, try to match against templates 126 | foreach ($this->templates as $template) { 127 | $params = $template->matchUri($uri); 128 | if ($params !== null) { 129 | return $template->read($uri, $params); 130 | } 131 | } 132 | 133 | return null; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Services/ResourceService/ResourceTemplate.php: -------------------------------------------------------------------------------- 1 | $this->uriTemplate, 36 | 'name' => $this->name, 37 | 'description' => $this->description, 38 | 'mimeType' => $this->mimeType, 39 | ], static fn ($v) => $v !== null); 40 | } 41 | 42 | /** 43 | * Check if this template matches the given URI and extract parameters. 44 | * 45 | * @param string $uri The URI to match against this template 46 | * @return array|null Array of parameters if match, null if no match 47 | */ 48 | public function matchUri(string $uri): ?array 49 | { 50 | return UriTemplateUtil::matchUri($this->uriTemplate, $uri); 51 | } 52 | 53 | /** 54 | * List all resources that match this template pattern. 55 | * 56 | * This method is called when clients request the resources/list endpoint. 57 | * If implemented, it should return an array of resource definitions that 58 | * can be generated from this template. 59 | * 60 | * @return array|null Array of resources matching this template, or null if listing is not supported 61 | */ 62 | public function list(): ?array 63 | { 64 | return null; // Default: no listing supported 65 | } 66 | 67 | /** 68 | * Read the content of a resource that matches this template. 69 | * 70 | * This method is called when a client requests to read a resource 71 | * whose URI matches this template. The implementation should generate 72 | * and return the resource content based on the extracted parameters. 73 | * 74 | * @param string $uri The full URI being requested 75 | * @param array $params Parameters extracted from the URI template 76 | * @return array Resource content array with 'uri', 'mimeType', and either 'text' or 'blob' 77 | */ 78 | abstract public function read(string $uri, array $params): array; 79 | } 80 | -------------------------------------------------------------------------------- /src/Services/SseAdapterFactory.php: -------------------------------------------------------------------------------- 1 | adapterType = $adapterType; 37 | } 38 | 39 | /** 40 | * Create and initialize the SSE adapter. 41 | * 42 | * @return SseAdapterInterface The created and initialized SSE adapter instance. 43 | * 44 | * @throws Exception If the adapter type is not supported or initialization fails. 45 | */ 46 | public function createAdapter(): SseAdapterInterface 47 | { 48 | if ($this->adapter === null) { 49 | $this->initializeAdapter(); 50 | } 51 | 52 | return $this->adapter; 53 | } 54 | 55 | /** 56 | * Initialize the adapter based on the configured type. 57 | * 58 | * @throws Exception If the adapter type is not supported or initialization fails. 59 | */ 60 | private function initializeAdapter(): void 61 | { 62 | $adapterConfig = Config::get('mcp-server.adapters.'.$this->adapterType, []); 63 | 64 | switch ($this->adapterType) { 65 | case 'redis': 66 | $this->adapter = new RedisAdapter; 67 | break; 68 | default: 69 | throw new Exception('Unsupported SSE adapter type: '.$this->adapterType); 70 | } 71 | 72 | $this->adapter->initialize($adapterConfig); 73 | } 74 | 75 | /** 76 | * Get the created adapter instance. 77 | * 78 | * @return SseAdapterInterface|null The created adapter instance, or null if not created yet. 79 | */ 80 | public function getAdapter(): ?SseAdapterInterface 81 | { 82 | return $this->adapter; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Services/ToolService/Examples/HelloWorldTool.php: -------------------------------------------------------------------------------- 1 | 'object', 31 | 'properties' => [ 32 | 'name' => [ 33 | 'type' => 'string', 34 | 'description' => 'Developer Name', 35 | ], 36 | ], 37 | 'required' => ['name'], 38 | ]; 39 | } 40 | 41 | public function annotations(): array 42 | { 43 | return []; 44 | } 45 | 46 | public function execute(array $arguments): array 47 | { 48 | $validator = Validator::make($arguments, [ 49 | 'name' => ['required', 'string'], 50 | ]); 51 | if ($validator->fails()) { 52 | throw new JsonRpcErrorException(message: $validator->errors()->toJson(), code: JsonRpcErrorCode::INVALID_REQUEST); 53 | } 54 | 55 | $name = $arguments['name'] ?? 'MCP'; 56 | 57 | return [ 58 | 'name' => $name, 59 | 'message' => "Hello, HelloWorld `{$name}` developer.", 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Services/ToolService/Examples/VersionCheckTool.php: -------------------------------------------------------------------------------- 1 | 'object', 30 | 'properties' => new stdClass, 31 | 'required' => [], 32 | ]; 33 | } 34 | 35 | public function annotations(): array 36 | { 37 | return []; 38 | } 39 | 40 | public function execute(array $arguments): string 41 | { 42 | $now = now()->format('Y-m-d H:i:s'); 43 | $version = App::version(); 44 | 45 | return "current Version: {$version} - {$now}"; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Services/ToolService/ToolInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $tools = []; 23 | 24 | /** 25 | * The Laravel service container instance. 26 | */ 27 | protected Container $container; 28 | 29 | /** 30 | * Constructor. 31 | * 32 | * @param Container|null $container The Laravel service container instance. If null, it resolves from the facade. 33 | */ 34 | public function __construct(?Container $container = null) 35 | { 36 | $this->container = $container ?? Container::getInstance(); 37 | } 38 | 39 | /** 40 | * Registers multiple tools at once. 41 | * 42 | * @param array $tools An array of tool class strings or ToolInterface instances. 43 | * @return $this The current ToolRepository instance for method chaining. 44 | * 45 | * @throws InvalidArgumentException If a tool does not implement ToolInterface. 46 | */ 47 | public function registerMany(array $tools): self 48 | { 49 | foreach ($tools as $tool) { 50 | $this->register($tool); 51 | } 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * Registers a single tool. 58 | * If a class string is provided, it resolves the tool from the container. 59 | * 60 | * @param string|ToolInterface $tool The tool class string or a ToolInterface instance. 61 | * @return $this The current ToolRepository instance for method chaining. 62 | * 63 | * @throws InvalidArgumentException If the provided $tool is not a string or ToolInterface, or if the resolved object does not implement ToolInterface. 64 | */ 65 | public function register(string|ToolInterface $tool): self 66 | { 67 | if (is_string($tool)) { 68 | $tool = $this->container->make($tool); 69 | } 70 | 71 | if (! $tool instanceof ToolInterface) { 72 | throw new InvalidArgumentException('Tool must implement the '.ToolInterface::class); 73 | } 74 | 75 | $this->tools[$tool->name()] = $tool; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Retrieves all registered tools. 82 | * 83 | * @return array An array of registered tool instances, keyed by their name. 84 | */ 85 | public function getTools(): array 86 | { 87 | return $this->tools; 88 | } 89 | 90 | /** 91 | * Retrieves a specific tool by its name. 92 | * 93 | * @param string $name The name of the tool to retrieve. 94 | * @return ToolInterface|null The tool instance if found, otherwise null. 95 | */ 96 | public function getTool(string $name): ?ToolInterface 97 | { 98 | return $this->tools[$name] ?? null; 99 | } 100 | 101 | /** 102 | * Generates an array of schemas for all registered tools, suitable for the MCP capabilities response. 103 | * Includes name, description, inputSchema, and optional annotations for each tool. 104 | * 105 | * @return array, annotations?: array}> An array of tool schemas. 106 | */ 107 | public function getToolSchemas(): array 108 | { 109 | $schemas = []; 110 | foreach ($this->tools as $tool) { 111 | $injectArray = []; 112 | if (empty($tool->inputSchema())) { 113 | // inputSchema cannot be empty, set a default value. 114 | $injectArray['inputSchema'] = [ 115 | 'type' => 'object', 116 | 'properties' => new stdClass, 117 | 'required' => [], 118 | ]; 119 | } 120 | if (! empty($tool->annotations())) { 121 | $injectArray['annotations'] = $tool->annotations(); 122 | } 123 | 124 | $schemas[] = [ 125 | 'name' => $tool->name(), 126 | 'description' => $tool->description(), 127 | 'inputSchema' => $tool->inputSchema(), 128 | ...$injectArray, 129 | ]; 130 | } 131 | 132 | return $schemas; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Transports/SseAdapters/RedisAdapter.php: -------------------------------------------------------------------------------- 1 | redis = Redis::connection($connection); 46 | 47 | if (isset($config['prefix'])) { 48 | $this->keyPrefix = $config['prefix']; 49 | } 50 | 51 | if (isset($config['ttl'])) { 52 | $this->messageTtl = (int) $config['ttl']; 53 | } 54 | } catch (Exception $e) { 55 | Log::error('Failed to initialize Redis SSE Adapter: '.$e->getMessage()); 56 | throw new Exception('Failed to initialize Redis SSE Adapter: '.$e->getMessage()); 57 | } 58 | } 59 | 60 | /** 61 | * Add a message to the queue for a specific client 62 | * 63 | * @param string $clientId The unique identifier for the client 64 | * @param string $message The message to be queued 65 | * 66 | * @throws Exception If the message cannot be added to the queue 67 | */ 68 | public function pushMessage(string $clientId, string $message): void 69 | { 70 | try { 71 | $key = $this->getQueueKey($clientId); 72 | 73 | $this->redis->rpush($key, $message); 74 | 75 | $this->redis->expire($key, $this->messageTtl); 76 | 77 | } catch (Exception $e) { 78 | Log::error('Failed to add message to Redis queue: '.$e->getMessage()); 79 | throw new Exception('Failed to add message to Redis queue: '.$e->getMessage()); 80 | } 81 | } 82 | 83 | /** 84 | * Get the Redis key for a client's message queue 85 | * 86 | * @param string $clientId The client ID 87 | * @return string The Redis key 88 | */ 89 | protected function getQueueKey(string $clientId): string 90 | { 91 | $appName = config('app.name'); 92 | 93 | return "{$appName}_{$this->keyPrefix}:client:{$clientId}"; 94 | } 95 | 96 | /** 97 | * Remove all messages for a specific client 98 | * 99 | * @param string $clientId The unique identifier for the client 100 | * 101 | * @throws Exception If the messages cannot be removed 102 | */ 103 | public function removeAllMessages(string $clientId): void 104 | { 105 | try { 106 | $key = $this->getQueueKey($clientId); 107 | 108 | $this->redis->del($key); 109 | 110 | } catch (Exception $e) { 111 | Log::error('Failed to remove messages from Redis queue: '.$e->getMessage()); 112 | throw new Exception('Failed to remove messages from Redis queue: '.$e->getMessage()); 113 | } 114 | } 115 | 116 | /** 117 | * Receive and remove all messages for a specific client 118 | * 119 | * @param string $clientId The unique identifier for the client 120 | * @return array Array of messages 121 | * 122 | * @throws Exception If the messages cannot be retrieved 123 | */ 124 | public function receiveMessages(string $clientId): array 125 | { 126 | try { 127 | $key = $this->getQueueKey($clientId); 128 | $messages = []; 129 | 130 | while (($message = $this->redis->lpop($key)) !== null && $message !== false) { 131 | $messages[] = $message; 132 | } 133 | 134 | return $messages; 135 | } catch (Exception $e) { 136 | throw new Exception('Failed to receive messages from Redis queue: '.$e->getMessage()); 137 | } 138 | } 139 | 140 | /** 141 | * Pop the oldest message from the queue for a specific client 142 | * 143 | * @param string $clientId The unique identifier for the client 144 | * @return string|null The message or null if the queue is empty 145 | * 146 | * @throws Exception If the message cannot be popped 147 | */ 148 | public function popMessage(string $clientId): ?string 149 | { 150 | try { 151 | $key = $this->getQueueKey($clientId); 152 | 153 | $message = $this->redis->lpop($key); 154 | 155 | if ($message === null || $message === false) { 156 | return null; 157 | } 158 | 159 | return $message; 160 | } catch (Exception $e) { 161 | Log::error('Failed to pop message from Redis queue: '.$e->getMessage()); 162 | throw new Exception('Failed to pop message from Redis queue: '.$e->getMessage()); 163 | } 164 | } 165 | 166 | /** 167 | * Check if there are any messages in the queue for a specific client 168 | * 169 | * @param string $clientId The unique identifier for the client 170 | * @return bool True if there are messages, false otherwise 171 | */ 172 | public function hasMessages(string $clientId): bool 173 | { 174 | try { 175 | $key = $this->getQueueKey($clientId); 176 | 177 | $count = $this->redis->llen($key); 178 | 179 | return $count > 0; 180 | } catch (Exception $e) { 181 | Log::error('Failed to check for messages in Redis queue: '.$e->getMessage()); 182 | 183 | return false; 184 | } 185 | } 186 | 187 | /** 188 | * Get the number of messages in the queue for a specific client 189 | * 190 | * @param string $clientId The unique identifier for the client 191 | * @return int The number of messages 192 | */ 193 | public function getMessageCount(string $clientId): int 194 | { 195 | try { 196 | $key = $this->getQueueKey($clientId); 197 | 198 | $count = $this->redis->llen($key); 199 | 200 | return (int) $count; 201 | } catch (Exception $e) { 202 | Log::error('Failed to get message count from Redis queue: '.$e->getMessage()); 203 | 204 | return 0; 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Transports/SseAdapters/SseAdapterInterface.php: -------------------------------------------------------------------------------- 1 | Array of messages 40 | * 41 | * @throws \Exception If the messages cannot be retrieved 42 | */ 43 | public function receiveMessages(string $clientId): array; 44 | 45 | /** 46 | * Pop the oldest message from the queue for a specific client 47 | * 48 | * @param string $clientId The unique identifier for the client 49 | * @return string|null The message or null if the queue is empty 50 | * 51 | * @throws \Exception If the message cannot be popped 52 | */ 53 | public function popMessage(string $clientId): ?string; 54 | 55 | /** 56 | * Check if there are any messages in the queue for a specific client 57 | * 58 | * @param string $clientId The unique identifier for the client 59 | * @return bool True if there are messages, false otherwise 60 | */ 61 | public function hasMessages(string $clientId): bool; 62 | 63 | /** 64 | * Get the number of messages in the queue for a specific client 65 | * 66 | * @param string $clientId The unique identifier for the client 67 | * @return int The number of messages 68 | */ 69 | public function getMessageCount(string $clientId): int; 70 | 71 | /** 72 | * Initialize the adapter with any required configuration 73 | * 74 | * @param array $config Configuration options for the adapter 75 | * 76 | * @throws \Exception If initialization fails 77 | */ 78 | public function initialize(array $config): void; 79 | } 80 | -------------------------------------------------------------------------------- /src/Transports/SseTransport.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | protected array $closeHandlers = []; 34 | 35 | /** 36 | * Callbacks executed on transport errors, typically via `triggerError()`. 37 | * 38 | * @var array 39 | */ 40 | protected array $errorHandlers = []; 41 | 42 | /** 43 | * Callbacks executed via `processMessage()` for adapter-mediated messages. 44 | * 45 | * @var array 46 | */ 47 | protected array $messageHandlers = []; 48 | 49 | /** 50 | * Optional adapter for message persistence and retrieval (e.g., Redis). 51 | * Enables simulation of request/response patterns over SSE. 52 | */ 53 | protected ?SseAdapterInterface $adapter = null; 54 | 55 | /** 56 | * Unique identifier for the client connection, generated during initialization. 57 | */ 58 | protected ?string $clientId = null; 59 | 60 | /** 61 | * Starts the SSE transport connection. 62 | * Sets the connected flag and initializes the transport. Idempotent. 63 | * 64 | * @throws Exception If initialization fails. 65 | */ 66 | public function start(): void 67 | { 68 | if ($this->connected) { 69 | return; 70 | } 71 | 72 | $this->connected = true; 73 | $this->initialize(); 74 | } 75 | 76 | /** 77 | * Initializes the transport: generates client ID and sends the initial 'endpoint' event. 78 | * Adapter-specific initialization might occur here or externally. 79 | * 80 | * @throws Exception If sending the initial event fails. 81 | */ 82 | public function initialize(): void 83 | { 84 | if ($this->clientId === null) { 85 | $this->clientId = Str::uuid()->toString(); 86 | } 87 | 88 | $this->sendEvent(event: 'endpoint', data: StringUtil::makeEndpoint(sessionId: $this->clientId)); 89 | } 90 | 91 | /** 92 | * Sends a formatted SSE event to the client and flushes output buffers. 93 | * 94 | * @param string $event The event name. 95 | * @param string $data The event data payload. 96 | */ 97 | private function sendEvent(string $event, string $data): void 98 | { 99 | // 헤더 설정이 이미 전송되었는지 확인 100 | if (! headers_sent()) { 101 | // 버퍼링 비활성화 102 | ini_set('output_buffering', 'off'); 103 | ini_set('zlib.output_compression', false); 104 | 105 | // 필수 SSE 헤더 추가 106 | header('Content-Type: text/event-stream'); 107 | header('Cache-Control: no-cache'); 108 | header('X-Accel-Buffering: no'); 109 | header('Connection: keep-alive'); 110 | } 111 | 112 | // 모든 버퍼 비우기 113 | if (! extension_loaded('swoole')) { 114 | // Limpa qualquer buffer de saída se não estiver usando Swoole 115 | while (ob_get_level() > 0) { 116 | ob_end_flush(); 117 | } 118 | } 119 | 120 | echo sprintf('event: %s', $event).PHP_EOL; 121 | echo sprintf('data: %s', $data).PHP_EOL; 122 | echo PHP_EOL; 123 | 124 | flush(); 125 | } 126 | 127 | /** 128 | * Sends a message payload as a 'message' type SSE event. 129 | * Encodes array messages to JSON. 130 | * 131 | * @param string|array $message The message content. 132 | * 133 | * @throws Exception If JSON encoding fails or sending the event fails. 134 | */ 135 | public function send(string|array $message): void 136 | { 137 | if (is_array($message)) { 138 | $message = json_encode($message); 139 | } 140 | 141 | $this->sendEvent(event: 'message', data: $message); 142 | } 143 | 144 | /** 145 | * Closes the connection, notifies handlers, cleans up adapter resources, and attempts a final 'close' event. 146 | * Idempotent. Errors during cleanup/final event are logged. 147 | * 148 | * @throws Exception From handlers if they throw exceptions. 149 | */ 150 | public function close(): void 151 | { 152 | if (! $this->connected) { 153 | return; 154 | } 155 | 156 | $this->connected = false; 157 | 158 | foreach ($this->closeHandlers as $handler) { 159 | try { 160 | call_user_func($handler); 161 | } catch (Exception $e) { 162 | Log::error('Error in SSE close handler: '.$e->getMessage()); 163 | } 164 | } 165 | 166 | if ($this->adapter !== null && $this->clientId !== null) { 167 | try { 168 | $this->adapter->removeAllMessages($this->clientId); 169 | } catch (Exception $e) { 170 | Log::error('Error cleaning up SSE adapter resources on close: '.$e->getMessage()); 171 | } 172 | } 173 | 174 | try { 175 | $this->sendEvent(event: 'close', data: '{"reason":"server_closed"}'); 176 | } catch (Exception $e) { 177 | Log::info('Could not send final SSE close event: '.$e->getMessage()); 178 | } 179 | } 180 | 181 | /** 182 | * Registers a callback to execute when `close()` is called. 183 | * 184 | * @param callable $handler The callback (takes no arguments). 185 | */ 186 | public function onClose(callable $handler): void 187 | { 188 | $this->closeHandlers[] = $handler; 189 | } 190 | 191 | /** 192 | * Registers a callback to execute on transport errors triggered by `triggerError()`. 193 | * 194 | * @param callable $handler The callback (receives string error message). 195 | */ 196 | public function onError(callable $handler): void 197 | { 198 | $this->errorHandlers[] = $handler; 199 | } 200 | 201 | /** 202 | * Checks if the client connection is still active using `connection_aborted()`. 203 | * 204 | * @return bool True if connected, false if aborted. 205 | */ 206 | public function isConnected(): bool 207 | { 208 | return connection_aborted() === 0; 209 | } 210 | 211 | /** 212 | * Receives messages for this client via the configured adapter. 213 | * Returns an empty array if no adapter, no messages, or on error. 214 | * Triggers error handlers on adapter failure. 215 | * 216 | * @return array An array of message payloads. 217 | */ 218 | public function receive(): array 219 | { 220 | if ($this->adapter !== null && $this->clientId !== null && $this->connected) { 221 | try { 222 | $messages = $this->adapter->receiveMessages($this->clientId); 223 | 224 | return $messages ?: []; 225 | } catch (Exception $e) { 226 | $this->triggerError('SSE Failed to receive messages via adapter: '.$e->getMessage()); 227 | } 228 | } elseif ($this->adapter === null) { 229 | Log::info('SSE Transport::receive called but no adapter is configured.'); 230 | } 231 | 232 | return []; 233 | } 234 | 235 | /** 236 | * Logs an error and invokes all registered error handlers. 237 | * Catches exceptions within error handlers themselves. 238 | * 239 | * @param string $message The error message. 240 | */ 241 | protected function triggerError(string $message): void 242 | { 243 | Log::error('SSE Transport error: '.$message); 244 | 245 | foreach ($this->errorHandlers as $handler) { 246 | try { 247 | call_user_func($handler, $message); 248 | } catch (Exception $e) { 249 | Log::error('Error in SSE error handler itself: '.$e->getMessage()); 250 | } 251 | } 252 | } 253 | 254 | /** 255 | * Sets the adapter instance used for message persistence/retrieval. 256 | * 257 | * @param SseAdapterInterface $adapter The adapter implementation. 258 | */ 259 | public function setAdapter(SseAdapterInterface $adapter): void 260 | { 261 | $this->adapter = $adapter; 262 | } 263 | 264 | /** 265 | * Pushes a message to the adapter for later retrieval by the target client. 266 | * Encodes the message to JSON before pushing. 267 | * 268 | * @param string $clientId The target client ID. 269 | * @param array $message The message payload (as an array). 270 | * 271 | * @throws Exception If adapter is not set, JSON encoding fails, or adapter push fails. 272 | */ 273 | public function pushMessage(string $clientId, array $message): void 274 | { 275 | if ($this->adapter === null) { 276 | throw new Exception('Cannot push message: SSE Adapter is not configured.'); 277 | } 278 | 279 | $messageString = json_encode($message, JSON_UNESCAPED_UNICODE); 280 | if ($messageString === false) { 281 | throw new Exception('Failed to JSON encode message for pushing: '.json_last_error_msg()); 282 | } 283 | 284 | $this->adapter->pushMessage(clientId: $clientId, message: $messageString); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/Transports/StreamableHttpTransport.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected array $closeHandlers = []; 29 | 30 | /** 31 | * Callbacks executed on transport errors, typically via `triggerError()`. 32 | * 33 | * @var array 34 | */ 35 | protected array $errorHandlers = []; 36 | 37 | /** 38 | * Unique identifier for the client connection, generated during initialization. 39 | */ 40 | protected ?string $clientId = null; 41 | 42 | /** 43 | * Starts the StreamableHttp transport connection. 44 | * Sets the connected flag and initializes the transport. Idempotent. 45 | * 46 | * @throws Exception If initialization fails. 47 | */ 48 | public function start(): void 49 | { 50 | if ($this->connected) { 51 | return; 52 | } 53 | 54 | $this->connected = true; 55 | $this->initialize(); 56 | } 57 | 58 | /** 59 | * Initializes the transport: generates client ID and sends the initial 'endpoint' event. 60 | * Adapter-specific initialization might occur here or externally. 61 | * 62 | * @throws Exception If sending the initial event fails. 63 | */ 64 | public function initialize(): void {} 65 | 66 | /** 67 | * Sends a message payload as a 'message' type StreamableHttp event. 68 | * Encodes array messages to JSON. 69 | * 70 | * @param string|array $message The message content. 71 | * 72 | * @throws Exception If JSON encoding fails or sending the event fails. 73 | */ 74 | public function send(string|array $message): void {} 75 | 76 | /** 77 | * Closes the connection, notifies handlers, cleans up adapter resources, and attempts a final 'close' event. 78 | * Idempotent. Errors during cleanup/final event are logged. 79 | * 80 | * @throws Exception From handlers if they throw exceptions. 81 | */ 82 | public function close(): void 83 | { 84 | if (! $this->connected) { 85 | return; 86 | } 87 | 88 | $this->connected = false; 89 | } 90 | 91 | /** 92 | * Registers a callback to execute when `close()` is called. 93 | * 94 | * @param callable $handler The callback (takes no arguments). 95 | */ 96 | public function onClose(callable $handler): void 97 | { 98 | $this->closeHandlers[] = $handler; 99 | } 100 | 101 | /** 102 | * Registers a callback to execute on transport errors triggered by `triggerError()`. 103 | * 104 | * @param callable $handler The callback (receives string error message). 105 | */ 106 | public function onError(callable $handler): void 107 | { 108 | $this->errorHandlers[] = $handler; 109 | } 110 | 111 | /** 112 | * Checks if the client connection is still active using `connection_aborted()`. 113 | * 114 | * @return bool True if connected, false if aborted. 115 | */ 116 | public function isConnected(): bool 117 | { 118 | return connection_aborted() === 0; 119 | } 120 | 121 | /** 122 | * Receives messages for this client via the configured adapter. 123 | * Returns an empty array if no adapter, no messages, or on error. 124 | * Triggers error handlers on adapter failure. 125 | * 126 | * @return array An array of message payloads. 127 | */ 128 | public function receive(): array 129 | { 130 | return []; 131 | } 132 | 133 | /** 134 | * Pushes a message to the adapter for later retrieval by the target client. 135 | * Encodes the message to JSON before pushing. 136 | * 137 | * @param string $clientId The target client ID. 138 | * @param array $message The message payload (as an array). 139 | * 140 | * @throws Exception If adapter is not set, JSON encoding fails, or adapter push fails. 141 | */ 142 | public function pushMessage(string $clientId, array $message): void {} 143 | } 144 | -------------------------------------------------------------------------------- /src/Transports/TransportInterface.php: -------------------------------------------------------------------------------- 1 | $value) { 83 | $uri = str_replace('{'.$name.'}', (string) $value, $uri); 84 | } 85 | 86 | return $uri; 87 | } 88 | 89 | /** 90 | * Validate that a URI template is well-formed. 91 | * 92 | * @param string $template URI template 93 | * @return bool True if valid, false otherwise 94 | */ 95 | public static function isValidTemplate(string $template): bool 96 | { 97 | // Check for balanced braces 98 | $openBraces = substr_count($template, '{'); 99 | $closeBraces = substr_count($template, '}'); 100 | 101 | if ($openBraces !== $closeBraces) { 102 | return false; 103 | } 104 | 105 | // Check for valid parameter syntax 106 | return preg_match('/\{[^{}]+\}/', $template) || $openBraces === 0; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/stubs/prompt.stub: -------------------------------------------------------------------------------- 1 | 'system', 'content' => 'You are a helpful assistant...'], 83 | * ['role' => 'user', 'content' => $this->text], 84 | * ]; 85 | * 86 | * Conditional Content: 87 | * Use PHP logic to customize prompts based on arguments: 88 | * $content = "Base prompt text"; 89 | * if ($arguments['advanced_mode']) { 90 | * $content .= " Include technical details and advanced options."; 91 | * } 92 | * 93 | * Resource References: 94 | * Include references to your resources in prompts: 95 | * "Analyze the data from file:///logs/{date}.log and provide insights." 96 | * 97 | * Tool Integration: 98 | * Guide LLMs on which tools to use: 99 | * "Use the search-products tool to find items matching {criteria}." 100 | * 101 | * @see https://modelcontextprotocol.io/docs/concepts/prompts 102 | */ 103 | class {{ className }} extends Prompt 104 | { 105 | /** 106 | * Unique identifier for this prompt. 107 | * Used by clients to request this specific prompt. 108 | * 109 | * NAMING BEST PRACTICES: 110 | * - Use descriptive, action-oriented names 111 | * - Include context or domain when relevant 112 | * - Examples: 'analyze-sales-data', 'debug-api-error', 'generate-user-report' 113 | * - Use kebab-case naming convention 114 | */ 115 | public string $name = '{{ name }}'; 116 | 117 | /** 118 | * Optional description shown in prompt listings. 119 | * Help users understand what this prompt is designed for. 120 | */ 121 | public ?string $description = 'A template for [describe the specific use case]'; 122 | 123 | /** 124 | * Define the arguments this prompt accepts. 125 | * Each argument can be required or optional. 126 | */ 127 | public array $arguments = [ 128 | [ 129 | 'name' => 'example_arg', 130 | 'description' => 'The main parameter for this prompt', 131 | 'required' => true, 132 | ], 133 | [ 134 | 'name' => 'optional_arg', 135 | 'description' => 'An optional parameter that enhances the prompt', 136 | 'required' => false, 137 | ], 138 | ]; 139 | 140 | /** 141 | * The prompt template text. 142 | * 143 | * Best practices: 144 | * - Be clear and specific about what you want the LLM to do 145 | * - Use {placeholders} for all dynamic values 146 | * - Include context about available tools or resources if relevant 147 | * - Structure complex prompts with clear sections 148 | */ 149 | public string $text = <<<'PROMPT' 150 | You are assisting with {example_arg}. 151 | 152 | {optional_arg} 153 | 154 | Please follow these guidelines: 155 | 1. [First instruction] 156 | 2. [Second instruction] 157 | 3. [Third instruction] 158 | 159 | Available tools for this task: 160 | - tool-name: Description of what it does 161 | - another-tool: Description of its purpose 162 | 163 | Begin by analyzing the requirements and proceed step by step. 164 | PROMPT; 165 | } 166 | -------------------------------------------------------------------------------- /src/stubs/resource.stub: -------------------------------------------------------------------------------- 1 | $this->uri, 137 | // 'mimeType' => $this->mimeType, 138 | // 'text' => $content, 139 | // ]; 140 | 141 | // Example 2: Reading from Laravel Storage 142 | // $content = Storage::disk('local')->get('file.txt'); 143 | // return [ 144 | // 'uri' => $this->uri, 145 | // 'mimeType' => $this->mimeType, 146 | // 'text' => $content, 147 | // ]; 148 | 149 | // Example 3: Reading binary data 150 | // $binaryData = File::get('/path/to/image.png'); 151 | // return [ 152 | // 'uri' => $this->uri, 153 | // 'mimeType' => 'image/png', 154 | // 'blob' => base64_encode($binaryData), 155 | // ]; 156 | 157 | // Example 4: Dynamic content generation 158 | // $data = [ 159 | // 'timestamp' => now()->toIso8601String(), 160 | // 'status' => 'active', 161 | // 'metrics' => $this->gatherMetrics(), 162 | // ]; 163 | // return [ 164 | // 'uri' => $this->uri, 165 | // 'mimeType' => 'application/json', 166 | // 'text' => json_encode($data, JSON_PRETTY_PRINT), 167 | // ]; 168 | 169 | // TODO: Replace this example with your actual resource reading logic 170 | 171 | // === IMPLEMENTATION EXAMPLES === 172 | // Choose the pattern that best fits your resource: 173 | 174 | // --- File System Resource --- 175 | // $filePath = storage_path('app/data/example.txt'); 176 | // if (!File::exists($filePath)) { 177 | // throw new \Exception("File not found: {$this->uri}"); 178 | // } 179 | // $content = File::get($filePath); 180 | // return [ 181 | // 'uri' => $this->uri, 182 | // 'mimeType' => $this->mimeType, 183 | // 'text' => $content, 184 | // ]; 185 | 186 | // --- Database Resource --- 187 | // $data = collect([ 188 | // 'users_count' => User::count(), 189 | // 'active_sessions' => Session::where('last_activity', '>', now()->subHour())->count(), 190 | // 'recent_orders' => Order::where('created_at', '>', now()->subDay())->count(), 191 | // 'generated_at' => now()->toISOString(), 192 | // ]); 193 | // return [ 194 | // 'uri' => $this->uri, 195 | // 'mimeType' => 'application/json', 196 | // 'text' => $data->toJson(JSON_PRETTY_PRINT), 197 | // ]; 198 | 199 | // --- External API Resource --- 200 | // $response = Http::timeout(10) 201 | // ->withHeaders(['Authorization' => 'Bearer ' . config('services.api.token')]) 202 | // ->get('https://api.example.com/data'); 203 | // if (!$response->successful()) { 204 | // throw new \Exception("API request failed: {$response->status()}"); 205 | // } 206 | // return [ 207 | // 'uri' => $this->uri, 208 | // 'mimeType' => 'application/json', 209 | // 'text' => $response->body(), 210 | // ]; 211 | 212 | // --- Configuration Resource --- 213 | // $config = [ 214 | // 'app_name' => config('app.name'), 215 | // 'environment' => config('app.env'), 216 | // 'debug_mode' => config('app.debug'), 217 | // 'timezone' => config('app.timezone'), 218 | // 'features' => [ 219 | // 'mcp_enabled' => config('mcp-server.enabled'), 220 | // 'tools_count' => count(config('mcp-server.tools')), 221 | // ], 222 | // ]; 223 | // return [ 224 | // 'uri' => $this->uri, 225 | // 'mimeType' => 'application/json', 226 | // 'text' => json_encode($config, JSON_PRETTY_PRINT), 227 | // ]; 228 | 229 | // --- Log File Resource --- 230 | // $logPath = storage_path('logs/laravel.log'); 231 | // if (!File::exists($logPath)) { 232 | // return [ 233 | // 'uri' => $this->uri, 234 | // 'mimeType' => 'text/plain', 235 | // 'text' => 'No log file found.', 236 | // ]; 237 | // } 238 | // $content = File::get($logPath); 239 | // return [ 240 | // 'uri' => $this->uri, 241 | // 'mimeType' => 'text/plain', 242 | // 'text' => $content, 243 | // ]; 244 | 245 | // Default example implementation 246 | $exampleData = [ 247 | 'resource_name' => '{{ className }}', 248 | 'generated_at' => now()->toISOString(), 249 | 'sample_data' => [ 250 | 'message' => 'This is example content from {{ className }}', 251 | 'instructions' => 'Replace this with your actual resource data', 252 | 'suggestions' => [ 253 | 'What data should this resource provide?', 254 | 'How often does it change?', 255 | 'What format is most useful for consumers?', 256 | 'Are there any access restrictions?', 257 | 'Should it include metadata or just raw data?', 258 | ], 259 | ], 260 | 'implementation_tips' => [ 261 | 'Use try-catch for error handling', 262 | 'Consider caching for expensive operations', 263 | 'Validate access permissions if needed', 264 | 'Include helpful metadata in responses', 265 | 'Test with different scenarios and edge cases', 266 | ], 267 | ]; 268 | 269 | return [ 270 | 'uri' => $this->uri, 271 | 'mimeType' => 'application/json', 272 | 'text' => json_encode($exampleData, JSON_PRETTY_PRINT), 273 | ]; 274 | 275 | } catch (\Exception $e) { 276 | // Handle errors appropriately 277 | // You might want to log the error and return a user-friendly message 278 | throw new \RuntimeException( 279 | "Failed to read resource {$this->uri}: " . $e->getMessage() 280 | ); 281 | } 282 | } 283 | 284 | /** 285 | * Optional: Override to calculate size dynamically. 286 | */ 287 | // public function getSize(): ?int 288 | // { 289 | // // Example: Get file size 290 | // // return File::size('/path/to/file.txt'); 291 | // return null; 292 | // } 293 | } 294 | -------------------------------------------------------------------------------- /src/stubs/resource_template.stub: -------------------------------------------------------------------------------- 1 | 'file:///example/users/user123.json', 142 | // 'name' => 'User 123', 143 | // 'description' => 'User data for user123', 144 | // 'mimeType' => $this->mimeType, 145 | // ], 146 | // [ 147 | // 'uri' => 'file:///example/products/prod-456.json', 148 | // 'name' => 'Product 456', 149 | // 'description' => 'Product data for prod-456', 150 | // 'mimeType' => $this->mimeType, 151 | // ], 152 | // ]; 153 | 154 | // Option 3: Dynamic list from database with caching (RECOMMENDED) 155 | // return Cache::remember('mcp.resources.example.list', 300, function () { 156 | // $items = YourModel::select(['id', 'name'])->get(); 157 | // return $items->map(function ($item) { 158 | // return [ 159 | // 'uri' => "file:///example/items/{$item->id}.json", 160 | // 'name' => "Item: {$item->name}", 161 | // 'description' => "Data for {$item->name}", 162 | // 'mimeType' => $this->mimeType, 163 | // ]; 164 | // })->toArray(); 165 | // }); 166 | 167 | return null; // Default: no listing support 168 | } 169 | 170 | /** 171 | * Read the content of a resource that matches this template. 172 | * 173 | * This method is called when a client requests to read a resource 174 | * whose URI matches this template pattern. Extract the parameters 175 | * from the URI and generate the appropriate content. 176 | * 177 | * @param string $uri The full URI being requested 178 | * @param array $params Parameters extracted from the URI template 179 | * @return array Resource content array with 'uri', 'mimeType', and either 'text' or 'blob' 180 | */ 181 | public function read(string $uri, array $params): array 182 | { 183 | // Extract parameters from the template variables 184 | $category = $params['category'] ?? 'unknown'; 185 | $id = $params['id'] ?? 'unknown'; 186 | 187 | // TODO: Implement your resource reading logic here 188 | // This is where you would: 189 | // 1. Validate the parameters 190 | // 2. Fetch data from your database/API/filesystem 191 | // 3. Format the response appropriately 192 | 193 | // Example implementation: 194 | $data = [ 195 | 'category' => $category, 196 | 'id' => $id, 197 | 'timestamp' => now()->toISOString(), 198 | 'message' => "This is example data for {$category}/{$id}", 199 | // TODO: Replace with real data 200 | ]; 201 | 202 | return [ 203 | 'uri' => $uri, 204 | 'mimeType' => $this->mimeType, 205 | 'text' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), 206 | ]; 207 | } 208 | 209 | /** 210 | * Optional: Additional template examples for complex patterns 211 | */ 212 | // More template examples: 213 | // 214 | // Date-based: 'file:///logs/{year}/{month}/{day}.log' 215 | // With query: 'api://search/products{?name,category,minPrice,maxPrice}' 216 | // Optional path: 'file:///data{/type}{/subtype}/latest.json' 217 | // Multiple formats: 'api://export/{dataset}.{format}' where format = json|csv|xml 218 | } 219 | --------------------------------------------------------------------------------