An extensible Model Context Protocol (MCP) server
4.7K
PHP 8.4 MCP (Model Context Protocol) server. Docker image. Mount your src/ directory, expose as MCP tools.
One-line install (recommended):
curl -fsSL https://raw.githubusercontent.com/zero-to-prod/mcp-server/main/install.sh | bash
The installer will:
Or manually:
Create template files (README.md, Example.php, .env.example):
docker run --rm -v $(pwd):/init davidsmith3/mcp-server:latest init
Edit .env with your settings (MCP_SERVER_NAME, ports, etc.)
cp .env.example .env
Option A: docker-compose (recommended)
docker compose up -d
claude mcp add --transport http mcp1 http://localhost:8093
When adding or modifying environment variables in .env:
1. Restart Docker containers:
docker compose down && docker compose up -d
2. Reconnect MCP client:
/mcp commandEnvironment variables only load at container startup. Changes require full restart.
When adding, removing, or modifying MCP tools (controller methods with #[McpTool], #[McpResource], etc.):
No Docker restart needed. Just reconnect MCP client:
/mcp commandThe MCP client caches tool definitions. Reconnection forces discovery without restarting containers.
Each controller file is a self-contained plugin:
CRITICAL: Where to create controller files
Create controller files in the src/ directory of your project.
The src/ directory gets mounted as /app/src inside the Docker container.
Example project structure:
your-project/
├── .env
├── docker-compose.yml
└── src/
├── MyController.php <- Create controllers here
├── Redis.php <- Built-in controller (copied by installer)
└── Mongodb.php <- Built-in controller (copied by installer)
These files will be accessible at /app/src/MyController.php, /app/src/Redis.php, etc. inside the container.
File structure:
<?php
declare(strict_types=1);
// Namespaces are optional
// Include ALL imports needed by THIS file
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\ToolAnnotations;
class PluginController {
// All methods and dependencies in one file
}
All MCP tool names MUST follow: service.noun.action
.)by_id, awaiting_shipment)Pattern: service.noun.action
Examples:
✓ service.user.get
✓ service.users.list
✓ service.order.create
✓ service.item.search_by_id
✓ api.logs.aggregate
✗ getUser (missing service.noun)
✗ service_user_get (underscores not dots)
✗ Service.User.Get (not lowercase)
✗ service.users.get (plural noun for singular action)
Action verbs: get list create update delete search calculate transform aggregate
Access Redis directly via Redis.php controller. Provides 4 tools for key inspection and raw command execution.
Environment (.env):
REDIS_HOST=redis # Container name or IP
REDIS_PORT=6379
REDIS_PASSWORD= # Optional
Docker Compose (included by default):
services:
mcp:
depends_on: [ redis ]
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
redis.inspect - Get metadata + preview + TTL
redis.inspect("mykey") // Returns: {key, metadata: {type, size, count}, preview: [...], ttl}
Use first to explore keys before loading full data. Shows structure without full load.
redis.get - Retrieve full data from key
redis.get("mykey") // Returns: complete dataset
Use after confirming data size via inspect. Loads all data into context.
redis.exists - Check if key exists
redis.exists("mykey") // Returns: {key, exists: bool, ttl: int|null}
Quick validation without loading data.
redis.command - Execute raw Redis commands
redis.command("KEYS ref:*") // Find keys by pattern
redis.command("SCAN 0 MATCH ref:* COUNT 100") // Production-safe scanning
redis.command("TTL mykey") // Get time to live
Direct pass-through to Redis server. Supports all Redis commands (GET, SET, KEYS, SCAN, HGET, LRANGE, etc.).
WARNING: Destructive commands (DEL, FLUSHDB) execute without confirmation.
Standard workflow: redis.exists → redis.inspect → redis.get (load full data last)
Access MongoDB directly via Mongodb.php controller. Provides 5 tools for document operations and aggregations.
Environment (.env):
MONGODB_HOST=mongodb # Container name or IP
MONGODB_PORT=27017
MONGODB_USERNAME= # Optional
MONGODB_PASSWORD= # Optional
Docker Compose (included by default):
services:
mcp:
depends_on: [ redis, mongodb ]
mongodb:
image: mongo:8
volumes:
- mongodb-data:/data/db
mongodb.document.find - Query documents in collection
mongodb.document.find(
"mydb",
"users",
"{\"status\": \"active\", \"_limit\": 10}"
)
Returns matching documents. Use _limit in query to limit results.
mongodb.document.insert - Insert documents
// Insert one
mongodb.document.insert("mydb", "users", "{\"name\": \"John\", \"email\": \"[email protected]\"}")
// Insert many
mongodb.document.insert("mydb", "users", "[{\"name\": \"John\"}, {\"name\": \"Jane\"}]")
Supports single or bulk insert operations.
mongodb.document.update - Update documents
mongodb.document.update(
"mydb",
"users",
"{\"_id\": \"...\"}",
"{\"$set\": {\"status\": \"active\"}}"
)
Update one or many documents with MongoDB update operators. Use _multiple: true in filter to update many.
mongodb.document.delete - Delete documents
// Delete one
mongodb.document.delete("mydb", "users", "{\"_id\": \"...\"}")
// Delete many
mongodb.document.delete("mydb", "users", "{\"status\": \"archived\", \"_multiple\": true}")
Delete one or many documents matching filter criteria. Use _multiple: true for bulk deletion.
WARNING: Delete operations are permanent.
mongodb.data.aggregate - Run aggregation pipeline
mongodb.data.aggregate(
"mydb",
"orders",
"[
{\"$match\": {\"status\": \"completed\"}},
{\"$group\": {\"_id\": \"$userId\", \"total\": {\"$sum\": \"$amount\"}}},
{\"$sort\": {\"total\": -1}},
{\"$limit\": 10}
]"
)
Execute complex data transformations and analytics using MongoDB's aggregation framework.
MongoDB authentication is optional. To enable:
MONGODB_USERNAME=admin
MONGODB_PASSWORD=secure_password
docker compose restart
Standard workflow:
mongodb.document.find with query filtersmongodb.document.insert, mongodb.document.update, or mongodb.document.deletemongodb.data.aggregate for complex queries and reportingAccess container logs using Docker commands. Logs contain PHP errors, MCP server output, and application errors.
Find container name:
docker ps # Running containers
docker compose ps # Compose services
View logs:
docker logs mcp-server --tail 200 # Last 200 lines
docker logs -f mcp-server # Follow (real-time)
docker logs mcp-server --since 1h # Last hour
docker compose logs mcp # Compose service
Search and filter (pipe to grep, same as datadog):
docker logs mcp-server 2>&1 | grep -i error
docker logs mcp-server 2>&1 | grep "tool_name"
Check environment:
docker exec mcp-server env | grep MCP # MCP config
docker exec mcp-server env | grep REDIS # Redis config
After creating or modifying tools, verify functionality:
Manual reconnection required. There is no command line tool to reconnect. The agent must prompt the user to manually reconnect using their MCP client.
For Claude Code CLI users: Use the /mcp command to reconnect.
Test your tools directly in your MCP client (Claude Desktop or Claude Code CLI) after reconnecting.
Validation checklist:
Check container logs:
docker logs mcp1
Check syntax errors:
# Path inside Docker container (your src/ directory is mounted to /app/src)
docker exec mcp1 php -l /app/src/YourController.php
Verify environment:
docker exec mcp1 env | grep MCP
Variables read by the server (public/index.php):
| Variable | Default | Description | Used In |
|---|---|---|---|
| MCP_SERVER_NAME | MCP Server | Server display name | index.php:92 |
| APP_VERSION | 0.0.0 | Version string | index.php:92 |
| APP_DEBUG | false | Enable debug logs (true/false) | index.php:29,55 |
| MCP_CONTROLLER_PATHS | src | Colon-separated controller paths to scan | index.php:81-83 |
| REDIS_HOST | redis | Redis host (container name or IP) | Redis.php:18 |
| REDIS_PORT | 6379 | Redis port | Redis.php:19 |
| REDIS_PASSWORD | - | Redis password (optional) | Redis.php:20 |
| MONGODB_HOST | mongodb | MongoDB host (container name or IP) | Mongodb.php:18 |
| MONGODB_PORT | 27017 | MongoDB port | Mongodb.php:19 |
| MONGODB_USERNAME | - | MongoDB username (optional) | Mongodb.php:20 |
| MONGODB_PASSWORD | - | MongoDB password (optional) | Mongodb.php:21 |
Additional variables in .env.example (not used in code):
| Variable | Note |
|---|---|
| MCP_SESSIONS_DIR | Hardcoded to storage/mcp-sessions in index.php:8 |
| API_KEY | Available for controller use, not used by core |
| PORT | Docker-specific, used in docker-compose.yml |
| DOCKER_IMAGE | Docker-specific, used in docker-compose.yml |
Official source: https://github.com/modelcontextprotocol/php-sdk
Reference the official PHP SDK repository for:
<?php
declare(strict_types=1);
// Namespaces are optional
class ControllerName {
// methods with attributes
}
Syntax:
#[McpTool(
name: 'tool_name',
description: 'Concise tool description (1-2 sentences). Key behavior if needed.',
annotations: new ToolAnnotations(
title: 'tool_name' // MUST match name exactly
)
)]
public function method(
#[Schema(
type: 'TYPE',
description: 'Purpose. Valid values/format. Example: "value"',
pattern: '/regex/', // optional validation
enum: ['val1', 'val2'] // optional enum constraint
)]
TYPE $param
): RETURN_TYPE {
if (/* error */) {throw new ToolCallException('error: details');}
return $result;
}
ToolAnnotations title: MUST match the tool name exactly. Pattern: title: 'service.noun.action' matches name: 'service.noun.action'
CRITICAL: annotations placement
✅ CORRECT: Place annotations ONLY in #[McpTool(...)] at method level
#[McpTool(
name: 'tool.name',
description: 'Description...',
annotations: new ToolAnnotations(title: 'tool.name') // <- HERE
)]
public function method(
#[Schema(type: 'string', description: 'Description...')] // <- NO annotations
string $param
)
❌ WRONG: Never place annotations inside #[Schema(...)] for parameters
#[Schema(
type: 'string',
description: 'Description...',
annotations: new ToolAnnotations(...) // <- NEVER DO THIS
)]
Return types: primitives, arrays, or explicit content objects (TextContent, ImageContent, AudioContent, EmbeddedResource)
Schema types: string number integer boolean array object null
Example:
#[McpTool(
name: 'divide',
description: 'Divides two numbers. Returns float result. Throws exception if divisor is zero.',
annotations: new ToolAnnotations(
title: 'divide'
)
)]
public function divide(
#[Schema(
type: 'number',
description: 'Dividend (number to be divided). Example: 10.5, -20, 100'
)]
float $a,
#[Schema(
type: 'number',
description: 'Divisor (cannot be zero). Example: 2.5, -4, 0.1'
)]
float $b
): float {
if ($b === 0.0) {throw new ToolCallException('cannot divide by zero');}
return $a / $b;
}
Syntax:
#[McpResource(
uri: 'scheme://path', // required, RFC 3986
name: 'Name', // optional
description: 'Concise resource description (what data it provides).',
mimeType: 'application/json', // optional
size: 1024 // optional, bytes
)]
public function method(): mixed {
if (/* error */) {throw new ResourceReadException('error: details');}
return $data;
}
URI schemes: file:// https:// git:// config:// data:// db:// api:// (custom)
Return types: primitives, arrays, Stream, SplFileInfo, TextResourceContents, BlobResourceContents, ['text' => '...'], ['blob' => 'base64...']
Example:
#[McpResource(
uri: 'config://app/settings',
name: 'Application Settings',
description: 'Returns application configuration as JSON (runtime settings, feature flags, environment values).',
mimeType: 'application/json'
)]
public function getSettings(): array {
$file = '/path/to/settings.json';
if (!file_exists($file)) {throw new ResourceReadException("not found: {$file}");}
return json_decode(file_get_contents($file), true);
}
Syntax:
#[McpResourceTemplate(
uriTemplate: 'scheme://path/{var}', // required, RFC 6570
name: 'Name', // optional
description: 'Concise resource template description (what data it provides by variable).',
mimeType: 'application/json' // optional
)]
public function method(
#[Schema(
type: 'string',
description: 'Variable description (format, constraints). Example: "value"',
pattern: '/^[a-z0-9]+$/' // optional validation
)]
string $var
): mixed {
if (/* error */) {throw new ResourceReadException('error: details');}
return $data;
}
Rules:
{variable} placeholdersExample:
#[McpResourceTemplate(
uriTemplate: 'data://user/{userId}',
name: 'User Data',
description: 'Returns user data by ID from data store. Throws exception if not found.',
mimeType: 'application/json'
)]
public function getUser(
#[Schema(
type: 'string',
description: 'User ID (alphanumeric lowercase). Example: "user123", "abc456"',
pattern: '/^[a-z0-9]+$/'
)]
string $userId
): array {
if (!ctype_alnum($userId)) {throw new ResourceReadException('userId must be alphanumeric');}
if (!$user = $this->find($userId)) {throw new ResourceReadException("not found: {$userId}");}
return $user;
}
Syntax:
#[McpPrompt(
name: 'name', // required
description: 'Concise prompt description (what it generates and purpose).'
)]
public function method(
#[Schema(
type: 'TYPE',
description: 'Parameter description. Valid values. Example: "value"',
enum: ['opt1', 'opt2'] // optional
)]
TYPE $param
): array {
if (/* error */) {throw new PromptGetException('error: details');}
return [['role' => 'user', 'content' => ['type' => 'text', 'text' => 'prompt']]];
}
Return formats:
[['role' => 'user', 'content' => ['type' => 'text', 'text' => '...']]]['user' => 'message', 'assistant' => 'response']Valid roles: user (input/questions), assistant (responses/instructions)
Example:
#[McpPrompt(
name: 'review',
description: 'Generates code review prompt with configurable style and rigor level.'
)]
public function review(
#[Schema(
type: 'string',
description: 'Review style. Valid: strict, balanced (default), lenient. Example: "balanced"',
enum: ['strict', 'balanced', 'lenient']
)]
string $style = 'balanced'
): array {
$valid = ['strict', 'balanced', 'lenient'];
if (!in_array($style, $valid, true)) {throw new PromptGetException("invalid '{$style}': " . implode('|', $valid));}
return [['role' => 'user', 'content' => ['type' => 'text', 'text' => "Review with {$style} style"]]];
}
#[Schema(
type: 'TYPE', // required: string|number|integer|boolean|array|object|null
description: 'Concise parameter description. Valid values/format. Example: "value"',
definition: [...], // optional: complete JSON schema (highest priority)
// string constraints
minLength: 1,
maxLength: 100,
pattern: '/regex/',
format: 'email', // email|uri|date-time
// number constraints
minimum: 0,
maximum: 100,
exclusiveMinimum: 0,
exclusiveMaximum: 100,
// array constraints
minItems: 1,
maxItems: 10,
uniqueItems: true,
// object constraints
properties: [...], // property schemas
required: ['field1'],
patternProperties: [...], // regex-based properties
// enum constraint (any type)
enum: ['opt1', 'opt2']
)]
Schema generation priority (highest to lowest):
#[Schema(definition: [...])] - complete JSON schema#[Schema(...)] attributes#[Schema(...)] attributesExamples:
#[Schema(
type: 'string',
format: 'email',
description: 'User email address. Example: "[email protected]"'
)]
string $email
#[Schema(
type: 'integer',
minimum: 1,
maximum: 100,
description: 'Page number. Range: 1-100, Default: 1'
)]
int $page
#[Schema(
type: 'string',
enum: ['asc', 'desc'],
description: 'Sort order. Valid: asc (ascending), desc (descending)'
)]
string $order
#[Schema(
type: 'array',
minItems: 1,
maxItems: 10,
description: 'Array of tags. Range: 1-10 items. Example: ["tag1", "tag2", "tag3"]'
)]
array $tags
#[Schema(
type: 'string',
minLength: 5,
maxLength: 50,
description: 'Username (5-50 chars, alphanumeric and underscore). Example: "john_doe", "user123"'
)]
string $username
Types:
// 1. Value lists (static strings)
#[CompletionProvider(['opt1', 'opt2', 'opt3'])]
string $param
// 2. Enum classes (backed or unit enums)
#[CompletionProvider(MyEnum::class)]
string $param
// 3. Custom classes (implementing ProviderInterface)
#[CompletionProvider(CustomProvider::class)]
string $param
Example:
#[McpTool(
name: 'search',
description: 'Searches items with configurable sorting. Returns ordered results.',
annotations: new ToolAnnotations(
title: 'search'
)
)]
public function search(
#[Schema(
type: 'string',
description: 'Sort order. Valid: asc, desc, relevance (default). Example: "relevance"'
)]
#[CompletionProvider(['asc', 'desc', 'relevance'])]
string $sort = 'relevance'
): array {
return ['results' => []];
}
Exceptions:
use Mcp\Exception\ToolCallException; // tools
use Mcp\Exception\ResourceReadException; // resources
use Mcp\Exception\PromptGetException; // prompts
Message format: type error: details (lowercase, concise)
Patterns:
// empty
if (empty($val)) {throw new ToolCallException('param empty');}
// length
if (strlen($val) > 100) {throw new ToolCallException('param too long: max 100');}
// format
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {throw new ToolCallException("invalid email: {$email}");}
// enum
$valid = ['a', 'b', 'c'];
if (!in_array($val, $valid, true)) {throw new ToolCallException("invalid '{$val}': " . implode('|', $valid));}
// exists
if (!file_exists($path)) {throw new ToolCallException("not found: {$path}");}
// pattern
if (!preg_match('/pattern/', $val)) {throw new ToolCallException('invalid format: must match pattern');}
Validation order:
Generic exceptions (internal errors only):
try {
$db->connect();
} catch (\PDOException $e) {
error_log("Internal: " . $e->getMessage());
throw new \RuntimeException('db failed'); // no details to client
}
Server::builder()
->addTool(callable: $callable, name: 'tool_name', description: 'desc')
->addResource(callable: $callable, uri: 'scheme://path', name: 'name', description: 'desc')
->addResourceTemplate(callable: $callable, uriTemplate: 'scheme://{var}', name: 'name', description: 'desc')
->addPrompt(callable: $callable, name: 'prompt_name', description: 'desc')
->build();
Callable formats: closures, [ClassName::class, 'method'], [$object, 'method'], InvokableClass::class
Rule: Manual registrations override discovered elements with same identifier
Server::builder()
->setServerInfo(name: 'Name', version: '1.0', description: 'desc', icons: [...], website: 'url')
->setPaginationLimit(50) // max items per page (default: 50)
->setInstructions('AI guidance text') // usage instructions for AI models
->setDiscovery(basePath: __DIR__, scanDirs: ['src'], excludeDirs: ['vendor'], cache: $psr16)
->setSession(store: $sessionStore, ttl: 3600) // or just ttl for InMemorySessionStore
->setLogger($psr3Logger) // PSR-3 logger
->setContainer($psr11Container) // PSR-11 DI container
->setEventDispatcher($psr14Dispatcher) // PSR-14 event dispatcher
->addRequestHandler('method_name', callable) // custom JSON-RPC handler
->addNotificationHandler('notification_name', callable) // custom notification handler
->build()
->run($transport);
// 1. InMemorySessionStore (default, volatile)
new InMemorySessionStore(ttl: 3600, prefix: 'session_')
// 2. FileSessionStore (persistent)
new FileSessionStore(path: '/path/to/sessions')
// 3. Psr16StoreSession (Redis, Memcached, etc.)
new Psr16StoreSession(cache: $psr16Cache, ttl: 3600, prefix: 'mcp_')
// Custom: implement SessionStoreInterface
interface SessionStoreInterface {
public function exists(string $id): bool;
public function read(string $id): ?array;
public function write(string $id, array $data): void;
public function destroy(string $id): void;
public function gc(int $maxlifetime): void;
}
Content type
Image
Digest
sha256:1bffd23b7…
Size
206.7 MB
Last updated
9 months ago
docker pull davidsmith3/mcp-server