If you have used MCP servers like Context7 or DBHub inside Claude Code or Cursor, you already know how powerful they are. A single MCP server can give an AI assistant the ability to query databases, pull live documentation, or interact with APIs -- all without leaving your editor. But what happens when the pre-built servers do not cover your use case?
You build your own. And it is easier than you think.
By the end of this tutorial, you will have a working MCP server written in PHP that exposes custom deployment tools to any MCP-compatible client. You will understand both the attribute-based API and the programmatic registration approach. Then you will deploy it to a remote server using DeployHQ so your entire team can use it over HTTP.
What You Will Build
Rather than yet another calculator demo, we are building something practical: a deployment status MCP server. It exposes three tools that interact with a deployment API:
- get_deploy_status -- Check the status of a specific deployment by ID
- list_recent_deploys -- List the most recent deployments with their status and timestamps
- trigger_deploy -- Kick off a new deployment to a specified environment
These tools make the server directly useful for anyone managing deployments. Once connected, you can ask Claude Code things like What was the last deploy to production?
or Trigger a staging deploy
and it will call your MCP server behind the scenes.
The same patterns apply to any API you want to wrap -- internal tools, monitoring dashboards, CI/CD pipelines, content management systems.
Prerequisites
Before starting, make sure you have:
- PHP 8.1 or higher installed (
php -vto check) - Composer for dependency management
- Claude Code or Cursor installed as your MCP client
- Basic familiarity with PHP classes, type declarations, and attributes
Part 1: Project Setup
Create a new project directory and install dependencies:
mkdir deploy-mcp-server
cd deploy-mcp-server
composer init --name=yourorg/deploy-mcp-server --type=project --no-interaction
Install the official MCP PHP SDK -- a collaboration between Symfony and the PHP Foundation, maintained under the Model Context Protocol organisation:
composer require mcp/sdk
Add Symfony Finder. The SDK lists it as a suggestion rather than a hard dependency, but attribute discovery -- the setDiscovery() call we use below -- needs it, and without it your tools simply never register:
composer require symfony/finder
Install Guzzle for making HTTP requests to the deployment API:
composer require guzzlehttp/guzzle
Create the file structure:
deploy-mcp-server/
server.php # Main server code
src/
DeployTools.php # Tool definitions
composer.json
README.md
We include Guzzle because our tools will make HTTP requests to a deployment API. In a real project, you would swap this for whatever HTTP client your API needs -- or use PHP's built-in file_get_contents for simple GET requests.
Part 2: Your First MCP Server
Let us start with the absolute minimum: a server that exposes a single tool. This is the hello world
of MCP server development.
Create src/PingTool.php:
<?php
declare(strict_types=1);
namespace App;
use Mcp\Capability\Attribute\McpTool;
class PingTool
{
/** Check if the deploy server is running. */
#[McpTool]
public function ping(): string
{
return 'Deploy MCP server is online.';
}
}
Create server.php:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('deploy-server', '1.0.0')
->setDiscovery(__DIR__, ['src'])
->build();
$transport = new StdioTransport();
$server->run($transport);
That is a fully functional MCP server. Let us break down what is happening:
#[McpTool]-- This PHP attribute marks a method as an MCP tool. The SDK reads the method name, docblock, type declarations, and parameter defaults to automatically generate the tool's JSON schema. The docblock becomes the tool description that the LLM sees.Server::builder()-- Creates a fluent builder for configuring the server. The name is how MCP clients identify your server.setDiscovery(__DIR__, ['src'])-- Tells the SDK to scan thesrc/directory for classes with#[McpTool],#[McpResource], or#[McpPrompt]attributes. This is the equivalent of automatic route discovery in a web framework.StdioTransport-- Communicates via standard input/output. This is the default for local development and Claude Code integration.
Make sure Composer's autoloader knows about your src/ directory. Add this to composer.json:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Then regenerate the autoloader:
composer dump-autoload
Run it to verify there are no errors:
php server.php
The process will start and wait for MCP messages on stdin. Press Ctrl+C to stop it. We will connect a real client in Part 4.
Part 3: Adding Real Tools
Now let us build the actual deployment tools. We will add typed parameters, structured error handling, and a resource endpoint.
Create src/DeployTools.php:
<?php
declare(strict_types=1);
namespace App;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\McpResource;
class DeployTools
{
private Client $http;
private string $apiBaseUrl;
private string $apiToken;
public function __construct()
{
$this->apiBaseUrl = getenv('DEPLOY_API_URL') ?: 'https://api.example.com';
$this->apiToken = getenv('DEPLOY_API_TOKEN') ?: '';
$this->http = new Client([
'base_uri' => $this->apiBaseUrl,
'timeout' => 30.0,
'headers' => [
'Authorization' => 'Bearer ' . $this->apiToken,
'Content-Type' => 'application/json',
],
]);
}
/**
* Get the current status of a deployment by its ID.
*
* @param string $deployId The unique identifier for the deployment
*/
#[McpTool(name: 'get_deploy_status')]
public function getDeployStatus(string $deployId): string
{
try {
$response = $this->http->get("/deploys/{$deployId}");
$data = json_decode($response->getBody()->getContents(), true);
$status = $data['status'] ?? 'unknown';
$environment = $data['environment'] ?? 'unknown';
$startedAt = $data['started_at'] ?? 'N/A';
$finishedAt = $data['finished_at'] ?? 'N/A';
$commitSha = $data['commit_sha'] ?? 'N/A';
return implode("\n", [
"Deployment {$deployId}",
" Status: {$status}",
" Environment: {$environment}",
" Commit: " . ($commitSha !== 'N/A' ? substr($commitSha, 0, 8) : 'N/A'),
" Started: {$startedAt}",
" Finished: {$finishedAt}",
]);
} catch (RequestException $e) {
$code = $e->getResponse()?->getStatusCode() ?? 'unknown';
return "Error fetching deploy {$deployId}: HTTP {$code}";
} catch (ConnectException) {
return "Error: Could not connect to deployment API at {$this->apiBaseUrl}";
}
}
/**
* List recent deployments for a given environment.
*
* @param string $environment Target environment (production, staging, development)
* @param int $limit Number of deployments to return (default 5, max 20)
*/
#[McpTool(name: 'list_recent_deploys')]
public function listRecentDeploys(
string $environment = 'production',
int $limit = 5,
): string {
$limit = min($limit, 20);
try {
$response = $this->http->get('/deploys', [
'query' => [
'environment' => $environment,
'limit' => $limit,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$deploys = $data['deploys'] ?? [];
if (empty($deploys)) {
return "No recent deployments found for {$environment}.";
}
$statusIcons = [
'success' => '[OK]',
'failed' => '[FAIL]',
'running' => '[...]',
'pending' => '[WAIT]',
];
$lines = ["Recent deployments to {$environment}:\n"];
foreach ($deploys as $deploy) {
$icon = $statusIcons[$deploy['status'] ?? ''] ?? '[?]';
$id = substr($deploy['id'], 0, 8);
$message = substr($deploy['commit_message'] ?? 'No message', 0, 60);
$createdAt = $deploy['created_at'] ?? 'unknown';
$lines[] = " {$icon} {$id} - {$message} ({$createdAt})";
}
return implode("\n", $lines);
} catch (RequestException $e) {
$code = $e->getResponse()?->getStatusCode() ?? 'unknown';
return "Error listing deploys: HTTP {$code}";
} catch (ConnectException) {
return "Error: Could not connect to deployment API at {$this->apiBaseUrl}";
}
}
/**
* Trigger a new deployment to the specified environment.
*
* @param string $environment Target environment (production, staging, development)
* @param string $branch Git branch to deploy (default: main)
* @param string $commitSha Specific commit SHA to deploy (optional, uses branch HEAD if empty)
*/
#[McpTool(name: 'trigger_deploy')]
public function triggerDeploy(
string $environment,
string $branch = 'main',
string $commitSha = '',
): string {
if ($environment === 'production' && $commitSha === '') {
return 'Safety check: Production deployments require an explicit '
. 'commit_sha. Please provide the exact commit to deploy.';
}
$payload = [
'environment' => $environment,
'branch' => $branch,
];
if ($commitSha !== '') {
$payload['commit_sha'] = $commitSha;
}
try {
$response = $this->http->post('/deploys', ['json' => $payload]);
$data = json_decode($response->getBody()->getContents(), true);
$deployId = $data['id'] ?? 'unknown';
return implode("\n", [
'Deployment triggered successfully.',
" Deploy ID: {$deployId}",
" Environment: {$environment}",
" Branch: {$branch}",
' Commit: ' . ($commitSha ?: "HEAD of {$branch}"),
'',
"Use get_deploy_status with ID '{$deployId}' to monitor progress.",
]);
} catch (RequestException $e) {
$code = $e->getResponse()?->getStatusCode() ?? 'unknown';
$body = $e->getResponse()?->getBody()->getContents() ?? '';
return "Error triggering deploy: HTTP {$code} - {$body}";
} catch (ConnectException) {
return "Error: Could not connect to deployment API at {$this->apiBaseUrl}";
}
}
/** Return the current server configuration. */
#[McpResource(uri: 'config://deploy-server')]
public function getServerConfig(): string
{
return implode("\n", [
'Deploy MCP Server Configuration',
" API URL: {$this->apiBaseUrl}",
' Token configured: ' . ($this->apiToken ? 'Yes' : 'No'),
' Server time: ' . gmdate('c'),
]);
}
}
There are several things worth highlighting here:
Typed parameters with docblocks. The SDK reads the @param tags from each docblock to extract parameter descriptions. These descriptions are sent to the LLM so it knows what each parameter expects. PHP type declarations (string, int) are converted into JSON schema types automatically.
Default values. Parameters with defaults like string $environment = 'production' become optional in the tool schema. The LLM can omit them and the default kicks in.
Error handling. Every tool catches HTTP errors and connection failures, returning human-readable error messages instead of crashing. This is critical because the LLM sees your return value, so Error: API returned 403
is far more useful than an uncaught exception trace.
Safety checks. The triggerDeploy tool refuses production deployments without an explicit commit SHA. Building guardrails into your tools prevents the LLM from accidentally doing something dangerous.
Resources. The #[McpResource] attribute exposes read-only data that clients can access. Resources are like GET endpoints -- they provide context without side effects.
Part 4: Connecting to Claude Code
Claude Code reads MCP server definitions from .mcp.json at your repository root (project scope, checked into Git and shared with your team) or from ~/.claude.json (user scope, private to you). They do not go in settings.json -- that file carries enabledMcpServers and disabledMcpServers approval lists, not server definitions.
The quickest way to register the server is the CLI, which writes the file for you:
claude mcp add --scope project --transport stdio deploy-server \
--env DEPLOY_API_URL=https://api.yourcompany.com \
--env DEPLOY_API_TOKEN=your-api-token-here \
-- php /absolute/path/to/deploy-mcp-server/server.php
Or write .mcp.json by hand:
{
"mcpServers": {
"deploy-server": {
"type": "stdio",
"command": "php",
"args": ["/absolute/path/to/deploy-mcp-server/server.php"],
"env": {
"DEPLOY_API_URL": "https://api.yourcompany.com",
"DEPLOY_API_TOKEN": "your-api-token-here"
}
}
}
}
A few important notes:
- The path must be absolute. Relative paths will not resolve correctly.
- Environment variables defined in
envare passed to the server process. This is how you inject API keys without hardcoding them. - Commit
.mcp.jsononly when the values in it are safe to share. Keep real tokens in~/.claude.jsonor in your shell environment instead.
For Claude Desktop, the config file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS. For Cursor, use Settings > MCP Servers or edit .cursor/mcp.json — the shape is the same.
Testing the Connection
Restart Claude Code (or your MCP client) after saving the configuration. Then try asking:
Use the deploy server to list recent staging deployments
Claude Code will discover the list_recent_deploys tool, call it with environment="staging", and display the results. You can also test with:
What's the status of deployment abc123?
Trigger a deploy to staging from the feature/auth branch
If the server fails to start, check the logs. For Claude Code, MCP server stderr output appears in the debug console. Common issues include:
- Wrong PHP path (use
which phpto find it) - Missing dependencies (run
composer installin the project directory) - Invalid JSON in the config file
- PHP version too low (8.1+ required)
You can also test the server independently using the MCP Inspector:
npx -y @modelcontextprotocol/inspector
This opens a browser-based tool where you can connect to your server, browse its tools, and call them interactively.
Part 5: Programmatic Tool Registration
The attribute-based approach we have been using is the most convenient way to define tools. But the SDK also supports programmatic registration, which is useful when you need dynamic tools or want to avoid scanning directories.
Here is the same server using the builder API instead of attributes:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('deploy-server', '1.0.0')
->addTool(
fn(): string => 'Deploy MCP server is online.',
name: 'ping',
description: 'Check if the deploy server is running.',
)
->build();
$transport = new StdioTransport();
$server->run($transport);
Use named arguments here. The signature is addTool($handler, ?string $name, ?string $title, ?string $description, ...), so a third positional argument lands in $title and your tool ships with no description at all -- which is the one field the LLM actually reads when deciding whether to call it.
The SDK still infers parameter schemas from the callable's type declarations. You can also pass class method references:
$server = Server::builder()
->setServerInfo('deploy-server', '1.0.0')
->addTool([DeployTools::class, 'getDeployStatus'], name: 'get_deploy_status')
->addTool([DeployTools::class, 'listRecentDeploys'], name: 'list_recent_deploys')
->addTool([DeployTools::class, 'triggerDeploy'], name: 'trigger_deploy')
->build();
For most projects, attribute-based discovery is the right choice — it keeps tool definitions next to the implementation and eliminates manual registration. Use programmatic registration when you need tools generated at runtime or when you want explicit control over what gets exposed.
Part 6: Adding HTTP Transport
Stdio transport works well for local tools: the client launches your script and talks to it over pipes. But if you want the server to live on a remote machine and serve your whole team, you need HTTP.
The MCP protocol's recommended transport is Streamable HTTP -- a single endpoint that handles both request/response and streaming.
Streamable HTTP is PSR-7, not a web server
This is the part people get wrong. StreamableHttpTransport does not start a listener and does not claim a URL path. It takes the current PSR-7 server request, and $server->run() hands you back a PSR-7 response to emit. Your existing web stack -- nginx and PHP-FPM, FrankenPHP, Symfony, Laravel -- does the listening and the routing.
You need a PSR-17 factory and an emitter alongside the SDK:
composer require nyholm/psr7 laminas/laminas-httphandlerrunner
Then public/index.php becomes:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
$server = Server::builder()
->setServerInfo('deploy-server', '1.0.0')
->setDiscovery(dirname(__DIR__), ['src'])
->build();
$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
);
$response = $server->run($transport);
(new SapiEmitter())->emit($response);
The same src/DeployTools.php serves both transports untouched -- only the entry point changes. To keep the stdio entry point for local development, leave server.php as it is and add public/index.php beside it.
You can try it without any web server at all using PHP's built-in one:
php -S 127.0.0.1:8000 public/index.php
Configuring Clients for Remote Servers
When your MCP server runs over HTTP, clients connect to a URL instead of launching a process:
claude mcp add --scope project --transport http deploy-server \
https://mcp.yourcompany.com/mcp \
--header "Authorization: Bearer your-token"
In .mcp.json that is:
{
"mcpServers": {
"deploy-server": {
"type": "http",
"url": "https://mcp.yourcompany.com/mcp",
"headers": {
"Authorization": "Bearer your-token"
}
}
}
}
No command or args -- the client connects directly over HTTP. The path (/mcp here) is whatever your nginx config routes to public/index.php; it is your choice, not the SDK's.
Part 7: Deploying with DeployHQ
You have a working MCP server. Now let us deploy it to a remote server so it is always available and your team can connect to it from anywhere.
Step 1: Push to Git
Initialize a Git repository and push your code:
cd deploy-mcp-server
git init
git add server.php src/ composer.json composer.lock README.md
git commit -m "Initial MCP server"
git remote add origin git@github.com:yourorg/deploy-mcp-server.git
git push -u origin main
Make sure your .gitignore excludes the vendor directory and any .env files:
vendor/
.env
Step 2: Set Up the DeployHQ Project
- Log in to DeployHQ and create a new project.
- Connect your repository -- DeployHQ supports GitHub, GitLab, and Bitbucket, and can deploy automatically on every push once it is connected.
- Add a new server connection. If you are deploying via SSH, follow the SSH/SFTP deployment guide to configure access.
Step 3: Configure Build Commands
If your project needs a build step, configure a build pipeline in DeployHQ. For a PHP MCP server, a typical build command is:
composer install --no-dev --optimize-autoloader
This installs production dependencies and optimizes the class autoloader. The vendor/ directory is deployed alongside your code.
Step 4: Configure SSH Commands
After DeployHQ copies your files to the server, you need to restart the MCP server process. Add SSH commands that run after deployment:
cd /var/www/deploy-mcp-server
composer install --no-dev --optimize-autoloader
sudo systemctl reload php8.3-fpm
Step 5: Serve It Behind nginx and PHP-FPM
An HTTP MCP server is an ordinary PHP request/response application. Do not run it as a long-lived php server.php process -- there is nothing to keep alive, and a bare PHP process in front of the internet gives you no TLS, no process recycling, and no connection handling. Serve it the way you would serve any PHP app.
An nginx server block for the MCP endpoint:
server {
listen 443 ssl;
server_name mcp.yourcompany.com;
root /var/www/deploy-mcp-server/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300;
}
}
Two details worth getting right:
- Point
rootatpublic/, not at the project directory. Yoursrc/,vendor/and any.envmust not be reachable over HTTP. - Raise
fastcgi_read_timeout. MCP calls that stream progress can outlive the default 60-second FastCGI timeout, and a truncated response looks to the client like a server crash.
Reload after a deploy rather than restarting, so in-flight requests finish:
sudo systemctl reload php8.3-fpm
If you are using the stdio transport instead, there is nothing to run on the server at all -- the MCP client launches php server.php itself, on the developer's own machine. Deploying a stdio server to a remote host does nothing useful.
Step 6: Environment Variables
Never commit API keys to Git. On your deployment server, set environment variables through:
- The PHP-FPM pool config (
/etc/php/8.3/fpm/pool.d/www.conf), usingenv[DEPLOY_API_URL] = ...andenv[DEPLOY_API_TOKEN] = ... - A
.envfile that is not in version control (loaded with phpdotenv) - DeployHQ's environment variable configuration
The nginx block above terminates TLS, so once DNS points at the server your team can connect to https://mcp.yourcompany.com/mcp from anywhere.
Deployment Workflow Summary
Once everything is wired up, the workflow is:
- Push code changes to your Git repository
- DeployHQ detects the push and starts a deployment
- Files are copied to your server via SSH/SFTP
- SSH commands install dependencies and reload PHP-FPM
- The MCP server comes back online with the new code
- All connected clients automatically reconnect
Best Practices
Error Handling
Every tool should catch exceptions and return meaningful error messages. The LLM sees your return value, so Error: API returned 403 -- check your token permissions
is far more useful than an uncaught exception trace.
Logging
For stdio servers, never write to stdout — it corrupts the JSON-RPC protocol. Use error_log() or a PSR-3 logger configured to write to stderr or a file instead.
Security
- Never expose write operations without authentication. If your server can trigger deploys, it must verify who is calling it.
- Validate all inputs. Do not trust that the LLM will send well-formed parameters.
- Use environment variables for secrets. Never hardcode API tokens, database passwords, or private keys.
- Add safety checks for destructive actions. The
trigger_deploytool's production guard is a good example. - Rate limit expensive operations to prevent accidental abuse.
Testing
Test your tools as regular PHP methods before connecting them to an MCP client. A simple PHPUnit test can instantiate your tool class and verify return values against mocked HTTP responses. For integration testing, use the MCP Inspector (npx -y @modelcontextprotocol/inspector) to verify tool schemas and call tools interactively.
You now have the building blocks to wrap any API, database, or internal tool as an MCP server. The same patterns work for monitoring dashboards, CI/CD pipelines, content management systems — anything with an API. If you would rather consume one than write one, we publish our own: the DeployHQ MCP server exposes deployments to Claude Code and Cursor directly, and the MCP server documentation covers setup. For more ideas on how other teams structure their tools, see our rundown of the MCP servers worth installing.
Get Started with DeployHQ
Ready to deploy your MCP server -- or any application? Sign up for DeployHQ and go from git push to live in minutes.
If you have questions or need help, reach out at support@deployhq.com or find us on Twitter/X.