-
Notifications
You must be signed in to change notification settings - Fork 179
Expand file tree
/
Copy pathClientGateway.php
More file actions
467 lines (420 loc) · 19 KB
/
Copy pathClientGateway.php
File metadata and controls
467 lines (420 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server;
use Mcp\Exception\ClientException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\Content;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Enum\SamplingContext;
use Mcp\Schema\Extension\ExtensionIdentifier;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\ModelPreferences;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Notification\ProgressNotification;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\ListRootsResult;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolChoice;
use Mcp\Server\Session\SessionInterface;
/**
* @final
* Helper class for tools to communicate with the client.
*
* This class provides a clean API for element handlers to send requests and notifications
* to the client. It uses PHP Fibers internally to make the communication appear
* synchronous while the transport handles all blocking operations.
*
* Example usage in a tool:
* ```php
* public function analyze(string $text, RequestContext $context): string {
* $client = $context->getClientGateway();
* // Send progress notification
* $client->notify(new ProgressNotification("Starting analysis..."));
*
* // Request LLM sampling from client
* $result = $client->sample($text);
*
* return $result->content->text;
* }
* ```
*
* @phpstan-type SampleOptions array{
* preferences?: ModelPreferences,
* systemPrompt?: string,
* temperature?: float,
* includeContext?: SamplingContext,
* stopSequences?: string[],
* metadata?: array<string, mixed>,
* tools?: Tool[],
* toolChoice?: ToolChoice,
* }
*
* @author Kyrian Obikwelu <[email protected]>
*/
class ClientGateway
{
public function __construct(
private readonly SessionInterface $session,
) {
}
/**
* Send a notification to the client (fire and forget).
*
* This suspends the Fiber to let the transport flush the notification via SSE,
* then immediately resumes execution.
*/
public function notify(Notification $notification): void
{
\Fiber::suspend([
'type' => 'notification',
'notification' => $notification,
'session_id' => $this->session->getId()->toRfc4122(),
]);
}
/**
* Convenience method to send a logging notification to the client.
*
* @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28.
* Log to stderr (stdio) or use OpenTelemetry instead.
*/
public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void
{
trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.');
$this->notify(new LoggingMessageNotification($level, $data, $logger));
}
/**
* Convenience method to send a progress notification to the client.
*/
public function progress(float $progress, ?float $total = null, ?string $message = null): void
{
$meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []);
$progressToken = $meta['progressToken'] ?? null;
if (null === $progressToken) {
// Per the spec the client never asked for progress, so just bail.
return;
}
$this->notify(new ProgressNotification($progressToken, $progress, $total, $message));
}
/**
* Convenience method for LLM sampling requests.
*
* @param SamplingMessage[]|TextContent|AudioContent|ImageContent|string $message The message for the LLM
* @param int $maxTokens Maximum tokens to generate
* @param int $timeout The timeout in seconds
* @param SampleOptions $options Additional sampling options (temperature, etc.)
* Context values other than `none` require the client's
* sampling.context capability; tools and toolChoice require
* the client's sampling.tools capability.
*
* @return CreateSamplingMessageResult The sampling response
*
* @throws ClientException if the client request results in an error message
*
* @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28.
* Integrate with an LLM provider's API directly instead.
*/
public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult
{
trigger_deprecation('mcp/sdk', '0.8', 'MCP sampling is deprecated since protocol revision 2026-07-28 (SEP-2577); integrate with an LLM provider\'s API directly instead.');
$preferences = $options['preferences'] ?? null;
if (null !== $preferences && !$preferences instanceof ModelPreferences) {
throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.');
}
if (\is_string($message)) {
$message = new TextContent($message);
}
$messages = $message instanceof TextContent || $message instanceof AudioContent || $message instanceof ImageContent
? [new SamplingMessage(Role::User, $message)]
: $message;
$request = new CreateSamplingMessageRequest(
messages: $messages,
maxTokens: $maxTokens,
preferences: $preferences,
systemPrompt: $options['systemPrompt'] ?? null,
includeContext: $options['includeContext'] ?? null,
temperature: $options['temperature'] ?? null,
stopSequences: $options['stopSequences'] ?? null,
metadata: $options['metadata'] ?? null,
tools: $options['tools'] ?? null,
toolChoice: $options['toolChoice'] ?? null,
);
// Fail here rather than letting the client reject the request with -32602.
$request->validateToolFlow();
$response = $this->request($request, $timeout);
if ($response instanceof Error) {
throw new ClientException($response);
}
return CreateSamplingMessageResult::fromArray($response->result);
}
/**
* Convenience method for form-mode elicitation requests.
*
* Requests additional information from the user via the client. The user can
* accept (providing the requested data), decline, or cancel the request.
*
* Serves every revision, by two different mechanics. Where the client can be
* asked while the request is open, it is: an `elicitation/create` goes out and
* this call returns its answer. From 2026-07-28 on there are no
* server-initiated requests, so the ask ends the request instead and the call
* returns once the client re-sends it with the answer — see
* {@see Stateless\ElicitationReplay} for what that costs, namely
* that the handler is entered once per ask.
*
* @param string $message A human-readable message describing what information is needed
* @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user
* @param int $timeout The timeout in seconds; unused where the answer arrives on a later request
* @param string|null $key Names this ask, so it keeps resolving to the same answer across the rounds
* a revision without server-initiated requests needs. Defaults to the ask's
* position in the handler, which only a handler asking in a different order
* every time needs to override.
*
* @return ElicitResult The elicitation response containing the user's action and any provided content
*
* @throws ClientException if the client request results in an error message
*/
public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120, ?string $key = null): ElicitResult
{
return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout, $key);
}
/**
* Convenience method for url-mode elicitation requests.
*
* Sends the user to $url to complete the interaction out of band — an OAuth
* consent screen, a checkout, a form hosted elsewhere. The result carries only
* the user's action; unlike form mode there is no content to read back, so
* whatever the user did there has to be picked up through the URL's own channel.
*
* Portable across revisions on the same terms as {@see self::elicit()}.
*
* @param string|null $key names this ask across the rounds of a multi round-trip call
*
* @throws ClientException if the client request results in an error message
* @throws InvalidArgumentException if the client did not declare url-mode elicitation
*/
public function elicitUrl(string $message, string $url, int $timeout = 120, ?string $key = null): ElicitResult
{
// URL mode only exists from 2025-11-25 on, and only for clients declaring it
if (!$this->supportsElicitationUrl()) {
throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.');
}
return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout, $key);
}
/**
* Request the list of filesystem roots exposed by the client.
*
* Roots are the client's "workspace folders" — the directories or files the
* server is allowed to operate on. The client answers the roots/list request
* with a list of file:// URIs.
*
* @param int $timeout The timeout in seconds
*
* @return ListRootsResult The roots exposed by the client
*
* @throws ClientException if the client request results in an error message
*
* @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28.
* Pass directories or files through tool arguments, resource URIs or server
* configuration instead.
*/
public function listRoots(int $timeout = 120): ListRootsResult
{
trigger_deprecation('mcp/sdk', '0.8', 'MCP roots are deprecated since protocol revision 2026-07-28 (SEP-2577); pass directories through tool arguments or resource URIs instead.');
$request = new ListRootsRequest();
$response = $this->request($request, $timeout);
if ($response instanceof Error) {
throw new ClientException($response);
}
return ListRootsResult::fromArray($response->result);
}
/**
* Check if the connected client supports roots.
*
* Roots allow servers to ask the client for the set of directories or files
* it is permitted to operate on. This method checks the client's advertised
* capabilities to determine if roots/list requests are supported.
*
* @return bool True if the client supports roots, false otherwise
*/
public function supportsRoots(): bool
{
$capabilities = (array) $this->session->get('client_capabilities', []);
// MCP spec: capability presence indicates support (value is typically {} or [])
return \array_key_exists('roots', $capabilities);
}
/**
* Check if the connected client supports elicitation.
*
* Elicitation allows servers to request additional information from users
* during tool execution. This method checks the client's advertised capabilities
* to determine if elicitation/create requests are supported.
*
* @return bool True if the client supports elicitation, false otherwise
*/
public function supportsElicitation(): bool
{
$capabilities = (array) $this->session->get('client_capabilities', []);
// MCP spec: capability presence indicates support (value is typically {} or [])
return \array_key_exists('elicitation', $capabilities);
}
/**
* Check if the connected client supports sampling.
*
* Sampling lets a server borrow the client's model during tool execution.
* This method checks the client's advertised capabilities to determine if
* sampling/createMessage requests are supported.
*
* @return bool True if the client supports sampling, false otherwise
*/
public function supportsSampling(): bool
{
$capabilities = (array) $this->session->get('client_capabilities', []);
// MCP spec: capability presence indicates support (value is typically {} or [])
return \array_key_exists('sampling', $capabilities);
}
/**
* Check if the connected client supports tools during sampling.
*
* Per the spec a server MUST NOT put `tools` or `toolChoice` on a
* `sampling/createMessage` request unless the client advertised
* `sampling.tools`, so check this before passing either option to
* {@see self::sample()}.
*
* @return bool True if the client supports tool-enabled sampling, false otherwise
*/
public function supportsSamplingTools(): bool
{
return $this->hasSubCapability('sampling', 'tools');
}
/**
* Check if the connected client supports context inclusion during sampling.
*
* The `includeContext` values other than `none` are soft-deprecated and should
* only be sent when the client advertised `sampling.context`.
*
* @return bool True if the client supports sampling context, false otherwise
*/
public function supportsSamplingContext(): bool
{
return $this->hasSubCapability('sampling', 'context');
}
/**
* Check if the connected client supports url-mode elicitation.
*
* An `elicitation` capability naming no mode declares form mode — the only shape
* that existed before URL elicitation — so url mode has to be named explicitly.
*
* @return bool True if the client supports url-mode elicitation, false otherwise
*/
public function supportsElicitationUrl(): bool
{
return $this->hasSubCapability('elicitation', 'url');
}
/**
* Check if the connected client negotiated the given protocol extension.
*
* Extensions are advertised under `capabilities.extensions` keyed by their
* reverse-DNS id, e.g. `McpApps::EXTENSION_ID` — a server offering MCP Apps
* should check this before pointing a tool at a `ui://` resource, and fall back
* to a text-only result otherwise.
*
* @return bool True if the client advertised the extension, false otherwise
*/
public function supportsExtension(ExtensionIdentifier|string $id): bool
{
return $this->hasSubCapability('extensions', (string) $id);
}
/**
* Sub-capabilities are declared by the presence of a (possibly empty) object, so
* only the key matters — not whatever it holds. The value arrives as an object on
* a live session and as an array once the session has round-tripped through JSON,
* hence both shapes.
*/
private function hasSubCapability(string $capability, string $name): bool
{
$capabilities = (array) $this->session->get('client_capabilities', []);
$declared = $capabilities[$capability] ?? null;
if (\is_array($declared)) {
return \array_key_exists($name, $declared);
}
return \is_object($declared) && property_exists($declared, $name);
}
/**
* @throws ClientException if the client request results in an error message
*/
private function sendElicitation(ElicitRequest $request, int $timeout, ?string $key = null): ElicitResult
{
$response = $this->suspend($request, $timeout, $key);
if ($response instanceof Error) {
throw new ClientException($response);
}
return ElicitResult::fromArray($response->result, $request->mode);
}
/**
* Send a request to the client and wait for a response (blocking).
*
* This suspends the Fiber and waits for the client to respond. The transport
* handles polling the session for the response and resuming the Fiber when ready.
*
* Public for {@see InputRequiredShim}, which sends the requests a handler
* embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows
* nothing about their kinds. Prefer the typed methods above.
*
* @param Request $request The request to send
* @param int $timeout Maximum time to wait for response (seconds)
*
* @return Response<array<string, mixed>>|Error The client's response message
*
* @throws RuntimeException If Fiber support is not available
*
* @internal
*/
public function request(Request $request, int $timeout = 120): Response|Error
{
return $this->suspend($request, $timeout);
}
/**
* Hands the request to whatever is driving this fiber and waits for its answer.
*
* @param string|null $key the name an elicitation's answer is filed under when
* the revision serving this call answers by asking
* ({@see Stateless\ElicitationReplay});
* ignored by every leg that has a live client to ask
*
* @return Response<array<string, mixed>>|Error the peer's answer
*/
private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error
{
$response = \Fiber::suspend([
'type' => 'request',
'request' => $request,
'session_id' => $this->session->getId()->toRfc4122(),
'timeout' => $timeout,
'input_key' => $key,
]);
if (!$response instanceof Response && !$response instanceof Error) {
throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
}
return $response;
}
}