[Server] Add Bidirectional Client Communication Support (#109)
* feat(server): add bidirectional client communication support * fix(server): use correct session ID string conversion method * fix: clean up docblocks for request handler methods in Builder * fix: merge artifacts
This commit is contained in:
parent
0963d243bc
commit
f1b471a219
38 changed files with 1973 additions and 445 deletions
|
|
@ -61,6 +61,7 @@
|
|||
"Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/",
|
||||
"Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/",
|
||||
"Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/",
|
||||
"Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/",
|
||||
"Mcp\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
73
examples/custom-method-handlers/CallToolRequestHandler.php
Normal file
73
examples/custom-method-handlers/CallToolRequestHandler.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?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\Example\CustomMethodHandlers;
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\CallToolRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Tool;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/** @implements RequestHandlerInterface<CallToolResult> */
|
||||
class CallToolRequestHandler implements RequestHandlerInterface
|
||||
{
|
||||
/**
|
||||
* @param array<string, Tool> $toolDefinitions
|
||||
*/
|
||||
public function __construct(private array $toolDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof CallToolRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<CallToolResult>|Error
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
\assert($request instanceof CallToolRequest);
|
||||
|
||||
$name = $request->name;
|
||||
$args = $request->arguments ?? [];
|
||||
|
||||
if (!isset($this->toolDefinitions[$name])) {
|
||||
return new Error($request->getId(), Error::METHOD_NOT_FOUND, \sprintf('Tool not found: %s', $name));
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($name) {
|
||||
case 'say_hello':
|
||||
$greetName = (string) ($args['name'] ?? 'world');
|
||||
$result = [new TextContent(\sprintf('Hello, %s!', $greetName))];
|
||||
break;
|
||||
case 'sum':
|
||||
$a = (float) ($args['a'] ?? 0);
|
||||
$b = (float) ($args['b'] ?? 0);
|
||||
$result = [new TextContent((string) ($a + $b))];
|
||||
break;
|
||||
default:
|
||||
$result = [new TextContent('Unknown tool')];
|
||||
}
|
||||
|
||||
return new Response($request->getId(), new CallToolResult($result));
|
||||
} catch (\Throwable $e) {
|
||||
return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true));
|
||||
}
|
||||
}
|
||||
}
|
||||
46
examples/custom-method-handlers/ListToolsRequestHandler.php
Normal file
46
examples/custom-method-handlers/ListToolsRequestHandler.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?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\Example\CustomMethodHandlers;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ListToolsRequest;
|
||||
use Mcp\Schema\Result\ListToolsResult;
|
||||
use Mcp\Schema\Tool;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/** @implements RequestHandlerInterface<ListToolsResult> */
|
||||
class ListToolsRequestHandler implements RequestHandlerInterface
|
||||
{
|
||||
/**
|
||||
* @param array<string, Tool> $toolDefinitions
|
||||
*/
|
||||
public function __construct(private array $toolDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ListToolsRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<ListToolsResult>
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
\assert($request instanceof ListToolsRequest);
|
||||
|
||||
return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,19 +13,11 @@
|
|||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
chdir(__DIR__);
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\CallToolRequest;
|
||||
use Mcp\Schema\Request\ListToolsRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ListToolsResult;
|
||||
use Mcp\Example\CustomMethodHandlers\CallToolRequestHandler;
|
||||
use Mcp\Example\CustomMethodHandlers\ListToolsRequestHandler;
|
||||
use Mcp\Schema\ServerCapabilities;
|
||||
use Mcp\Schema\Tool;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Mcp\Server\Transport\StdioTransport;
|
||||
|
||||
logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...');
|
||||
|
|
@ -58,73 +50,8 @@ $toolDefinitions = [
|
|||
),
|
||||
];
|
||||
|
||||
$listToolsHandler = new class($toolDefinitions) implements RequestHandlerInterface {
|
||||
/**
|
||||
* @param array<string, Tool> $toolDefinitions
|
||||
*/
|
||||
public function __construct(private array $toolDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ListToolsRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
assert($request instanceof ListToolsRequest);
|
||||
|
||||
return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null));
|
||||
}
|
||||
};
|
||||
|
||||
$callToolHandler = new class($toolDefinitions) implements RequestHandlerInterface {
|
||||
/**
|
||||
* @param array<string, Tool> $toolDefinitions
|
||||
*/
|
||||
public function __construct(private array $toolDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof CallToolRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
assert($request instanceof CallToolRequest);
|
||||
|
||||
$name = $request->name;
|
||||
$args = $request->arguments ?? [];
|
||||
|
||||
if (!isset($this->toolDefinitions[$name])) {
|
||||
return new Error($request->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name));
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($name) {
|
||||
case 'say_hello':
|
||||
$greetName = (string) ($args['name'] ?? 'world');
|
||||
$result = [new TextContent(sprintf('Hello, %s!', $greetName))];
|
||||
break;
|
||||
case 'sum':
|
||||
$a = (float) ($args['a'] ?? 0);
|
||||
$b = (float) ($args['b'] ?? 0);
|
||||
$result = [new TextContent((string) ($a + $b))];
|
||||
break;
|
||||
default:
|
||||
$result = [new TextContent('Unknown tool')];
|
||||
}
|
||||
|
||||
return new Response($request->getId(), new CallToolResult($result));
|
||||
} catch (Throwable $e) {
|
||||
return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$listToolsHandler = new ListToolsRequestHandler($toolDefinitions);
|
||||
$callToolHandler = new CallToolRequestHandler($toolDefinitions);
|
||||
$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false);
|
||||
|
||||
$server = Server::builder()
|
||||
|
|
|
|||
124
examples/http-client-communication/server.php
Normal file
124
examples/http-client-communication/server.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?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.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
chdir(__DIR__);
|
||||
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\JsonRpc\Error as JsonRpcError;
|
||||
use Mcp\Schema\ServerCapabilities;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\ClientGateway;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
|
||||
$psr17Factory = new Psr17Factory();
|
||||
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
|
||||
$request = $creator->fromGlobals();
|
||||
|
||||
$sessionDir = __DIR__.'/sessions';
|
||||
$capabilities = new ServerCapabilities(logging: true, tools: true);
|
||||
|
||||
$server = Server::builder()
|
||||
->setServerInfo('HTTP Client Communication Demo', '1.0.0')
|
||||
->setLogger(logger())
|
||||
->setContainer(container())
|
||||
->setSession(new FileSessionStore($sessionDir))
|
||||
->setCapabilities($capabilities)
|
||||
->addTool(
|
||||
function (string $projectName, array $milestones, ClientGateway $client): array {
|
||||
$client->log(LoggingLevel::Info, sprintf('Preparing project briefing for "%s"', $projectName));
|
||||
|
||||
$totalSteps = max(1, count($milestones));
|
||||
|
||||
foreach ($milestones as $index => $milestone) {
|
||||
$progress = ($index + 1) / $totalSteps;
|
||||
$message = sprintf('Analyzing milestone "%s"', $milestone);
|
||||
|
||||
$client->progress(progress: $progress, total: 1, message: $message);
|
||||
|
||||
usleep(150_000); // Simulate work being done
|
||||
}
|
||||
|
||||
$prompt = sprintf(
|
||||
'Draft a concise stakeholder briefing for the project "%s". Highlight key milestones: %s. Focus on risks and next steps.',
|
||||
$projectName,
|
||||
implode(', ', $milestones)
|
||||
);
|
||||
|
||||
$response = $client->sample(
|
||||
prompt: $prompt,
|
||||
maxTokens: 400,
|
||||
timeout: 90,
|
||||
options: ['temperature' => 0.4]
|
||||
);
|
||||
|
||||
if ($response instanceof JsonRpcError) {
|
||||
throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message));
|
||||
}
|
||||
|
||||
$result = $response->result;
|
||||
$content = $result->content instanceof TextContent ? trim((string) $result->content->text) : '';
|
||||
|
||||
$client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.');
|
||||
|
||||
return [
|
||||
'project' => $projectName,
|
||||
'milestones_reviewed' => $milestones,
|
||||
'briefing' => $content,
|
||||
'model' => $result->model,
|
||||
'stop_reason' => $result->stopReason,
|
||||
];
|
||||
},
|
||||
name: 'prepare_project_briefing',
|
||||
description: 'Compile a stakeholder briefing with live logging, progress updates, and LLM sampling.'
|
||||
)
|
||||
->addTool(
|
||||
function (string $serviceName, ClientGateway $client): array {
|
||||
$client->log(LoggingLevel::Info, sprintf('Starting maintenance checks for "%s"', $serviceName));
|
||||
|
||||
$steps = [
|
||||
'Verifying health metrics',
|
||||
'Checking recent deployments',
|
||||
'Reviewing alert stream',
|
||||
'Summarizing findings',
|
||||
];
|
||||
|
||||
foreach ($steps as $index => $step) {
|
||||
$progress = ($index + 1) / count($steps);
|
||||
|
||||
$client->progress(progress: $progress, total: 1, message: $step);
|
||||
|
||||
usleep(120_000); // Simulate work being done
|
||||
}
|
||||
|
||||
$client->log(LoggingLevel::Info, sprintf('Maintenance checks complete for "%s"', $serviceName));
|
||||
|
||||
return [
|
||||
'service' => $serviceName,
|
||||
'status' => 'operational',
|
||||
'notes' => 'No critical issues detected during automated sweep.',
|
||||
];
|
||||
},
|
||||
name: 'run_service_maintenance',
|
||||
description: 'Simulate service maintenance with logging and progress notifications.'
|
||||
)
|
||||
->build();
|
||||
|
||||
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory, logger());
|
||||
|
||||
$response = $server->run($transport);
|
||||
|
||||
(new SapiEmitter())->emit($response);
|
||||
115
examples/stdio-client-communication/server.php
Normal file
115
examples/stdio-client-communication/server.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env php
|
||||
<?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.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
chdir(__DIR__);
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\JsonRpc\Error as JsonRpcError;
|
||||
use Mcp\Schema\ServerCapabilities;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\ClientGateway;
|
||||
use Mcp\Server\Transport\StdioTransport;
|
||||
|
||||
$capabilities = new ServerCapabilities(logging: true, tools: true);
|
||||
|
||||
$server = Server::builder()
|
||||
->setServerInfo('STDIO Client Communication Demo', '1.0.0')
|
||||
->setLogger(logger())
|
||||
->setContainer(container())
|
||||
->setCapabilities($capabilities)
|
||||
->addTool(
|
||||
function (string $incidentTitle, ClientGateway $client): array {
|
||||
$client->log(LoggingLevel::Warning, sprintf('Incident triage started: %s', $incidentTitle));
|
||||
|
||||
$steps = [
|
||||
'Collecting telemetry',
|
||||
'Assessing scope',
|
||||
'Coordinating responders',
|
||||
];
|
||||
|
||||
foreach ($steps as $index => $step) {
|
||||
$progress = ($index + 1) / count($steps);
|
||||
|
||||
$client->progress(progress: $progress, total: 1, message: $step);
|
||||
|
||||
usleep(180_000); // Simulate work being done
|
||||
}
|
||||
|
||||
$prompt = sprintf(
|
||||
'Provide a concise response strategy for incident "%s" based on the steps completed: %s.',
|
||||
$incidentTitle,
|
||||
implode(', ', $steps)
|
||||
);
|
||||
|
||||
$sampling = $client->sample(
|
||||
prompt: $prompt,
|
||||
maxTokens: 350,
|
||||
timeout: 90,
|
||||
options: ['temperature' => 0.5]
|
||||
);
|
||||
|
||||
if ($sampling instanceof JsonRpcError) {
|
||||
throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $sampling->code, $sampling->message));
|
||||
}
|
||||
|
||||
$result = $sampling->result;
|
||||
$recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : '';
|
||||
|
||||
$client->log(LoggingLevel::Info, sprintf('Incident triage completed for %s', $incidentTitle));
|
||||
|
||||
return [
|
||||
'incident' => $incidentTitle,
|
||||
'recommended_actions' => $recommendation,
|
||||
'model' => $result->model,
|
||||
];
|
||||
},
|
||||
name: 'coordinate_incident_response',
|
||||
description: 'Coordinate an incident response with logging, progress, and sampling.'
|
||||
)
|
||||
->addTool(
|
||||
function (string $dataset, ClientGateway $client): array {
|
||||
$client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset));
|
||||
|
||||
$tasks = [
|
||||
'Validating schema',
|
||||
'Scanning for anomalies',
|
||||
'Reviewing statistical summary',
|
||||
];
|
||||
|
||||
foreach ($tasks as $index => $task) {
|
||||
$progress = ($index + 1) / count($tasks);
|
||||
|
||||
$client->progress(progress: $progress, total: 1, message: $task);
|
||||
|
||||
usleep(140_000); // Simulate work being done
|
||||
}
|
||||
|
||||
$client->log(LoggingLevel::Info, sprintf('Dataset "%s" passed automated checks.', $dataset));
|
||||
|
||||
return [
|
||||
'dataset' => $dataset,
|
||||
'status' => 'passed',
|
||||
'notes' => 'No significant integrity issues detected during automated checks.',
|
||||
];
|
||||
},
|
||||
name: 'run_dataset_quality_checks',
|
||||
description: 'Perform dataset quality checks with progress updates and logging.'
|
||||
)
|
||||
->build();
|
||||
|
||||
$transport = new StdioTransport();
|
||||
|
||||
$status = $server->run($transport);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
namespace Mcp\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use Mcp\Server\ClientGateway;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||
|
||||
/**
|
||||
|
|
@ -409,10 +410,19 @@ class SchemaGenerator
|
|||
$parametersInfo = [];
|
||||
|
||||
foreach ($reflection->getParameters() as $rp) {
|
||||
$reflectionType = $rp->getType();
|
||||
|
||||
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
||||
$typeName = $reflectionType->getName();
|
||||
|
||||
if (is_a($typeName, ClientGateway::class, true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$paramName = $rp->getName();
|
||||
$paramTag = $paramTags['$'.$paramName] ?? null;
|
||||
|
||||
$reflectionType = $rp->getType();
|
||||
$typeString = $this->getParameterTypeString($rp, $paramTag);
|
||||
$description = $this->docBlockParser->getParamDescription($paramTag);
|
||||
$hasDefault = $rp->isDefaultValueAvailable();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry;
|
|||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Exception\RegistryException;
|
||||
use Mcp\Server\ClientGateway;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +90,17 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
$paramName = $parameter->getName();
|
||||
$paramPosition = $parameter->getPosition();
|
||||
|
||||
// Check if parameter is a special injectable type
|
||||
$type = $parameter->getType();
|
||||
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
|
||||
$typeName = $type->getName();
|
||||
|
||||
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
|
||||
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($arguments[$paramName])) {
|
||||
$argument = $arguments[$paramName];
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,13 @@ abstract class Request implements HasMethodInterface, MessageInterface
|
|||
$request->id = $data['id'];
|
||||
|
||||
if (isset($data['params']['_meta'])) {
|
||||
$request->meta = $data['params']['_meta'];
|
||||
$meta = $data['params']['_meta'];
|
||||
if ($meta instanceof \stdClass) {
|
||||
$meta = (array) $meta;
|
||||
}
|
||||
if (\is_array($meta)) {
|
||||
$request->meta = $meta;
|
||||
}
|
||||
}
|
||||
|
||||
return $request;
|
||||
|
|
@ -75,6 +81,33 @@ abstract class Request implements HasMethodInterface, MessageInterface
|
|||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getMeta(): ?array
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
public function withId(string|int $id): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->id = $id;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $meta
|
||||
*/
|
||||
public function withMeta(?array $meta): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->meta = $meta;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RequestData
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,23 +14,26 @@ namespace Mcp\Schema\JsonRpc;
|
|||
use Mcp\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
* @template TResult
|
||||
*
|
||||
* @phpstan-type ResponseData array{
|
||||
* jsonrpc: string,
|
||||
* id: string|int,
|
||||
* result: array<string, mixed>,
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class Response implements MessageInterface
|
||||
{
|
||||
/**
|
||||
* @param string|int $id this MUST be the same as the value of the id member in the Request Object
|
||||
* @param ResultInterface|array<string, mixed> $result the value of this member is determined by the method invoked on the Server
|
||||
* @param string|int $id this MUST be the same as the value of the id member in the Request Object
|
||||
* @param TResult $result the value of this member is determined by the method invoked on the Server
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string|int $id,
|
||||
public readonly ResultInterface|array $result,
|
||||
/** @var TResult */
|
||||
public readonly mixed $result,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +44,8 @@ class Response implements MessageInterface
|
|||
|
||||
/**
|
||||
* @param ResponseData $data
|
||||
*
|
||||
* @return self<array<string, mixed>>
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
|
|
@ -67,7 +72,7 @@ class Response implements MessageInterface
|
|||
* @return array{
|
||||
* jsonrpc: string,
|
||||
* id: string|int,
|
||||
* result: ResultInterface,
|
||||
* result: mixed,
|
||||
* }
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Schema\Result;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Content\AudioContent;
|
||||
use Mcp\Schema\Content\ImageContent;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
|
@ -40,6 +41,51 @@ class CreateSamplingMessageResult implements ResultInterface
|
|||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
if (!isset($data['role']) || !\is_string($data['role'])) {
|
||||
throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.');
|
||||
}
|
||||
|
||||
if (!isset($data['content']) || !\is_array($data['content'])) {
|
||||
throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.');
|
||||
}
|
||||
|
||||
if (!isset($data['model']) || !\is_string($data['model'])) {
|
||||
throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.');
|
||||
}
|
||||
|
||||
$role = Role::from($data['role']);
|
||||
$contentPayload = $data['content'];
|
||||
|
||||
$content = self::hydrateContent($contentPayload);
|
||||
$stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null;
|
||||
|
||||
return new self($role, $content, $data['model'], $stopReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $contentData
|
||||
*/
|
||||
private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent
|
||||
{
|
||||
$type = $contentData['type'] ?? null;
|
||||
|
||||
if (!\is_string($type)) {
|
||||
throw new InvalidArgumentException('Missing or invalid "type" in sampling content payload.');
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'text' => TextContent::fromArray($contentData),
|
||||
'image' => ImageContent::fromArray($contentData),
|
||||
'audio' => AudioContent::fromArray($contentData),
|
||||
default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* role: string,
|
||||
|
|
|
|||
|
|
@ -43,12 +43,12 @@ final class Server
|
|||
*/
|
||||
public function run(TransportInterface $transport): mixed
|
||||
{
|
||||
$this->logger->info('Running server...');
|
||||
|
||||
$transport->initialize();
|
||||
|
||||
$this->protocol->connect($transport);
|
||||
|
||||
$this->logger->info('Running server...');
|
||||
|
||||
try {
|
||||
return $transport->listen();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ final class Builder
|
|||
private ?string $instructions = null;
|
||||
|
||||
/**
|
||||
* @var array<int, RequestHandlerInterface>
|
||||
* @var array<int, RequestHandlerInterface<mixed>>
|
||||
*/
|
||||
private array $requestHandlers = [];
|
||||
|
||||
|
|
@ -176,6 +176,8 @@ final class Builder
|
|||
|
||||
/**
|
||||
* Register a single custom method handler.
|
||||
*
|
||||
* @param RequestHandlerInterface<mixed> $handler
|
||||
*/
|
||||
public function addRequestHandler(RequestHandlerInterface $handler): self
|
||||
{
|
||||
|
|
@ -187,7 +189,7 @@ final class Builder
|
|||
/**
|
||||
* Register multiple custom method handlers.
|
||||
*
|
||||
* @param iterable<int, RequestHandlerInterface> $handlers
|
||||
* @param iterable<RequestHandlerInterface<mixed>> $handlers
|
||||
*/
|
||||
public function addRequestHandlers(iterable $handlers): self
|
||||
{
|
||||
|
|
|
|||
183
src/Server/ClientGateway.php
Normal file
183
src/Server/ClientGateway.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?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\Schema\Content\SamplingMessage;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\Enum\Role;
|
||||
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\Result\CreateSamplingMessageResult;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* 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, ClientGateway $client): string {
|
||||
* // Send progress notification
|
||||
* $client->notify(new ProgressNotification("Starting analysis..."));
|
||||
*
|
||||
* // Request LLM sampling from client
|
||||
* $response = $client->request(new SamplingRequest($text));
|
||||
*
|
||||
* return $response->content->text;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
final 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.
|
||||
*/
|
||||
public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void
|
||||
{
|
||||
$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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function request(Request $request, int $timeout = 120): Response|Error
|
||||
{
|
||||
$response = \Fiber::suspend([
|
||||
'type' => 'request',
|
||||
'request' => $request,
|
||||
'session_id' => $this->session->getId()->toRfc4122(),
|
||||
'timeout' => $timeout,
|
||||
]);
|
||||
|
||||
if (!$response instanceof Response && !$response instanceof Error) {
|
||||
throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and send an LLM sampling requests.
|
||||
*
|
||||
* @param CreateSamplingMessageRequest $request The request to send
|
||||
* @param int $timeout The timeout in seconds
|
||||
*
|
||||
* @return Response<CreateSamplingMessageResult>|Error The sampling response
|
||||
*/
|
||||
public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error
|
||||
{
|
||||
$response = $this->request($request, $timeout);
|
||||
|
||||
if ($response instanceof Error) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$result = CreateSamplingMessageResult::fromArray($response->result);
|
||||
|
||||
return new Response($response->getId(), $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for LLM sampling requests.
|
||||
*
|
||||
* @param string $prompt The prompt for the LLM
|
||||
* @param int $maxTokens Maximum tokens to generate
|
||||
* @param int $timeout The timeout in seconds
|
||||
* @param array<string, mixed> $options Additional sampling options (temperature, etc.)
|
||||
*
|
||||
* @return Response<CreateSamplingMessageResult>|Error The sampling response
|
||||
*/
|
||||
public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error
|
||||
{
|
||||
$preferences = $options['preferences'] ?? null;
|
||||
if (\is_array($preferences)) {
|
||||
$preferences = ModelPreferences::fromArray($preferences);
|
||||
}
|
||||
|
||||
if (null !== $preferences && !$preferences instanceof ModelPreferences) {
|
||||
throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.');
|
||||
}
|
||||
|
||||
$samplingRequest = new CreateSamplingMessageRequest(
|
||||
messages: [
|
||||
new SamplingMessage(Role::User, new TextContent(text: $prompt)),
|
||||
],
|
||||
maxTokens: $maxTokens,
|
||||
preferences: $preferences,
|
||||
systemPrompt: $options['systemPrompt'] ?? null,
|
||||
includeContext: $options['includeContext'] ?? null,
|
||||
temperature: $options['temperature'] ?? null,
|
||||
stopSequences: $options['stopSequences'] ?? null,
|
||||
metadata: $options['metadata'] ?? null,
|
||||
);
|
||||
|
||||
return $this->createMessage($samplingRequest, $timeout);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ use Psr\Log\LoggerInterface;
|
|||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<CallToolResult>
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
|
|
@ -43,6 +45,9 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
return $request instanceof CallToolRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<CallToolResult>|Error
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
\assert($request instanceof CallToolRequest);
|
||||
|
|
@ -58,6 +63,8 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
throw new ToolNotFoundException($request);
|
||||
}
|
||||
|
||||
$arguments['_session'] = $session;
|
||||
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
if (!$result instanceof CallToolResult) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ use Psr\Container\ContainerInterface;
|
|||
/**
|
||||
* Handles completion/complete requests.
|
||||
*
|
||||
* @implements RequestHandlerInterface<CompletionCompleteResult>
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
final class CompletionCompleteHandler implements RequestHandlerInterface
|
||||
|
|
@ -39,6 +41,9 @@ final class CompletionCompleteHandler implements RequestHandlerInterface
|
|||
return $request instanceof CompletionCompleteRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<CompletionCompleteResult>|Error
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
\assert($request instanceof CompletionCompleteRequest);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ use Psr\Log\LoggerInterface;
|
|||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<GetPromptResult>
|
||||
*
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class GetPromptHandler implements RequestHandlerInterface
|
||||
|
|
@ -42,6 +44,9 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
return $request instanceof GetPromptRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<GetPromptResult>|Error
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
\assert($request instanceof GetPromptRequest);
|
||||
|
|
@ -55,6 +60,8 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
throw new PromptNotFoundException($request);
|
||||
}
|
||||
|
||||
$arguments['_session'] = $session;
|
||||
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
$formatted = $reference->formatResult($result);
|
||||
|
|
@ -67,11 +74,11 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
} catch (PromptGetException|ExceptionInterface $e) {
|
||||
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
|
||||
|
||||
return Error::forInternalError('Error while handling prompt', $request->getId());
|
||||
return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
|
||||
|
||||
return Error::forInternalError('Error while handling prompt', $request->getId());
|
||||
return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ use Mcp\Server\Configuration;
|
|||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<InitializeResult>
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class InitializeHandler implements RequestHandlerInterface
|
||||
|
|
@ -35,6 +37,9 @@ final class InitializeHandler implements RequestHandlerInterface
|
|||
return $request instanceof InitializeRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Response<InitializeResult>
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
\assert($request instanceof InitializeRequest);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListPromptsResult;
|
|||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<ListPromptsResult>
|
||||
*
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class ListPromptsHandler implements RequestHandlerInterface
|
||||
|
|
@ -36,6 +38,8 @@ final class ListPromptsHandler implements RequestHandlerInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return Response<ListPromptsResult>
|
||||
*
|
||||
* @throws InvalidCursorException
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListResourceTemplatesResult;
|
|||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<ListResourceTemplatesResult>
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class ListResourceTemplatesHandler implements RequestHandlerInterface
|
||||
|
|
@ -36,6 +38,8 @@ final class ListResourceTemplatesHandler implements RequestHandlerInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return Response<ListResourceTemplatesResult>
|
||||
*
|
||||
* @throws InvalidCursorException
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue