Skip to content

Commit 31b5a54

Browse files
committed
fix: sanitize wildcard parameters in ListRoutes and add tests
1 parent c832eb0 commit 31b5a54

File tree

2 files changed

+200
-2
lines changed

2 files changed

+200
-2
lines changed

src/Mcp/Tools/ListRoutes.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ public function handle(array $arguments): ToolResult
5858
];
5959

6060
foreach ($optionMap as $argKey => $cliOption) {
61-
if (array_key_exists($argKey, $arguments) && ! empty($arguments[$argKey]) && $arguments[$argKey] !== '*') {
62-
$options['--'.$cliOption] = $arguments[$argKey];
61+
if (! empty($arguments[$argKey] ?? '')) {
62+
$sanitizedValue = $this->sanitizeWildcards($arguments[$argKey], $argKey);
63+
if ($sanitizedValue !== '') {
64+
$options['--'.$cliOption] = $sanitizedValue;
65+
}
6366
}
6467
}
6568

@@ -78,6 +81,15 @@ public function handle(array $arguments): ToolResult
7881
return ToolResult::text($routesOutput);
7982
}
8083

84+
private function sanitizeWildcards(string $value, string $parameter): string
85+
{
86+
if (in_array($parameter, ['path', 'except_path'])) {
87+
return $value;
88+
}
89+
90+
return str_replace(['*', '?'], '', $value);
91+
}
92+
8193
/**
8294
* @param array<string|bool> $options
8395
*/
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Support\Facades\Route;
6+
use Laravel\Boost\Mcp\Tools\ListRoutes;
7+
use Laravel\Mcp\Server\Tools\ToolResult;
8+
9+
beforeEach(function () {
10+
Route::get('/admin/dashboard', function () {
11+
return 'admin dashboard';
12+
})->name('admin.dashboard');
13+
14+
Route::post('/admin/users', function () {
15+
return 'admin users';
16+
})->name('admin.users.store');
17+
18+
Route::get('/user/profile', function () {
19+
return 'user profile';
20+
})->name('user.profile');
21+
22+
Route::get('/api/two-factor/enable', function () {
23+
return 'two-factor enable';
24+
})->name('two-factor.enable');
25+
26+
Route::get('/api/v1/posts', function () {
27+
return 'posts';
28+
})->name('api.posts.index');
29+
30+
Route::put('/api/v1/posts/{id}', function ($id) {
31+
return "update post $id";
32+
})->name('api.posts.update');
33+
});
34+
35+
test('it returns list of routes without filters', function () {
36+
$tool = new ListRoutes;
37+
$result = $tool->handle([]);
38+
39+
expect($result)->toBeInstanceOf(ToolResult::class);
40+
$data = $result->toArray();
41+
expect($data['isError'])->toBeFalse()
42+
->and($data['content'][0]['text'])->toBeString()
43+
->and($data['content'][0]['text'])->toContain('GET|HEAD')
44+
->and($data['content'][0]['text'])->toContain('admin.dashboard')
45+
->and($data['content'][0]['text'])->toContain('user.profile');
46+
});
47+
48+
test('it sanitizes name parameter wildcards and filters correctly', function () {
49+
$tool = new ListRoutes;
50+
51+
$result = $tool->handle(['name' => '*admin*']);
52+
$output = $result->toArray()['content'][0]['text'];
53+
54+
expect($result)->toBeInstanceOf(ToolResult::class)
55+
->and($result->toArray()['isError'])->toBeFalse()
56+
->and($output)->toContain('admin.dashboard')
57+
->and($output)->toContain('admin.users.store')
58+
->and($output)->not->toContain('user.profile')
59+
->and($output)->not->toContain('two-factor.enable');
60+
61+
$result = $tool->handle(['name' => '*two-factor*']);
62+
$output = $result->toArray()['content'][0]['text'];
63+
64+
expect($output)->toContain('two-factor.enable')
65+
->and($output)->not->toContain('admin.dashboard')
66+
->and($output)->not->toContain('user.profile');
67+
68+
$result = $tool->handle(['name' => '*api*']);
69+
$output = $result->toArray()['content'][0]['text'];
70+
71+
expect($output)->toContain('api.posts.index')
72+
->and($output)->toContain('api.posts.update')
73+
->and($output)->not->toContain('admin.dashboard')
74+
->and($output)->not->toContain('user.profile');
75+
});
76+
77+
test('it sanitizes method parameter wildcards and filters correctly', function () {
78+
$tool = new ListRoutes;
79+
80+
$result = $tool->handle(['method' => 'GET*POST']);
81+
$output = $result->toArray()['content'][0]['text'];
82+
83+
expect($result->toArray()['isError'])->toBeFalse()
84+
->and($output)->toContain('ERROR Your application doesn\'t have any routes matching the given criteria.');
85+
86+
$result = $tool->handle(['method' => '*GET*']);
87+
$output = $result->toArray()['content'][0]['text'];
88+
89+
expect($output)->toContain('admin.dashboard')
90+
->and($output)->toContain('user.profile')
91+
->and($output)->toContain('api.posts.index')
92+
->and($output)->not->toContain('admin.users.store');
93+
94+
$result = $tool->handle(['method' => '*POST*']);
95+
$output = $result->toArray()['content'][0]['text'];
96+
97+
expect($output)->toContain('admin.users.store')
98+
->and($output)->not->toContain('admin.dashboard');
99+
});
100+
101+
test('it preserves wildcards in path parameters', function () {
102+
$tool = new ListRoutes;
103+
104+
$result = $tool->handle(['path' => '/admin/*']);
105+
expect($result)->toBeInstanceOf(ToolResult::class)
106+
->and($result->toArray()['isError'])->toBeFalse();
107+
108+
$output = $result->toArray()['content'][0]['text'];
109+
expect($output)->not->toContain('Failed to list routes');
110+
111+
$result = $tool->handle(['except_path' => '/nonexistent/*']);
112+
expect($result)->toBeInstanceOf(ToolResult::class)
113+
->and($result->toArray()['isError'])->toBeFalse();
114+
115+
$output = $result->toArray()['content'][0]['text'];
116+
expect($output)->toContain('admin.dashboard')
117+
->and($output)->toContain('user.profile');
118+
});
119+
120+
test('it handles edge cases and empty results correctly', function () {
121+
$tool = new ListRoutes;
122+
123+
$result = $tool->handle(['name' => '*']);
124+
expect($result)->toBeInstanceOf(ToolResult::class)
125+
->and($result->toArray()['isError'])->toBeFalse();
126+
127+
$output = $result->toArray()['content'][0]['text'];
128+
expect($output)->toContain('admin.dashboard')
129+
->and($output)->toContain('user.profile')
130+
->and($output)->toContain('two-factor.enable');
131+
132+
$result = $tool->handle(['name' => '*nonexistent*']);
133+
$output = $result->toArray()['content'][0]['text'];
134+
135+
expect($output)->toContain('ERROR Your application doesn\'t have any routes matching the given criteria.');
136+
137+
$result = $tool->handle(['name' => '']);
138+
$output = $result->toArray()['content'][0]['text'];
139+
140+
expect($output)->toContain('admin.dashboard')
141+
->and($output)->toContain('user.profile');
142+
});
143+
144+
test('it handles multiple parameters with wildcard sanitization', function () {
145+
$tool = new ListRoutes;
146+
147+
$result = $tool->handle([
148+
'name' => '*admin*',
149+
'method' => '*GET*',
150+
]);
151+
152+
$output = $result->toArray()['content'][0]['text'];
153+
154+
expect($result->toArray()['isError'])->toBeFalse()
155+
->and($output)->toContain('admin.dashboard')
156+
->and($output)->not->toContain('admin.users.store')
157+
->and($output)->not->toContain('user.profile');
158+
159+
$result = $tool->handle([
160+
'name' => '*user*',
161+
'method' => '*POST*',
162+
]);
163+
164+
$output = $result->toArray()['content'][0]['text'];
165+
166+
if (str_contains($output, 'admin.users.store')) {
167+
expect($output)->toContain('admin.users.store');
168+
} else {
169+
expect($output)->toContain('ERROR Your application doesn\'t have any routes matching the given criteria.');
170+
}
171+
});
172+
173+
test('it handles the original problematic wildcard case', function () {
174+
$tool = new ListRoutes;
175+
176+
$result = $tool->handle(['name' => '*/two-factor/']);
177+
expect($result)->toBeInstanceOf(ToolResult::class)
178+
->and($result->toArray()['isError'])->toBeFalse();
179+
180+
$output = $result->toArray()['content'][0]['text'];
181+
if (str_contains($output, 'two-factor.enable')) {
182+
expect($output)->toContain('two-factor.enable');
183+
} else {
184+
expect($output)->toContain('ERROR');
185+
}
186+
});

0 commit comments

Comments
 (0)