MCP Server Tool with PHP, Docker and Caddy

backend

Assumed Knowledge

Before you dive in, make sure you’re comfortable with:

⚠️ One more thing: I build in a remote dev environment, not on localhost. That means I don’t need to mess with tunnels or ngrok-style workarounds, and it’s closer to what things look like in production. If you’re working locally, you’ll need to adjust your code accordingly.


What We’re Building

We’ll stand up a minimal but production-minded MCP server in PHP. The idea is to give you a clean scaffold you can extend with real tools later. The stack looks like this:

At the end, you’ll have a working tool that responds to “ping,” returning JSON with an ok flag, a timestamp, and an optional echo string.


Step 1: Project Layout

We’ll use a predictable directory layout so configs and code don’t get messy later.

mcpv2/
├─ bin/
│  └─ mcp-http.php
├─ src/                # for future tools
├─ composer.json
├─ Dockerfile
├─ docker-compose.yml
├─ caddy/
│  └─ Caddyfile
└─ mcpo-config.json    # optional, for Open WebUI

Create this folder structure first.


Step 2: PHP MCP Server

We’ll define the MCP server in PHP, using php-mcp/server. It gives us a framework for tools, transports, and capabilities.

composer.json

{
  "name": "Phippsy/mcpv2",
  "require": {
    "php": "^8.1",
    "php-mcp/server": "^3.3"
  },
  "autoload": {
    "psr-4": {
      "Phippsy\\Mcp\\": "src/"
    }
  }
}

bin/mcp-http.php

This file launches the MCP server, registers a ping tool, and listens on port 8080.

#!/usr/bin/env php
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use PhpMcp\Server\Server;
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Server\Transports\StreamableHttpServerTransport;

$server = Server::make()
    ->withServerInfo(
        getenv('APP_NAME') ?: 'Phippsy MCP',
        getenv('APP_VERSION') ?: '0.1.0'
    )
    ->withCapabilities(ServerCapabilities::make(
        resources: true,
        resourcesSubscribe: true,
        prompts: true,
        tools: true
    ))
    // Register a tiny test tool BEFORE build()
    ->withTool(
        /**
         * Health check that returns ok + timestamp (+ echo if provided).
         *
         * @param string|null $echo
         * @return array<string, mixed>
         */
        function (?string $echo = null): array {
            $out = [
                'ok' => true,
                'ts' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM),
            ];
            if ($echo !== null && $echo !== '') {
                $out['echo'] = $echo;
            }
            return $out;
        },
        name: 'ping',
        description: 'Health check that returns ok + timestamp.'
    )
    ->build();

// Optional: auto-discover future #[McpTool] classes under src/
$server->discover(basePath: dirname(__DIR__), scanDirs: ['src']);

// Streamable HTTP ⇒ single endpoint at /mcp
$transport = new StreamableHttpServerTransport(
    host: '0.0.0.0',
    port: 8080
);

$server->listen($transport);

Step 3: Docker

Containerizing the server makes it portable and consistent.

Dockerfile

FROM php:8.3-cli-alpine

# sys deps for composer + (intl/zip ready if needed later)
RUN apk add --no-cache git unzip icu-libs

# install composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

# install deps
COPY composer.json composer.lock* /app/
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress || true

# app source
COPY src/ /app/src/
COPY bin/ /app/bin/

# prod install (ensures vendor is complete)
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress

EXPOSE 8080

docker-compose.yml

This defines three services: the PHP MCP server, the optional MCPO bridge, and Caddy for TLS.

services:
  mcp:
    build: .
    container_name: mcp-php
    environment:
      APP_NAME: "Phippsy MCP"
      APP_VERSION: "0.1.0"
    command: ["php", "/app/bin/mcp-http.php"]
    expose:
      - "8080"
    networks: [mcp_net]
    restart: unless-stopped

  # OPTIONAL: MCPO bridge so Open WebUI can use your MCP server
  mcpo:
    image: ghcr.io/open-webui/mcpo:main
    container_name: mcpo
    depends_on: [mcp]
    volumes:
      - ./mcpo-config.json:/app/config.json:ro
    environment:
      MCPO_HOST: 0.0.0.0
      MCPO_PORT: 8000
    command: ["mcpo", "--config", "/app/config.json", "--hot-reload"]
    expose:
      - "8000"
    networks: [mcp_net]
    restart: unless-stopped

  # Caddy reverse proxy with TLS for the public domain
  caddy:
    image: caddy:2
    container_name: caddy
    depends_on: [mcp, mcpo]
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    ports:
      - "80:80"
      - "443:443"
    networks: [mcp_net]
    restart: unless-stopped

networks:
  mcp_net:

volumes:
  caddy_data:
  caddy_config:

Step 4: Caddy Reverse Proxy

Caddy handles TLS and request rewriting so clients always land on /mcp. Replace mcp.YOURDOMAIN.com with your actual domain.

caddy/Caddyfile

# Public MCP (single streamable endpoint)
mcp.YOURDOMAIN.com {
    encode gzip zstd
    log {
        output stdout
        format console
    }

    # Rewrite legacy/bad paths to the single endpoint
    @bad path /mcp/message /mcp/sse /message /sse
    rewrite @bad /mcp

    # Ensure POSTs have Accept: application/json
    @post method POST
    handle @post {
        reverse_proxy mcp-php:8080 {
            header_up Accept "application/json"
            flush_interval -1
        }
    }

    # Everything else
    handle {
        reverse_proxy mcp-php:8080 {
            flush_interval -1
        }
    }
}

# OPTIONAL: MCPO for Open WebUI
mcpo.YOURDOMAIN.com {
    encode gzip zstd
    log {
        output stdout
        format console
    }
    reverse_proxy mcpo:8000
}

Step 5: Optional MCPO Config

If you want Open WebUI to connect, add mcpo-config.json.

{
  "mcpServers": {
    "Phippsy": {
      "type": "streamable_http",
      "url": "http://mcp-php:8080/mcp"
    }
  }
}

Step 6: Build & Run

Spin it up:

docker compose up -d --build

Watch logs:

docker logs -f caddy
docker logs -f mcp-php

Step 7: Test with curl

MCP over streamable HTTP requires a session. First initialize, capture the Mcp-Session-Id, then reuse it.

BASE="https://mcp.YOURDOMAIN.com/mcp"

# 1) initialize
SID=$(
  curl -sD - --http1.1 \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json' \
    "$BASE" \
    -d '{"jsonrpc":"2.0","id":"1","method":"initialize",
         "params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"8"}}}' \
  | awk -v IGNORECASE=1 '/^Mcp-Session-Id:/ {print $2}' | tr -d '\r'
)
echo "SID=$SID"

# 2) notify initialized
curl -s --http1.1 \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "Mcp-Session-Id: $SID" \
  "$BASE" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3) list tools
curl -s --http1.1 \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "Mcp-Session-Id: $SID" \
  "$BASE" \
  -d '{"jsonrpc":"2.0","id":"2","method":"tools/list"}' | jq

# 4) call ping
curl -s --http1.1 \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "Mcp-Session-Id: $SID" \
  "$BASE" \
  -d '{"jsonrpc":"2.0","id":"3","method":"tools/call",
       "params":{"name":"ping","arguments":{"echo":"Hello Phippsy!"}}}' | jq

Expected result:


Step 8: Wire into ElevenLabs

Inside ElevenLabs:

  1. Go to Integrations → MCP → Add Custom MCP Server.
  2. Name: Phippsy MCP.
  3. Server Type: Streamable HTTP
  4. Server URL: Type is value. URL is https://mcp.YOURDOMAIN.com/mcp.
  5. Test connection — you should see ping.
  6. Add it to your agent, enable the tool, and start a session.

If it fails:


Step 9: Optional — Open WebUI Integration

If you set up MCPO, Open WebUI can talk to your server:

Run tool ping (server: Phippsy) with {"echo":"Hello Phippsy!"} and return only the JSON.

Step 10: Next Steps

This scaffold is ready for production hardening:


TL;DR Checklist