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. 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()); + } +}