petstack / roxy-php
PHP SDK for writing MCP (Model Context Protocol) handlers served by the roxy proxy. Object model, typed builders, elicitation and structured output support.
Requires
- php: >=8.4
- ext-json: *
- ext-mbstring: *
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.68
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
- rector/rector: ^2.0
README
PHP SDK for writing roxy upstream handlers.
roxy is a high-performance MCP (Model Context Protocol) proxy written in Rust that bridges MCP clients to any backend handler. roxy-php is the official PHP library for writing those handlers — it gives you a typed, tested, object-oriented API instead of hand-rolling the internal JSON protocol.
With roxy-php you can stand up a production MCP server in a dozen lines of PHP.
Requires PHP 8.4+. Published under BSD-2-Clause.
Table of contents
- Why
- Installation
- Quick start
- API styles
- Building results
- Elicitation (multi-turn tool input)
- Error handling
- Full API reference
- Running your handler with roxy
- Integrating into existing frameworks
- Development
- License
Why
Writing an MCP server by hand means dealing with JSON-RPC framing, session management, transport negotiation, and MCP-specific nuances like elicitation, structured output, and resource links. You don't actually care about any of that — you just want to expose some tools and resources to Claude, Cursor, or Zed.
roxy handles all of the protocol plumbing in Rust. roxy-php gives you a small, focused SDK on top: you register tools, resources, and prompts with typed builders, and roxy-php takes care of routing, serialization, error conversion, and the upstream JSON envelope.
Installation
composer require petstack/roxy-php
You'll also need roxy itself installed and configured to point at your PHP handler. See the main roxy README for installation options (Homebrew, install script, .deb, .rpm, musl static tarball).
Quick start
Create handler.php:
<?php declare(strict_types=1); require_once __DIR__ . '/vendor/autoload.php'; use Petstack\Roxy\CallContext; use Petstack\Roxy\Result; use Petstack\Roxy\Server; $server = new Server(); $server->tool( name: 'echo', description: 'Echoes back the input message', inputSchema: [ 'type' => 'object', 'properties' => ['message' => ['type' => 'string']], 'required' => ['message'], ], handler: fn (array $args, CallContext $ctx): Result => Result::text((string) $args['message']), ); $server->run();
Start PHP-FPM and run roxy:
php-fpm --nodaemonize -d "listen=127.0.0.1:9000" -d "pm=static" -d "pm.max_children=4" roxy --upstream 127.0.0.1:9000 --upstream-entrypoint "$(pwd)/handler.php"
That's it. Your MCP client can now invoke the echo tool.
API styles
roxy-php supports two registration styles. They're fully interoperable — use either, or mix them in the same Server.
Functional style
Best for quick handlers with a single file. Every Server::tool(), resource(), and prompt() call registers a definition and a callable handler:
$server->tool( name: 'add', description: 'Adds two numbers', inputSchema: [ 'type' => 'object', 'properties' => [ 'a' => ['type' => 'number'], 'b' => ['type' => 'number'], ], 'required' => ['a', 'b'], ], outputSchema: [ 'type' => 'object', 'properties' => ['sum' => ['type' => 'number']], ], handler: function (array $args, CallContext $ctx): Result { $sum = $args['a'] + $args['b']; return Result::text("{$args['a']} + {$args['b']} = {$sum}") ->withStructured(['sum' => $sum]); }, );
Server methods return $this, so registrations can be chained.
Object-oriented style
For larger handlers, DI, or when you want unit-testable handler classes, subclass Tool, Resource, or Prompt and register an instance:
use Petstack\Roxy\{Tool, Result, CallContext}; final class AddTool extends Tool { public function __construct(private readonly Calculator $calc) { parent::__construct( name: 'add', description: 'Adds two numbers', inputSchema: [ 'type' => 'object', 'properties' => [ 'a' => ['type' => 'number'], 'b' => ['type' => 'number'], ], 'required' => ['a', 'b'], ], ); } public function call(array $arguments, CallContext $context): Result { $sum = $this->calc->add($arguments['a'], $arguments['b']); return Result::text("Sum: {$sum}") ->withStructured(['sum' => $sum]); } } $server->addTool(new AddTool(new Calculator()));
Same pattern for Resource (override read()) and Prompt (override get()).
Building results
Every handler must return a Result. The Result class is an immutable fluent builder with four factory methods:
// Simple text response Result::text('Hello, world!'); // Multiple content blocks at once Result::content( new TextContent('Line 1'), new TextContent('Line 2'), ); // Empty success (for fire-and-forget tools) Result::empty(); // Explicit error (prefer exceptions — see Error handling below) Result::error(code: 404, message: 'Not found');
Enrich a content result with with* methods — they return new instances:
Result::text('Booked') ->withText('Confirmation sent.') // another text block ->withResourceLink( // a resource_link block uri: 'myapp://bookings/42', name: 'booking-42', title: 'Booking #42', ) ->withStructured([ // structured_content payload 'booking_id' => 42, 'destination' => 'Tokyo', ]);
Result is immutable — each with* returns a new copy. Safe to share across concurrent requests.
Elicitation (multi-turn tool input)
MCP supports tools that ask the user for additional input mid-execution. roxy-php exposes this as Result::elicit():
public function call(array $arguments, CallContext $context): Result { // First call: no answers yet → ask the user. if (!$context->hasElicitationResults()) { return Result::elicit( message: 'Select flight class', schema: [ 'type' => 'object', 'properties' => [ 'class' => [ 'type' => 'string', 'enum' => ['economy', 'business', 'first'], ], ], 'required' => ['class'], ], context: ['step' => 1, 'destination' => $arguments['destination']], ); } // Follow-up call: user answered, $context carries the results. $class = $context->firstElicitationResult()['class']; // ...complete the tool return Result::text("Booked {$class}"); }
The context you pass on the first call is echoed back to you in the follow-up via $context->elicitationContext, so you can stash state across rounds without needing external storage.
You can chain multiple elicitation rounds — return another Result::elicit() until all data is collected.
Error handling
Three ways to produce an error response:
1. Throw a RoxyException subclass (preferred — clean call sites, automatic conversion):
use Petstack\Roxy\Exception\NotFoundException; use Petstack\Roxy\Exception\InvalidRequestException; handler: function (array $args, CallContext $ctx): Result { if (!isset($args['id'])) { throw new InvalidRequestException('id is required'); } $item = $db->findItem($args['id']) ?? throw new NotFoundException("Item {$args['id']} not found"); return Result::text($item->name); }
NotFoundException → code: 404, InvalidRequestException → code: 400. Custom codes: throw new RoxyException('message', 418).
2. Return Result::error() if you prefer explicit returns:
return Result::error(code: 409, message: 'Booking conflict');
3. Let unexpected exceptions bubble — any uncaught Throwable becomes a 500 error response. Your process stays alive and roxy sees a clean error.
Full API reference
Server
The main entry point. Construct one, register your handlers, call run().
| Method | Description |
|---|---|
__construct(?Transport $transport = null) |
Creates a server. Injects a custom transport in tests; defaults to StdioTransport. |
tool(name, description, inputSchema, handler, title?, outputSchema?) |
Register a tool with a callable. |
resource(uri, name, handler, title?, description?, mimeType?) |
Register a resource with a callable. |
prompt(name, handler, title?, description?, arguments?) |
Register a prompt with a callable. |
addTool(Tool) / addResource(Resource) / addPrompt(Prompt) |
Register a pre-built definition (for OO style). |
lazyTool(name, description, inputSchema, factory, title?, outputSchema?) |
Register a tool whose handler is built lazily by a factory on first invocation. |
lazyResource(uri, name, factory, title?, description?, mimeType?) |
Lazy variant of resource(). |
lazyPrompt(name, factory, title?, description?, arguments?) |
Lazy variant of prompt(). |
run(): void |
Read one request, dispatch, write the response. This is the entry point your handler file ends with. |
handleRaw(string $json): string |
Same as run() but operating on raw strings — useful for tests or custom transports. |
registry(): Registry |
Exposes the underlying registry (mostly for introspection / testing). |
Result
See Building results. Key methods:
Result::text(string)— single text blockResult::content(Content ...)— multiple blocksResult::empty()— no contentResult::elicit(message, schema, context?)— ask for more inputResult::error(code, message)— explicit error->withText(string)/->withResourceLink(...)/->withContent(Content)— append blocks->withStructured(mixed)— attachstructured_contentpayload->toArray(): array— serialize to wire format
CallContext
Passed as the second argument to every handler. Holds envelope metadata and elicitation state.
| Property / Method | Description |
|---|---|
$sessionId: ?string |
MCP session ID, if the client provided one |
$requestId: ?string |
Unique request ID assigned by roxy |
$elicitationResults: ?array |
User's answers to a prior elicitation, if this is a follow-up call |
$elicitationContext: mixed |
The context you passed on the original Result::elicit() call, echoed back |
hasElicitationResults(): bool |
Convenience: did the user answer a prior elicitation? |
firstElicitationResult(): ?array |
Convenience: the first answer, or null |
Tool, Resource, Prompt
Plain definition classes. All fields are readonly. Constructor takes metadata plus an optional handler callable. Subclass and override call() / read() / get() for the OO style.
Content types
| Class | Wire format |
|---|---|
TextContent(string $text) |
{"type":"text","text":"..."} |
ResourceLink(uri, name, title?, description?, mimeType?) |
{"type":"resource_link","uri":"...","name":"..."} |
Both implement the Content interface. Use with Result::content() and Result::withContent().
Exceptions
| Class | Default code | Use for |
|---|---|---|
RoxyException |
0 (falls back to 400) | Base class, custom codes |
InvalidRequestException |
400 | Bad input / missing fields |
NotFoundException |
404 | Unknown tool, resource, prompt, or entity |
Running your handler with roxy
FastCGI (PHP-FPM)
# Terminal 1 — PHP-FPM php-fpm --nodaemonize \ -d "listen=127.0.0.1:9000" \ -d "pm=static" \ -d "pm.max_children=4" # Terminal 2 — roxy roxy --upstream 127.0.0.1:9000 --upstream-entrypoint "$(pwd)/handler.php"
Unix socket works too:
roxy --upstream /var/run/php-fpm.sock --upstream-entrypoint "$(pwd)/handler.php"
MCP client configuration
For Claude Desktop and similar tools that spawn MCP servers via stdio:
{
"mcpServers": {
"my-php-server": {
"command": "roxy",
"args": [
"--upstream", "127.0.0.1:9000",
"--upstream-entrypoint", "/absolute/path/to/handler.php"
]
}
}
}
HTTP transport (for network clients)
roxy --transport http --port 8080 \
--upstream 127.0.0.1:9000 \
--upstream-entrypoint "$(pwd)/handler.php"
Your client connects to http://localhost:8080/sse.
Integrating into existing frameworks
The Server::run() + handler.php workflow documented above assumes roxy spawns a fresh PHP process per request. If instead you want to expose MCP endpoints inside an existing Symfony, Laravel, Magento, WordPress, PrestaShop, or Sylius application, you embed roxy-php as a library and drive it from your own controller / route / module.
The key is Server::handleRaw(string $body): string — a pure, framework-agnostic entry point that takes a raw JSON request body and returns a raw JSON response body. It never touches php://input or stdout, so it composes cleanly with any framework's request/response lifecycle.
Do not call $server->run() from inside a framework controller. By the time your controller runs, the framework has already consumed php://input into its own Request object, so the default StdioTransport would read an empty body and return a 400. Always pass the framework's request body to handleRaw() directly.
See examples/framework-controller.php for a runnable, framework-free demonstration of the pattern.
Symfony / Sylius
Register Server as a service and inject it into the controller that owns your MCP endpoint. Sylius is a Symfony application, so the same pattern applies verbatim.
// config/services.yaml services: Petstack\Roxy\Server: factory: ['@App\Mcp\ServerFactory', 'build']
// src/Mcp/ServerFactory.php namespace App\Mcp; use Petstack\Roxy\Server; final class ServerFactory { public function build(): Server { return new Server() ->tool( name: 'ping', description: 'Health check', inputSchema: ['type' => 'object'], handler: static fn ($args, $ctx) => \Petstack\Roxy\Result::text('pong'), ); // Register your real tools here — or see "Dependency injection // into Tool subclasses" below for the DI-friendly style. } }
// src/Controller/McpController.php namespace App\Controller; use Petstack\Roxy\Server; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class McpController { public function __construct(private readonly Server $server) {} #[Route('/mcp-api', methods: ['POST'])] public function __invoke(Request $request): Response { return new Response( $this->server->handleRaw($request->getContent()), Response::HTTP_OK, ['Content-Type' => 'application/json'], ); } }
Point your MCP client at https://your-app.example/mcp-api via roxy's HTTP transport, or proxy it directly if your client speaks HTTP MCP.
Laravel
Register Server in a service provider and resolve it in a route closure or invokable controller.
// app/Providers/AppServiceProvider.php use Petstack\Roxy\Server; public function register(): void { $this->app->singleton(Server::class, function (): Server { return new Server() ->tool( name: 'ping', description: 'Health check', inputSchema: ['type' => 'object'], handler: fn ($args, $ctx) => \Petstack\Roxy\Result::text('pong'), ); }); }
// routes/web.php (or routes/api.php) use Illuminate\Http\Request; use Illuminate\Http\Response; use Petstack\Roxy\Server; Route::post('/mcp-api', function (Request $request, Server $server): Response { return new Response( $server->handleRaw($request->getContent()), 200, ['Content-Type' => 'application/json'], ); });
Slim, Mezzio, Yii, Laminas (PSR-7)
roxy-php ships with a first-class PSR-7 adapter. Any framework or router that speaks PSR-7 — Slim, Mezzio, Yii, Laminas, ReactPHP, Amp, plus Symfony and Laravel through their PSR-7 bridges — can use it directly.
use Nyholm\Psr7\Factory\Psr17Factory; // or any PSR-17 implementation use Petstack\Roxy\Server; use Petstack\Roxy\Transport\Psr7Adapter; $factory = new Psr17Factory(); $server = new Server()->tool(/* ... */); $adapter = new Psr7Adapter($server, $factory, $factory); // Inside a Slim route / Mezzio handler / any PSR-15 middleware: $app->post('/mcp-api', fn ($request, $response) => $adapter->handle($request));
Any PSR-17 factory works — nyholm/psr7, guzzlehttp/psr7, laminas/laminas-diactoros, slim/psr7. The adapter is stateless and thread-safe, so you can build it once as a container service and reuse it.
If you prefer hand-wiring without the adapter, feed the raw body to handleRaw() and assemble the response yourself:
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; $app->post('/mcp-api', function ( ServerRequestInterface $request, ResponseInterface $response, ) use ($server): ResponseInterface { $response->getBody()->write($server->handleRaw((string) $request->getBody())); return $response->withHeader('Content-Type', 'application/json'); });
Magento 2
Create a module with a POST controller action. Use Magento\Framework\App\Request\Http::getContent() to pull the raw body, then write the response through RawFactory.
// app/code/Vendor/Mcp/Controller/Handler/Index.php namespace Vendor\Mcp\Controller\Handler; use Magento\Framework\App\Action\HttpPostActionInterface; use Magento\Framework\App\Request\Http as RequestHttp; use Magento\Framework\Controller\Result\RawFactory; use Petstack\Roxy\Server; class Index implements HttpPostActionInterface { public function __construct( private readonly Server $server, private readonly RequestHttp $request, private readonly RawFactory $rawFactory, ) {} public function execute() { $result = $this->rawFactory->create(); $result->setHeader('Content-Type', 'application/json', true); $result->setContents($this->server->handleRaw($this->request->getContent())); return $result; } }
Register the route in etc/frontend/routes.xml and build the Server in a di.xml-registered factory so Magento's object manager can inject dependencies into your Tool subclasses (see Dependency injection into Tool subclasses).
WordPress / WooCommerce
Register a REST route via register_rest_route(). WordPress wraps REST responses in its own JSON envelope, so either decode-and-return (letting WP re-encode) or send the raw response via header() + echo + exit.
// in your plugin file use Petstack\Roxy\Server; use Petstack\Roxy\Result; use Petstack\Roxy\CallContext; add_action('rest_api_init', function (): void { register_rest_route('mcp/v1', '/handler', [ 'methods' => 'POST', 'permission_callback' => function (): bool { // Gate the endpoint behind a capability or nonce check. return current_user_can('manage_options'); }, 'callback' => function (WP_REST_Request $request) { $server = new Server()->tool( name: 'list_products', description: 'Lists WooCommerce products', inputSchema: ['type' => 'object'], handler: function (array $args, CallContext $ctx): Result { $products = wc_get_products(['limit' => 10]); return Result::text(\wp_json_encode(\array_map( static fn ($p) => ['id' => $p->get_id(), 'name' => $p->get_name()], $products, ))); }, ); // Decode and return — WP will re-encode on the way out. return \json_decode($server->handleRaw($request->get_body()), true); }, ]); });
The JSON round-trip costs a few microseconds and keeps the response fully inside the REST API conventions (CORS, authentication, logging).
PrestaShop
Expose the endpoint through a module front controller.
// modules/mcp/controllers/front/handler.php use Petstack\Roxy\Server; class McpHandlerModuleFrontController extends ModuleFrontController { public function postProcess(): void { $server = $this->module->getContainer()->get(Server::class); $body = \file_get_contents('php://input') ?: ''; \header('Content-Type: application/json'); echo $server->handleRaw($body); exit; } }
Hit it at /index.php?fc=module&module=mcp&controller=handler.
Dependency injection into Tool subclasses
The object-oriented style (subclass Tool / Resource / Prompt) composes naturally with any DI container because each subclass is just a class — it can take whatever constructor dependencies the container knows how to wire.
final class ListOrdersTool extends Tool { public function __construct(private readonly OrderRepository $orders) { parent::__construct( name: 'list_orders', description: 'Lists recent orders', inputSchema: ['type' => 'object'], ); } public function call(array $arguments, CallContext $context): Result { $rows = $this->orders->recent(10); return Result::text(\json_encode(\array_map( static fn ($o) => ['id' => $o->id, 'total' => $o->total], $rows, ))); } }
Then register it through your framework's DI:
// Symfony: autowired via services.yaml $server->addTool($container->get(ListOrdersTool::class)); // Laravel: resolved via the app container $server->addTool(app(ListOrdersTool::class)); // Magento: object manager $server->addTool($this->objectManager->get(ListOrdersTool::class));
If eagerly resolving every tool on every request is too expensive — your tools pull repositories, HTTP clients, or other heavyweight services — roxy-php ships lazy registration helpers that carry the metadata eagerly but defer handler construction until the tool is actually invoked.
$server->lazyTool( name: 'list_orders', description: 'Lists recent orders', inputSchema: ['type' => 'object'], factory: fn (): callable => $container->get(ListOrdersHandler::class), );
The factory is a callable(): callable — it runs at most once per Server instance, and only on the first actual invocation of the tool. discover walks metadata without touching the factory, so introspection stays cheap regardless of how many tools you have. Same pattern is available for resources and prompts via lazyResource() / lazyPrompt().
The Symfony / Laravel / Magento / Sylius integration all compose naturally with lazy registration — the factory body is literally fn () => $container->get(...).
A runnable demo with a pretend container lives in examples/lazy-di.php.
Development
Setup
git clone https://github.com/petstack/roxy-php
cd roxy-php
composer install
Running the test suite
composer test # PHPUnit composer stan # PHPStan (level max) composer cs # PHP-CS-Fixer (dry run) composer rector # Rector (dry run) composer check # all of the above
Fixing style
composer cs-fix # apply PHP-CS-Fixer fixes composer rector-fix # apply Rector refactorings
Project layout
src/
Server.php Public entry point
Registry.php Tool/Resource/Prompt catalog
Dispatcher.php Routes request types to handlers
Tool.php Tool definition + base class
Resource.php Resource definition + base class
Prompt.php Prompt definition + base class
LazyTool.php Lazy (factory-resolved) Tool subclass
LazyResource.php Lazy Resource subclass
LazyPrompt.php Lazy Prompt subclass
PromptArgument.php Typed prompt argument descriptor
Result.php Immutable fluent builder for all responses
CallContext.php Per-call metadata + elicitation state
Content/
Content.php Interface
TextContent.php Text block
ResourceLink.php Resource link block
Exception/
RoxyException.php Base
NotFoundException.php
InvalidRequestException.php
Transport/
Transport.php Interface
StdioTransport.php Default: php://input → stdout
Psr7Adapter.php PSR-7 request/response adapter
tests/ PHPUnit test suite with fake transport
examples/
simple.php Functional style, ~30 LOC
advanced.php OO style with elicitation, structured output, resource links
framework-controller.php Embedding roxy-php inside a framework controller
psr7.php Driving roxy-php through the PSR-7 adapter
lazy-di.php Lazy tool registration with a DI container
License
BSD-2-Clause. Use it however you want, commercial or otherwise. roxy itself is AGPL-3.0; the SDK is permissively licensed so you can link it into any project.