Skip to content

Commit 3d14eeb

Browse files
committed
Added command to debug tool calls
1 parent d5f7a22 commit 3d14eeb

File tree

5 files changed

+372
-21
lines changed

5 files changed

+372
-21
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ This can happen when your "php" binary is not in the PATH. This can be solved in
281281
- Use the full path of the PHP binary. You can get it by running `which php` in your terminal.
282282
* This can be a good option to ensure you always use the proper version of PHP for a given project. If you use Herd, for example, your `php` will change depending on the selected version.
283283
284+
**Manually call tools and verify output**
285+
286+
Sometimes when building tools, you may be getting unexpected results and debugging from the MCP client can be difficult. You can manually call tools and verify the output by running the following command:
287+
288+
```bash
289+
php artisan loop:mcp:call
290+
```
291+
284292
**Make sure to check your application logs**
285293
286294
If you are getting an unkown error, check your application logs for more details.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Kirschbaum\Loop\Commands\Concerns;
4+
5+
use Exception;
6+
use Illuminate\Support\Facades\Auth;
7+
8+
trait AuthenticateUsers
9+
{
10+
protected function authenticateUser(): void
11+
{
12+
/** @var string|null */
13+
$authGuard = $this->option('auth-guard') ?? config('auth.defaults.guard');
14+
$userModel = $this->option('user-model') ?? 'App\\Models\\User';
15+
$user = $userModel::find($this->option('user-id'));
16+
17+
if (! $user) {
18+
throw new Exception(sprintf('User with ID %s not found. Model used: %s', $this->option('user-id'), $userModel));
19+
}
20+
21+
Auth::guard($authGuard)->login($user);
22+
23+
if ($this->option('debug')) {
24+
$this->info(sprintf('Authenticated with user ID %s', $this->option('user-id')));
25+
}
26+
}
27+
}

src/Commands/LoopMcpCallCommand.php

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kirschbaum\Loop\Commands;
6+
7+
use Exception;
8+
use Illuminate\Console\Command;
9+
use Kirschbaum\Loop\Commands\Concerns\AuthenticateUsers;
10+
use Kirschbaum\Loop\Loop;
11+
use Kirschbaum\Loop\McpHandler;
12+
use Prism\Prism\Tool;
13+
14+
class LoopMcpCallCommand extends Command
15+
{
16+
use AuthenticateUsers;
17+
18+
protected $signature = 'loop:mcp:call
19+
{--tool= : The name of the tool to call (optional, if not provided, you will be prompted to select a tool)}
20+
{--parameters=* : Array of key=value parameters for the tool (optional, if not provided, you will be prompted to enter the parameters)}
21+
{--user-id= : The user ID to authenticate the requests with}
22+
{--user-model= : The model to use to authenticate the requests with}
23+
{--auth-guard= : The Auth guard to use to authenticate the requests with}
24+
{--debug : Enable debug mode}';
25+
26+
protected $description = 'Call an MCP tool interactively. Great for testing and debugging.';
27+
28+
protected McpHandler $mcpHandler;
29+
30+
protected bool $printedToolsList = false;
31+
32+
protected string $lastToolName = '';
33+
34+
/** @var array<string, mixed> */
35+
protected $lastToolParameters = [];
36+
37+
public function handle(McpHandler $mcpHandler): int
38+
{
39+
$this->mcpHandler = $mcpHandler;
40+
41+
if ($this->option('user-id')) {
42+
$this->authenticateUser();
43+
} else {
44+
$this->promptForAuthentication();
45+
}
46+
47+
do {
48+
$toolName = $this->getToolName();
49+
50+
if (! $toolName) {
51+
$this->error('No tool selected. Exiting...');
52+
53+
return Command::FAILURE;
54+
}
55+
56+
if ($toolName === '_repeat_last_tool_call') {
57+
$toolName = $this->lastToolName;
58+
$parameters = $this->lastToolParameters;
59+
} else {
60+
$parameters = $this->getToolParameters($toolName);
61+
}
62+
63+
$this->comment(sprintf('----- Calling tool: %s -----', now()->format('Y-m-d H:i:s')));
64+
$this->newLine();
65+
$this->info("Tool: {$toolName}");
66+
if (! empty($parameters)) {
67+
$this->info('Parameters: '.json_encode($parameters, JSON_PRETTY_PRINT));
68+
}
69+
70+
try {
71+
$response = $this->mcpHandler->callTool($toolName, $parameters);
72+
73+
$this->lastToolName = $toolName;
74+
$this->lastToolParameters = $parameters;
75+
76+
$this->newLine();
77+
$this->comment('----- Response -----');
78+
$this->newLine();
79+
80+
if (isset($response['isError']) && $response['isError']) {
81+
$this->error($response['content'][0]['text'] ?? 'Unknown error');
82+
} else {
83+
$content = $response['content'][0]['text'] ?? 'No content returned';
84+
85+
if (is_string($content) && $this->isValidJson($content)) {
86+
$prettyJson = json_encode(json_decode($content, true), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
87+
$this->line($prettyJson ?: $content);
88+
} else {
89+
$this->line($content);
90+
}
91+
}
92+
93+
$this->newLine();
94+
$this->comment('----- End of Response -----');
95+
$this->newLine();
96+
97+
} catch (Exception $e) {
98+
$this->error('Error calling tool: '.$e->getMessage());
99+
100+
if ($this->option('debug')) {
101+
$this->error('Stack trace:');
102+
$this->error($e->getTraceAsString());
103+
}
104+
}
105+
} while (true);
106+
107+
return Command::SUCCESS;
108+
}
109+
110+
protected function getToolName(): ?string
111+
{
112+
$toolName = $this->option('tool');
113+
114+
if (! $toolName) {
115+
if ($this->printedToolsList) {
116+
$this->comment(sprintf('----- Tool call finished at %s -----', now()->format('Y-m-d H:i:s')));
117+
$this->comment('You can call another tool, repeat the last tool call or exit.');
118+
$this->newLine();
119+
}
120+
121+
$availableTools = $this->getAvailableTools();
122+
123+
if (empty($availableTools)) {
124+
$this->error('No tools available');
125+
126+
return null;
127+
}
128+
129+
$this->info('Available tools:');
130+
131+
foreach ($availableTools as $index => $tool) {
132+
$this->line(sprintf('%d. %s - %s', $index + 1, $tool['name'], $tool['description']));
133+
}
134+
135+
if ($this->printedToolsList) {
136+
$this->line(sprintf('%d. exit - Exit the command', count($availableTools) + 1));
137+
}
138+
139+
$toolNames = array_map(fn ($tool) => $tool['name'], $availableTools);
140+
if ($this->printedToolsList) {
141+
$toolNames[] = 'exit';
142+
}
143+
144+
$this->printedToolsList = true;
145+
146+
$choice = $this->anticipate(
147+
'Enter tool number or name (use the arrow keys for autocompletion)',
148+
$toolNames,
149+
);
150+
151+
if (is_numeric($choice)) {
152+
$index = (int) $choice - 1;
153+
154+
if (isset($availableTools[$index])) {
155+
$toolName = $availableTools[$index]['name'];
156+
} elseif ($index === count($availableTools)) {
157+
// Exit option selected
158+
return null;
159+
}
160+
} else {
161+
if ($choice === 'exit') {
162+
return null;
163+
}
164+
$toolName = is_string($choice) ? $choice : null;
165+
}
166+
}
167+
168+
return is_string($toolName) ? $toolName : null;
169+
}
170+
171+
/**
172+
* @return array<string, mixed>
173+
*/
174+
protected function getToolParameters(string $toolName): array
175+
{
176+
/** @var array<int, string>|null */
177+
$optionParameters = $this->option('parameters');
178+
179+
/** @var array<string, mixed> */
180+
$parameters = [];
181+
182+
// Parse command line parameters first
183+
if ($optionParameters) {
184+
foreach ($optionParameters as $param) {
185+
if ($param && strpos($param, '=') !== false) {
186+
[$key, $value] = explode('=', $param, 2);
187+
$parameters[trim($key)] = trim($value);
188+
}
189+
}
190+
}
191+
192+
// Get tool schema to know what parameters are needed
193+
$tool = $this->getToolByName($toolName);
194+
if (! $tool) {
195+
$this->warn("Tool '{$toolName}' not found. Using provided parameters only.");
196+
197+
return $parameters;
198+
}
199+
200+
$toolParameters = $tool->parameters();
201+
$requiredParameters = $tool->requiredParameters();
202+
203+
// Ask for missing required parameters
204+
foreach ($requiredParameters as $requiredParam) {
205+
if (! isset($parameters[$requiredParam])) {
206+
$paramInfo = $toolParameters[$requiredParam] ?? [];
207+
$description = is_string($paramInfo['description'] ?? null) ? $paramInfo['description'] : '';
208+
$type = is_string($paramInfo['type'] ?? null) ? $paramInfo['type'] : 'string';
209+
210+
$prompt = "Enter value for '{$requiredParam}'";
211+
if ($description) {
212+
$prompt .= " ({$description})";
213+
}
214+
$prompt .= ':';
215+
216+
$value = $this->ask($prompt) ?? '';
217+
218+
// Convert types if needed
219+
if ($type === 'integer' && is_string($value)) {
220+
$value = (int) $value;
221+
} elseif ($type === 'boolean' && is_string($value)) {
222+
$value = in_array(strtolower($value), ['true', '1', 'yes', 'y']);
223+
} elseif ($type === 'array' && is_string($value)) {
224+
$decoded = json_decode($value, true);
225+
$value = is_array($decoded) ? $decoded : explode(',', $value);
226+
}
227+
228+
$parameters[$requiredParam] = $value;
229+
}
230+
}
231+
232+
// Offer to fill optional parameters
233+
foreach ($toolParameters as $paramName => $paramInfo) {
234+
if (! in_array($paramName, $requiredParameters) && ! isset($parameters[$paramName])) {
235+
$description = is_string($paramInfo['description'] ?? null) ? $paramInfo['description'] : '';
236+
$type = is_string($paramInfo['type'] ?? null) ? $paramInfo['type'] : 'string';
237+
238+
$prompt = "Enter value for optional parameter '{$paramName}'";
239+
if ($description) {
240+
$prompt .= " ({$description})";
241+
}
242+
$prompt .= ' [leave empty to skip]:';
243+
244+
$value = $this->ask($prompt);
245+
246+
if ($value !== null && $value !== '') {
247+
// Convert types if needed
248+
if ($type === 'integer' && is_string($value)) {
249+
$value = (int) $value;
250+
} elseif ($type === 'boolean' && is_string($value)) {
251+
$value = in_array(strtolower($value), ['true', '1', 'yes', 'y']);
252+
} elseif ($type === 'array' && is_string($value)) {
253+
$decoded = json_decode($value, true);
254+
$value = is_array($decoded) ? $decoded : explode(',', $value);
255+
}
256+
257+
$parameters[$paramName] = $value;
258+
}
259+
}
260+
}
261+
262+
return $parameters;
263+
}
264+
265+
/**
266+
* @return array<int, array{name: string, description: string}>
267+
*/
268+
protected function getAvailableTools(): array
269+
{
270+
try {
271+
$toolsList = $this->mcpHandler->listTools();
272+
/** @var array<int, array{name: string, description: string}> */
273+
$tools = $toolsList['tools'];
274+
275+
if ($this->lastToolName) {
276+
$tools[] = ['name' => '_repeat_last_tool_call', 'description' => 'Repeat the last tool call'];
277+
}
278+
279+
return $tools;
280+
} catch (Exception $e) {
281+
$this->error('Error fetching tools: '.$e->getMessage());
282+
283+
return [];
284+
}
285+
}
286+
287+
protected function getToolByName(string $name): ?Tool
288+
{
289+
try {
290+
// Use the listTools method to get tools instead of accessing the protected property
291+
$toolsList = $this->mcpHandler->listTools();
292+
$tools = $toolsList['tools'];
293+
294+
foreach ($tools as $toolData) {
295+
if (is_array($toolData) && isset($toolData['name']) && $toolData['name'] === $name) {
296+
return app(Loop::class)->getPrismTool($name);
297+
}
298+
}
299+
300+
return null;
301+
} catch (Exception) {
302+
return null;
303+
}
304+
}
305+
306+
protected function promptForAuthentication(): void
307+
{
308+
if ($this->confirm('Would you like to authenticate with a user ID for this session?', false)) {
309+
$userId = $this->ask('Enter user ID');
310+
311+
if ($userId) {
312+
// Set the option programmatically so authenticateUser() can access it
313+
$this->input->setOption('user-id', $userId);
314+
$this->authenticateUser();
315+
316+
$this->info('Authentication successful!');
317+
$this->newLine();
318+
}
319+
}
320+
}
321+
322+
protected function isValidJson(string $string): bool
323+
{
324+
json_decode($string);
325+
326+
return json_last_error() === JSON_ERROR_NONE;
327+
}
328+
}

0 commit comments

Comments
 (0)