Skip to content

Commit 90c363a

Browse files
committed
Fix MCP client tool issues and add ping support
1 parent d5acc69 commit 90c363a

File tree

13 files changed

+331
-37
lines changed

13 files changed

+331
-37
lines changed

src/Client/Client.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Laravel\Mcp\Client\Methods\CallTool;
1111
use Laravel\Mcp\Client\Methods\Initialize;
1212
use Laravel\Mcp\Client\Methods\ListTools;
13+
use Laravel\Mcp\Client\Methods\Ping;
1314

1415
class Client
1516
{
@@ -28,15 +29,20 @@ class Client
2829
'initialize' => Initialize::class,
2930
'tools/list' => ListTools::class,
3031
'tools/call' => CallTool::class,
32+
'ping' => Ping::class,
3133
];
3234

35+
/**
36+
* @param array<string, mixed> $capabilities
37+
*/
3338
public function __construct(
3439
protected ClientTransport $transport,
3540
protected string $name = 'laravel-mcp-client',
3641
protected ?int $cacheTtl = null,
3742
protected string $protocolVersion = '2025-11-25',
43+
protected array $capabilities = [],
3844
) {
39-
$this->context = new ClientContext($transport, $this->name, $this->protocolVersion);
45+
$this->context = new ClientContext($transport, $this->name, $this->protocolVersion, $this->capabilities);
4046
}
4147

4248
public function connect(): static
@@ -72,13 +78,18 @@ public function tools(): Collection
7278
return Cache::get($cacheKey);
7379
}
7480

75-
$result = $this->callMethod('tools/list');
81+
$allTools = [];
82+
$cursor = null;
7683

77-
/** @var array<int, array<string, mixed>> $toolDefinitions */
78-
$toolDefinitions = $result['tools'] ?? [];
84+
do {
85+
$params = $cursor !== null ? ['cursor' => $cursor] : [];
86+
$result = $this->callMethod('tools/list', $params);
87+
array_push($allTools, ...($result['tools'] ?? []));
88+
$cursor = $result['nextCursor'] ?? null;
89+
} while ($cursor !== null);
7990

8091
/** @var Collection<int, ClientTool> $tools */
81-
$tools = collect($toolDefinitions)->map(
92+
$tools = collect($allTools)->map(
8293
fn (array $definition): ClientTool => ClientTool::fromArray($definition, $this)
8394
);
8495

@@ -122,6 +133,11 @@ public function isConnected(): bool
122133
return $this->initialized && $this->transport->isConnected();
123134
}
124135

136+
public function ping(): void
137+
{
138+
$this->callMethod('ping');
139+
}
140+
125141
public function clearCache(): void
126142
{
127143
Cache::forget("mcp-client:{$this->name}:tools");

src/Client/ClientContext.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ class ClientContext
1414
{
1515
protected int $requestId = 0;
1616

17+
/**
18+
* @param array<string, mixed> $capabilities
19+
*/
1720
public function __construct(
1821
protected ClientTransport $transport,
1922
public string $clientName,
2023
public string $protocolVersion = '2025-11-25',
24+
public array $capabilities = [],
2125
) {}
2226

2327
/**

src/Client/ClientManager.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function client(string $name): Client
3434
name: $name,
3535
cacheTtl: isset($config['cache_ttl']) ? (int) $config['cache_ttl'] : null,
3636
protocolVersion: (string) config('mcp.protocol_version', '2025-11-25'),
37+
capabilities: (array) ($config['capabilities'] ?? []),
3738
);
3839

3940
$client->connect();

src/Client/Methods/Initialize.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public function handle(ClientContext $context, array $params = []): array
1717
{
1818
$response = $context->sendRequest('initialize', [
1919
'protocolVersion' => $context->protocolVersion,
20-
'capabilities' => (object) [],
20+
'capabilities' => $context->capabilities !== [] ? $context->capabilities : (object) [],
2121
'clientInfo' => [
2222
'name' => $context->clientName,
2323
'version' => '1.0.0',

src/Client/Methods/ListTools.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class ListTools implements ClientMethod
1515
*/
1616
public function handle(ClientContext $context, array $params = []): array
1717
{
18-
$response = $context->sendRequest('tools/list');
18+
$response = $context->sendRequest('tools/list', $params);
1919

2020
return $response['result'] ?? [];
2121
}

src/Client/Methods/Ping.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Client\Methods;
6+
7+
use Laravel\Mcp\Client\ClientContext;
8+
use Laravel\Mcp\Client\Contracts\ClientMethod;
9+
10+
class Ping implements ClientMethod
11+
{
12+
/**
13+
* @param array<string, mixed> $params
14+
* @return array<string, mixed>
15+
*/
16+
public function handle(ClientContext $context, array $params = []): array
17+
{
18+
$response = $context->sendRequest('ping');
19+
20+
return $response['result'] ?? [];
21+
}
22+
}

src/Client/Transport/HttpClientTransport.php

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Laravel\Mcp\Client\Transport;
66

77
use Illuminate\Http\Client\Factory;
8+
use Illuminate\Http\Client\Response;
89
use Laravel\Mcp\Client\Contracts\ClientTransport;
910
use Laravel\Mcp\Client\Exceptions\ClientException;
1011
use Laravel\Mcp\Client\Exceptions\ConnectionException;
@@ -36,17 +37,7 @@ public function connect(): void
3637

3738
public function send(string $message): string
3839
{
39-
$this->ensureConnected();
40-
41-
$response = $this->http
42-
->timeout((int) $this->timeout)
43-
->withHeaders($this->buildHeaders())
44-
->withBody($message, 'application/json')
45-
->post($this->url);
46-
47-
if ($response->header('MCP-Session-Id')) {
48-
$this->sessionId = $response->header('MCP-Session-Id');
49-
}
40+
$response = $this->post($message);
5041

5142
if (! $response->successful()) {
5243
throw new ClientException("HTTP request failed with status {$response->status()}.");
@@ -57,21 +48,18 @@ public function send(string $message): string
5748

5849
public function notify(string $message): void
5950
{
60-
$this->ensureConnected();
61-
62-
$response = $this->http
63-
->timeout((int) $this->timeout)
64-
->withHeaders($this->buildHeaders())
65-
->withBody($message, 'application/json')
66-
->post($this->url);
67-
68-
if ($response->header('MCP-Session-Id')) {
69-
$this->sessionId = $response->header('MCP-Session-Id');
70-
}
51+
$this->post($message);
7152
}
7253

7354
public function disconnect(): void
7455
{
56+
if ($this->connected && $this->sessionId !== null) {
57+
$this->http
58+
->timeout((int) $this->timeout)
59+
->withHeaders($this->buildHeaders())
60+
->delete($this->url);
61+
}
62+
7563
$this->connected = false;
7664
$this->sessionId = null;
7765
}
@@ -86,9 +74,10 @@ public function isConnected(): bool
8674
*/
8775
protected function buildHeaders(): array
8876
{
89-
$headers = array_merge($this->headers, [
77+
$headers = [
78+
...$this->headers,
9079
'Accept' => 'application/json, text/event-stream',
91-
]);
80+
];
9281

9382
if ($this->sessionId !== null) {
9483
$headers['MCP-Session-Id'] = $this->sessionId;
@@ -97,6 +86,23 @@ protected function buildHeaders(): array
9786
return $headers;
9887
}
9988

89+
protected function post(string $message): Response
90+
{
91+
$this->ensureConnected();
92+
93+
$response = $this->http
94+
->timeout((int) $this->timeout)
95+
->withHeaders($this->buildHeaders())
96+
->withBody($message, 'application/json')
97+
->post($this->url);
98+
99+
if ($response->header('MCP-Session-Id')) {
100+
$this->sessionId = $response->header('MCP-Session-Id');
101+
}
102+
103+
return $response;
104+
}
105+
100106
protected function ensureConnected(): void
101107
{
102108
if (! $this->connected) {

src/Client/Transport/StdioClientTransport.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public function connect(): void
4949

5050
$this->process = $process;
5151

52-
stream_set_blocking($this->pipes[1], true);
52+
stream_set_blocking($this->pipes[1], false);
5353
}
5454

5555
public function send(string $message): string
@@ -59,13 +59,21 @@ public function send(string $message): string
5959
fwrite($this->pipes[0], $message."\n");
6060
fflush($this->pipes[0]);
6161

62-
$response = fgets($this->pipes[1]);
62+
$startTime = microtime(true);
6363

64-
if ($response === false) {
65-
throw new ConnectionException('Failed to read response from process.');
66-
}
64+
while (true) {
65+
$response = fgets($this->pipes[1]);
66+
67+
if ($response !== false) {
68+
return trim($response);
69+
}
6770

68-
return trim($response);
71+
if ((microtime(true) - $startTime) >= $this->timeout) {
72+
throw new ConnectionException("Read timeout after {$this->timeout} seconds.");
73+
}
74+
75+
usleep(10000);
76+
}
6977
}
7078

7179
public function notify(string $message): void

tests/Unit/Client/ClientTest.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,131 @@
197197
$client->clearCache();
198198
});
199199

200+
it('paginates tools list using cursor', function (): void {
201+
$transport = new FakeClientTransport([
202+
json_encode([
203+
'jsonrpc' => '2.0',
204+
'id' => 1,
205+
'result' => [
206+
'protocolVersion' => '2025-11-25',
207+
'capabilities' => [],
208+
'serverInfo' => ['name' => 'test', 'version' => '1.0.0'],
209+
],
210+
]),
211+
json_encode([
212+
'jsonrpc' => '2.0',
213+
'id' => 2,
214+
'result' => [
215+
'tools' => [
216+
['name' => 'tool-1', 'description' => 'First tool'],
217+
],
218+
'nextCursor' => 'cursor-abc',
219+
],
220+
]),
221+
json_encode([
222+
'jsonrpc' => '2.0',
223+
'id' => 3,
224+
'result' => [
225+
'tools' => [
226+
['name' => 'tool-2', 'description' => 'Second tool'],
227+
],
228+
],
229+
]),
230+
]);
231+
232+
$client = new Client($transport, 'test-client');
233+
$client->connect();
234+
235+
$tools = $client->tools();
236+
237+
expect($tools)->toHaveCount(2)
238+
->and($tools->first()->name())->toBe('tool-1')
239+
->and($tools->last()->name())->toBe('tool-2');
240+
241+
$sent = $transport->sentMessages();
242+
expect($sent)->toHaveCount(3);
243+
244+
$secondRequest = json_decode($sent[1], true);
245+
expect($secondRequest['method'])->toBe('tools/list')
246+
->and($secondRequest)->not->toHaveKey('params');
247+
248+
$thirdRequest = json_decode($sent[2], true);
249+
expect($thirdRequest['method'])->toBe('tools/list')
250+
->and($thirdRequest['params']['cursor'])->toBe('cursor-abc');
251+
});
252+
253+
it('sends ping request', function (): void {
254+
$transport = new FakeClientTransport([
255+
json_encode([
256+
'jsonrpc' => '2.0',
257+
'id' => 1,
258+
'result' => [
259+
'protocolVersion' => '2025-11-25',
260+
'capabilities' => [],
261+
'serverInfo' => ['name' => 'test', 'version' => '1.0.0'],
262+
],
263+
]),
264+
json_encode([
265+
'jsonrpc' => '2.0',
266+
'id' => 2,
267+
'result' => [],
268+
]),
269+
]);
270+
271+
$client = new Client($transport, 'test-client');
272+
$client->connect();
273+
274+
$client->ping();
275+
276+
$sent = $transport->sentMessages();
277+
expect($sent)->toHaveCount(2);
278+
279+
$pingRequest = json_decode($sent[1], true);
280+
expect($pingRequest['method'])->toBe('ping');
281+
});
282+
283+
it('sends capabilities during initialization', function (): void {
284+
$transport = new FakeClientTransport([
285+
json_encode([
286+
'jsonrpc' => '2.0',
287+
'id' => 1,
288+
'result' => [
289+
'protocolVersion' => '2025-11-25',
290+
'capabilities' => [],
291+
'serverInfo' => ['name' => 'test', 'version' => '1.0.0'],
292+
],
293+
]),
294+
]);
295+
296+
$client = new Client($transport, 'test-client', capabilities: ['sampling' => []]);
297+
$client->connect();
298+
299+
$sent = $transport->sentMessages();
300+
$initRequest = json_decode($sent[0], true);
301+
expect($initRequest['params']['capabilities'])->toBe(['sampling' => []]);
302+
});
303+
304+
it('sends empty capabilities object when none configured', function (): void {
305+
$transport = new FakeClientTransport([
306+
json_encode([
307+
'jsonrpc' => '2.0',
308+
'id' => 1,
309+
'result' => [
310+
'protocolVersion' => '2025-11-25',
311+
'capabilities' => [],
312+
'serverInfo' => ['name' => 'test', 'version' => '1.0.0'],
313+
],
314+
]),
315+
]);
316+
317+
$client = new Client($transport, 'test-client');
318+
$client->connect();
319+
320+
$sent = $transport->sentMessages();
321+
$initRequest = json_decode($sent[0], true);
322+
expect($initRequest['params']['capabilities'])->toBeEmpty();
323+
});
324+
200325
it('clears cache', function (): void {
201326
$transport = new FakeClientTransport([
202327
json_encode([

0 commit comments

Comments
 (0)