If you have used [MCP servers](https://www.deployhq.com/blog/build-your-first-mcp-server-model-context-protocol-guide) like [Context7](https://www.deployhq.com/blog/context7-guide-stop-ai-hallucinations-with-live-docs) or [DBHub](https://www.deployhq.com/blog/how-to-generate-sql-queries-with-ai-step-by-step-guide-using-claude-code-and-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](https://www.deployhq.com) 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 -v` to check)
- **[Composer](https://getcomposer.org/)** for dependency management
- **[Claude Code](https://www.deployhq.com/blog/getting-started-with-claude-code-the-ai-coding-assistant-for-your-terminal)** 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:

1. **`#[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.

2. **`Server::builder()`** -- Creates a fluent builder for configuring the server. The name is how MCP clients identify your server.

3. **`setDiscovery( __DIR__ , ['src'])`** -- Tells the SDK to scan the `src/` directory for classes with `#[McpTool]`, `#[McpResource]`, or `#[McpPrompt]` attributes. This is the equivalent of automatic route discovery in a web framework.

4. **`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 `env` are passed to the server process. This is how you inject API keys without hardcoding them.
- Commit `.mcp.json` only when the values in it are safe to share. Keep real tokens in `~/.claude.json` or 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 php` to find it)
- Missing dependencies (run `composer install` in 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

1. Log in to [DeployHQ](https://www.deployhq.com) and create a new project.
2. Connect your repository -- [DeployHQ](https://www.deployhq.com) supports GitHub, GitLab, and Bitbucket, and can [deploy automatically on every push](https://www.deployhq.com/features/automatic-deployments) once it is connected.
3. Add a new server connection. If you are deploying via SSH, follow the [SSH/SFTP deployment guide](https://www.deployhq.com/blog/github-sftp) to configure access.

### Step 3: Configure Build Commands

If your project needs a build step, configure a [build pipeline](https://www.deployhq.com/blog/the-deployhq-build-pipeline) in [DeployHQ](https://www.deployhq.com). 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](https://www.deployhq.com) copies your files to the server, you need to restart the MCP server process. Add [SSH commands](https://www.deployhq.com/support/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 `root` at `public/`** , not at the project directory. Your `src/`, `vendor/` and any `.env` must 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`), using `env[DEPLOY_API_URL] = ...` and `env[DEPLOY_API_TOKEN] = ...`
- A `.env` file that is not in version control (loaded with [phpdotenv](https://github.com/phpdotenv/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:

1. Push code changes to your Git repository
2. [DeployHQ](https://www.deployhq.com) detects the push and starts a deployment
3. Files are copied to your server via SSH/SFTP
4. SSH commands install dependencies and reload PHP-FPM
5. The MCP server comes back online with the new code
6. 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_deploy` tool'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](https://www.deployhq.com/blog/introducing-the-deployhq-mcp-server-ai-powered-deployment-management) exposes deployments to Claude Code and Cursor directly, and the [MCP server documentation](https://www.deployhq.com/support/mcp-server) covers setup. For more ideas on how other teams structure their tools, see our [rundown of the MCP servers worth installing](https://www.deployhq.com/blog/best-mcp-servers-for-web-developers).

* * *

## Get Started with DeployHQ

Ready to deploy your MCP server -- or any application? [Sign up for DeployHQ](https://www.deployhq.com/signup) and go from git push to live in minutes.

If you have questions or need help, reach out at [support@deployhq.com](mailto:support@deployhq.com) or find us on [Twitter/X](https://x.com/deployhq).

