Assumed Knowledge
Before you dive in, make sure you’re comfortable with:
- Running and troubleshooting Docker containers.
- Basic familiarity with PHP 8.1+ and Composer.
- Understanding of MCP (Model Context Protocol) concepts — servers, tools, and transport.
- Basic reverse proxy/TLS setup (Caddy, Nginx, etc.).
⚠️ 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:
- A PHP MCP server exposing a single tool:
ping
. - Transport via Streamable HTTP (single endpoint
/mcp
). - Caddy as reverse proxy with TLS and some safety rewrites.
- (Optional) MCPO bridge to use the tool inside Open WebUI.
- Verified locally with
curl
, then wired into ElevenLabs.
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:
tools/list
showsping
.tools/call
echoes JSON withok: true
, timestamp, and your string.
Step 8: Wire into ElevenLabs
Inside ElevenLabs:
- Go to Integrations → MCP → Add Custom MCP Server.
- Name:
Phippsy MCP
. - Server Type:
Streamable HTTP
- Server URL: Type is
value
. URL ishttps://mcp.YOURDOMAIN.com/mcp
. - Test connection — you should see
ping
. - Add it to your agent, enable the tool, and start a session.
If it fails:
- Watch Caddy logs with
docker logs -f caddy | grep YOURDOMAIN
. - Confirm ElevenLabs is hitting
/mcp
. - If curl works but EL errors, try switching to a model that supports OpenAI tool calls.
Step 9: Optional — Open WebUI Integration
If you set up MCPO, Open WebUI can talk to your server:
- Go to Settings → Tools → Manage Tool Servers → Add.
- URL:
https://mcpo.YOURDOMAIN.com/Phippsy
. - Then call:
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:
- Add auth with bearer tokens in Caddy.
- Rate limit or IP allowlist if exposed publicly.
- Write real tools under
src/
using#[McpTool]
or->withTool(...)
. - Pin dependencies and set up CI/CD for deployments.
TL;DR Checklist
- [ ] Files match this guide.
- [ ]
docker compose up -d --build
succeeds. - [ ]
curl
init → session ID captured. - [ ]
tools/list
showsping
. - [ ]
tools/call
returns JSON. - [ ] ElevenLabs integration works at
https://mcp.YOURDOMAIN.com/mcp
. - [ ] Agent uses a tool-calling model.