Skip to content

Commit 3ef864c

Browse files
[Client] Feat: Implement MCP client component (#192)
* feat(client): add complete MCP client SDK with STDIO and HTTP transports Add Client, Builder, ClientProtocol, ClientSession, and transport implementations for communicating with MCP servers. Supports tools, resources, prompts, and real-time progress/logging notifications. * refactor(examples): reorganize examples into server/ and client/ folders * feat: add SamplingRequestHandler for handling server sampling requests * feat: add setLoggingLevel method to control server logging verbosity * refactor: make progress token dependent on request ID * docs: Update server IP in usage instructions and add notes on sampling server requirements. * feat: add complete() method for completion/complete requests * refactor: properly parse and type initialize result * refactor: standardize method names * fix: revert changes to server related components * fix: revert changes to server related components * refactor: Restructure client transports and handlers into dedicated namespaces. * fix: cs and php-stan errors fixes(partial) * feat: Refactor request handlers to return `Response` or `Error` objects directly, adding a `SamplingException` and error logging for sampling requests. * feat: Replace `ClientTransportInterface` with `TransportInterface` and move response result deserialization to client methods. * refactor: remove redundant timeout check for intialize on transports The processFiber that executes per tick while fiber is suspended already handles timeouts while waiting * feat: broaden symfony/http-client dependency to support Symfony 5.4 to 8.0 * Update examples/client/README.md Co-authored-by: Christopher Hertel <mail@christopher-hertel.de> * Update examples/client/README.md Co-authored-by: Christopher Hertel <mail@christopher-hertel.de> * refactor: remove outgoing message queue from session and simplify transport send method to directly dispatch messages. * refactor: Replace ClientSession with ClientState for clearer intent on runtime state management * refactor: rename connectAndInitialize to connect for consistency * chore: cs fix * chore: use SamplingCallbackInterface instead of a closure for sampling request handler --------- Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
1 parent a5a7383 commit 3ef864c

31 files changed

Lines changed: 2731 additions & 9 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@
8585
},
8686
"sort-packages": true
8787
}
88-
}
88+
}

examples/client/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Client Examples
2+
3+
These examples demonstrate how to use the MCP PHP Client SDK.
4+
5+
## STDIO Client
6+
7+
Connects to an MCP server running as a child process:
8+
9+
```bash
10+
php examples/client/stdio_discovery_calculator.php
11+
```
12+
13+
## HTTP Client
14+
15+
Connects to an MCP server over HTTP:
16+
17+
```bash
18+
# First, start an HTTP server
19+
php -S localhost:8000 examples/server/discovery-calculator/server.php
20+
21+
# Then run the client
22+
php examples/client/http_discovery_calculator.php
23+
```
24+
25+
## Requirements
26+
27+
All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
/**
4+
* HTTP Client Communication Example.
5+
*
6+
* This example demonstrates server-to-client communication over HTTP:
7+
* - Logging notifications
8+
* - Progress notifications (via SSE streaming)
9+
* - Sampling requests (mocked LLM response)
10+
*
11+
* Usage:
12+
* 1. Start the server: php -S 127.0.0.1:8000 examples/server/client-communication/server.php
13+
* 2. Run this script: php examples/client/http_client_communication.php
14+
*
15+
* Note: PHP's built-in server works for listing tools, calling tools, and receiving
16+
* progress/logging notifications. However, sampling requires a concurrent-capable server
17+
* (e.g., Symfony CLI, PHP-FPM) since the server must process the client's sampling
18+
* response while the original tool request is still pending.
19+
*
20+
* Eg. symfony serve --passthru=examples/server/client-communication/server.php --no-tls
21+
*/
22+
23+
declare(strict_types=1);
24+
25+
/*
26+
* This file is part of the official PHP MCP SDK.
27+
*
28+
* A collaboration between Symfony and the PHP Foundation.
29+
*
30+
* For the full copyright and license information, please view the LICENSE
31+
* file that was distributed with this source code.
32+
*/
33+
34+
require_once __DIR__.'/../../vendor/autoload.php';
35+
36+
use Mcp\Client;
37+
use Mcp\Client\Handler\Notification\LoggingNotificationHandler;
38+
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
39+
use Mcp\Client\Handler\Request\SamplingRequestHandler;
40+
use Mcp\Client\Transport\HttpTransport;
41+
use Mcp\Schema\ClientCapabilities;
42+
use Mcp\Schema\Content\TextContent;
43+
use Mcp\Schema\Enum\Role;
44+
use Mcp\Schema\Notification\LoggingMessageNotification;
45+
use Mcp\Schema\Request\CreateSamplingMessageRequest;
46+
use Mcp\Schema\Result\CreateSamplingMessageResult;
47+
48+
$endpoint = 'http://127.0.0.1:8000';
49+
50+
$loggingNotificationHandler = new LoggingNotificationHandler(static function (LoggingMessageNotification $n) {
51+
echo "[LOG {$n->level->value}] {$n->data}\n";
52+
});
53+
54+
$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface {
55+
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
56+
{
57+
echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n";
58+
59+
$mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '.
60+
'2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.';
61+
62+
return new CreateSamplingMessageResult(
63+
role: Role::Assistant,
64+
content: new TextContent($mockResponse),
65+
model: 'mock-gpt-4',
66+
stopReason: 'end_turn',
67+
);
68+
}
69+
});
70+
71+
$client = Client::builder()
72+
->setClientInfo('HTTP Client Communication Test', '1.0.0')
73+
->setInitTimeout(30)
74+
->setRequestTimeout(120)
75+
->setCapabilities(new ClientCapabilities(sampling: true))
76+
->addNotificationHandler($loggingNotificationHandler)
77+
->addRequestHandler($samplingRequestHandler)
78+
->build();
79+
80+
$transport = new HttpTransport(endpoint: $endpoint);
81+
82+
try {
83+
echo "Connecting to MCP server at {$endpoint}...\n";
84+
$client->connect($transport);
85+
86+
$serverInfo = $client->getServerInfo();
87+
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
88+
89+
echo "Available tools:\n";
90+
$toolsResult = $client->listTools();
91+
foreach ($toolsResult->tools as $tool) {
92+
echo " - {$tool->name}\n";
93+
}
94+
echo "\n";
95+
96+
echo "Calling 'run_dataset_quality_checks'...\n\n";
97+
$result = $client->callTool(
98+
name: 'run_dataset_quality_checks',
99+
arguments: ['dataset' => 'sales_transactions_q4'],
100+
onProgress: static function (float $progress, ?float $total, ?string $message) {
101+
$percent = $total > 0 ? round(($progress / $total) * 100) : '?';
102+
echo "[PROGRESS {$percent}%] {$message}\n";
103+
}
104+
);
105+
106+
echo "\nResult:\n";
107+
foreach ($result->content as $content) {
108+
if ($content instanceof TextContent) {
109+
echo $content->text."\n";
110+
}
111+
}
112+
113+
echo "\nCalling 'coordinate_incident_response'...\n\n";
114+
$result = $client->callTool(
115+
name: 'coordinate_incident_response',
116+
arguments: ['incidentTitle' => 'Database connection pool exhausted'],
117+
onProgress: static function (float $progress, ?float $total, ?string $message) {
118+
$percent = $total > 0 ? round(($progress / $total) * 100) : '?';
119+
echo "[PROGRESS {$percent}%] {$message}\n";
120+
}
121+
);
122+
123+
echo "\nResult:\n";
124+
foreach ($result->content as $content) {
125+
if ($content instanceof TextContent) {
126+
echo $content->text."\n";
127+
}
128+
}
129+
} catch (Throwable $e) {
130+
echo "Error: {$e->getMessage()}\n";
131+
echo $e->getTraceAsString()."\n";
132+
} finally {
133+
$client->disconnect();
134+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/**
4+
* HTTP Client Example.
5+
*
6+
* This example demonstrates how to use the MCP client with an HTTP transport
7+
* to communicate with a remote MCP server over HTTP.
8+
*
9+
* Usage: php examples/client/http_discovery_calculator.php
10+
*
11+
* Before running, start an HTTP MCP server:
12+
* php -S localhost:8080 examples/server/http-discovery-calculator/server.php
13+
*/
14+
15+
declare(strict_types=1);
16+
17+
/*
18+
* This file is part of the official PHP MCP SDK.
19+
*
20+
* A collaboration between Symfony and the PHP Foundation.
21+
*
22+
* For the full copyright and license information, please view the LICENSE
23+
* file that was distributed with this source code.
24+
*/
25+
26+
require_once __DIR__.'/../../vendor/autoload.php';
27+
28+
use Mcp\Client;
29+
use Mcp\Client\Transport\HttpTransport;
30+
31+
$endpoint = 'http://localhost:8000';
32+
33+
$client = Client::builder()
34+
->setClientInfo('HTTP Example Client', '1.0.0')
35+
->setInitTimeout(30)
36+
->setRequestTimeout(60)
37+
->build();
38+
39+
$transport = new HttpTransport($endpoint);
40+
41+
try {
42+
echo "Connecting to MCP server at {$endpoint}...\n";
43+
$client->connect($transport);
44+
45+
echo "Connected! Server info:\n";
46+
$serverInfo = $client->getServerInfo();
47+
echo ' Name: '.($serverInfo->name ?? 'unknown')."\n";
48+
echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n";
49+
50+
echo "Available tools:\n";
51+
$toolsResult = $client->listTools();
52+
foreach ($toolsResult->tools as $tool) {
53+
echo " - {$tool->name}: {$tool->description}\n";
54+
}
55+
echo "\n";
56+
57+
echo "Available resources:\n";
58+
$resourcesResult = $client->listResources();
59+
foreach ($resourcesResult->resources as $resource) {
60+
echo " - {$resource->uri}: {$resource->name}\n";
61+
}
62+
echo "\n";
63+
64+
echo "Available prompts:\n";
65+
$promptsResult = $client->listPrompts();
66+
foreach ($promptsResult->prompts as $prompt) {
67+
echo " - {$prompt->name}: {$prompt->description}\n";
68+
}
69+
echo "\n";
70+
} catch (Throwable $e) {
71+
echo "Error: {$e->getMessage()}\n";
72+
echo $e->getTraceAsString()."\n";
73+
} finally {
74+
echo "Disconnecting...\n";
75+
$client->disconnect();
76+
echo "Done.\n";
77+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
/**
4+
* STDIO Client Communication Example.
5+
*
6+
* This example demonstrates server-to-client communication:
7+
* - Logging notifications
8+
* - Progress notifications
9+
* - Sampling requests (mocked LLM response)
10+
*
11+
* Usage: php examples/client/stdio_client_communication.php
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
/*
17+
* This file is part of the official PHP MCP SDK.
18+
*
19+
* A collaboration between Symfony and the PHP Foundation.
20+
*
21+
* For the full copyright and license information, please view the LICENSE
22+
* file that was distributed with this source code.
23+
*/
24+
25+
require_once __DIR__.'/../../vendor/autoload.php';
26+
27+
use Mcp\Client;
28+
use Mcp\Client\Handler\Notification\LoggingNotificationHandler;
29+
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
30+
use Mcp\Client\Handler\Request\SamplingRequestHandler;
31+
use Mcp\Client\Transport\StdioTransport;
32+
use Mcp\Schema\ClientCapabilities;
33+
use Mcp\Schema\Content\TextContent;
34+
use Mcp\Schema\Enum\Role;
35+
use Mcp\Schema\Notification\LoggingMessageNotification;
36+
use Mcp\Schema\Request\CreateSamplingMessageRequest;
37+
use Mcp\Schema\Result\CreateSamplingMessageResult;
38+
39+
$loggingNotificationHandler = new LoggingNotificationHandler(static function (LoggingMessageNotification $n) {
40+
echo "[LOG {$n->level->value}] {$n->data}\n";
41+
});
42+
43+
$samplingRequestHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface {
44+
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
45+
{
46+
echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n";
47+
48+
$mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '.
49+
'2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.';
50+
51+
return new CreateSamplingMessageResult(
52+
role: Role::Assistant,
53+
content: new TextContent($mockResponse),
54+
model: 'mock-gpt-4',
55+
stopReason: 'end_turn',
56+
);
57+
}
58+
});
59+
60+
$client = Client::builder()
61+
->setClientInfo('STDIO Client Communication Test', '1.0.0')
62+
->setInitTimeout(30)
63+
->setRequestTimeout(120)
64+
->setCapabilities(new ClientCapabilities(sampling: true))
65+
->addNotificationHandler($loggingNotificationHandler)
66+
->addRequestHandler($samplingRequestHandler)
67+
->build();
68+
69+
$transport = new StdioTransport(
70+
command: 'php',
71+
args: [__DIR__.'/../server/client-communication/server.php'],
72+
);
73+
74+
try {
75+
echo "Connecting to MCP server...\n";
76+
$client->connect($transport);
77+
78+
$serverInfo = $client->getServerInfo();
79+
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
80+
81+
echo "Available tools:\n";
82+
$toolsResult = $client->listTools();
83+
foreach ($toolsResult->tools as $tool) {
84+
echo " - {$tool->name}\n";
85+
}
86+
echo "\n";
87+
88+
echo "Calling 'run_dataset_quality_checks'...\n\n";
89+
$result = $client->callTool(
90+
name: 'run_dataset_quality_checks',
91+
arguments: ['dataset' => 'customer_orders_2024'],
92+
onProgress: static function (float $progress, ?float $total, ?string $message) {
93+
$percent = $total > 0 ? round(($progress / $total) * 100) : '?';
94+
echo "[PROGRESS {$percent}%] {$message}\n";
95+
}
96+
);
97+
98+
echo "\nResult:\n";
99+
foreach ($result->content as $content) {
100+
if ($content instanceof TextContent) {
101+
echo $content->text."\n";
102+
}
103+
}
104+
105+
echo "\nCalling 'coordinate_incident_response'...\n\n";
106+
$result = $client->callTool(
107+
name: 'coordinate_incident_response',
108+
arguments: ['incidentTitle' => 'Database connection pool exhausted'],
109+
onProgress: static function (float $progress, ?float $total, ?string $message) {
110+
$percent = $total > 0 ? round(($progress / $total) * 100) : '?';
111+
echo "[PROGRESS {$percent}%] {$message}\n";
112+
}
113+
);
114+
115+
echo "\nResult:\n";
116+
foreach ($result->content as $content) {
117+
if ($content instanceof TextContent) {
118+
echo $content->text."\n";
119+
}
120+
}
121+
} catch (Throwable $e) {
122+
echo "Error: {$e->getMessage()}\n";
123+
echo $e->getTraceAsString()."\n";
124+
} finally {
125+
$client->disconnect();
126+
}

0 commit comments

Comments
 (0)