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.

Maintainers

Package info

github.com/petstack/roxy-php

pkg:composer/petstack/roxy-php

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.0 2026-04-12 00:52 UTC

This package is auto-updated.

Last update: 2026-04-12 01:02:33 UTC


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

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);
}

NotFoundExceptioncode: 404, InvalidRequestExceptioncode: 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 block
  • Result::content(Content ...) — multiple blocks
  • Result::empty() — no content
  • Result::elicit(message, schema, context?) — ask for more input
  • Result::error(code, message) — explicit error
  • ->withText(string) / ->withResourceLink(...) / ->withContent(Content) — append blocks
  • ->withStructured(mixed) — attach structured_content payload
  • ->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.