Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/core-concepts/tools-function-calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,7 @@ class SearchTool extends Tool
$this
->as('search')
->for('useful when you need to search for current events')
->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.')
->using($this);
->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.');
}

public function __invoke(string $query): string
Expand Down
8 changes: 5 additions & 3 deletions src/Concerns/CallsTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@ protected function executeToolsWithConcurrency(array $tools, array $groupedToolC
* @param Tool[] $tools
* @return array{toolResult: ToolResult, events: array<int, ToolResultEvent|ArtifactEvent>}
*/
protected function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
protected static function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
{
$events = [];

try {
$tool = $this->resolveTool($toolCall->name, $tools);
$tool = self::resolveTool($toolCall->name, $tools);
$output = call_user_func_array(
$tool->handle(...),
$toolCall->arguments()
Expand Down Expand Up @@ -224,8 +224,10 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes

/**
* @param Tool[] $tools
*
* @throws PrismException
*/
protected function resolveTool(string $name, array $tools): Tool
protected static function resolveTool(string $name, array $tools): Tool
{
try {
return collect($tools)
Expand Down
39 changes: 37 additions & 2 deletions src/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class Tool
/** @var array <int, string> */
protected array $requiredParameters = [];

/** @var Closure():mixed|callable():mixed */
/** @var Closure():mixed|callable():mixed|null */
protected $fn;

/** @var null|false|Closure(Throwable,array<int|string,mixed>):string */
Expand Down Expand Up @@ -68,6 +68,10 @@ public function for(string $description): self

public function using(Closure|callable $fn): self
{
if ($fn === $this) {
return $this;
}

$this->fn = $fn;

return $this;
Expand Down Expand Up @@ -262,7 +266,9 @@ public function failedHandler(): null|false|Closure
public function handle(...$args): string|ToolOutput|ToolError
{
try {
$value = call_user_func($this->fn, ...$args);
$callable = $this->resolveHandler();

$value = call_user_func($callable, ...$args);

if (is_string($value)) {
return $value;
Expand All @@ -281,6 +287,35 @@ public function handle(...$args): string|ToolOutput|ToolError
}
}

/**
* Resolve the callable handler for this tool.
*
* Priority: explicit $fn > invokable subclass (__invoke) > error.
* Also unwraps SerializableClosure wrappers that break named arguments.
*/
protected function resolveHandler(): callable
{
$fn = $this->fn;

if ($fn === null && method_exists($this, '__invoke')) {
$fn = $this;
}

if ($fn === null) {
throw new PrismException("Tool handler not defined for tool: {$this->name}");
}

// After ProcessDriver deserialization, $fn may become a
// SerializableClosure\Serializers\Native whose __invoke doesn't
// forward PHP 8 named arguments. Unwrap via getClosure() to
// recover the real Closure so named-arg spreading works.
if (is_object($fn) && method_exists($fn, 'getClosure')) {
return $fn->getClosure();
}

return $fn;
}

protected function shouldHandleErrors(): bool
{
return $this->failedHandler !== false;
Expand Down
3 changes: 1 addition & 2 deletions src/Tools/LaravelMcpTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ class LaravelMcpTool extends Tool
public function __construct(private readonly \Laravel\Mcp\Server\Tool $tool)
{
$this->as($tool->name())
->for($tool->description())
->using($this);
->for($tool->description());

$data = $tool->toArray();
$properties = $data['inputSchema']['properties'] ?? [];
Expand Down
41 changes: 41 additions & 0 deletions tests/ToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,47 @@ public function __invoke(string $query): string
->toBe('The event is at 3pm eastern');
});

it('handles invokable subclass with using($this) without circular reference', function (): void {
$tool = new class extends Tool
{
public function __construct()
{
parent::__construct();
$this->as('test_tool')
->for('A test tool')
->withParameter(new StringSchema('query', 'the query'))
->using($this);
}

public function __invoke(string $query): string
{
return "Result: $query";
}
};

expect($tool->handle(query: 'hello'))->toBe('Result: hello');
});

it('invokable subclass works without calling using() at all', function (): void {
$tool = new class extends Tool
{
public function __construct()
{
parent::__construct();
$this->as('auto_tool')
->for('Auto-detected invokable')
->withParameter(new StringSchema('input', 'the input'));
}

public function __invoke(string $input): string
{
return "Auto: $input";
}
};

expect($tool->handle(input: 'test'))->toBe('Auto: test');
});

it('can have fluent parameters', function (): void {
$tool = (new Tool)
->as('test tool')
Expand Down
Loading