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