From 1ff29a3412a411c015a4463b127b7a4efadf8b27 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Wed, 8 Apr 2026 22:38:13 +0100 Subject: [PATCH 1/2] feat: Modern RPC Server design Remodels the old horde/rpc into a PSR-7 based Request Handler / Middleware approach. Emits events for a PSR-14 EventListener instead of native logging/observability handling. Currently implements soap (wsdl-less, backed by php-ext-soap) and json-rpc (now both for 1.1 and 2.0 protocols) On top of JSON-RPC it also models a simple MCP protocol for tools and resources. The new library is decoupled from Horde context, i.e. the Registry is now one of many pluggable API providers. The Registry API provider may need to migrate into a separate package in a later stage but we are not there yet. In middleware mode, the protocol handlers check if the incoming request looks like it is for them to handle and if not, they pass to the next middleware. In handler mode, a request not matching protocol expectations emits an appropriate error response format. Implementing ActiveSync and DAV Protocol Handlers in the same fashion needs some ground work in other areas first. --- src/JsonRpc/Dispatch/CallableMapProvider.php | 71 ++++ .../Dispatch/HordeRegistryApiProvider.php | 88 +++++ src/JsonRpc/Dispatch/MathApiProvider.php | 143 ++++++++ src/JsonRpc/Dispatch/MethodDescriptor.php | 6 + src/JsonRpc/JsonRpcClient.php | 102 ++++++ src/JsonRpc/JsonRpcHandler.php | 19 +- src/JsonRpc/Transport/HttpHandler.php | 56 +++- .../Middleware/JsonRpcMiddleware.php | 57 ---- src/Mcp/AuthContext.php | 66 ++++ src/Mcp/McpError.php | 61 ++++ src/Mcp/McpRouter.php | 162 +++++++++ src/Mcp/McpServer.php | 86 +++++ src/Mcp/Protocol/ResourceContent.php | 41 +++ src/Mcp/Protocol/ResourceDescriptor.php | 41 +++ src/Mcp/Protocol/ServerCapabilities.php | 46 +++ src/Mcp/Protocol/ServerInfo.php | 37 +++ src/Mcp/Protocol/ToolDescriptor.php | 107 ++++++ src/Mcp/ResourceProviderInterface.php | 38 +++ src/Mcp/Transport/HttpHandler.php | 180 ++++++++++ src/Soap/SoapCallHandler.php | 48 +++ src/Soap/SoapHandler.php | 164 ++++++++++ .../Dispatch/CallableMapProviderTest.php | 78 +++++ .../Dispatch/HordeRegistryApiProviderTest.php | 144 ++++++++ .../JsonRpc/Dispatch/MathApiProviderTest.php | 135 ++++++++ .../JsonRpc/Dispatch/MethodDescriptorTest.php | 40 +++ test/Unit/JsonRpc/JsonRpcClientTest.php | 140 ++++++++ test/Unit/JsonRpc/JsonRpcHandlerTest.php | 6 +- .../JsonRpc/Transport/HttpHandlerTest.php | 122 +++++++ .../Transport/JsonRpcMiddlewareTest.php | 138 -------- test/Unit/Mcp/AuthContextTest.php | 55 ++++ test/Unit/Mcp/McpRouterTest.php | 240 ++++++++++++++ test/Unit/Mcp/Protocol/ToolDescriptorTest.php | 108 ++++++ test/Unit/Mcp/Transport/HttpHandlerTest.php | 307 ++++++++++++++++++ test/Unit/Soap/SoapHandlerTest.php | 266 +++++++++++++++ 34 files changed, 3194 insertions(+), 204 deletions(-) create mode 100644 src/JsonRpc/Dispatch/CallableMapProvider.php create mode 100644 src/JsonRpc/Dispatch/HordeRegistryApiProvider.php create mode 100644 src/JsonRpc/Dispatch/MathApiProvider.php create mode 100644 src/JsonRpc/JsonRpcClient.php delete mode 100644 src/JsonRpc/Transport/Middleware/JsonRpcMiddleware.php create mode 100644 src/Mcp/AuthContext.php create mode 100644 src/Mcp/McpError.php create mode 100644 src/Mcp/McpRouter.php create mode 100644 src/Mcp/McpServer.php create mode 100644 src/Mcp/Protocol/ResourceContent.php create mode 100644 src/Mcp/Protocol/ResourceDescriptor.php create mode 100644 src/Mcp/Protocol/ServerCapabilities.php create mode 100644 src/Mcp/Protocol/ServerInfo.php create mode 100644 src/Mcp/Protocol/ToolDescriptor.php create mode 100644 src/Mcp/ResourceProviderInterface.php create mode 100644 src/Mcp/Transport/HttpHandler.php create mode 100644 src/Soap/SoapCallHandler.php create mode 100644 src/Soap/SoapHandler.php create mode 100644 test/Unit/JsonRpc/Dispatch/CallableMapProviderTest.php create mode 100644 test/Unit/JsonRpc/Dispatch/HordeRegistryApiProviderTest.php create mode 100644 test/Unit/JsonRpc/Dispatch/MathApiProviderTest.php create mode 100644 test/Unit/JsonRpc/JsonRpcClientTest.php delete mode 100644 test/Unit/JsonRpc/Transport/JsonRpcMiddlewareTest.php create mode 100644 test/Unit/Mcp/AuthContextTest.php create mode 100644 test/Unit/Mcp/McpRouterTest.php create mode 100644 test/Unit/Mcp/Protocol/ToolDescriptorTest.php create mode 100644 test/Unit/Mcp/Transport/HttpHandlerTest.php create mode 100644 test/Unit/Soap/SoapHandlerTest.php diff --git a/src/JsonRpc/Dispatch/CallableMapProvider.php b/src/JsonRpc/Dispatch/CallableMapProvider.php new file mode 100644 index 0000000..d848968 --- /dev/null +++ b/src/JsonRpc/Dispatch/CallableMapProvider.php @@ -0,0 +1,71 @@ + fn(float $a, float $b): float => $a + $b, + * 'ping' => fn(): string => 'pong', + * ]); + */ +final class CallableMapProvider implements ApiProviderInterface, MethodInvokerInterface +{ + /** @var array */ + private readonly array $methods; + + /** @var array */ + private readonly array $descriptors; + + /** + * @param array $methods Name→callable map + * @param array $descriptors Optional descriptors keyed by method name. + * Methods without an explicit descriptor get a minimal auto-generated one. + */ + public function __construct(array $methods, array $descriptors = []) + { + $this->methods = $methods; + + $merged = []; + foreach ($methods as $name => $callable) { + $merged[$name] = $descriptors[$name] ?? new MethodDescriptor($name); + } + $this->descriptors = $merged; + } + + public function hasMethod(string $method): bool + { + return isset($this->methods[$method]); + } + + public function getMethodDescriptor(string $method): ?MethodDescriptor + { + return $this->descriptors[$method] ?? null; + } + + public function listMethods(): array + { + return array_values($this->descriptors); + } + + public function invoke(string $method, array $params): Result + { + return new Result(($this->methods[$method])(...$params)); + } +} diff --git a/src/JsonRpc/Dispatch/HordeRegistryApiProvider.php b/src/JsonRpc/Dispatch/HordeRegistryApiProvider.php new file mode 100644 index 0000000..2040761 --- /dev/null +++ b/src/JsonRpc/Dispatch/HordeRegistryApiProvider.php @@ -0,0 +1,88 @@ +registry->hasMethod($this->dotToSlash($method)); + } + + public function getMethodDescriptor(string $method): ?MethodDescriptor + { + if (!$this->hasMethod($method)) { + return null; + } + + return new MethodDescriptor($method); + } + + public function listMethods(): array + { + $descriptors = []; + foreach ($this->registry->listMethods() as $slashMethod) { + $dotMethod = $this->slashToDot($slashMethod); + $descriptors[] = new MethodDescriptor($dotMethod); + } + + return $descriptors; + } + + public function invoke(string $method, array $params): Result + { + $slashMethod = $this->dotToSlash($method); + + if (!$this->registry->hasMethod($slashMethod)) { + throw new MethodNotFoundException($method); + } + + try { + $result = $this->registry->call($slashMethod, $params); + } catch (Horde_Exception $e) { + throw new InternalErrorException($e->getMessage(), previous: $e); + } + + return new Result($result); + } + + private function dotToSlash(string $method): string + { + return str_replace('.', '/', $method); + } + + private function slashToDot(string $method): string + { + return str_replace('/', '.', $method); + } +} diff --git a/src/JsonRpc/Dispatch/MathApiProvider.php b/src/JsonRpc/Dispatch/MathApiProvider.php new file mode 100644 index 0000000..6df5901 --- /dev/null +++ b/src/JsonRpc/Dispatch/MathApiProvider.php @@ -0,0 +1,143 @@ +inner = new CallableMapProvider( + [ + 'math.add' => fn(float $a, float $b): float => $a + $b, + 'math.subtract' => fn(float $a, float $b): float => $a - $b, + 'math.multiply' => fn(float $a, float $b): float => $a * $b, + 'math.divide' => static function (float $a, float $b): float { + if ($b == 0.0) { + throw new InvalidParamsException('Division by zero'); + } + + return $a / $b; + }, + ], + [ + 'math.add' => new MethodDescriptor( + 'math.add', + 'Add two numbers', + [ + ['name' => 'a', 'type' => 'float', 'required' => true], + ['name' => 'b', 'type' => 'float', 'required' => true], + ], + 'float', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'First number'], + 'b' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['a', 'b'], + ], + ), + 'math.subtract' => new MethodDescriptor( + 'math.subtract', + 'Subtract b from a', + [ + ['name' => 'a', 'type' => 'float', 'required' => true], + ['name' => 'b', 'type' => 'float', 'required' => true], + ], + 'float', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'Minuend'], + 'b' => ['type' => 'number', 'description' => 'Subtrahend'], + ], + 'required' => ['a', 'b'], + ], + ), + 'math.multiply' => new MethodDescriptor( + 'math.multiply', + 'Multiply two numbers', + [ + ['name' => 'a', 'type' => 'float', 'required' => true], + ['name' => 'b', 'type' => 'float', 'required' => true], + ], + 'float', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'First factor'], + 'b' => ['type' => 'number', 'description' => 'Second factor'], + ], + 'required' => ['a', 'b'], + ], + ), + 'math.divide' => new MethodDescriptor( + 'math.divide', + 'Divide a by b (throws InvalidParamsException on division by zero)', + [ + ['name' => 'a', 'type' => 'float', 'required' => true], + ['name' => 'b', 'type' => 'float', 'required' => true], + ], + 'float', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'Dividend'], + 'b' => ['type' => 'number', 'description' => 'Divisor (must not be zero)'], + ], + 'required' => ['a', 'b'], + ], + ), + ], + ); + } + + public static function create(): self + { + return new self(); + } + + public function hasMethod(string $method): bool + { + return $this->inner->hasMethod($method); + } + + public function getMethodDescriptor(string $method): ?MethodDescriptor + { + return $this->inner->getMethodDescriptor($method); + } + + public function listMethods(): array + { + return $this->inner->listMethods(); + } + + public function invoke(string $method, array $params): Result + { + return $this->inner->invoke($method, $params); + } +} diff --git a/src/JsonRpc/Dispatch/MethodDescriptor.php b/src/JsonRpc/Dispatch/MethodDescriptor.php index 694c289..0f2941b 100644 --- a/src/JsonRpc/Dispatch/MethodDescriptor.php +++ b/src/JsonRpc/Dispatch/MethodDescriptor.php @@ -21,11 +21,17 @@ * @param string $description Human-readable description * @param array $parameters Parameter descriptors (name, type, required, description) * @param ?string $returnType Return type description + * @param ?array $inputSchema JSON Schema for MCP tool input (auto-generated from $parameters if null) + * @param ?array $outputSchema JSON Schema for MCP tool output (optional) + * @param array $permissions Required permissions (empty = public, like Horde's $_noPerms) */ public function __construct( public string $name, public string $description = '', public array $parameters = [], public ?string $returnType = null, + public ?array $inputSchema = null, + public ?array $outputSchema = null, + public array $permissions = [], ) {} } diff --git a/src/JsonRpc/JsonRpcClient.php b/src/JsonRpc/JsonRpcClient.php new file mode 100644 index 0000000..63433c1 --- /dev/null +++ b/src/JsonRpc/JsonRpcClient.php @@ -0,0 +1,102 @@ +call('math.add', [3, 4]); + * + * // With a custom PSR-18 client: + * $client = new JsonRpcClient('https://api.example.com/rpc', $guzzleClient); + */ +final class JsonRpcClient +{ + private readonly HttpClient $transport; + + public function __construct( + private readonly string $url, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + Version $version = Version::V2_0, + ) { + $streamFactory ??= new StreamFactory(); + $requestFactory ??= new RequestFactory(); + + if ($httpClient === null) { + $responseFactory = new ResponseFactory(); + $httpClient = new Curl($responseFactory, $streamFactory, new Options()); + } + + $this->transport = new HttpClient( + $httpClient, + $requestFactory, + $streamFactory, + new Codec(), + $version, + ); + } + + /** + * Send a JSON-RPC request and return the result. + * + * @throws \Horde\Rpc\JsonRpc\Exception\JsonRpcThrowable On JSON-RPC error + * @throws \Psr\Http\Client\ClientExceptionInterface On HTTP transport failure + */ + public function call(string $method, array $params = []): mixed + { + return $this->transport->call($this->url, $method, $params); + } + + /** + * Send a JSON-RPC notification (no response expected). + * + * @throws \Psr\Http\Client\ClientExceptionInterface On HTTP transport failure + */ + public function notify(string $method, array $params = []): void + { + $this->transport->notify($this->url, $method, $params); + } + + /** + * Send a batch of JSON-RPC requests (2.0 only). + * + * @param list $requests + * @return list + * @throws \Psr\Http\Client\ClientExceptionInterface On HTTP transport failure + */ + public function batch(array $requests): array + { + return $this->transport->batch($this->url, $requests); + } +} diff --git a/src/JsonRpc/JsonRpcHandler.php b/src/JsonRpc/JsonRpcHandler.php index d217082..dc3266e 100644 --- a/src/JsonRpc/JsonRpcHandler.php +++ b/src/JsonRpc/JsonRpcHandler.php @@ -16,10 +16,10 @@ use Horde\Rpc\JsonRpc\Dispatch\MethodInvokerInterface; use Horde\Rpc\JsonRpc\Protocol\Codec; use Horde\Rpc\JsonRpc\Transport\HttpHandler; -use Horde\Rpc\JsonRpc\Transport\Middleware\JsonRpcMiddleware; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\MiddlewareInterface; /** * Convenience facade that wires all JSON-RPC components together. @@ -38,6 +38,7 @@ public function __construct( StreamFactoryInterface $streamFactory, EventDispatcherInterface $eventDispatcher, int $maxBatchSize = 100, + string $path = '/rpc/jsonrpc', ) { $codec = new Codec(); $dispatcher = new Dispatcher($provider, $invoker); @@ -49,11 +50,16 @@ public function __construct( $streamFactory, $eventDispatcher, $maxBatchSize, + $path, ); } /** - * Get the PSR-15 request handler for direct use. + * Get the handler for direct use or as middleware. + * + * The returned object implements both RequestHandlerInterface and + * MiddlewareInterface, so it can be used directly or piped into + * a middleware stack. */ public function getHandler(): HttpHandler { @@ -61,10 +67,13 @@ public function getHandler(): HttpHandler } /** - * Get a PSR-15 middleware that routes matching requests to the handler. + * Get the handler typed as MiddlewareInterface for middleware stacks. + * + * Returns the same object as getHandler(). Provided for readability + * when the caller only needs the middleware interface. */ - public function getMiddleware(string $path = '/rpc/jsonrpc'): JsonRpcMiddleware + public function getMiddleware(): MiddlewareInterface { - return new JsonRpcMiddleware($this->httpHandler, $path); + return $this->httpHandler; } } diff --git a/src/JsonRpc/Transport/HttpHandler.php b/src/JsonRpc/Transport/HttpHandler.php index 3faf11b..8770fac 100644 --- a/src/JsonRpc/Transport/HttpHandler.php +++ b/src/JsonRpc/Transport/HttpHandler.php @@ -33,16 +33,25 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Throwable; /** - * PSR-15 request handler for JSON-RPC over HTTP. + * PSR-15 handler and middleware for JSON-RPC over HTTP. + * + * Implements both RequestHandlerInterface (direct routing) and + * MiddlewareInterface (protocol detection in a middleware stack). + * + * As middleware: detects JSON-RPC requests by method, path, and + * content type, handles matching requests or passes through. + * + * As handler: processes the request as JSON-RPC unconditionally. * * Returns HTTP 200 for all JSON-RPC responses (success and error), * HTTP 204 for JSON-RPC 2.0 notifications. */ -final class HttpHandler implements RequestHandlerInterface +final class HttpHandler implements RequestHandlerInterface, MiddlewareInterface { public function __construct( private readonly Codec $codec, @@ -51,9 +60,52 @@ public function __construct( private readonly StreamFactoryInterface $streamFactory, private readonly EventDispatcherInterface $eventDispatcher, private readonly int $maxBatchSize = 100, + private readonly string $path = '/rpc/jsonrpc', ) {} + /** + * PSR-15 MiddlewareInterface: detect JSON-RPC request or pass through. + */ + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $next, + ): ResponseInterface { + if ($this->matches($request)) { + return $this->handleJsonRpc($request); + } + + return $next->handle($request); + } + + /** + * PSR-15 RequestHandlerInterface: handle request as JSON-RPC. + */ public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->handleJsonRpc($request); + } + + /** + * Check whether this request looks like a JSON-RPC request. + * + * Matches: POST + configured path + JSON content type. + */ + private function matches(ServerRequestInterface $request): bool + { + if ($request->getMethod() !== 'POST') { + return false; + } + + if ($request->getUri()->getPath() !== $this->path) { + return false; + } + + $contentType = $request->getHeaderLine('Content-Type'); + + return str_contains($contentType, 'json'); + } + + private function handleJsonRpc(ServerRequestInterface $request): ResponseInterface { $body = (string) $request->getBody(); diff --git a/src/JsonRpc/Transport/Middleware/JsonRpcMiddleware.php b/src/JsonRpc/Transport/Middleware/JsonRpcMiddleware.php deleted file mode 100644 index a446648..0000000 --- a/src/JsonRpc/Transport/Middleware/JsonRpcMiddleware.php +++ /dev/null @@ -1,57 +0,0 @@ -matches($request)) { - return $this->handler->handle($request); - } - - return $next->handle($request); - } - - private function matches(ServerRequestInterface $request): bool - { - if ($request->getMethod() !== 'POST') { - return false; - } - - if ($request->getUri()->getPath() !== $this->path) { - return false; - } - - $contentType = $request->getHeaderLine('Content-Type'); - - return str_contains($contentType, 'json'); - } -} diff --git a/src/Mcp/AuthContext.php b/src/Mcp/AuthContext.php new file mode 100644 index 0000000..ce350f9 --- /dev/null +++ b/src/Mcp/AuthContext.php @@ -0,0 +1,66 @@ + $permissions Granted permission identifiers + * @param bool $authenticated Whether the user is authenticated + */ + public function __construct( + public array $permissions = [], + public bool $authenticated = false, + ) {} + + public static function anonymous(): self + { + return new self(); + } + + /** + * @param list $permissions Granted permissions + */ + public static function withPermissions(array $permissions): self + { + return new self($permissions, true); + } + + public function hasPermission(string $permission): bool + { + return in_array($permission, $this->permissions, true); + } + + /** + * @param list $permissions Required permissions + */ + public function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (!$this->hasPermission($permission)) { + return false; + } + } + + return true; + } +} diff --git a/src/Mcp/McpError.php b/src/Mcp/McpError.php new file mode 100644 index 0000000..5eb8730 --- /dev/null +++ b/src/Mcp/McpError.php @@ -0,0 +1,61 @@ +jsonRpcCode; + } + + public function getErrorData(): mixed + { + return $this->errorData; + } + + public static function methodNotFound(string $method): self + { + return new self("Method not found: $method", -32601); + } + + public static function toolNotFound(string $name): self + { + return new self("Unknown tool: $name", -32602); + } + + public static function resourceNotFound(string $uri): self + { + return new self("Resource not found", -32002, ['uri' => $uri]); + } + + public static function unauthorized(string $method): self + { + return new self("Unauthorized: $method requires authentication", -32603); + } + + public static function invalidRequest(string $message): self + { + return new self($message, -32600); + } +} diff --git a/src/Mcp/McpRouter.php b/src/Mcp/McpRouter.php new file mode 100644 index 0000000..0c51eb8 --- /dev/null +++ b/src/Mcp/McpRouter.php @@ -0,0 +1,162 @@ + $this->handleInitialize($params), + 'ping' => $this->handlePing(), + 'tools/list' => $this->handleToolsList(), + 'tools/call' => $this->handleToolsCall($params, $authContext), + 'resources/list' => $this->handleResourcesList(), + 'resources/read' => $this->handleResourcesRead($params), + default => throw McpError::methodNotFound($method), + }; + } + + /** + * Check if a method is a notification (no response expected). + */ + public function isNotification(string $method): bool + { + return $method === 'notifications/initialized' + || str_starts_with($method, 'notifications/'); + } + + private function handleInitialize(array $params): array + { + return [ + 'protocolVersion' => self::PROTOCOL_VERSION, + 'capabilities' => $this->capabilities->toArray(), + 'serverInfo' => $this->serverInfo->toArray(), + ]; + } + + private function handlePing(): array + { + return []; + } + + private function handleToolsList(): array + { + $tools = []; + foreach ($this->provider->listMethods() as $descriptor) { + $tools[] = ToolDescriptor::fromMethodDescriptor($descriptor)->toArray(); + } + + return ['tools' => $tools]; + } + + private function handleToolsCall(array $params, AuthContext $authContext): array + { + $name = $params['name'] ?? ''; + $arguments = $params['arguments'] ?? []; + + if (!$this->provider->hasMethod($name)) { + throw McpError::toolNotFound($name); + } + + // Per-method auth check + $descriptor = $this->provider->getMethodDescriptor($name); + if ($descriptor !== null && !empty($descriptor->permissions)) { + if (!$authContext->hasAllPermissions($descriptor->permissions)) { + throw McpError::unauthorized($name); + } + } + + try { + $result = $this->invoker->invoke($name, (array) $arguments); + $encoded = json_encode($result->value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + return [ + 'content' => [ + ['type' => 'text', 'text' => $encoded], + ], + 'isError' => false, + ]; + } catch (\Throwable $e) { + return [ + 'content' => [ + ['type' => 'text', 'text' => $e->getMessage()], + ], + 'isError' => true, + ]; + } + } + + private function handleResourcesList(): array + { + if ($this->resourceProvider === null) { + throw McpError::methodNotFound('resources/list'); + } + + $resources = []; + foreach ($this->resourceProvider->listResources() as $desc) { + $resources[] = $desc->toArray(); + } + + return ['resources' => $resources]; + } + + private function handleResourcesRead(array $params): array + { + if ($this->resourceProvider === null) { + throw McpError::methodNotFound('resources/read'); + } + + $uri = $params['uri'] ?? ''; + if (!$this->resourceProvider->hasResource($uri)) { + throw McpError::resourceNotFound($uri); + } + + $content = $this->resourceProvider->readResource($uri); + + return ['contents' => [$content->toArray()]]; + } +} diff --git a/src/Mcp/McpServer.php b/src/Mcp/McpServer.php new file mode 100644 index 0000000..54f518a --- /dev/null +++ b/src/Mcp/McpServer.php @@ -0,0 +1,86 @@ +getHandler(); + */ +final class McpServer +{ + private readonly HttpHandler $httpHandler; + + public function __construct( + ServerInfo $serverInfo, + ApiProviderInterface $provider, + MethodInvokerInterface $invoker, + ResponseFactoryInterface $responseFactory, + StreamFactoryInterface $streamFactory, + ?ResourceProviderInterface $resourceProvider = null, + string $path = '/mcp', + ) { + $capabilities = new ServerCapabilities( + tools: true, + resources: $resourceProvider !== null, + ); + + $router = new McpRouter( + $serverInfo, + $capabilities, + $provider, + $invoker, + $resourceProvider, + ); + + $this->httpHandler = new HttpHandler($router, $responseFactory, $streamFactory, $path); + } + + /** + * Get the handler for direct use or as middleware. + * + * The returned object implements both RequestHandlerInterface and + * MiddlewareInterface, so it can be used directly or piped into + * a middleware stack. + */ + public function getHandler(): HttpHandler + { + return $this->httpHandler; + } + + /** + * Get the handler typed as MiddlewareInterface for middleware stacks. + * + * Returns the same object as getHandler(). Provided for readability + * when the caller only needs the middleware interface. + */ + public function getMiddleware(): MiddlewareInterface + { + return $this->httpHandler; + } +} diff --git a/src/Mcp/Protocol/ResourceContent.php b/src/Mcp/Protocol/ResourceContent.php new file mode 100644 index 0000000..1ec2b03 --- /dev/null +++ b/src/Mcp/Protocol/ResourceContent.php @@ -0,0 +1,41 @@ + $this->uri]; + if ($this->mimeType !== null) { + $data['mimeType'] = $this->mimeType; + } + if ($this->text !== null) { + $data['text'] = $this->text; + } + if ($this->blob !== null) { + $data['blob'] = $this->blob; + } + + return $data; + } +} diff --git a/src/Mcp/Protocol/ResourceDescriptor.php b/src/Mcp/Protocol/ResourceDescriptor.php new file mode 100644 index 0000000..fd0cd64 --- /dev/null +++ b/src/Mcp/Protocol/ResourceDescriptor.php @@ -0,0 +1,41 @@ + $this->uri, + 'name' => $this->name, + ]; + if ($this->description !== '') { + $data['description'] = $this->description; + } + if ($this->mimeType !== null) { + $data['mimeType'] = $this->mimeType; + } + + return $data; + } +} diff --git a/src/Mcp/Protocol/ServerCapabilities.php b/src/Mcp/Protocol/ServerCapabilities.php new file mode 100644 index 0000000..f9b054b --- /dev/null +++ b/src/Mcp/Protocol/ServerCapabilities.php @@ -0,0 +1,46 @@ +tools) { + $toolsCap = []; + if ($this->toolsListChanged) { + $toolsCap['listChanged'] = true; + } + $caps['tools'] = empty($toolsCap) ? new \stdClass() : $toolsCap; + } + if ($this->resources) { + $resCap = []; + if ($this->resourcesListChanged) { + $resCap['listChanged'] = true; + } + $caps['resources'] = empty($resCap) ? new \stdClass() : $resCap; + } + + return $caps; + } +} diff --git a/src/Mcp/Protocol/ServerInfo.php b/src/Mcp/Protocol/ServerInfo.php new file mode 100644 index 0000000..ddc9663 --- /dev/null +++ b/src/Mcp/Protocol/ServerInfo.php @@ -0,0 +1,37 @@ + $this->name, + 'version' => $this->version, + ]; + if ($this->description !== '') { + $data['description'] = $this->description; + } + + return $data; + } +} diff --git a/src/Mcp/Protocol/ToolDescriptor.php b/src/Mcp/Protocol/ToolDescriptor.php new file mode 100644 index 0000000..b598985 --- /dev/null +++ b/src/Mcp/Protocol/ToolDescriptor.php @@ -0,0 +1,107 @@ +inputSchema ?? self::buildInputSchema($desc->parameters); + + return new self( + $desc->name, + $desc->description, + $inputSchema, + $desc->outputSchema, + ); + } + + public function toArray(): array + { + $data = [ + 'name' => $this->name, + 'description' => $this->description, + 'inputSchema' => $this->inputSchema, + ]; + if ($this->outputSchema !== null) { + $data['outputSchema'] = $this->outputSchema; + } + + return $data; + } + + /** + * Auto-generate a JSON Schema from a parameters array. + * + * Each parameter entry is expected to have: name, type, required (optional). + */ + private static function buildInputSchema(array $parameters): array + { + if (empty($parameters)) { + return ['type' => 'object', 'additionalProperties' => false]; + } + + $properties = []; + $required = []; + foreach ($parameters as $param) { + $name = $param['name']; + $prop = []; + if (isset($param['type'])) { + $prop['type'] = self::phpTypeToJsonSchema($param['type']); + } + if (isset($param['description'])) { + $prop['description'] = $param['description']; + } + $properties[$name] = $prop; + if (!empty($param['required'])) { + $required[] = $name; + } + } + + $schema = ['type' => 'object', 'properties' => $properties]; + if (!empty($required)) { + $schema['required'] = $required; + } + + return $schema; + } + + private static function phpTypeToJsonSchema(string $type): string + { + return match ($type) { + 'int', 'integer' => 'integer', + 'float', 'double', 'number' => 'number', + 'bool', 'boolean' => 'boolean', + 'array' => 'array', + 'string' => 'string', + default => 'string', + }; + } +} diff --git a/src/Mcp/ResourceProviderInterface.php b/src/Mcp/ResourceProviderInterface.php new file mode 100644 index 0000000..fb088ac --- /dev/null +++ b/src/Mcp/ResourceProviderInterface.php @@ -0,0 +1,38 @@ + + */ + public function listResources(): array; + + /** + * Check if a resource exists. + */ + public function hasResource(string $uri): bool; + + /** + * Read a resource's content. + */ + public function readResource(string $uri): ResourceContent; +} diff --git a/src/Mcp/Transport/HttpHandler.php b/src/Mcp/Transport/HttpHandler.php new file mode 100644 index 0000000..41988c8 --- /dev/null +++ b/src/Mcp/Transport/HttpHandler.php @@ -0,0 +1,180 @@ +matches($request)) { + return $this->handleMcp($request); + } + + return $next->handle($request); + } + + /** + * PSR-15 RequestHandlerInterface: handle request as MCP. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->handleMcp($request); + } + + /** + * Check whether this request looks like an MCP request. + * + * Matches: POST + configured path + JSON content type. + */ + private function matches(ServerRequestInterface $request): bool + { + if ($request->getMethod() !== 'POST') { + return false; + } + + if ($request->getUri()->getPath() !== $this->path) { + return false; + } + + $contentType = $request->getHeaderLine('Content-Type'); + + return str_contains($contentType, 'json'); + } + + private function handleMcp(ServerRequestInterface $request): ResponseInterface + { + $body = (string) $request->getBody(); + + try { + $decoded = json_decode($body, associative: false, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return $this->jsonRpcError(null, -32700, 'Parse error'); + } + + if (!($decoded instanceof \stdClass)) { + return $this->jsonRpcError(null, -32600, 'Invalid Request'); + } + + $method = $decoded->method ?? null; + $params = $decoded->params ?? []; + $id = $decoded->id ?? null; + + if (!is_string($method)) { + return $this->jsonRpcError($id, -32600, 'Invalid Request: method must be a string'); + } + + // Notifications: no id, no response body + if ($id === null || $this->router->isNotification($method)) { + return $this->responseFactory->createResponse(202); + } + + $authContext = $this->extractAuthContext($request); + + try { + $result = $this->router->route($method, $params, $authContext); + } catch (McpError $e) { + return $this->jsonRpcError($id, $e->getJsonRpcCode(), $e->getMessage(), $e->getErrorData()); + } + + return $this->jsonRpcResult($id, $result); + } + + private function extractAuthContext(ServerRequestInterface $request): AuthContext + { + $permissions = $request->getAttribute('auth_permissions', []); + $authenticated = $request->getAttribute('authenticated', false); + + if (!is_array($permissions)) { + $permissions = []; + } + + return new AuthContext($permissions, (bool) $authenticated); + } + + private function jsonRpcResult(string|int $id, mixed $result): ResponseInterface + { + $payload = [ + 'jsonrpc' => '2.0', + 'result' => $result, + 'id' => $id, + ]; + + return $this->jsonResponse($payload); + } + + private function jsonRpcError( + string|int|null $id, + int $code, + string $message, + mixed $data = null, + ): ResponseInterface { + $error = ['code' => $code, 'message' => $message]; + if ($data !== null) { + $error['data'] = $data; + } + + $payload = [ + 'jsonrpc' => '2.0', + 'error' => $error, + 'id' => $id, + ]; + + return $this->jsonResponse($payload); + } + + private function jsonResponse(array $payload): ResponseInterface + { + $json = json_encode($payload, self::JSON_FLAGS); + $body = $this->streamFactory->createStream($json); + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($body); + } +} diff --git a/src/Soap/SoapCallHandler.php b/src/Soap/SoapCallHandler.php new file mode 100644 index 0000000..e6e1ac7 --- /dev/null +++ b/src/Soap/SoapCallHandler.php @@ -0,0 +1,48 @@ +provider->hasMethod($method)) { + throw new SoapFault('Server', sprintf('Method "%s" is not defined', $method)); + } + + $result = $this->invoker->invoke($method, $params); + + return $result->value; + } +} diff --git a/src/Soap/SoapHandler.php b/src/Soap/SoapHandler.php new file mode 100644 index 0000000..74cf629 --- /dev/null +++ b/src/Soap/SoapHandler.php @@ -0,0 +1,164 @@ +matches($request)) { + return $this->handleSoap($request); + } + + return $next->handle($request); + } + + /** + * PSR-15 RequestHandlerInterface: handle request as SOAP. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->handleSoap($request); + } + + /** + * Check whether this request looks like a SOAP request. + * + * Matches: POST + configured path + SOAP content type. + * SOAP 1.1 uses text/xml, SOAP 1.2 uses application/soap+xml. + * SOAPAction header is optional (present in 1.1, may be absent in 1.2). + */ + private function matches(ServerRequestInterface $request): bool + { + if ($request->getMethod() !== 'POST') { + return false; + } + + if ($request->getUri()->getPath() !== $this->path) { + return false; + } + + return $this->isSoapContentType($request->getHeaderLine('Content-Type')); + } + + private function isSoapContentType(string $contentType): bool + { + return str_contains($contentType, 'text/xml') + || str_contains($contentType, 'application/soap+xml'); + } + + private function handleSoap(ServerRequestInterface $request): ResponseInterface + { + if (!extension_loaded('soap')) { + return $this->soapFaultResponse( + 'Server', + 'SOAP support requires the PHP soap extension', + 501, + ); + } + + $soapBody = (string) $request->getBody(); + if ($soapBody === '') { + return $this->soapFaultResponse('Client', 'Empty SOAP request body'); + } + + $callHandler = new SoapCallHandler($this->provider, $this->invoker); + + $server = new SoapServer(null, ['uri' => $this->serviceUri]); + $server->setObject($callHandler); + + ob_start(); + try { + $server->handle($soapBody); + } catch (\Throwable) { + ob_end_clean(); + return $this->soapFaultResponse('Server', 'Internal error'); + } + $responseXml = ob_get_clean(); + + if ($responseXml === false || $responseXml === '') { + return $this->soapFaultResponse('Server', 'Internal error'); + } + + $body = $this->streamFactory->createStream($responseXml); + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/xml; charset=utf-8') + ->withBody($body); + } + + private function soapFaultResponse( + string $faultCode, + string $faultString, + int $httpStatus = 500, + ): ResponseInterface { + $xml = sprintf( + '' + . '' + . '' + . '' + . 'SOAP-ENV:%s' + . '%s' + . '' + . '' + . '', + htmlspecialchars($faultCode, ENT_XML1, 'UTF-8'), + htmlspecialchars($faultString, ENT_XML1, 'UTF-8'), + ); + + $body = $this->streamFactory->createStream($xml); + + return $this->responseFactory->createResponse($httpStatus) + ->withHeader('Content-Type', 'text/xml; charset=utf-8') + ->withBody($body); + } +} diff --git a/test/Unit/JsonRpc/Dispatch/CallableMapProviderTest.php b/test/Unit/JsonRpc/Dispatch/CallableMapProviderTest.php new file mode 100644 index 0000000..96de333 --- /dev/null +++ b/test/Unit/JsonRpc/Dispatch/CallableMapProviderTest.php @@ -0,0 +1,78 @@ + fn() => null, + ]); + + $this->assertTrue($provider->hasMethod('test')); + $this->assertFalse($provider->hasMethod('nope')); + } + + public function testInvoke(): void + { + $provider = new CallableMapProvider([ + 'add' => fn(int $a, int $b) => $a + $b, + ]); + + $result = $provider->invoke('add', [3, 4]); + $this->assertSame(7, $result->value); + } + + public function testListMethodsAutoDescriptors(): void + { + $provider = new CallableMapProvider([ + 'a' => fn() => null, + 'b' => fn() => null, + ]); + + $methods = $provider->listMethods(); + $this->assertCount(2, $methods); + $this->assertSame('a', $methods[0]->name); + $this->assertSame('b', $methods[1]->name); + $this->assertSame('', $methods[0]->description); + } + + public function testExplicitDescriptors(): void + { + $desc = new MethodDescriptor('greet', 'Say hello', [], 'string'); + $provider = new CallableMapProvider( + ['greet' => fn(string $name) => "Hello $name"], + ['greet' => $desc], + ); + + $this->assertSame($desc, $provider->getMethodDescriptor('greet')); + $this->assertSame('Say hello', $provider->listMethods()[0]->description); + } + + public function testGetMethodDescriptorReturnsNullForUnknown(): void + { + $provider = new CallableMapProvider([]); + $this->assertNull($provider->getMethodDescriptor('nope')); + } + + public function testMixedExplicitAndAutoDescriptors(): void + { + $desc = new MethodDescriptor('a', 'Described'); + $provider = new CallableMapProvider( + ['a' => fn() => 1, 'b' => fn() => 2], + ['a' => $desc], + ); + + $this->assertSame('Described', $provider->getMethodDescriptor('a')->description); + $this->assertSame('', $provider->getMethodDescriptor('b')->description); + } +} diff --git a/test/Unit/JsonRpc/Dispatch/HordeRegistryApiProviderTest.php b/test/Unit/JsonRpc/Dispatch/HordeRegistryApiProviderTest.php new file mode 100644 index 0000000..d4f3bc1 --- /dev/null +++ b/test/Unit/JsonRpc/Dispatch/HordeRegistryApiProviderTest.php @@ -0,0 +1,144 @@ +createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturnCallback(fn(string $method) => $method === 'calendar/list' ? 'calendar' : false); + + $provider = new HordeRegistryApiProvider($registry); + + $this->assertTrue($provider->hasMethod('calendar.list')); + $this->assertFalse($provider->hasMethod('calendar.nope')); + } + + public function testHasMethodReturnsFalse(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturn(false); + + $provider = new HordeRegistryApiProvider($registry); + + $this->assertFalse($provider->hasMethod('nonexistent.method')); + } + + // --- Invoke --- + + public function testInvokeCallsRegistryWithSlashNotation(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturnCallback(fn(string $method) => $method === 'tasks/list' ? 'tasks' : false); + $registry->method('call') + ->willReturnCallback(function (string $method, array $params) { + $this->assertSame('tasks/list', $method); + $this->assertSame(['open'], $params); + + return ['task1', 'task2']; + }); + + $provider = new HordeRegistryApiProvider($registry); + $result = $provider->invoke('tasks.list', ['open']); + + $this->assertSame(['task1', 'task2'], $result->value); + } + + public function testInvokeThrowsMethodNotFoundForUnknown(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturn(false); + + $provider = new HordeRegistryApiProvider($registry); + + $this->expectException(MethodNotFoundException::class); + $provider->invoke('nope.method', []); + } + + public function testInvokeWrapsHordeException(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturn('app'); + $registry->method('call') + ->willThrowException(new Horde_Exception('Backend failure')); + + $provider = new HordeRegistryApiProvider($registry); + + $this->expectException(InternalErrorException::class); + $this->expectExceptionMessage('Backend failure'); + $provider->invoke('app.action', []); + } + + // --- listMethods slash-to-dot translation --- + + public function testListMethodsTranslatesSlashToDot(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('listMethods') + ->willReturn(['calendar/list', 'calendar/add', 'tasks/list']); + + $provider = new HordeRegistryApiProvider($registry); + $methods = $provider->listMethods(); + + $this->assertCount(3, $methods); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + $this->assertSame(['calendar.list', 'calendar.add', 'tasks.list'], $names); + } + + public function testListMethodsReturnsEmptyForNoMethods(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('listMethods') + ->willReturn([]); + + $provider = new HordeRegistryApiProvider($registry); + + $this->assertSame([], $provider->listMethods()); + } + + // --- getMethodDescriptor --- + + public function testGetMethodDescriptorForKnownMethod(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturnCallback(fn(string $method) => $method === 'calendar/list' ? 'calendar' : false); + + $provider = new HordeRegistryApiProvider($registry); + $desc = $provider->getMethodDescriptor('calendar.list'); + + $this->assertNotNull($desc); + $this->assertSame('calendar.list', $desc->name); + } + + public function testGetMethodDescriptorReturnsNullForUnknown(): void + { + $registry = $this->createStub(Horde_Registry::class); + $registry->method('hasMethod') + ->willReturn(false); + + $provider = new HordeRegistryApiProvider($registry); + + $this->assertNull($provider->getMethodDescriptor('nope.method')); + } +} diff --git a/test/Unit/JsonRpc/Dispatch/MathApiProviderTest.php b/test/Unit/JsonRpc/Dispatch/MathApiProviderTest.php new file mode 100644 index 0000000..45bb277 --- /dev/null +++ b/test/Unit/JsonRpc/Dispatch/MathApiProviderTest.php @@ -0,0 +1,135 @@ +provider = MathApiProvider::create(); + } + + // --- Method registration --- + + public function testHasMathMethods(): void + { + $this->assertTrue($this->provider->hasMethod('math.add')); + $this->assertTrue($this->provider->hasMethod('math.subtract')); + $this->assertTrue($this->provider->hasMethod('math.multiply')); + $this->assertTrue($this->provider->hasMethod('math.divide')); + $this->assertFalse($this->provider->hasMethod('math.nope')); + } + + // --- Arithmetic --- + + public function testAdd(): void + { + $this->assertSame(7.0, $this->provider->invoke('math.add', [3.0, 4.0])->value); + } + + public function testSubtract(): void + { + $this->assertSame(6.0, $this->provider->invoke('math.subtract', [10.0, 4.0])->value); + } + + public function testMultiply(): void + { + $this->assertSame(12.0, $this->provider->invoke('math.multiply', [3.0, 4.0])->value); + } + + public function testDivide(): void + { + $this->assertSame(2.5, $this->provider->invoke('math.divide', [5.0, 2.0])->value); + } + + public function testDivideByZero(): void + { + $this->expectException(InvalidParamsException::class); + $this->expectExceptionMessage('Division by zero'); + $this->provider->invoke('math.divide', [1.0, 0.0]); + } + + // --- Descriptors --- + + public function testListMethodsReturnsFourDescriptors(): void + { + $methods = $this->provider->listMethods(); + $this->assertCount(4, $methods); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + $this->assertContains('math.add', $names); + $this->assertContains('math.subtract', $names); + $this->assertContains('math.multiply', $names); + $this->assertContains('math.divide', $names); + } + + public function testDescriptorsHaveParameterMetadata(): void + { + $desc = $this->provider->getMethodDescriptor('math.add'); + $this->assertNotNull($desc); + $this->assertSame('Add two numbers', $desc->description); + $this->assertSame('float', $desc->returnType); + $this->assertCount(2, $desc->parameters); + $this->assertSame('a', $desc->parameters[0]['name']); + $this->assertSame('float', $desc->parameters[0]['type']); + $this->assertTrue($desc->parameters[0]['required']); + } + + // --- Integration with Dispatcher --- + + public function testDispatcherRoundTrip(): void + { + $dispatcher = new Dispatcher($this->provider, $this->provider); + $request = new Request(Version::V2_0, 'math.multiply', [6.0, 7.0], 1); + + $result = $dispatcher->dispatch($request); + + $this->assertSame(42.0, $result->value); + } + + public function testRpcDiscoverIncludesMathMethods(): void + { + $dispatcher = new Dispatcher($this->provider, $this->provider); + $request = new Request(Version::V2_0, 'rpc.discover', [], 1); + + $result = $dispatcher->dispatch($request); + + $names = array_column($result->value['methods'], 'name'); + $this->assertContains('math.add', $names); + $this->assertContains('math.divide', $names); + } + + // --- Codec round-trip --- + + public function testCodecRoundTrip(): void + { + $codec = new Codec(); + $dispatcher = new Dispatcher($this->provider, $this->provider); + + $decoded = $codec->decode('{"jsonrpc":"2.0","method":"math.add","params":[1.5,2.5],"id":1}'); + $this->assertInstanceOf(Request::class, $decoded); + + $result = $dispatcher->dispatch($decoded); + $response = new Response($decoded->version, $result->value, $decoded->id); + $json = $codec->encodeResponse($response); + + $payload = json_decode($json, true); + $this->assertEquals(4.0, $payload['result']); + $this->assertSame(1, $payload['id']); + } +} diff --git a/test/Unit/JsonRpc/Dispatch/MethodDescriptorTest.php b/test/Unit/JsonRpc/Dispatch/MethodDescriptorTest.php index 960332e..c64a793 100644 --- a/test/Unit/JsonRpc/Dispatch/MethodDescriptorTest.php +++ b/test/Unit/JsonRpc/Dispatch/MethodDescriptorTest.php @@ -30,5 +30,45 @@ public function testDefaults(): void $this->assertSame('', $descriptor->description); $this->assertSame([], $descriptor->parameters); $this->assertNull($descriptor->returnType); + $this->assertNull($descriptor->inputSchema); + $this->assertNull($descriptor->outputSchema); + $this->assertSame([], $descriptor->permissions); + } + + public function testInputSchemaAndOutputSchema(): void + { + $inputSchema = [ + 'type' => 'object', + 'properties' => ['a' => ['type' => 'number']], + 'required' => ['a'], + ]; + $outputSchema = [ + 'type' => 'object', + 'properties' => ['result' => ['type' => 'number']], + ]; + $descriptor = new MethodDescriptor( + 'calc', + inputSchema: $inputSchema, + outputSchema: $outputSchema, + ); + + $this->assertSame($inputSchema, $descriptor->inputSchema); + $this->assertSame($outputSchema, $descriptor->outputSchema); + } + + public function testPermissions(): void + { + $descriptor = new MethodDescriptor( + 'admin.reset', + permissions: ['horde:admin'], + ); + + $this->assertSame(['horde:admin'], $descriptor->permissions); + } + + public function testEmptyPermissionsMeansPublic(): void + { + $descriptor = new MethodDescriptor('public.method'); + $this->assertSame([], $descriptor->permissions); } } diff --git a/test/Unit/JsonRpc/JsonRpcClientTest.php b/test/Unit/JsonRpc/JsonRpcClientTest.php new file mode 100644 index 0000000..a78c561 --- /dev/null +++ b/test/Unit/JsonRpc/JsonRpcClientTest.php @@ -0,0 +1,140 @@ +streamFactory = new StreamFactory(); + } + + private function makeMockHttpClient(string $responseBody, int $statusCode = 200): ClientInterface + { + $sf = $this->streamFactory; + + return new class ($responseBody, $statusCode, $sf) implements ClientInterface { + public ?RequestInterface $lastRequest = null; + + public function __construct( + private readonly string $responseBody, + private readonly int $statusCode, + private readonly StreamFactory $sf, + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->lastRequest = $request; + + return new Response($this->statusCode, body: $this->sf->createStream($this->responseBody)); + } + }; + } + + public function testCallReturnsResult(): void + { + $http = $this->makeMockHttpClient('{"jsonrpc":"2.0","result":42,"id":1}'); + $client = new JsonRpcClient('http://example.com/rpc', $http); + + $result = $client->call('math.add', [3, 4]); + + $this->assertSame(42, $result); + } + + public function testCallSendsToConfiguredUrl(): void + { + $http = $this->makeMockHttpClient('{"jsonrpc":"2.0","result":null,"id":1}'); + $client = new JsonRpcClient('http://my-server.local/jsonrpc', $http); + + $client->call('test'); + + $this->assertSame('http://my-server.local/jsonrpc', (string) $http->lastRequest->getUri()); + } + + public function testNotifySendsNoId(): void + { + $http = $this->makeMockHttpClient('', 204); + $client = new JsonRpcClient('http://example.com/rpc', $http); + + $client->notify('log.event', ['data']); + + $body = json_decode((string) $http->lastRequest->getBody(), true); + $this->assertSame('log.event', $body['method']); + $this->assertArrayNotHasKey('id', $body); + } + + public function testBatchReturnsResponses(): void + { + $http = $this->makeMockHttpClient( + '[{"jsonrpc":"2.0","result":1,"id":1},{"jsonrpc":"2.0","result":2,"id":2}]' + ); + $client = new JsonRpcClient('http://example.com/rpc', $http); + + $results = $client->batch([ + ['method' => 'a'], + ['method' => 'b'], + ]); + + $this->assertCount(2, $results); + } + + public function testDefaultCurlClientConstruction(): void + { + // Construction with just a URL should succeed (uses Curl default) + $client = new JsonRpcClient('http://example.com/rpc'); + $this->assertInstanceOf(JsonRpcClient::class, $client); + } + + public function testAcceptsCustomClient(): void + { + $http = $this->makeMockHttpClient('{"jsonrpc":"2.0","result":"custom","id":1}'); + $client = new JsonRpcClient('http://example.com/rpc', $http); + + $result = $client->call('test'); + + $this->assertSame('custom', $result); + } + + public function testV11VersionPropagation(): void + { + $http = $this->makeMockHttpClient('{"version":"1.1","result":"ok","id":1}'); + $client = new JsonRpcClient( + 'http://example.com/rpc', + $http, + version: Version::V1_1, + ); + + $result = $client->call('test'); + + $body = json_decode((string) $http->lastRequest->getBody(), true); + $this->assertSame('1.1', $body['version']); + $this->assertArrayNotHasKey('jsonrpc', $body); + $this->assertSame('ok', $result); + } + + public function testSetsJsonContentType(): void + { + $http = $this->makeMockHttpClient('{"jsonrpc":"2.0","result":null,"id":1}'); + $client = new JsonRpcClient('http://example.com/rpc', $http); + + $client->call('test'); + + $this->assertSame('application/json', $http->lastRequest->getHeaderLine('Content-Type')); + $this->assertSame('application/json', $http->lastRequest->getHeaderLine('Accept')); + } +} diff --git a/test/Unit/JsonRpc/JsonRpcHandlerTest.php b/test/Unit/JsonRpc/JsonRpcHandlerTest.php index dd360a9..d661bff 100644 --- a/test/Unit/JsonRpc/JsonRpcHandlerTest.php +++ b/test/Unit/JsonRpc/JsonRpcHandlerTest.php @@ -9,11 +9,11 @@ use Horde\Http\StreamFactory; use Horde\Rpc\JsonRpc\JsonRpcHandler; use Horde\Rpc\JsonRpc\Transport\HttpHandler; -use Horde\Rpc\JsonRpc\Transport\Middleware\JsonRpcMiddleware; use Horde\Rpc\Test\Unit\JsonRpc\TestDouble\CallableMapProvider; use Horde\Rpc\Test\Unit\JsonRpc\TestDouble\RecordingEventDispatcher; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Psr\Http\Server\MiddlewareInterface; #[CoversClass(JsonRpcHandler::class)] class JsonRpcHandlerTest extends TestCase @@ -43,7 +43,9 @@ public function testGetMiddlewareReturnsMiddleware(): void new RecordingEventDispatcher(), ); - $this->assertInstanceOf(JsonRpcMiddleware::class, $facade->getMiddleware()); + $middleware = $facade->getMiddleware(); + $this->assertInstanceOf(MiddlewareInterface::class, $middleware); + $this->assertInstanceOf(HttpHandler::class, $middleware); } public function testFacadeHandlesRequest(): void diff --git a/test/Unit/JsonRpc/Transport/HttpHandlerTest.php b/test/Unit/JsonRpc/Transport/HttpHandlerTest.php index 6254ef1..eb3021e 100644 --- a/test/Unit/JsonRpc/Transport/HttpHandlerTest.php +++ b/test/Unit/JsonRpc/Transport/HttpHandlerTest.php @@ -7,6 +7,7 @@ use Horde\Http\ResponseFactory; use Horde\Http\ServerRequest; use Horde\Http\StreamFactory; +use Horde\Http\Uri; use Horde\Rpc\JsonRpc\Dispatch\Dispatcher; use Horde\Rpc\JsonRpc\Event\BatchProcessing; use Horde\Rpc\JsonRpc\Event\ErrorOccurred; @@ -19,6 +20,9 @@ use Horde\Rpc\Test\Unit\JsonRpc\TestDouble\RecordingEventDispatcher; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; #[CoversClass(HttpHandler::class)] class HttpHandlerTest extends TestCase @@ -285,4 +289,122 @@ public function testBatchProcessingEvent(): void $this->assertSame(1, $events[0]->requestCount); $this->assertSame(1, $events[0]->notificationCount); } + + // --- Middleware (process()) tests --- + + private function makeNextHandler(): RequestHandlerInterface + { + $responseFactory = new ResponseFactory(); + + return new class ($responseFactory) implements RequestHandlerInterface { + public function __construct( + private readonly \Psr\Http\Message\ResponseFactoryInterface $rf, + ) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->rf->createResponse(404); + } + }; + } + + private function makeMiddlewareRequest( + string $method, + string $path, + string $contentType = 'application/json', + ): ServerRequest { + return new ServerRequest( + method: $method, + uri: new Uri($path), + headers: ['Content-Type' => $contentType], + ); + } + + public function testProcessMatchingPostDelegatesToHandler(): void + { + $handler = $this->makeHandler(['test' => fn() => 'ok']); + $body = $this->streamFactory->createStream('{"jsonrpc":"2.0","method":"test","id":1}'); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/rpc/jsonrpc'), + body: $body, + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $decoded = $this->decodeResponseBody((string) $response->getBody()); + $this->assertSame('ok', $decoded['result']); + } + + public function testProcessGetPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = $this->makeMiddlewareRequest('GET', '/rpc/jsonrpc'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessWrongPathPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = $this->makeMiddlewareRequest('POST', '/other/path'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessWrongContentTypePassesToNext(): void + { + $handler = $this->makeHandler(); + $request = $this->makeMiddlewareRequest('POST', '/rpc/jsonrpc', 'text/xml'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessJsonRpcContentTypeAccepted(): void + { + $handler = $this->makeHandler(['test' => fn() => 'ok']); + $body = $this->streamFactory->createStream('{"jsonrpc":"2.0","method":"test","id":1}'); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/rpc/jsonrpc'), + body: $body, + headers: ['Content-Type' => 'application/json-rpc'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testProcessCustomPath(): void + { + $provider = new CallableMapProvider(['test' => fn() => 'ok']); + $handler = new HttpHandler( + new Codec(), + new Dispatcher($provider, $provider), + $this->responseFactory, + $this->streamFactory, + $this->events, + path: '/api/v2/rpc', + ); + $body = $this->streamFactory->createStream('{"jsonrpc":"2.0","method":"test","id":1}'); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/api/v2/rpc'), + body: $body, + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } } diff --git a/test/Unit/JsonRpc/Transport/JsonRpcMiddlewareTest.php b/test/Unit/JsonRpc/Transport/JsonRpcMiddlewareTest.php deleted file mode 100644 index a1e132c..0000000 --- a/test/Unit/JsonRpc/Transport/JsonRpcMiddlewareTest.php +++ /dev/null @@ -1,138 +0,0 @@ -streamFactory = new StreamFactory(); - } - - private function makeMockHandler(): RequestHandlerInterface - { - $responseFactory = new ResponseFactory(); - $streamFactory = $this->streamFactory; - - return new class ($responseFactory, $streamFactory) implements RequestHandlerInterface { - public function __construct( - private readonly \Psr\Http\Message\ResponseFactoryInterface $rf, - private readonly \Psr\Http\Message\StreamFactoryInterface $sf, - ) {} - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $body = $this->sf->createStream('{"jsonrpc":"2.0","result":"handled","id":1}'); - - return $this->rf->createResponse(200) - ->withBody($body) - ->withHeader('Content-Type', 'application/json'); - } - }; - } - - private function makeNextHandler(): RequestHandlerInterface - { - $responseFactory = new ResponseFactory(); - - return new class ($responseFactory) implements RequestHandlerInterface { - public function __construct( - private readonly \Psr\Http\Message\ResponseFactoryInterface $rf, - ) {} - - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->rf->createResponse(404); - } - }; - } - - private function makeServerRequest( - string $method, - string $path, - string $contentType = 'application/json', - ): ServerRequest { - return new ServerRequest( - method: $method, - uri: new Uri($path), - headers: ['Content-Type' => $contentType], - ); - } - - public function testPostJsonMatchingPathDelegatesToHandler(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler()); - $request = $this->makeServerRequest('POST', '/rpc/jsonrpc'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertStringContainsString('handled', (string) $response->getBody()); - } - - public function testGetPassesToNext(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler()); - $request = $this->makeServerRequest('GET', '/rpc/jsonrpc'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(404, $response->getStatusCode()); - } - - public function testWrongPathPassesToNext(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler()); - $request = $this->makeServerRequest('POST', '/other/path'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(404, $response->getStatusCode()); - } - - public function testWrongContentTypePassesToNext(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler()); - $request = $this->makeServerRequest('POST', '/rpc/jsonrpc', 'text/xml'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(404, $response->getStatusCode()); - } - - public function testJsonRpcContentTypeAccepted(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler()); - $request = $this->makeServerRequest('POST', '/rpc/jsonrpc', 'application/json-rpc'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(200, $response->getStatusCode()); - } - - public function testCustomPath(): void - { - $middleware = new JsonRpcMiddleware($this->makeMockHandler(), '/api/v2/rpc'); - $request = $this->makeServerRequest('POST', '/api/v2/rpc'); - - $response = $middleware->process($request, $this->makeNextHandler()); - - $this->assertSame(200, $response->getStatusCode()); - } -} diff --git a/test/Unit/Mcp/AuthContextTest.php b/test/Unit/Mcp/AuthContextTest.php new file mode 100644 index 0000000..69a570d --- /dev/null +++ b/test/Unit/Mcp/AuthContextTest.php @@ -0,0 +1,55 @@ +assertFalse($ctx->authenticated); + $this->assertSame([], $ctx->permissions); + } + + public function testWithPermissions(): void + { + $ctx = AuthContext::withPermissions(['read', 'write']); + + $this->assertTrue($ctx->authenticated); + $this->assertSame(['read', 'write'], $ctx->permissions); + } + + public function testHasPermission(): void + { + $ctx = AuthContext::withPermissions(['read', 'write']); + + $this->assertTrue($ctx->hasPermission('read')); + $this->assertTrue($ctx->hasPermission('write')); + $this->assertFalse($ctx->hasPermission('admin')); + } + + public function testHasAllPermissions(): void + { + $ctx = AuthContext::withPermissions(['read', 'write', 'admin']); + + $this->assertTrue($ctx->hasAllPermissions(['read', 'write'])); + $this->assertTrue($ctx->hasAllPermissions([])); + $this->assertFalse($ctx->hasAllPermissions(['read', 'delete'])); + } + + public function testAnonymousHasNoPermissions(): void + { + $ctx = AuthContext::anonymous(); + + $this->assertFalse($ctx->hasPermission('anything')); + $this->assertTrue($ctx->hasAllPermissions([])); + } +} diff --git a/test/Unit/Mcp/McpRouterTest.php b/test/Unit/Mcp/McpRouterTest.php new file mode 100644 index 0000000..81d356b --- /dev/null +++ b/test/Unit/Mcp/McpRouterTest.php @@ -0,0 +1,240 @@ +makeRouter(); + $result = $router->route('initialize', ['protocolVersion' => '2025-11-25'], AuthContext::anonymous()); + + $this->assertSame('2025-11-25', $result['protocolVersion']); + $this->assertSame('test-server', $result['serverInfo']['name']); + $this->assertSame('1.0.0', $result['serverInfo']['version']); + $this->assertArrayHasKey('capabilities', $result); + } + + // --- ping --- + + public function testPing(): void + { + $router = $this->makeRouter(); + $result = $router->route('ping', [], AuthContext::anonymous()); + + $this->assertSame([], $result); + } + + // --- tools/list --- + + public function testToolsList(): void + { + $router = $this->makeRouter([ + 'math.add' => fn(float $a, float $b) => $a + $b, + ]); + + $result = $router->route('tools/list', [], AuthContext::anonymous()); + + $this->assertCount(1, $result['tools']); + $this->assertSame('math.add', $result['tools'][0]['name']); + $this->assertArrayHasKey('inputSchema', $result['tools'][0]); + } + + // --- tools/call --- + + public function testToolsCall(): void + { + $router = $this->makeRouter([ + 'math.add' => fn(float $a, float $b) => $a + $b, + ]); + + $result = $router->route('tools/call', [ + 'name' => 'math.add', + 'arguments' => [3.0, 4.0], + ], AuthContext::anonymous()); + + $this->assertFalse($result['isError']); + $this->assertSame('7', $result['content'][0]['text']); + $this->assertSame('text', $result['content'][0]['type']); + } + + public function testToolsCallUnknownTool(): void + { + $router = $this->makeRouter([]); + + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unknown tool: nope'); + $router->route('tools/call', ['name' => 'nope'], AuthContext::anonymous()); + } + + public function testToolsCallExceptionBecomesErrorResult(): void + { + $router = $this->makeRouter([ + 'bad' => fn() => throw new \RuntimeException('boom'), + ]); + + $result = $router->route('tools/call', [ + 'name' => 'bad', + 'arguments' => [], + ], AuthContext::anonymous()); + + $this->assertTrue($result['isError']); + $this->assertSame('boom', $result['content'][0]['text']); + } + + // --- auth --- + + public function testToolsCallPermissionDenied(): void + { + $router = $this->makeRouter( + ['admin.reset' => fn() => 'done'], + ['admin.reset' => new MethodDescriptor('admin.reset', permissions: ['horde:admin'])], + ); + + $this->expectException(McpError::class); + $this->expectExceptionMessage('Unauthorized'); + $router->route('tools/call', [ + 'name' => 'admin.reset', + 'arguments' => [], + ], AuthContext::anonymous()); + } + + public function testToolsCallPermissionGranted(): void + { + $router = $this->makeRouter( + ['admin.reset' => fn() => 'done'], + ['admin.reset' => new MethodDescriptor('admin.reset', permissions: ['horde:admin'])], + ); + + $result = $router->route('tools/call', [ + 'name' => 'admin.reset', + 'arguments' => [], + ], AuthContext::withPermissions(['horde:admin'])); + + $this->assertFalse($result['isError']); + } + + public function testToolsCallPublicMethodNoAuthRequired(): void + { + $router = $this->makeRouter([ + 'ping' => fn() => 'pong', + ]); + + $result = $router->route('tools/call', [ + 'name' => 'ping', + 'arguments' => [], + ], AuthContext::anonymous()); + + $this->assertFalse($result['isError']); + } + + // --- notifications --- + + public function testIsNotification(): void + { + $router = $this->makeRouter(); + + $this->assertTrue($router->isNotification('notifications/initialized')); + $this->assertTrue($router->isNotification('notifications/tools/list_changed')); + $this->assertFalse($router->isNotification('initialize')); + $this->assertFalse($router->isNotification('tools/call')); + } + + // --- resources --- + + public function testResourcesList(): void + { + $rp = $this->createStub(ResourceProviderInterface::class); + $rp->method('listResources')->willReturn([ + new ResourceDescriptor('file:///readme.md', 'README', 'Project readme', 'text/markdown'), + ]); + + $router = $this->makeRouter([], [], $rp); + $result = $router->route('resources/list', [], AuthContext::anonymous()); + + $this->assertCount(1, $result['resources']); + $this->assertSame('file:///readme.md', $result['resources'][0]['uri']); + } + + public function testResourcesRead(): void + { + $rp = $this->createStub(ResourceProviderInterface::class); + $rp->method('hasResource')->willReturn(true); + $rp->method('readResource')->willReturn( + new ResourceContent('file:///readme.md', 'Hello', mimeType: 'text/markdown'), + ); + + $router = $this->makeRouter([], [], $rp); + $result = $router->route('resources/read', ['uri' => 'file:///readme.md'], AuthContext::anonymous()); + + $this->assertSame('Hello', $result['contents'][0]['text']); + } + + public function testResourcesReadNotFound(): void + { + $rp = $this->createStub(ResourceProviderInterface::class); + $rp->method('hasResource')->willReturn(false); + + $router = $this->makeRouter([], [], $rp); + + $this->expectException(McpError::class); + $this->expectExceptionMessage('Resource not found'); + $router->route('resources/read', ['uri' => 'file:///nope'], AuthContext::anonymous()); + } + + public function testResourcesListWithoutProvider(): void + { + $router = $this->makeRouter([]); + + $this->expectException(McpError::class); + $router->route('resources/list', [], AuthContext::anonymous()); + } + + // --- unknown method --- + + public function testUnknownMethod(): void + { + $router = $this->makeRouter([]); + + $this->expectException(McpError::class); + $this->expectExceptionMessage('Method not found'); + $router->route('unknown/method', [], AuthContext::anonymous()); + } +} diff --git a/test/Unit/Mcp/Protocol/ToolDescriptorTest.php b/test/Unit/Mcp/Protocol/ToolDescriptorTest.php new file mode 100644 index 0000000..14f901f --- /dev/null +++ b/test/Unit/Mcp/Protocol/ToolDescriptorTest.php @@ -0,0 +1,108 @@ + 'object', + 'properties' => ['a' => ['type' => 'number']], + 'required' => ['a'], + ]; + $desc = new MethodDescriptor('calc', 'Calculate', inputSchema: $schema); + + $tool = ToolDescriptor::fromMethodDescriptor($desc); + + $this->assertSame('calc', $tool->name); + $this->assertSame('Calculate', $tool->description); + $this->assertSame($schema, $tool->inputSchema); + } + + public function testFromMethodDescriptorAutoGeneratesSchema(): void + { + $desc = new MethodDescriptor('math.add', 'Add', [ + ['name' => 'a', 'type' => 'float', 'required' => true], + ['name' => 'b', 'type' => 'float', 'required' => true], + ]); + + $tool = ToolDescriptor::fromMethodDescriptor($desc); + + $this->assertSame('object', $tool->inputSchema['type']); + $this->assertSame('number', $tool->inputSchema['properties']['a']['type']); + $this->assertSame('number', $tool->inputSchema['properties']['b']['type']); + $this->assertSame(['a', 'b'], $tool->inputSchema['required']); + } + + public function testFromMethodDescriptorNoParams(): void + { + $desc = new MethodDescriptor('ping'); + + $tool = ToolDescriptor::fromMethodDescriptor($desc); + + $this->assertSame(['type' => 'object', 'additionalProperties' => false], $tool->inputSchema); + } + + public function testFromMethodDescriptorWithOutputSchema(): void + { + $output = ['type' => 'object', 'properties' => ['result' => ['type' => 'number']]]; + $desc = new MethodDescriptor('calc', outputSchema: $output); + + $tool = ToolDescriptor::fromMethodDescriptor($desc); + + $this->assertSame($output, $tool->outputSchema); + } + + public function testToArray(): void + { + $tool = new ToolDescriptor( + 'test', + 'A test tool', + ['type' => 'object'], + ); + + $arr = $tool->toArray(); + + $this->assertSame('test', $arr['name']); + $this->assertSame('A test tool', $arr['description']); + $this->assertSame(['type' => 'object'], $arr['inputSchema']); + $this->assertArrayNotHasKey('outputSchema', $arr); + } + + public function testToArrayWithOutputSchema(): void + { + $output = ['type' => 'number']; + $tool = new ToolDescriptor('test', 'Test', ['type' => 'object'], $output); + + $arr = $tool->toArray(); + + $this->assertSame($output, $arr['outputSchema']); + } + + public function testTypeMapping(): void + { + $desc = new MethodDescriptor('types', 'Type test', [ + ['name' => 'i', 'type' => 'int', 'required' => true], + ['name' => 's', 'type' => 'string', 'required' => false], + ['name' => 'b', 'type' => 'bool'], + ['name' => 'a', 'type' => 'array'], + ]); + + $tool = ToolDescriptor::fromMethodDescriptor($desc); + + $this->assertSame('integer', $tool->inputSchema['properties']['i']['type']); + $this->assertSame('string', $tool->inputSchema['properties']['s']['type']); + $this->assertSame('boolean', $tool->inputSchema['properties']['b']['type']); + $this->assertSame('array', $tool->inputSchema['properties']['a']['type']); + $this->assertSame(['i'], $tool->inputSchema['required']); + } +} diff --git a/test/Unit/Mcp/Transport/HttpHandlerTest.php b/test/Unit/Mcp/Transport/HttpHandlerTest.php new file mode 100644 index 0000000..8b1a7a3 --- /dev/null +++ b/test/Unit/Mcp/Transport/HttpHandlerTest.php @@ -0,0 +1,307 @@ +responseFactory = new ResponseFactory(); + $this->streamFactory = new StreamFactory(); + } + + private function makeHandler(array $methods = [], array $descriptors = []): HttpHandler + { + $provider = new CallableMapProvider($methods, $descriptors); + $router = new McpRouter( + new ServerInfo('test', '1.0.0'), + new ServerCapabilities(tools: true), + $provider, + $provider, + ); + + return new HttpHandler($router, $this->responseFactory, $this->streamFactory); + } + + private function makeRequest(string $json, array $attributes = []): ServerRequest + { + $body = $this->streamFactory->createStream($json); + $request = new ServerRequest( + method: 'POST', + uri: '/mcp', + body: $body, + headers: ['Content-Type' => 'application/json'], + ); + foreach ($attributes as $key => $value) { + $request = $request->withAttribute($key, $value); + } + + return $request; + } + + private function decodeBody(string $body): array + { + return json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } + + // --- initialize --- + + public function testInitialize(): void + { + $handler = $this->makeHandler(); + $request = $this->makeRequest('{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-11-25"},"id":1}'); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('2.0', $body['jsonrpc']); + $this->assertSame(1, $body['id']); + $this->assertSame('2025-11-25', $body['result']['protocolVersion']); + $this->assertSame('test', $body['result']['serverInfo']['name']); + } + + // --- notifications return 202 --- + + public function testNotificationReturns202(): void + { + $handler = $this->makeHandler(); + $request = $this->makeRequest('{"jsonrpc":"2.0","method":"notifications/initialized"}'); + + $response = $handler->handle($request); + + $this->assertSame(202, $response->getStatusCode()); + } + + // --- tools/list --- + + public function testToolsList(): void + { + $handler = $this->makeHandler([ + 'math.add' => fn(float $a, float $b) => $a + $b, + ]); + $request = $this->makeRequest('{"jsonrpc":"2.0","method":"tools/list","id":2}'); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertCount(1, $body['result']['tools']); + $this->assertSame('math.add', $body['result']['tools'][0]['name']); + } + + // --- tools/call --- + + public function testToolsCall(): void + { + $handler = $this->makeHandler([ + 'math.add' => fn(float $a, float $b) => $a + $b, + ]); + $request = $this->makeRequest( + '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"math.add","arguments":[3,4]},"id":3}' + ); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($body['result']['isError']); + $this->assertSame('7', $body['result']['content'][0]['text']); + } + + // --- auth --- + + public function testToolsCallUnauthorized(): void + { + $handler = $this->makeHandler( + ['secret' => fn() => 'data'], + ['secret' => new MethodDescriptor('secret', permissions: ['admin'])], + ); + $request = $this->makeRequest( + '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"secret","arguments":[]},"id":4}' + ); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame(-32603, $body['error']['code']); + $this->assertStringContainsString('Unauthorized', $body['error']['message']); + } + + public function testToolsCallAuthorizedViaAttributes(): void + { + $handler = $this->makeHandler( + ['secret' => fn() => 'data'], + ['secret' => new MethodDescriptor('secret', permissions: ['admin'])], + ); + $request = $this->makeRequest( + '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"secret","arguments":[]},"id":5}', + ['auth_permissions' => ['admin'], 'authenticated' => true], + ); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertFalse($body['result']['isError']); + } + + // --- error handling --- + + public function testInvalidJson(): void + { + $handler = $this->makeHandler(); + $request = $this->makeRequest('{bad json'); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame(-32700, $body['error']['code']); + } + + public function testUnknownMethod(): void + { + $handler = $this->makeHandler(); + $request = $this->makeRequest('{"jsonrpc":"2.0","method":"nope","id":6}'); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame(-32601, $body['error']['code']); + } + + // --- ping --- + + public function testPing(): void + { + $handler = $this->makeHandler(); + $request = $this->makeRequest('{"jsonrpc":"2.0","method":"ping","id":7}'); + + $response = $handler->handle($request); + $body = $this->decodeBody((string) $response->getBody()); + + $this->assertSame([], $body['result']); + } + + // --- Middleware (process()) tests --- + + private function makeNextHandler(): RequestHandlerInterface + { + $responseFactory = new ResponseFactory(); + + return new class ($responseFactory) implements RequestHandlerInterface { + public function __construct( + private readonly \Psr\Http\Message\ResponseFactoryInterface $rf, + ) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->rf->createResponse(404); + } + }; + } + + public function testProcessMatchingPostDelegatesToHandler(): void + { + $handler = $this->makeHandler(); + $body = $this->streamFactory->createStream('{"jsonrpc":"2.0","method":"ping","id":1}'); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/mcp'), + body: $body, + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $decoded = $this->decodeBody((string) $response->getBody()); + $this->assertSame([], $decoded['result']); + } + + public function testProcessGetPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'GET', + uri: new Uri('/mcp'), + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessWrongPathPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/other/path'), + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessWrongContentTypePassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/mcp'), + headers: ['Content-Type' => 'text/xml'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessCustomPath(): void + { + $provider = new CallableMapProvider([]); + $router = new McpRouter( + new ServerInfo('test', '1.0.0'), + new ServerCapabilities(tools: true), + $provider, + $provider, + ); + $handler = new HttpHandler($router, $this->responseFactory, $this->streamFactory, '/custom/mcp'); + $body = $this->streamFactory->createStream('{"jsonrpc":"2.0","method":"ping","id":1}'); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/custom/mcp'), + body: $body, + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/test/Unit/Soap/SoapHandlerTest.php b/test/Unit/Soap/SoapHandlerTest.php new file mode 100644 index 0000000..afaf022 --- /dev/null +++ b/test/Unit/Soap/SoapHandlerTest.php @@ -0,0 +1,266 @@ +responseFactory = new ResponseFactory(); + $this->streamFactory = new StreamFactory(); + } + + private function makeHandler(array $methods = []): SoapHandler + { + $provider = new CallableMapProvider($methods); + + return new SoapHandler( + $provider, + $provider, + $this->responseFactory, + $this->streamFactory, + ); + } + + private function makeNextHandler(): RequestHandlerInterface + { + $responseFactory = new ResponseFactory(); + + return new class ($responseFactory) implements RequestHandlerInterface { + public function __construct( + private readonly \Psr\Http\Message\ResponseFactoryInterface $rf, + ) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->rf->createResponse(404); + } + }; + } + + private function makeSoapRequest( + string $method, + array $params = [], + string $path = '/rpc/soap', + string $contentType = 'text/xml', + ): ServerRequest { + $paramXml = ''; + foreach ($params as $name => $value) { + $paramXml .= sprintf('<%s>%s', $name, htmlspecialchars((string) $value, ENT_XML1), $name); + } + + $soapBody = sprintf( + '' + . '' + . '' + . '%s' + . '' + . '', + $method, + $paramXml, + $method, + ); + + $body = $this->streamFactory->createStream($soapBody); + + return new ServerRequest( + method: 'POST', + uri: new Uri($path), + body: $body, + headers: [ + 'Content-Type' => $contentType, + 'SOAPAction' => '"urn:horde-rpc-soap#' . $method . '"', + ], + ); + } + + // --- Middleware detection tests --- + + public function testProcessTextXmlMatchesSoap(): void + { + $handler = $this->makeHandler(['ping' => fn() => 'pong']); + $request = $this->makeSoapRequest('ping'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('text/xml', $response->getHeaderLine('Content-Type')); + } + + public function testProcessSoapXmlContentTypeMatches(): void + { + $handler = $this->makeHandler(['ping' => fn() => 'pong']); + $request = $this->makeSoapRequest('ping', contentType: 'application/soap+xml; charset=utf-8'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testProcessGetPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'GET', + uri: new Uri('/rpc/soap'), + headers: ['Content-Type' => 'text/xml'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessWrongPathPassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/other/path'), + headers: ['Content-Type' => 'text/xml'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testProcessJsonContentTypePassesToNext(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/rpc/soap'), + headers: ['Content-Type' => 'application/json'], + ); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(404, $response->getStatusCode()); + } + + // --- Handler (handle()) tests --- + + public function testHandleEmptyBody(): void + { + $handler = $this->makeHandler(); + $body = $this->streamFactory->createStream(''); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/rpc/soap'), + body: $body, + headers: ['Content-Type' => 'text/xml'], + ); + + $response = $handler->handle($request); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringContainsString('Empty SOAP request body', (string) $response->getBody()); + } + + // --- SOAP invocation tests (require ext-soap) --- + + #[RequiresPhpExtension('soap')] + public function testSoapMethodInvocation(): void + { + $handler = $this->makeHandler([ + 'ping' => fn() => 'pong', + ]); + $request = $this->makeSoapRequest('ping'); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $responseBody = (string) $response->getBody(); + $this->assertStringContainsString('pong', $responseBody); + } + + #[RequiresPhpExtension('soap')] + public function testSoapMethodNotFound(): void + { + $handler = $this->makeHandler([]); + $request = $this->makeSoapRequest('nonexistent'); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $responseBody = (string) $response->getBody(); + // SoapServer returns a SOAP Fault + $this->assertStringContainsString('Fault', $responseBody); + $this->assertStringContainsString('not defined', $responseBody); + } + + #[RequiresPhpExtension('soap')] + public function testSoapMethodWithParameters(): void + { + $handler = $this->makeHandler([ + 'math.add' => fn(float $a, float $b) => $a + $b, + ]); + + // SOAP with positional params + $soapBody = '' + . '' + . '' + . '' + . '3' + . '4' + . '' + . '' + . ''; + + $body = $this->streamFactory->createStream($soapBody); + $request = new ServerRequest( + method: 'POST', + uri: new Uri('/rpc/soap'), + body: $body, + headers: ['Content-Type' => 'text/xml'], + ); + + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('7', (string) $response->getBody()); + } + + // --- Custom path test --- + + public function testCustomPath(): void + { + $provider = new CallableMapProvider(['ping' => fn() => 'pong']); + $handler = new SoapHandler( + $provider, + $provider, + $this->responseFactory, + $this->streamFactory, + path: '/api/soap', + ); + $request = $this->makeSoapRequest('ping', path: '/api/soap'); + + $response = $handler->process($request, $this->makeNextHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } +} From 7b405c122069f6b12315cd9383575f6f07736505 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Wed, 8 Apr 2026 23:31:34 +0100 Subject: [PATCH 2/2] docs: Document the new handlers --- README.md | 68 ++++++-- doc/JSONRPC-USAGE.md | 378 +++++++++++++++++++++++++++++++++++++++++++ doc/MCP-USAGE.md | 306 +++++++++++++++++++++++++++++++++++ doc/SOAP-USAGE.md | 153 ++++++++++++++++++ 4 files changed, 896 insertions(+), 9 deletions(-) create mode 100644 doc/JSONRPC-USAGE.md create mode 100644 doc/MCP-USAGE.md create mode 100644 doc/SOAP-USAGE.md diff --git a/README.md b/README.md index 1faf6d9..5d7a28d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,61 @@ # Horde RPC Library -Various RPC-like adapters into Horde's API system - -- SOAP -- XMLRPC (relies on xmlrpc language extension, probably not usable in PHP 8.x until we re-implement it in pure PHP) -- json-rpc -- PHPGroupware flavoured XMLRPC (deprecated, probably useless by now) -- syncml (deprecated, delegates to horde/syncml) -- activesync (delegates to horde/activesync) -- webdav, caldav, carddav (delegates to horde/dav) \ No newline at end of file +RPC adapters for Horde's API system. Modern implementations share a +dispatch layer (`ApiProviderInterface`, `MethodInvokerInterface`) across +protocols — write a provider once, serve it over JSON-RPC, MCP, and SOAP. + +## Protocols + +### JSON-RPC + +| | | +|-|-| +| Modern | [`src/JsonRpc/`](src/JsonRpc/) — PSR-7/15/18, JSON-RPC [1.1](https://www.jsonrpc.org/historical/json-rpc-1-1-alt.html) and [2.0](https://www.jsonrpc.org/specification) | +| Legacy | [`lib/Horde/Rpc/Jsonrpc.php`](lib/Horde/Rpc/Jsonrpc.php) | +| Docs | [`doc/JSONRPC-USAGE.md`](doc/JSONRPC-USAGE.md) | + +### MCP (Model Context Protocol) + +| | | +|-|-| +| Modern | [`src/Mcp/`](src/Mcp/) — [MCP spec](https://modelcontextprotocol.io/specification/2025-11-25), Streamable HTTP transport | +| Docs | [`doc/MCP-USAGE.md`](doc/MCP-USAGE.md) | + +### SOAP + +| | | +|-|-| +| Modern | [`src/Soap/`](src/Soap/) — PSR-15, ext-soap (optional) in WSDL-less mode | +| Legacy | [`lib/Horde/Rpc/Soap.php`](lib/Horde/Rpc/Soap.php) | +| Docs | [`doc/SOAP-USAGE.md`](doc/SOAP-USAGE.md) | + +### XML-RPC + +| | | +|-|-| +| Legacy | [`lib/Horde/Rpc/Xmlrpc.php`](lib/Horde/Rpc/Xmlrpc.php) — requires ext-xmlrpc, will probably not work in most modern PHP installations [XML-RPC spec](http://xmlrpc.com/spec.md) | + +### Delegation Protocols + +These delegate to other Horde packages: + +- **ActiveSync** — [`lib/Horde/Rpc/ActiveSync.php`](lib/Horde/Rpc/ActiveSync.php) → `horde/activesync` +- **WebDAV / CalDAV / CardDAV** — [`lib/Horde/Rpc/Webdav.php`](lib/Horde/Rpc/Webdav.php) → `horde/dav` +- **SyncML** — [`lib/Horde/Rpc/Syncml.php`](lib/Horde/Rpc/Syncml.php) → `horde/syncml` (deprecated) +- **PHPGroupware XML-RPC** — [`lib/Horde/Rpc/Phpgw.php`](lib/Horde/Rpc/Phpgw.php) (deprecated) + +## Shared Dispatch Layer + +``` +JsonRpcHandler ──┐ +McpServer ───────┤── ApiProviderInterface / MethodInvokerInterface +SoapHandler ─────┘ +``` +### Providers + +[`CallableMapProvider`](src/JsonRpc/Dispatch/CallableMapProvider.php) - a generic map of callables to API names +[`MathApiProvider`](src/JsonRpc/Dispatch/MathApiProvider.php) - an educational example API provider +[`HordeRegistryApiProvider`](src/JsonRpc/Dispatch/HordeRegistryApiProvider.php) - Integrates the Horde Registry Inter-App API + +All modern handlers implement both `RequestHandlerInterface` and +`MiddlewareInterface` for flexible middleware stacking. diff --git a/doc/JSONRPC-USAGE.md b/doc/JSONRPC-USAGE.md new file mode 100644 index 0000000..2a74c42 --- /dev/null +++ b/doc/JSONRPC-USAGE.md @@ -0,0 +1,378 @@ +# JSON-RPC Usage Guide + +The `Horde\Rpc\JsonRpc` namespace provides a modern JSON-RPC implementation +supporting both **JSON-RPC 1.1** and **2.0** protocols. It is built on top of +PSR-7 (HTTP Messages), PSR-14 (Event Dispatcher), PSR-15 (Server Handlers), +PSR-17 (HTTP Factories), and PSR-18 (HTTP Client). + +## Quick Start + +### Calling a JSON-RPC Server + +```php +use Horde\Rpc\JsonRpc\JsonRpcClient; + +$client = new JsonRpcClient('https://api.example.com/rpc'); + +// Single call +$result = $client->call('math.add', [3, 4]); +// => 7 + +// Notification (fire-and-forget, no response) +$client->notify('log.event', ['user logged in']); + +// Batch (JSON-RPC 2.0 only) +$results = $client->batch([ + ['method' => 'math.add', 'params' => [1, 2]], + ['method' => 'math.multiply', 'params' => [3, 4]], + ['method' => 'log.event', 'params' => ['batch started'], 'notification' => true], +]); +``` + +By default, `JsonRpcClient` uses `Horde\Http\Client\Curl` as the HTTP +transport. You can inject any PSR-18 compliant client: + +```php +use Horde\Rpc\JsonRpc\JsonRpcClient; + +// Guzzle, Symfony HttpClient, or any PSR-18 implementation +$client = new JsonRpcClient( + 'https://api.example.com/rpc', + $guzzleClient, +); +``` + +### Using JSON-RPC 1.1 + +```php +use Horde\Rpc\JsonRpc\JsonRpcClient; +use Horde\Rpc\JsonRpc\Protocol\Version; + +$client = new JsonRpcClient( + 'https://legacy.example.com/rpc', + version: Version::V1_1, +); +``` + +## Building a JSON-RPC Server + +### Step 1: Create an API Provider + +An API provider tells the dispatcher which methods are available and how to +call them. Implement `ApiProviderInterface` (method registry) and +`MethodInvokerInterface` (method execution). + +The simplest approach is `CallableMapProvider` -- a name-to-callable map: + +```php +use Horde\Rpc\JsonRpc\Dispatch\CallableMapProvider; + +$provider = new CallableMapProvider([ + 'ping' => fn(): string => 'pong', + 'math.add' => fn(float $a, float $b): float => $a + $b, + 'user.get' => fn(int $id): array => $userRepository->find($id), +]); +``` + +### Step 2: Wire the Handler + +Use the `JsonRpcHandler` facade to wire everything together: + +```php +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; +use Horde\Rpc\JsonRpc\JsonRpcHandler; + +$handler = new JsonRpcHandler( + provider: $provider, // ApiProviderInterface + invoker: $provider, // MethodInvokerInterface (same object) + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + eventDispatcher: $eventDispatcher, // PSR-14 EventDispatcherInterface +); +``` + +### Step 3: Handle Requests + +The handler implements both `RequestHandlerInterface` and +`MiddlewareInterface`, so the same object works for direct routing +and in a middleware stack. + +**As a PSR-15 RequestHandler** (direct routing): + +```php +// $request is a PSR-7 ServerRequestInterface +$response = $handler->getHandler()->handle($request); +``` + +**As a PSR-15 Middleware** (in a middleware stack): + +```php +// The handler auto-detects JSON-RPC requests by path and content type +$app->pipe($handler->getHandler()); +``` + +The handler matches `POST` requests to the configured path with a JSON +content type. Non-matching requests pass through to the next handler. + +## Writing a Custom Provider + +### Using CallableMapProvider + +For simple APIs, pass a map of method names to callables: + +```php +use Horde\Rpc\JsonRpc\Dispatch\CallableMapProvider; +use Horde\Rpc\JsonRpc\Dispatch\MethodDescriptor; + +$provider = new CallableMapProvider( + // Method callables + [ + 'greet' => fn(string $name): string => "Hello, $name!", + 'time' => fn(): string => date('c'), + ], + // Optional: explicit method descriptors for rpc.discover + [ + 'greet' => new MethodDescriptor( + name: 'greet', + description: 'Greet someone by name', + parameters: [ + ['name' => 'name', 'type' => 'string', 'required' => true], + ], + returnType: 'string', + ), + ], +); +``` + +Methods without an explicit descriptor get a minimal auto-generated one +(name only, empty description). + +### Implementing the Interfaces Directly + +For more complex providers, implement the interfaces yourself. The +`MathApiProvider` included in this library demonstrates the pattern -- it +composes a `CallableMapProvider` internally: + +```php +use Horde\Rpc\JsonRpc\Dispatch\ApiProviderInterface; +use Horde\Rpc\JsonRpc\Dispatch\MethodInvokerInterface; +use Horde\Rpc\JsonRpc\Dispatch\MethodDescriptor; +use Horde\Rpc\JsonRpc\Dispatch\Result; + +final class MyApiProvider implements ApiProviderInterface, MethodInvokerInterface +{ + public function hasMethod(string $method): bool + { + return in_array($method, ['myapp.status', 'myapp.version'], true); + } + + public function getMethodDescriptor(string $method): ?MethodDescriptor + { + return $this->hasMethod($method) + ? new MethodDescriptor($method) + : null; + } + + public function listMethods(): array + { + return [ + new MethodDescriptor('myapp.status', 'Get application status'), + new MethodDescriptor('myapp.version', 'Get application version'), + ]; + } + + public function invoke(string $method, array $params): Result + { + return match ($method) { + 'myapp.status' => new Result(['healthy' => true]), + 'myapp.version' => new Result('2.0.0'), + }; + } +} +``` + +### The MathApiProvider Example + +The library ships with `MathApiProvider` as a ready-to-use example: + +```php +use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider; +use Horde\Rpc\JsonRpc\JsonRpcHandler; + +$math = MathApiProvider::create(); + +$handler = new JsonRpcHandler( + provider: $math, + invoker: $math, + responseFactory: new \Horde\Http\ResponseFactory(), + streamFactory: new \Horde\Http\StreamFactory(), + eventDispatcher: $eventDispatcher, +); +``` + +It provides four methods with full descriptor metadata: + +| Method | Parameters | Returns | Description | +|-----------------|------------------|---------|-------------------------------| +| `math.add` | `a`, `b` (float) | float | Add two numbers | +| `math.subtract` | `a`, `b` (float) | float | Subtract b from a | +| `math.multiply` | `a`, `b` (float) | float | Multiply two numbers | +| `math.divide` | `a`, `b` (float) | float | Divide a by b | + +`math.divide` throws `InvalidParamsException` on division by zero. + +## Built-in System Methods + +The `Dispatcher` provides two built-in fallback methods: + +- **`rpc.discover`** -- Returns a service description listing all available + methods with their descriptors (name, description, parameters, return type). +- **`rpc.ping`** -- Returns `"pong"`. + +These are fallbacks: if your provider implements `rpc.discover` or `rpc.ping` +itself, the provider's implementation wins. + +```php +$client = new JsonRpcClient('https://api.example.com/rpc'); + +// Discover available methods +$description = $client->call('rpc.discover'); +// => ['methods' => [['name' => 'math.add', 'description' => '...', ...], ...]] + +// Health check +$pong = $client->call('rpc.ping'); +// => 'pong' +``` + +## Error Handling + +### Client-Side + +`JsonRpcClient` throws exceptions from the `Horde\Rpc\JsonRpc\Exception` +namespace. All implement `JsonRpcThrowable`: + +```php +use Horde\Rpc\JsonRpc\Exception\InternalErrorException; +use Horde\Rpc\JsonRpc\Exception\JsonRpcThrowable; +use Psr\Http\Client\ClientExceptionInterface; + +try { + $result = $client->call('math.divide', [1, 0]); +} catch (JsonRpcThrowable $e) { + // JSON-RPC error (method not found, invalid params, internal error, ...) + echo $e->getMessage(); + echo $e->getJsonRpcCode(); // e.g. -32602 +} catch (ClientExceptionInterface $e) { + // HTTP transport error (connection refused, timeout, ...) + echo $e->getMessage(); +} +``` + +### Server-Side + +Throw exceptions from the `Exception` namespace in your provider to return +structured JSON-RPC errors: + +```php +use Horde\Rpc\JsonRpc\Exception\InvalidParamsException; +use Horde\Rpc\JsonRpc\Exception\MethodNotFoundException; + +// In your invoke() method: +throw new InvalidParamsException('age must be positive'); +// => {"error": {"code": -32602, "message": "age must be positive"}} +``` + +Any non-`JsonRpcThrowable` exception thrown by a provider is caught and +sanitized to a generic "Internal error" response. Internal details are never +leaked to the client. + +| Exception | Code | When to use | +|----------------------------|--------|------------------------------------| +| `ParseException` | -32700 | Malformed JSON (handled by Codec) | +| `InvalidRequestException` | -32600 | Structurally invalid request | +| `MethodNotFoundException` | -32601 | Method does not exist | +| `InvalidParamsException` | -32602 | Wrong or invalid parameters | +| `InternalErrorException` | -32603 | Server-side processing failure | +| `ServerErrorException` | -32000 to -32099 | Application-defined errors | + +## Events (PSR-14) + +The server handler emits events via a PSR-14 `EventDispatcherInterface`. Wire +up listeners for logging, metrics, or auditing: + +| Event | When | Key Properties | +|------------------------|-----------------------------------------|-----------------------------| +| `RequestReceived` | Valid request decoded | `request` | +| `RequestDispatched` | Method executed successfully | `request`, `result`, `duration` | +| `NotificationReceived` | Notification processed | `request` | +| `ErrorOccurred` | Error response being sent | `error`, `request` | +| `BatchProcessing` | Batch request starting | `requestCount`, `notificationCount` | + +Events live in `Horde\Rpc\JsonRpc\Event\`. + +## Horde Registry Migration + +For existing Horde installations, `HordeRegistryApiProvider` bridges the old +`Horde_Rpc_Jsonrpc` behavior to the new adapter. It translates dot-notation +method names (`calendar.list`) to the slash-notation (`calendar/list`) that +`Horde_Registry` expects: + +```php +use Horde\Rpc\JsonRpc\Dispatch\HordeRegistryApiProvider; +use Horde\Rpc\JsonRpc\JsonRpcHandler; + +$provider = new HordeRegistryApiProvider($registry); + +$handler = new JsonRpcHandler( + provider: $provider, + invoker: $provider, + responseFactory: new \Horde\Http\ResponseFactory(), + streamFactory: new \Horde\Http\StreamFactory(), + eventDispatcher: $eventDispatcher, +); +``` + +## Architecture Overview + +``` +JSON-RPC Client MCP Client (LLM) SOAP Client + | | | +JsonRpcClient MCP Streamable SoapClient (PHP) + | HTTP POST | + v v v +JsonRpcHandler McpServer SoapHandler + => Codec => McpRouter => ext-soap + => Dispatcher | => SoapCallHandler + => HttpHandler | | + | | | + +---------+----------+--------+---------+ + | | + Shared Dispatch Layer + ApiProviderInterface + MethodInvokerInterface + MethodDescriptor +``` + +All three protocol handlers implement both `RequestHandlerInterface` and +`MiddlewareInterface`. Stack them in a middleware pipeline and each one +claims its protocol or passes through: + +```php +$app->pipe($soapHandler); // text/xml → SOAP +$app->pipe($mcpHandler); // /mcp + JSON → MCP +$app->pipe($jsonRpcHandler); // /rpc/jsonrpc + JSON → JSON-RPC +``` + +Three layers with clear boundaries: + +- **Protocol** (`Protocol\`) -- Wire format: Codec, Request, Response, Error, + Batch, Version, ErrorCode +- **Dispatch** (`Dispatch\`) -- Business logic: provider interfaces, + dispatcher, result types (shared with MCP and SOAP) +- **Transport** (`Transport\`) -- HTTP integration: PSR-15 dual-interface + handler (handler + middleware), PSR-18 client + +The dispatch layer is shared across all three protocols. Write your +provider once, serve it over JSON-RPC, MCP, and SOAP. See +[MCP-USAGE.md](MCP-USAGE.md) and [SOAP-USAGE.md](SOAP-USAGE.md). diff --git a/doc/MCP-USAGE.md b/doc/MCP-USAGE.md new file mode 100644 index 0000000..63b65df --- /dev/null +++ b/doc/MCP-USAGE.md @@ -0,0 +1,306 @@ +# MCP Server Usage Guide + +The `Horde\Rpc\Mcp` namespace provides a Model Context Protocol (MCP) server +implementation. MCP is built on JSON-RPC 2.0 and allows AI language models to +discover and invoke tools and read resources exposed by your application. + +The MCP layer shares the same dispatch interfaces (`ApiProviderInterface`, +`MethodInvokerInterface`) as the JSON-RPC adapter, so the same provider code +serves both protocols. + +## Quick Start + +### MCP Server with MathApiProvider + +```php +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; +use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider; +use Horde\Rpc\Mcp\McpServer; +use Horde\Rpc\Mcp\Protocol\ServerInfo; + +$math = MathApiProvider::create(); + +$server = new McpServer( + new ServerInfo('horde-math', '1.0.0', 'Math tools for LLMs'), + $math, + $math, + new ResponseFactory(), + new StreamFactory(), +); + +// As a PSR-15 handler (direct routing) +$response = $server->getHandler()->handle($request); + +// As PSR-15 middleware (in a middleware stack) +// The handler auto-detects MCP requests by path and content type +$app->pipe($server->getHandler()); +``` + +An MCP client (like Claude) can now discover and call the math tools: + +``` +Client → POST /mcp +{"jsonrpc":"2.0","method":"tools/list","id":1} + +Server → +{"jsonrpc":"2.0","result":{"tools":[ + {"name":"math.add","description":"Add two numbers","inputSchema":{...}}, + {"name":"math.subtract",...}, + {"name":"math.multiply",...}, + {"name":"math.divide",...} +]},"id":1} + +Client → POST /mcp +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"math.add","arguments":[3,4]},"id":2} + +Server → +{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"7"}],"isError":false},"id":2} +``` + +## Creating Tools from Existing Providers + +Any class implementing `ApiProviderInterface` + `MethodInvokerInterface` works +as an MCP tool provider. No MCP-specific code is needed in the provider itself. + +### Using CallableMapProvider + +```php +use Horde\Rpc\JsonRpc\Dispatch\CallableMapProvider; +use Horde\Rpc\JsonRpc\Dispatch\MethodDescriptor; +use Horde\Rpc\Mcp\McpServer; +use Horde\Rpc\Mcp\Protocol\ServerInfo; + +$provider = new CallableMapProvider( + [ + 'weather.get' => fn(string $city) => fetchWeather($city), + 'time.now' => fn() => date('c'), + ], + [ + 'weather.get' => new MethodDescriptor( + 'weather.get', + 'Get weather for a city', + [['name' => 'city', 'type' => 'string', 'required' => true]], + 'string', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'city' => ['type' => 'string', 'description' => 'City name'], + ], + 'required' => ['city'], + ], + ), + ], +); + +$server = new McpServer( + new ServerInfo('my-tools', '1.0.0'), + $provider, $provider, + new ResponseFactory(), new StreamFactory(), +); +``` + +### MethodDescriptor and MCP Tool Schema + +The `MethodDescriptor` bridges to MCP's tool format. The key fields for MCP: + +| Field | Purpose | MCP Mapping | +|-------|---------|-------------| +| `name` | Tool name | `tools/list → name` | +| `description` | Tool description | `tools/list → description` | +| `inputSchema` | JSON Schema for input | `tools/list → inputSchema` | +| `outputSchema` | JSON Schema for output | `tools/list → outputSchema` | +| `parameters` | Parameter metadata | Auto-generates `inputSchema` if not set | +| `permissions` | Required permissions | Checked before `tools/call` | + +If you provide `inputSchema` explicitly, it is used as-is. If omitted, it is +auto-generated from the `parameters` array with PHP-to-JSON-Schema type mapping. + +## Serving Both JSON-RPC and MCP + +The same provider can serve both protocols simultaneously: + +```php +use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider; +use Horde\Rpc\JsonRpc\JsonRpcHandler; +use Horde\Rpc\Mcp\McpServer; +use Horde\Rpc\Mcp\Protocol\ServerInfo; + +$math = MathApiProvider::create(); + +// JSON-RPC endpoint +$jsonRpcHandler = new JsonRpcHandler( + $math, $math, + $responseFactory, $streamFactory, $eventDispatcher, +); + +// MCP endpoint +$mcpServer = new McpServer( + new ServerInfo('horde-math', '1.0.0'), + $math, $math, + $responseFactory, $streamFactory, +); + +// Wire into middleware stack +$app->pipe($jsonRpcHandler->getMiddleware('/rpc/jsonrpc')); +$app->pipe($mcpServer->getMiddleware('/mcp')); +``` + +## Resources + +MCP resources provide read-only data for context. Implement +`ResourceProviderInterface`: + +```php +use Horde\Rpc\Mcp\Protocol\ResourceContent; +use Horde\Rpc\Mcp\Protocol\ResourceDescriptor; +use Horde\Rpc\Mcp\ResourceProviderInterface; + +class DocsResourceProvider implements ResourceProviderInterface +{ + public function listResources(): array + { + return [ + new ResourceDescriptor( + 'docs://api/overview', + 'API Overview', + 'Overview of the API endpoints', + 'text/markdown', + ), + ]; + } + + public function hasResource(string $uri): bool + { + return $uri === 'docs://api/overview'; + } + + public function readResource(string $uri): ResourceContent + { + return new ResourceContent( + $uri, + text: '# API Overview\n\nThis API provides...', + mimeType: 'text/markdown', + ); + } +} +``` + +Wire it into the server: + +```php +$server = new McpServer( + new ServerInfo('my-app', '1.0.0'), + $provider, $provider, + $responseFactory, $streamFactory, + resourceProvider: new DocsResourceProvider(), +); +``` + +The server automatically advertises `resources` capability when a resource +provider is given. + +## Authentication and Permissions + +### Per-Method Permissions + +Following Horde's `$_noPerms` pattern, methods declare their permission +requirements in `MethodDescriptor::$permissions`: + +```php +// Public method (no auth required) -- like being in $_noPerms +new MethodDescriptor('time.now') +// permissions defaults to [] = public + +// Protected method (requires authentication) +new MethodDescriptor( + 'admin.reset', + 'Reset application state', + permissions: ['horde:admin'], +) +``` + +The MCP router checks permissions before invoking a tool. If the caller lacks +the required permissions, a JSON-RPC error (-32603 "Unauthorized") is returned. + +### How Auth Context Works + +The `AuthContext` is extracted from PSR-7 request attributes set by upstream +auth middleware: + +| Attribute | Type | Purpose | +|-----------|------|---------| +| `auth_permissions` | `string[]` | Granted permission identifiers | +| `authenticated` | `bool` | Whether the user is authenticated | + +Wire your auth middleware before the MCP handler in your middleware stack: + +```php +// Auth middleware sets request attributes +$app->pipe($jwtAuthMiddleware); // sets auth_permissions, authenticated +$app->pipe($mcpServer->getMiddleware('/mcp')); +``` + +### Public vs Protected Tools + +```php +$provider = new CallableMapProvider( + [ + 'ping' => fn() => 'pong', // public + 'admin.reset' => fn() => resetApp(), // protected + ], + [ + 'ping' => new MethodDescriptor('ping'), + // permissions = [] → public, no auth check + + 'admin.reset' => new MethodDescriptor( + 'admin.reset', + permissions: ['horde:admin'], + ), + // permissions = ['horde:admin'] → requires auth + ], +); +``` + +## MCP Protocol Lifecycle + +The MCP handshake follows this sequence: + +``` +Client → {"jsonrpc":"2.0","method":"initialize","params":{...},"id":1} +Server → {"jsonrpc":"2.0","result":{"protocolVersion":"2025-11-25","capabilities":{...},"serverInfo":{...}},"id":1} +Client → {"jsonrpc":"2.0","method":"notifications/initialized"} +Server → 202 Accepted +``` + +After initialization, the client can call `tools/list`, `tools/call`, +`resources/list`, `resources/read`, and `ping`. + +## Architecture Overview + +``` +JSON-RPC Client MCP Client (LLM) SOAP Client + | | | +JsonRpcClient MCP Streamable SoapClient (PHP) + | HTTP POST | + v v v +JsonRpcHandler McpServer SoapHandler + => Codec (facade) => ext-soap + => Dispatcher => McpRouter => SoapCallHandler + => HttpHandler => tools/list | + | => tools/call | + | => resources/* | + +---------+----------+--------+--------+ + | | + Shared Dispatch Layer + ApiProviderInterface + MethodInvokerInterface + MethodDescriptor + CallableMapProvider + MathApiProvider + HordeRegistryApiProvider +``` + +Three protocols, one dispatch layer. Write your provider once, serve it +over JSON-RPC, MCP, and SOAP. All handlers implement both +`RequestHandlerInterface` and `MiddlewareInterface` for flexible stacking. diff --git a/doc/SOAP-USAGE.md b/doc/SOAP-USAGE.md new file mode 100644 index 0000000..2e2d31e --- /dev/null +++ b/doc/SOAP-USAGE.md @@ -0,0 +1,153 @@ +# SOAP Usage Guide + +The `Horde\Rpc\Soap` namespace provides a SOAP server implementation built +on PHP's ext-soap, PSR-7 (HTTP Messages), and PSR-15 (Server Handlers). + +The SOAP handler shares the same dispatch interfaces (`ApiProviderInterface`, +`MethodInvokerInterface`) as JSON-RPC and MCP, so the same provider code +serves all three protocols. + +## Requirements + +SOAP support requires the PHP `soap` extension (`ext-soap`). The extension +is **optional** at the library level -- the handler returns a meaningful +SOAP Fault response (HTTP 501) when ext-soap is not loaded, and does not +interfere with other protocols in the middleware stack. + +## Quick Start + +```php +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; +use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider; +use Horde\Rpc\Soap\SoapHandler; + +$math = MathApiProvider::create(); + +$handler = new SoapHandler( + provider: $math, + invoker: $math, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), +); + +// As a PSR-15 handler (direct routing) +$response = $handler->handle($request); + +// As PSR-15 middleware (in a middleware stack) +// Detects SOAP requests by Content-Type (text/xml, application/soap+xml) +$app->pipe($handler); +``` + +## SOAP Detection + +The handler identifies SOAP requests by: + +1. **HTTP Method**: `POST` +2. **Path**: matches the configured path (default: `/rpc/soap`) +3. **Content-Type**: `text/xml` (SOAP 1.1) or `application/soap+xml` (SOAP 1.2) + +The `SOAPAction` header is accepted when present but not required for +matching (SOAP 1.2 may omit it). + +## Graceful Degradation + +When ext-soap is not loaded, the handler: + +- **As middleware**: still matches SOAP requests by Content-Type and path, + but returns a SOAP Fault XML response with HTTP 501 +- **As handler**: returns the same 501 SOAP Fault +- **Does not interfere** with JSON-RPC or MCP handlers in the stack + +```xml + + + + + SOAP-ENV:Server + SOAP support requires the PHP soap extension + + + +``` + +## Stacking All Three Protocols + +All protocol handlers implement both `RequestHandlerInterface` and +`MiddlewareInterface`. Stack them in a middleware pipeline -- each one +claims its protocol or passes through: + +```php +use Horde\Http\ResponseFactory; +use Horde\Http\StreamFactory; +use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider; +use Horde\Rpc\JsonRpc\JsonRpcHandler; +use Horde\Rpc\Mcp\McpServer; +use Horde\Rpc\Mcp\Protocol\ServerInfo; +use Horde\Rpc\Soap\SoapHandler; + +$math = MathApiProvider::create(); +$responseFactory = new ResponseFactory(); +$streamFactory = new StreamFactory(); + +// JSON-RPC +$jsonRpc = new JsonRpcHandler( + $math, $math, + $responseFactory, $streamFactory, $eventDispatcher, +); + +// MCP +$mcp = new McpServer( + new ServerInfo('horde-math', '1.0.0'), + $math, $math, + $responseFactory, $streamFactory, +); + +// SOAP +$soap = new SoapHandler( + $math, $math, + $responseFactory, $streamFactory, +); + +// Stack: each protocol detects and claims, or passes through +$app->pipe($soap); // text/xml → SOAP +$app->pipe($mcp->getHandler()); // /mcp + JSON → MCP +$app->pipe($jsonRpc->getHandler()); // /rpc/jsonrpc + JSON → JSON-RPC +``` + +## Custom Configuration + +```php +$handler = new SoapHandler( + provider: $provider, + invoker: $provider, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + path: '/api/soap', // custom path (default: /rpc/soap) + serviceUri: 'urn:my-app-soap-service', // SOAP service URI (default: urn:horde-rpc-soap) +); +``` + +## Architecture Overview + +``` +JSON-RPC Client MCP Client (LLM) SOAP Client + | | | +JsonRpcClient MCP Streamable SoapClient (PHP) + | HTTP POST | + v v v +JsonRpcHandler McpServer SoapHandler + => Codec => McpRouter => ext-soap + => Dispatcher | => SoapCallHandler + => HttpHandler | | + | | | + +---------+----------+--------+---------+ + | | + Shared Dispatch Layer + ApiProviderInterface + MethodInvokerInterface + MethodDescriptor +``` + +Three protocols, one dispatch layer. Write your provider once, serve it +over JSON-RPC, MCP, and SOAP.