diff --git a/README.md b/README.md index 85ec5d4..50353ad 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ Mcp::tool([CalculatorService::class, 'add']) Mcp::tool(EmailService::class) ->description('Send emails to users'); +// Register a closure as a tool with custom input schema +Mcp::tool(function(float $x, float $y): float { + return $x * $y; +}) + ->name('multiply') + ->description('Multiply two numbers') + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'number', 'description' => 'First number'], + 'y' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['x', 'y'], + ]); + // Register a resource with metadata Mcp::resource('config://app/settings', [UserService::class, 'getAppSettings']) ->name('app_settings') @@ -102,16 +117,47 @@ Mcp::resource('config://app/settings', [UserService::class, 'getAppSettings']) ->mimeType('application/json') ->size(1024); +// Register a closure as a resource +Mcp::resource('system://time', function(): string { + return now()->toISOString(); +}) + ->name('current_time') + ->description('Get current server time') + ->mimeType('text/plain'); + // Register a resource template for dynamic content Mcp::resourceTemplate('user://{userId}/profile', [UserService::class, 'getUserProfile']) ->name('user_profile') ->description('Get user profile by ID') ->mimeType('application/json'); +// Register a closure as a resource template +Mcp::resourceTemplate('file://{path}', function(string $path): string { + if (!file_exists($path) || !is_readable($path)) { + throw new \InvalidArgumentException("File not found or not readable: {$path}"); + } + return file_get_contents($path); +}) + ->name('file_reader') + ->description('Read file contents by path') + ->mimeType('text/plain'); + // Register a prompt generator Mcp::prompt([PromptService::class, 'generateWelcome']) ->name('welcome_user') ->description('Generate a personalized welcome message'); + +// Register a closure as a prompt +Mcp::prompt(function(string $topic, string $tone = 'professional'): array { + return [ + [ + 'role' => 'user', + 'content' => "Write a {$tone} summary about {$topic}. Make it informative and engaging." + ] + ]; +}) + ->name('topic_summary') + ->description('Generate topic summary prompts'); ``` **Available Fluent Methods:** @@ -120,14 +166,23 @@ Mcp::prompt([PromptService::class, 'generateWelcome']) - `name(string $name)`: Override the inferred name - `description(string $description)`: Set a custom description +**For Tools:** +- `annotations(ToolAnnotations $annotations)`: Add MCP tool annotations +- `inputSchema(array $schema)`: Define custom JSON schema for parameters + **For Resources:** - `mimeType(string $mimeType)`: Specify content type - `size(int $size)`: Set content size in bytes -- `annotations(array|Annotations $annotations)`: Add MCP annotations +- `annotations(Annotations $annotations)`: Add MCP annotations + +**For Resource Templates:** +- `mimeType(string $mimeType)`: Specify content type +- `annotations(Annotations $annotations)`: Add MCP annotations **Handler Formats:** - `[ClassName::class, 'methodName']` - Class method - `InvokableClass::class` - Invokable class with `__invoke()` method +- `function(...) { ... }` - Callables (v3.2+) ### 2. Attribute-Based Discovery @@ -717,6 +772,69 @@ Create a dedicated log channel in `config/logging.php`: ## Migration Guide +### From v3.0 to v3.1 + +**New Handler Types:** + +Laravel MCP v3.1 introduces support for closure handlers, expanding beyond just class methods and invokable classes: + +```php +// v3.0 and earlier - Class-based handlers only +Mcp::tool([CalculatorService::class, 'add']) + ->name('add_numbers'); + +Mcp::tool(EmailService::class) // Invokable class + ->name('send_email'); + +// v3.1+ - Now supports closures +Mcp::tool(function(float $x, float $y): float { + return $x * $y; +}) + ->name('multiply') + ->description('Multiply two numbers'); + +Mcp::resource('system://time', function(): string { + return now()->toISOString(); +}) + ->name('current_time'); +``` + +**Input Schema Support:** + +Tools can now define custom JSON schemas for parameter validation: + +```php +// v3.1+ - Custom input schema +Mcp::tool([CalculatorService::class, 'calculate']) + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'operation' => [ + 'type' => 'string', + 'enum' => ['add', 'subtract', 'multiply', 'divide'] + ], + 'numbers' => [ + 'type' => 'array', + 'items' => ['type' => 'number'], + 'minItems' => 2 + ] + ], + 'required' => ['operation', 'numbers'] + ]); +``` + +**Enhanced Blueprint Methods:** + +New fluent methods available on blueprints: + +```php +->inputSchema(array $schema) // Define custom parameter schema +``` + +**No Breaking Changes:** + +All existing v3.0 code continues to work without modification. The new features are additive enhancements. + ### From v2.x to v3.x **Configuration Changes:** diff --git a/composer.json b/composer.json index 0dbf8fd..ef52894 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": "^8.1", "laravel/framework": "^9.46 || ^10.34 || ^11.29 || ^12.0", - "php-mcp/server": "^3.1" + "php-mcp/server": "^3.2" }, "require-dev": { "laravel/pint": "^1.13", diff --git a/src/Blueprints/PromptBlueprint.php b/src/Blueprints/PromptBlueprint.php index d097a09..06b91ee 100644 --- a/src/Blueprints/PromptBlueprint.php +++ b/src/Blueprints/PromptBlueprint.php @@ -8,8 +8,11 @@ class PromptBlueprint { public ?string $description = null; + /** + * @param string|array|callable $handler + */ public function __construct( - public array|string $handler, + public mixed $handler, public ?string $name = null ) {} diff --git a/src/Blueprints/ResourceBlueprint.php b/src/Blueprints/ResourceBlueprint.php index 3501310..0e98b9e 100644 --- a/src/Blueprints/ResourceBlueprint.php +++ b/src/Blueprints/ResourceBlueprint.php @@ -4,6 +4,7 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\Annotations; class ResourceBlueprint @@ -18,9 +19,12 @@ class ResourceBlueprint public ?Annotations $annotations = null; + /** + * @param string|array|callable $handler + */ public function __construct( public string $uri, - public array|string $handler, + public mixed $handler, ) {} public function name(string $name): static diff --git a/src/Blueprints/ResourceTemplateBlueprint.php b/src/Blueprints/ResourceTemplateBlueprint.php index 14c53dd..6a50c39 100644 --- a/src/Blueprints/ResourceTemplateBlueprint.php +++ b/src/Blueprints/ResourceTemplateBlueprint.php @@ -4,6 +4,7 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\Annotations; class ResourceTemplateBlueprint @@ -16,9 +17,12 @@ class ResourceTemplateBlueprint public ?Annotations $annotations = null; + /** + * @param string|array|callable $handler + */ public function __construct( public string $uriTemplate, - public array|string $handler, + public mixed $handler, ) {} public function name(string $name): static diff --git a/src/Blueprints/ToolBlueprint.php b/src/Blueprints/ToolBlueprint.php index 4d7b26d..56b8223 100644 --- a/src/Blueprints/ToolBlueprint.php +++ b/src/Blueprints/ToolBlueprint.php @@ -4,15 +4,20 @@ namespace PhpMcp\Laravel\Blueprints; +use Closure; use PhpMcp\Schema\ToolAnnotations; class ToolBlueprint { public ?string $description = null; public ?ToolAnnotations $annotations = null; + public ?array $inputSchema = null; + /** + * @param string|array|callable $handler + */ public function __construct( - public array|string $handler, + public mixed $handler, public ?string $name = null ) {} @@ -36,4 +41,11 @@ public function annotations(ToolAnnotations $annotations): static return $this; } + + public function inputSchema(array $inputSchema): static + { + $this->inputSchema = $inputSchema; + + return $this; + } } diff --git a/src/Facades/Mcp.php b/src/Facades/Mcp.php index cd571ed..ee8e509 100644 --- a/src/Facades/Mcp.php +++ b/src/Facades/Mcp.php @@ -11,10 +11,10 @@ use PhpMcp\Laravel\Blueprints\ToolBlueprint; /** - * @method static ToolBlueprint tool(string|array $handlerOrName, array|string|null $handler = null) - * @method static ResourceBlueprint resource(string $uri, array|string $handler) - * @method static ResourceTemplateBlueprint resourceTemplate(string $uriTemplate, array|string $handler) - * @method static PromptBlueprint prompt(string|array $handlerOrName, array|string|null $handler = null) + * @method static ToolBlueprint tool(string|callable|array $handlerOrName, callable|array|string|null $handler = null) + * @method static ResourceBlueprint resource(string $uri, callable|array|string $handler) + * @method static ResourceTemplateBlueprint resourceTemplate(string $uriTemplate, callable|array|string $handler) + * @method static PromptBlueprint prompt(string|callable|array $handlerOrName, callable|array|string|null $handler = null) * * @see \PhpMcp\Laravel\McpRegistrar */ diff --git a/src/McpRegistrar.php b/src/McpRegistrar.php index 0935e0f..7fff3fa 100644 --- a/src/McpRegistrar.php +++ b/src/McpRegistrar.php @@ -34,14 +34,14 @@ public function __construct() {} * Mcp::tool('tool_name', $handler) * Mcp::tool($handler) // Name will be inferred */ - public function tool(string|array ...$args): ToolBlueprint + public function tool(string|callable|array ...$args): ToolBlueprint { $name = null; $handler = null; - if (count($args) === 1 && (is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { + if (count($args) === 1 && (is_callable($args[0]) || is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { $handler = $args[0]; - } elseif (count($args) === 2 && is_string($args[0]) && (is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { + } elseif (count($args) === 2 && is_string($args[0]) && (is_callable($args[1]) || is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { $name = $args[0]; $handler = $args[1]; } else { @@ -57,7 +57,7 @@ public function tool(string|array ...$args): ToolBlueprint /** * Register a new resource. */ - public function resource(string $uri, array|string $handler): ResourceBlueprint + public function resource(string $uri, callable|array|string $handler): ResourceBlueprint { $pendingResource = new ResourceBlueprint($uri, $handler); $this->pendingResources[] = $pendingResource; @@ -68,7 +68,7 @@ public function resource(string $uri, array|string $handler): ResourceBlueprint /** * Register a new resource template. */ - public function resourceTemplate(string $uriTemplate, array|string $handler): ResourceTemplateBlueprint + public function resourceTemplate(string $uriTemplate, callable|array|string $handler): ResourceTemplateBlueprint { $pendingResourceTemplate = new ResourceTemplateBlueprint($uriTemplate, $handler); $this->pendingResourceTemplates[] = $pendingResourceTemplate; @@ -83,14 +83,14 @@ public function resourceTemplate(string $uriTemplate, array|string $handler): Re * Mcp::prompt('prompt_name', $handler) * Mcp::prompt($handler) // Name will be inferred */ - public function prompt(string|array ...$args): PromptBlueprint + public function prompt(string|callable|array ...$args): PromptBlueprint { $name = null; $handler = null; - if (count($args) === 1 && (is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { + if (count($args) === 1 && (is_callable($args[0]) || is_array($args[0]) || (is_string($args[0]) && (class_exists($args[0]) || is_callable($args[0]))))) { $handler = $args[0]; - } elseif (count($args) === 2 && is_string($args[0]) && (is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { + } elseif (count($args) === 2 && is_string($args[0]) && (is_callable($args[1]) || is_array($args[1]) || (is_string($args[1]) && (class_exists($args[1]) || is_callable($args[1]))))) { $name = $args[0]; $handler = $args[1]; } else { @@ -106,7 +106,7 @@ public function prompt(string|array ...$args): PromptBlueprint public function applyBlueprints(ServerBuilder $builder): void { foreach ($this->pendingTools as $pendingTool) { - $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description, $pendingTool->annotations); + $builder->withTool($pendingTool->handler, $pendingTool->name, $pendingTool->description, $pendingTool->annotations, $pendingTool->inputSchema); } foreach ($this->pendingResources as $pendingResource) { diff --git a/tests/Feature/ManualRegistrationTest.php b/tests/Feature/ManualRegistrationTest.php index 72bb15d..7f569bb 100644 --- a/tests/Feature/ManualRegistrationTest.php +++ b/tests/Feature/ManualRegistrationTest.php @@ -32,6 +32,7 @@ public function test_can_manually_register_a_tool() $this->assertEquals('manual_test_tool', $tool->schema->name); $this->assertEquals('A manually registered test tool.', $tool->schema->description); $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); + $this->assertTrue($tool->isManual); $this->assertArrayHasKey('input', $tool->schema->inputSchema['properties']); $this->assertEquals('string', $tool->schema->inputSchema['properties']['input']['type']); } @@ -52,6 +53,7 @@ public function test_can_manually_register_tool_using_handler_only() $this->assertNotNull($tool); $this->assertEquals([ManualTestHandler::class, 'handleTool'], $tool->handler); + $this->assertTrue($tool->isManual); $this->assertEquals('A sample tool handler.', $tool->schema->description); } @@ -81,6 +83,7 @@ public function test_can_manually_register_a_resource() $this->assertEquals(1024, $resource->schema->size); $this->assertEquals(['priority' => 0.8], $resource->schema->annotations->toArray()); $this->assertEquals([ManualTestHandler::class, 'handleResource'], $resource->handler); + $this->assertTrue($resource->isManual); } public function test_can_manually_register_a_prompt_with_invokable_class_handler() @@ -102,6 +105,7 @@ public function test_can_manually_register_a_prompt_with_invokable_class_handler $this->assertEquals('manual_invokable_prompt', $prompt->schema->name); $this->assertEquals('A prompt handled by an invokable class.', $prompt->schema->description); $this->assertEquals(ManualTestInvokableHandler::class, $prompt->handler); + $this->assertTrue($prompt->isManual); } public function test_can_manually_register_a_resource_template_via_facade() @@ -127,5 +131,103 @@ public function test_can_manually_register_a_resource_template_via_facade() $this->assertEquals('A sample resource template handler.', $template->schema->description); $this->assertEquals('application/vnd.api+json', $template->schema->mimeType); $this->assertEquals([ManualTestHandler::class, 'handleTemplate'], $template->handler); + $this->assertTrue($template->isManual); + } + + public function test_can_manually_register_closure_handlers_and_custom_input_schema() + { + $definitionsContent = <<<'PHP' + name('multiply') + ->description('Multiply two numbers') + ->inputSchema([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'number', 'description' => 'First number'], + 'y' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['x', 'y'], + ]); + + // Test closure resource + Mcp::resource('system://time', function(): string { + return now()->toISOString(); + }) + ->name('current_time') + ->description('Get current server time') + ->mimeType('text/plain'); + + // Test closure resource template + Mcp::resourceTemplate('calculation://{operation}', function(string $operation): string { + return "Result of {$operation}"; + }) + ->name('calculator') + ->description('Perform calculations') + ->mimeType('text/plain'); + + // Test closure prompt + Mcp::prompt(function(string $topic): array { + return [ + [ + 'role' => 'user', + 'content' => "Write about {$topic}", + ] + ]; + }) + ->name('write_about') + ->description('Generate writing prompts'); + PHP; + $this->setMcpDefinitions($definitionsContent); + + $registry = $this->app->make('mcp.registry'); + + // Test closure tool + $tool = $registry->getTool('multiply'); + $this->assertInstanceOf(RegisteredTool::class, $tool); + $this->assertEquals('multiply', $tool->schema->name); + $this->assertEquals('Multiply two numbers', $tool->schema->description); + $this->assertInstanceOf(\Closure::class, $tool->handler); + $this->assertTrue($tool->isManual); + + // Test custom input schema + $schema = $tool->schema->inputSchema; + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('x', $schema['properties']); + $this->assertArrayHasKey('y', $schema['properties']); + $this->assertEquals('number', $schema['properties']['x']['type']); + $this->assertEquals('number', $schema['properties']['y']['type']); + $this->assertEquals(['x', 'y'], $schema['required']); + + // Test closure resource + $resource = $registry->getResource('system://time'); + $this->assertInstanceOf(RegisteredResource::class, $resource); + $this->assertEquals('current_time', $resource->schema->name); + $this->assertEquals('Get current server time', $resource->schema->description); + $this->assertEquals('text/plain', $resource->schema->mimeType); + $this->assertInstanceOf(\Closure::class, $resource->handler); + $this->assertTrue($resource->isManual); + + // Test closure resource template + $template = $registry->getResource('calculation://add'); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $template); + $this->assertEquals('calculator', $template->schema->name); + $this->assertEquals('Perform calculations', $template->schema->description); + $this->assertEquals('text/plain', $template->schema->mimeType); + $this->assertInstanceOf(\Closure::class, $template->handler); + $this->assertTrue($template->isManual); + + // Test closure prompt + $prompt = $registry->getPrompt('write_about'); + $this->assertInstanceOf(RegisteredPrompt::class, $prompt); + $this->assertEquals('write_about', $prompt->schema->name); + $this->assertEquals('Generate writing prompts', $prompt->schema->description); + $this->assertInstanceOf(\Closure::class, $prompt->handler); + $this->assertTrue($prompt->isManual); } }