A comprehensive guide to developing Model Context Protocol servers using the logiscape/mcp-sdk-php SDK.
- Introduction
- Getting Started
- Part 1: Tools
- Part 2: Prompts
- Part 3: Resources
- Part 4: Deploying Remote MCP Servers
- Part 5: Securing Remote Servers with OAuth
- Part 6: Structured Output
- Part 7: Returning Rich Content
- Part 8: Requesting Input with Elicitation
- Part 9: Server-Initiated LLM Sampling
- Part 10: Providing Completions
- Part 11: Emitting Notifications, Logging, and Progress
- Part 12: Multi-Capability Servers
- Appendix A: Configuration Reference
- Appendix B: Deployment Checklist
The Model Context Protocol (MCP) is an open standard that enables AI applications to interact with external data sources and tools through a uniform interface. MCP servers expose three core primitives:
- Tools -- Functions the AI model can invoke to perform actions (model-controlled)
- Prompts -- Reusable message templates the user can select (user-controlled)
- Resources -- Data that provides context to the model (application-controlled)
The logiscape/mcp-sdk-php SDK implements the MCP specification (including the latest 2025-11-25 revision) for PHP 8.1+. It provides a McpServer convenience wrapper that lets you build a fully functional MCP server in just a few lines of code. The same server file can run locally via stdio or remotely over HTTP -- making it deployable to standard cPanel/Apache hosting with zero infrastructure changes. Servers can also request information back from the client: elicitation (form mode since 2025-06-18, URL mode since 2025-11-25) and server-initiated LLM sampling (in the base spec since 2024-11-05, with tool-enabled sampling added in 2025-11-25) work across both transports, and the HTTP transport auto-suspends and resumes long-running tool calls to make that possible on stateless PHP.
- A local MCP server that Claude Desktop, Cursor, or any MCP client launches as a subprocess
- A remote MCP server hosted on your web hosting that any MCP client connects to over HTTPS
- A dual-mode server that works both ways from a single PHP file
- PHP 8.1 or higher
- Composer
ext-curlandext-json(typically enabled by default)- For local/stdio servers: CLI access
- For remote/HTTP servers: Apache with
mod_rewrite(standard on cPanel hosting)
composer require logiscape/mcp-sdk-php<?php
// server.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('my-first-server');
$server
->tool('hello', 'Say hello to someone', function (string $name): string {
return "Hello, {$name}! Welcome to MCP.";
})
->run();The run() method detects the environment automatically:
- CLI (
php server.php) -- uses the stdio transport for local MCP connections - Web server (accessed via HTTP) -- uses the HTTP transport for remote MCP connections
You can also force a specific transport:
runStdio()-- always use stdiorunHttp()-- always use HTTP
Add to your Claude Desktop claude_desktop_config.json:
{
"mcpServers": {
"my-server": {
"command": "php",
"args": ["/absolute/path/to/server.php"]
}
}
}Once deployed to a web server (covered in Part 4), the MCP endpoint URL is simply the URL to your PHP file:
https://yoursite.com/mcp-server.php
Tools are the most powerful MCP primitive. They let an AI model take action -- call an API, query a database, transform data, or interact with any system your PHP code can reach. The model discovers available tools, decides when to use them, and invokes them with the appropriate arguments.
- The MCP client asks your server for its list of tools (
tools/list) - The AI model sees each tool's name, description, and parameter schema
- When the model decides to use a tool, the client sends a
tools/callrequest - Your server executes the callback and returns the result
<?php
// tools_basic.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('basic-tools');
// A tool that converts temperature units.
// The SDK uses reflection to automatically build the JSON Schema
// from the callback's parameter types.
$server->tool(
'convert-temperature',
'Convert a temperature between Celsius and Fahrenheit',
function (float $value, string $unit): string {
$unit = strtolower($unit);
if ($unit === 'c' || $unit === 'celsius') {
$result = ($value * 9 / 5) + 32;
return "{$value}C = {$result}F";
}
if ($unit === 'f' || $unit === 'fahrenheit') {
$result = ($value - 32) * 5 / 9;
return "{$value}F = {$result}C";
}
return "Unknown unit '{$unit}'. Use 'C' or 'F'.";
}
);
$server->run();The SDK inspects the callback with PHP reflection and produces a JSON Schema that conforms to the JSON Schema draft 2020-12 dialect:
float $valuebecomes{ "type": "number" }in the JSON Schemastring $unitbecomes{ "type": "string" }- Required vs. optional is determined by whether the parameter has a default value
- The top-level schema is always
{ "type": "object" }withpropertiesandrequiredpopulated from the callback's signature
Reflection-built schemas omit the $schema declaration -- 2020-12 is the assumed default for tool input schemas and adding it explicitly would be redundant. Hand-written schemas (covered in Custom Input Schemas below) are free to include $schema themselves and to use any 2020-12 keyword the spec defines, including $defs, $ref, oneOf/anyOf/allOf, additionalProperties, patternProperties, and unevaluatedProperties. The SDK enforces only the structural constraints the MCP spec requires of a tool input schema -- the top-level type must be "object", properties (when present) must be a JSON object, and required (when present) must be an array of non-empty strings -- and passes every other keyword through to the wire unchanged.
Two known limits of the reflection path: the auto-generated description for each property is a placeholder ("Parameter: <name>"), and union types or nested objects collapse to plain string because the reflector can't represent richer structure without help. When either matters, override the schema with the $inputSchema parameter -- see Custom Input Schemas.
<?php
// tools_optional.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('optional-params');
// Parameters with default values become optional in the schema.
$server->tool(
'search-products',
'Search a product catalog by keyword',
function (string $query, int $limit = 10, string $sort = 'relevance'): string {
// In a real server, this would query a database.
$results = [
['name' => 'Widget A', 'price' => 9.99],
['name' => 'Widget B', 'price' => 14.99],
['name' => 'Gadget C', 'price' => 24.99],
];
$output = "Results for '{$query}' (limit: {$limit}, sort: {$sort}):\n";
foreach (array_slice($results, 0, $limit) as $i => $product) {
$output .= ($i + 1) . ". {$product['name']} - \${$product['price']}\n";
}
return $output;
}
);
$server->run();In this example $query is required, while $limit and $sort are optional and will use their default values if the model doesn't supply them.
When an exception is thrown inside a tool callback, the SDK catches it and returns the error message to the model with isError: true. This lets the model self-correct rather than crashing the server.
<?php
// tools_errors.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('error-handling');
$server->tool(
'divide',
'Divide one number by another',
function (float $numerator, float $denominator): string {
if ($denominator == 0) {
// Throwing an exception inside a tool callback returns the
// error to the model as a tool execution error (isError: true).
// This is intentional -- it lets the model understand what
// went wrong and try a different approach.
throw new \InvalidArgumentException(
'Division by zero is not allowed. Please provide a non-zero denominator.'
);
}
$result = $numerator / $denominator;
return "{$numerator} / {$denominator} = {$result}";
}
);
$server->run();Reflection covers the common case, but it can only describe what PHP's type system can express. When you need a richer schema -- nested objects, enums beyond bool, value constraints, custom descriptions for the model -- pass an $inputSchema to tool() and the SDK will use it instead of building one from the callback's signature. The SDK enforces the spec-required envelope before serialization (top-level type: object, well-formed properties and required -- see the rules below), and any other keyword you include rides alongside unchanged. This is also how you opt into JSON Schema 2020-12 features like $defs, $ref, additionalProperties, and oneOf/anyOf/allOf.
<?php
// tools_custom_schema.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('custom-schema');
$server->tool(
name: 'create-user',
description: 'Create a user record with a structured address',
callback: function (string $name, array $address): string {
return "Created user '{$name}' at {$address['street']}, {$address['city']}";
},
inputSchema: [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'properties' => [
'name' => [
'type' => 'string',
'description' => 'Full display name',
'minLength' => 1,
'maxLength' => 200,
],
'address' => ['$ref' => '#/$defs/address'],
],
'required' => ['name', 'address'],
'additionalProperties' => false,
'$defs' => [
'address' => [
'type' => 'object',
'properties' => [
'street' => ['type' => 'string'],
'city' => ['type' => 'string'],
],
'required' => ['street', 'city'],
'additionalProperties' => false,
],
],
],
);
$server->run();A few rules worth knowing:
- The top-level
typemust be"object". The MCP spec requires this for tool input schemas, and the SDK enforces it: theinputSchemaarray is merged on top of['type' => 'object']so you can omittypeentirely (the default kicks in), but if you do supply it, it must be exactly"object". Passing any other value --"string","array", etc. -- causestool()to throwInvalidArgumentExceptionrather than silently overwriting it. UseoneOf/anyOfor nestedpropertiesto express richer shapes within the object envelope. propertiesandrequiredare shape-checked.properties(when present) must be an associative array;required(when present) must be a list of non-empty strings. Anything else throwsInvalidArgumentExceptionat registration time. Every other keyword you supply --$schema,$defs,$ref,additionalProperties, etc. -- is stored as-is and emitted verbatim on the wire.$schemais optional but recommended for hand-written schemas. MCP defaults to JSON Schema draft 2020-12 for tool input, so omitting$schemaworks -- but declaring it explicitly removes any ambiguity for spec-strict clients and signals intent to readers.- Reflection is bypassed entirely when
$inputSchemais set. Optional and required parameters in the PHP signature are ignored; the schema'srequiredarray is the source of truth.
Chain as many tools as you need. Each ->tool() call returns the server instance for fluent chaining.
<?php
// tools_multiple.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('text-utilities');
$server
->tool('word-count', 'Count the words in a text', function (string $text): string {
$count = str_word_count($text);
return "The text contains {$count} word(s).";
})
->tool('slugify', 'Convert text to a URL-friendly slug', function (string $text): string {
$slug = strtolower(trim($text));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
return $slug;
})
->tool('extract-emails', 'Extract email addresses from text', function (string $text): string {
preg_match_all('/[\w.\-]+@[\w.\-]+\.\w+/', $text, $matches);
$emails = $matches[0];
if (empty($emails)) {
return 'No email addresses found.';
}
return "Found " . count($emails) . " email(s):\n" . implode("\n", $emails);
})
->run();Prompts are reusable message templates that a user can select in their MCP client. Unlike tools (which the model calls autonomously), prompts are user-initiated -- they appear as slash commands or in a prompt library UI. Prompts are ideal for standardizing common interactions: code review templates, analysis frameworks, report formats, etc.
- The MCP client fetches available prompts (
prompts/list) - The user selects a prompt (e.g., via a slash command)
- The client sends
prompts/getwith the user's arguments - Your server returns one or more messages that seed the conversation
<?php
// prompts_basic.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('basic-prompts');
// A prompt that generates a code review request.
// Like tools, arguments are auto-generated from the callback's parameters.
$server->prompt(
'code-review',
'Generate a structured code review request',
function (string $language, string $code): string {
return <<<PROMPT
Please review the following {$language} code. Analyze it for:
1. **Correctness** -- Are there any bugs or logic errors?
2. **Security** -- Are there any vulnerabilities (injection, XSS, etc.)?
3. **Performance** -- Are there any inefficiencies?
4. **Readability** -- Is the code clean and well-structured?
5. **Best Practices** -- Does it follow {$language} conventions?
Code to review:
```{$language}
{$code}
```
PROMPT;
}
);
$server->run();When the callback returns a string, the SDK wraps it as a single user-role message. The model then responds to it as if the user had typed that message.
Return an array of strings to create a multi-message conversation starter:
<?php
// prompts_multi_message.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('multi-message-prompts');
// Returning an array produces multiple user messages.
$server->prompt(
'debug-session',
'Start a structured debugging session',
function (string $error_message, string $context = 'web application'): array {
return [
"I'm encountering the following error in my {$context}:\n\n```\n{$error_message}\n```",
"Please help me debug this step by step. Start by identifying the most likely root causes, then suggest specific diagnostic steps I can take.",
];
}
);
$server->run();For full control over message roles and content types, return a GetPromptResult directly:
<?php
// prompts_advanced.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\GetPromptResult;
use Mcp\Types\PromptMessage;
use Mcp\Types\TextContent;
use Mcp\Types\Role;
$server = new McpServer('advanced-prompts');
// Returning a GetPromptResult gives full control over the message structure,
// including the ability to mix user and assistant roles.
$server->prompt(
'sql-assistant',
'Start an interactive SQL query building session',
function (string $table_name, string $database_type = 'MySQL'): GetPromptResult {
return new GetPromptResult(
description: "SQL assistant for {$database_type}",
messages: [
new PromptMessage(
role: Role::USER,
content: new TextContent(
text: "I need help writing a {$database_type} query for the '{$table_name}' table."
)
),
new PromptMessage(
role: Role::ASSISTANT,
content: new TextContent(
text: "I'd be happy to help you write a {$database_type} query for the '{$table_name}' table. To write the best query, could you tell me:\n\n1. What columns does the table have?\n2. What do you want the query to do? (SELECT, INSERT, UPDATE, aggregate, join, etc.)\n3. Are there any specific conditions or filters?"
)
),
]
);
}
);
$server->run();By including an assistant message, you prime the model to continue in a specific conversational style.
A prompt message is not limited to text. A PromptMessage can carry TextContent, ImageContent, AudioContent, or an EmbeddedResource -- useful when the template needs to seed the conversation with a screenshot, a diagram, or a file the model should reason about. The string and array shortcuts only ever produce text messages, so to include non-text content you build the GetPromptResult explicitly.
This prompt seeds the conversation with an image and asks the model to describe it:
<?php
// prompts_image.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\GetPromptResult;
use Mcp\Types\PromptMessage;
use Mcp\Types\TextContent;
use Mcp\Types\ImageContent;
use Mcp\Types\Role;
$server = new McpServer('image-prompts');
$server->prompt(
'describe-logo',
'Ask the model to describe the company logo',
function (): GetPromptResult {
// Set this to your actual image file
$bytes = file_get_contents(__DIR__ . '/assets/logo.png');
return new GetPromptResult(
description: 'Logo description starter',
messages: [
new PromptMessage(
role: Role::USER,
content: new TextContent(text: 'Describe this logo in one sentence:'),
),
// ImageContent carries base64-encoded image bytes and a MIME type.
new PromptMessage(
role: Role::USER,
content: new ImageContent(
data: base64_encode($bytes),
mimeType: 'image/png',
),
),
],
);
}
);
$server->run();To embed a full resource instead -- a config file, a document, a record the model should treat as addressable context -- wrap a TextResourceContents (or BlobResourceContents for binary) in an EmbeddedResource:
<?php
// prompts_embedded_resource.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\GetPromptResult;
use Mcp\Types\PromptMessage;
use Mcp\Types\TextContent;
use Mcp\Types\EmbeddedResource;
use Mcp\Types\TextResourceContents;
use Mcp\Types\Role;
$server = new McpServer('embedded-prompts');
$server->prompt(
'review-config',
'Ask the model to review the current application configuration',
function (): GetPromptResult {
$config = json_encode(['debug' => false, 'cache' => 'redis'], JSON_PRETTY_PRINT);
return new GetPromptResult(
messages: [
new PromptMessage(
role: Role::USER,
content: new TextContent(text: 'Review this configuration for problems:'),
),
// The embedded resource keeps its own URI so the model (and
// client) can refer to it as addressable context, not loose text.
new PromptMessage(
role: Role::USER,
content: new EmbeddedResource(
resource: new TextResourceContents(
text: $config,
uri: 'config://app.json',
mimeType: 'application/json',
),
),
),
],
);
}
);
$server->run();Note the TextResourceContents argument order: text first, then uri, then the optional mimeType.
Resources expose data that provides context to the AI model. They are identified by URIs and can represent anything: files, database records, API responses, configuration, or live system data. Resources are typically loaded by the application (or user) rather than invoked by the model -- they're about providing information, not taking action.
- The MCP client fetches available resources (
resources/list) - The client or user selects resources to include as context
- The client sends
resources/readwith the resource URI - Your server returns the content (text or binary)
<?php
// resources_basic.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('basic-resources');
// A text resource. When the callback returns a string,
// the SDK wraps it as TextResourceContents.
$server->resource(
uri: 'config://app-settings',
name: 'Application Settings',
description: 'Current application configuration',
callback: function (): string {
// In production, this might read from a config file or database
return json_encode([
'app_name' => 'My Application',
'version' => '2.1.0',
'environment' => 'production',
'features' => [
'dark_mode' => true,
'notifications' => true,
'beta_features' => false,
],
], JSON_PRETTY_PRINT);
},
mimeType: 'application/json'
);
$server->run();<?php
// resources_multiple.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('multi-resources');
$server
->resource(
uri: 'docs://api-reference',
name: 'API Reference',
description: 'REST API endpoint documentation',
callback: function (): string {
return <<<'DOC'
## API Endpoints
### GET /api/users
Returns a paginated list of users.
Parameters: page (int), per_page (int, max 100)
### GET /api/users/{id}
Returns a single user by ID.
### POST /api/users
Creates a new user.
Body: { "name": string, "email": string }
### PUT /api/users/{id}
Updates an existing user.
Body: { "name"?: string, "email"?: string }
### DELETE /api/users/{id}
Deletes a user. Requires admin role.
DOC;
},
mimeType: 'text/markdown'
)
->resource(
uri: 'schema://users-table',
name: 'Users Table Schema',
description: 'Database schema for the users table',
callback: function (): string {
return <<<'SQL'
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
role ENUM('user', 'admin', 'moderator') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_role (role)
);
SQL;
},
mimeType: 'text/plain'
)
->resource(
uri: 'info://server-status',
name: 'Server Status',
description: 'Live server health information',
callback: function (): string {
return json_encode([
'php_version' => PHP_VERSION,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'uptime' => @file_get_contents('/proc/uptime') ?: 'N/A',
'disk_free' => disk_free_space('.'),
'timestamp' => date('c'),
], JSON_PRETTY_PRINT);
},
mimeType: 'application/json'
)
->run();When a callback returns an SplFileObject or a PHP stream resource, the SDK automatically base64-encodes it as a BlobResourceContents:
<?php
// resources_binary.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('binary-resources');
// Serve a binary file (e.g., a logo image).
// Returning an SplFileObject triggers base64 encoding.
$server->resource(
uri: 'file://company-logo',
name: 'Company Logo',
description: 'The company logo in PNG format',
callback: function (): \SplFileObject {
$path = __DIR__ . '/assets/logo.png';
return new \SplFileObject($path, 'r');
},
mimeType: 'image/png'
);
// Serve a dynamically generated CSV using a PHP stream resource
$server->resource(
uri: 'file://report-csv',
name: 'Monthly Report CSV',
description: 'Generated CSV export of monthly report data',
callback: function () {
$stream = fopen('php://temp', 'r+');
fputcsv($stream, ['Date', 'Revenue', 'Orders']);
fputcsv($stream, ['2025-11-01', '12500.00', '145']);
fputcsv($stream, ['2025-11-02', '13200.00', '162']);
fputcsv($stream, ['2025-11-03', '11800.00', '138']);
rewind($stream);
return $stream;
},
mimeType: 'text/csv'
);
$server->run();For full control over the response, return a ReadResourceResult directly:
<?php
// resources_advanced.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\ReadResourceResult;
use Mcp\Types\TextResourceContents;
$server = new McpServer('advanced-resources');
$server->resource(
uri: 'multi://combined-context',
name: 'Combined Context',
description: 'Returns multiple content items in a single resource read',
callback: function (): ReadResourceResult {
return new ReadResourceResult(
contents: [
new TextResourceContents(
uri: 'multi://combined-context#schema',
text: 'CREATE TABLE orders (id INT PRIMARY KEY, total DECIMAL(10,2));',
mimeType: 'text/plain'
),
new TextResourceContents(
uri: 'multi://combined-context#sample-data',
text: json_encode([
['id' => 1, 'total' => 99.99],
['id' => 2, 'total' => 149.50],
]),
mimeType: 'application/json'
),
]
);
}
);
$server->run();A resource template describes a whole family of resources with a single URI pattern instead of registering each URI one by one. When a client reads a URI that matches the pattern, the SDK extracts the variables from the URI and hands them to your callback. Templates are advertised through resources/templates/list so clients can discover the pattern, and matching resources/read calls are routed to your template handler automatically.
Register one with resourceTemplate():
<?php
// resource_template.php
require 'vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('templated-resources');
// The {userId} placeholder matches a single path segment. When a client reads
// "users://42/profile", the SDK extracts userId = "42" and passes it to the
// callback by name -- the parameter name must match the template variable.
$server->resourceTemplate(
uriTemplate: 'users://{userId}/profile',
name: 'User Profile',
callback: function (string $userId): string {
// In a real server, look the user up in a database.
return json_encode([
'id' => $userId,
'name' => "User {$userId}",
'tier' => 'standard',
], JSON_PRETTY_PRINT);
},
description: 'Profile data for a given user ID',
mimeType: 'application/json',
);
$server->run();A few rules govern the template syntax:
{var}matches a single path segment -- everything except/. Use it for IDs, slugs, and other single-segment values.{+var}matches greedily, including/. Use it for file-like paths whose value spans multiple segments. A templatefiles:///{+path}readingfiles:///docs/2026/report.txtyieldspath = "docs/2026/report.txt".- Variables arrive as named parameters. The SDK matches each template variable to a callback parameter by name (not position) using reflection, so name your parameters to match the placeholders. Percent-encoded values are decoded for you.
- Only those two forms are supported. Other RFC 6570 operators (
{?query},{#frag},{var:3},{a,b}, etc.) throwInvalidArgumentExceptionat registration time, so the server never advertises a pattern it can't actually match.
Here is the multi-segment form:
$server->resourceTemplate(
uriTemplate: 'files:///{+path}',
name: 'Project File',
callback: function (string $path): string {
// {+path} captures the full remainder, so reading
// files:///docs/guide.md gives $path === 'docs/guide.md'.
return "Contents of {$path}";
},
);Two things worth knowing:
- An exact resource registered with
resource()always wins over a template; templates are tried in registration order only when no exact URI matches. - The content the SDK returns carries the concrete request URI (e.g.
users://42/profile), not the template pattern.
To suggest values for a template's variables as the user types, pair it with a completion provider -- see Part 10.
One of the great strengths of this PHP SDK is that remote MCP servers work on standard shared hosting -- the same cPanel/Apache environment that runs millions of PHP sites. No special server software, no long-running processes, no WebSockets.
The SDK's HTTP transport is designed for PHP's traditional request-response lifecycle:
- The MCP client sends HTTP POST requests to your PHP file
- Apache/PHP processes each request independently
- Session state is persisted to files between requests
- The SDK handles all JSON-RPC protocol details
<?php
// mcp-server.php -- deploy this to your web hosting
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('my-remote-server');
$server
->tool('server-time', 'Get the current server time', function (string $timezone = 'UTC'): string {
try {
$tz = new \DateTimeZone($timezone);
} catch (\Exception $e) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$now = new \DateTime('now', $tz);
return $now->format('Y-m-d H:i:s T');
})
->run();Because run() detects the environment, this same file works locally via php mcp-server.php and remotely when accessed via https://yoursite.com/mcp-server.php.
-
Upload files -- Upload your project (including
vendor/) to a directory insidepublic_html/ -
Directory structure:
public_html/ └── mcp/ ├── vendor/ │ └── ... ├── mcp_sessions/ (auto-created, must be writable) ├── mcp-server.php └── .htaccess -
Create
.htaccessfor clean URL and security:# Deny access to sensitive directories <IfModule mod_rewrite.c> RewriteEngine On # Block direct access to vendor and session directories RewriteRule ^vendor/ - [F,L] RewriteRule ^mcp_sessions/ - [F,L] </IfModule>
-
Verify PHP version -- Ensure PHP 8.1+ is selected in cPanel's "MultiPHP Manager"
-
Test -- Your MCP endpoint is now live at:
https://yoursite.com/mcp/mcp-server.php
For more control over the HTTP transport:
<?php
// mcp-server-configured.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Transport\Http\FileSessionStore;
$server = new McpServer('configured-server');
$server
->httpOptions([
'session_timeout' => 1800, // 30-minute session timeout
'max_queue_size' => 500, // Message queue limit
'enable_sse' => false, // Plain JSON responses (default; see "Streaming and graceful fallback" below)
'shared_hosting' => true, // Optimize for shared hosting
'server_header' => 'My-MCP-Server/1.0',
'allowed_origins' => ['yoursite.com'], // DNS rebinding protection (see below)
])
->sessionStore(new FileSessionStore(__DIR__ . '/mcp_sessions'))
->tool('ping', 'Check if the server is alive', function (): string {
return 'pong';
})
->run();The HTTP transport can respond to a single POST in one of three wire formats: a plain JSON body, a buffered text/event-stream body (one HTTP response that happens to be SSE-framed), or a live-flushed SSE stream that emits frames as the tool runs. Two settings control which one a given request gets:
enable_sse(defaultfalse) is the master switch. While it's off, every POST gets a plain JSON response regardless of what the client asked for -- this is the safe default for compatibility with arbitrary shared hosts. Set it totrueto let the transport negotiate SSE with clients that advertisetext/event-streamin theirAcceptheader. The default is intentionally conservative because every spec-compliant MCP client lists both media types inAccept, so flipping to SSE silently would change the wireContent-Typeon every deployment.sse_mode(default'auto') is a secondary mode that only kicks in once SSE has been enabled and the transport has chosen SSE for a given request. It decides between buffered and live-flushed framing. Whenenable_sse => falseit has no effect.
Live flushing needs a PHP runtime that actually delivers flush() output to the client, which is not a given on shared hosting -- zlib.output_compression, SAPI-owned output_buffering, mod_deflate, and a few similar settings will swallow flushes or interleave compressed chunks with the SSE framing. The SDK detects all of that automatically. Environment::canStreamSse() walks the output buffer stack and checks each relevant ini setting; if anything would break live streaming, the transport silently downgrades to the buffered body. No request ever hangs waiting for a flush the environment refuses to deliver, and the client always gets a spec-compliant response.
To opt into SSE and pick a mode:
$server->httpOptions([
'enable_sse' => true, // required to respond with text/event-stream
'sse_mode' => 'auto', // 'auto' | 'streaming' | 'buffered'
]);The modes behave as follows (after enable_sse => true):
'auto'(default) -- live-flush when (a) the runtime supports it and (b) the client's request carries a_meta.progressToken, otherwise buffer. Short JSON-RPC round-trips stay on the simpler buffered path; live streaming is reserved for tools that actually emit progress.'streaming'-- live-flush whenever the runtime permits; fall back to buffered only when it does not.'buffered'-- always buffer, even on FrankenPHP or RoadRunner where live streaming would work. Use this when you specifically want the SSE wire format without any mid-response flushing.
If you leave enable_sse => false (the default), the server never emits SSE and the sse_mode setting is ignored. That is the right call for the widest shared-hosting compatibility; only flip it on when you have a reason to (long-running tools emitting progress, clients that explicitly prefer SSE, etc.).
The SDK implements the modern Streamable HTTP transport -- a single endpoint that accepts JSON-RPC over POST and can answer with either plain JSON or SSE, as described above. This is the transport the current spec defines.
It does not implement the deprecated HTTP+SSE dual-endpoint transport from the 2024-11-05 revision -- the older design that used a separate long-lived GET /sse stream alongside a separate POST endpoint. The spec deprecated that transport in favor of Streamable HTTP, and this SDK targets only the modern form.
The practical consequence is narrow: a client that speaks only the old dual-endpoint transport cannot connect to a server built with this SDK over HTTP. This is intentional and does not reduce Streamable HTTP coverage -- any client implementing the current transport connects normally, and protocol-version negotiation still lets the server speak older protocol revisions (including 2024-11-05 message shapes) over the modern transport.
The MCP spec requires servers to validate the Origin header to prevent DNS rebinding attacks. The SDK handles this automatically for local development servers and provides the allowed_origins config option for remote deployments.
Local servers (PHP built-in server): Protection is auto-enabled. When you run php -S localhost:3000 server.php, the SDK automatically rejects requests from non-localhost origins. No configuration needed.
Remote servers (Apache, nginx, etc.): You should set allowed_origins to the hostname(s) your server is accessible from. This is important when browser-based MCP clients connect to your server, since browsers send Origin headers that will be validated:
$server->httpOptions([
'allowed_origins' => ['mcp.example.com'],
]);The values are hostnames (not full URLs), and matching is port-agnostic. Multiple hostnames are supported:
$server->httpOptions([
'allowed_origins' => ['mcp.example.com', 'staging.example.com'],
]);If allowed_origins is not set on a production web server, Origin validation is disabled and browser origins are not restricted. This may be acceptable for deployments that only serve non-browser MCP clients and rely on OAuth bearer tokens, but browser-accessible HTTP endpoints should configure allowed_origins so the server can reject unexpected web origins.
<?php
// production-server.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Transport\Http\FileSessionStore;
// Suppress warnings in production (MCP protocol uses stdout)
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/logs/mcp-error.log');
$server = new McpServer('production-server');
$server
->httpOptions([
'session_timeout' => 3600,
'shared_hosting' => true,
])
->sessionStore(new FileSessionStore(__DIR__ . '/mcp_sessions'))
->tool('status', 'Get server status', function (): string {
return json_encode([
'status' => 'healthy',
'version' => '1.0.0',
'timestamp' => date('c'),
]);
})
->run();Two protocol-level concerns are worth knowing about even though the convenience wrapper handles most of the work for you.
Pings. MCP defines a ping request/response pair distinct from any application-level "ping" tool a server might expose -- it's a no-argument health check that lets either side verify the connection is still live. The SDK auto-registers a built-in ping handler on every Server instance that returns the empty result the spec mandates, so a client calling sendPing() against an McpServer works out of the box with no code on your part. There is nothing to configure and nothing to register; the handler is wired in Mcp\Server\Server::__construct() before any of your tool/prompt/resource registrations run.
Cancellation. When a client decides to abort an in-flight request -- the user clicked cancel, a higher-level orchestrator timed out, the model produced a tool call the user rejected -- it sends a notifications/cancelled carrying the requestId it wants stopped. Per the spec, the receiver SHOULD stop processing, free associated resources, and not send a response for the cancelled request. The SDK delivers the notification through the hook below; the actual decision to stop work is yours.
The SDK gives you the lower-level Server::registerNotificationHandler() hook to receive the notification. Keep in mind that PHP's synchronous, single-threaded execution means the SDK cannot interrupt a tool that is already running its own code -- the handler fires only when the SDK is next reading from the transport, never during a busy handler. So the realistic use is bookkeeping and cleanup (recording which request IDs the client abandoned, freeing resources, suppressing follow-up notifications), not preempting work in progress:
<?php
// cancellation_handler.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\NotificationParams;
$server = new McpServer('cancellation-aware');
// Application-owned cancellation set; keys are the integer request IDs the
// client asked to stop. Your own cleanup/logging code consults it.
$cancelled = [];
// Register the notification handler on the underlying Server instance.
// The handler receives a NotificationParams whose `requestId` field carries
// the integer request ID the client wants cancelled.
$server->getServer()->registerNotificationHandler(
'notifications/cancelled',
function (?NotificationParams $params) use (&$cancelled): void {
if ($params === null || !isset($params->requestId)) {
return;
}
// Record the abandoned request ID. The SDK will not interrupt a
// running tool for you; consult this set from your own cleanup or
// logging code once control returns to the SDK's message loop.
$cancelled[(int) $params->requestId] = true;
}
);
$server->run();A few things worth knowing:
- No mid-tool preemption. Over stdio the message loop dispatches one message at a time, so a cancellation that arrives while your tool is running is only seen after the tool returns -- by which point its response has usually already been sent. Over HTTP on standard shared hosting each POST runs in its own process with its own memory, so an in-memory flag is neither shared across requests nor readable by an already-running handler. Either way the SDK cannot abort a tool from the outside; honoring a cancel is cooperative and only possible at points where your own code chooses to check.
- There is no acknowledgement. Don't try to send a response from the notification handler; cancels are notifications, not requests, and writing back will produce an invalid JSON-RPC frame.
For most servers no handler is needed at all -- the protocol still works correctly without it, the cancel is just ignored. That is explicitly allowed: the spec says a receiver MAY ignore a cancellation whose request has already completed or cannot be cancelled. For work that must be genuinely abortable mid-flight, the spec's task-augmented requests (tasks/cancel, enabled via enableTasks()) are the intended mechanism -- still experimental and out of scope for this guide.
Remote MCP servers are publicly accessible over HTTP, so authentication is essential. The MCP specification uses OAuth 2.1 for authorization. In this model, your MCP server acts as a resource server that validates tokens issued by an external authorization server (Auth0, Okta, Keycloak, Azure AD, or any OAuth 2.1 / OpenID Connect provider).
The SDK provides built-in JWT validation. You don't need to implement the OAuth flow yourself -- your authorization provider handles token issuance, and the SDK validates incoming tokens on every request.
MCP Client Authorization Server Your MCP Server
| (Auth0, Okta, etc.) (PHP + SDK)
| | |
|-- 1. Get access token ------->| |
|<-- 2. Access token -----------| |
| | |
|-- 3. MCP request + Bearer token ----------------------->|
| | |-- 4. Validate JWT
| | | (verify signature,
| | | check issuer, audience,
| | | expiry)
|<-- 5. MCP response ------------------------------------ |
The SDK handles step 4 automatically. You configure it with your provider's details.
Most providers (Auth0, Okta, Keycloak, Azure AD, Google) use RS256 with a JWKS endpoint. The SDK fetches the public keys automatically.
<?php
// secured-server-rs256.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Auth\JwtTokenValidator;
$server = new McpServer('secured-server');
// Configure JWT validation for your provider.
// Replace these values with your actual authorization server details.
$tokenValidator = new JwtTokenValidator(
key: '', // Not used for JWKS-based validation
algorithm: 'RS256',
issuer: 'https://your-tenant.auth0.com/',
audience: 'https://yoursite.com/mcp-server.php',
jwksUri: 'https://your-tenant.auth0.com/.well-known/jwks.json'
);
$server
->withAuth(
tokenValidator: $tokenValidator,
authorizationServers: 'https://your-tenant.auth0.com/',
resourceId: 'https://yoursite.com/mcp-server.php'
)
->tool('protected-data', 'Access protected data', function (): string {
return 'This data is only accessible with a valid token.';
})
->run();HS256 uses a shared secret and is simpler to configure for development or when your provider supports it:
<?php
// secured-server-hs256.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Auth\JwtTokenValidator;
$server = new McpServer('secured-server');
$tokenValidator = new JwtTokenValidator(
key: 'your-shared-secret-at-least-32-characters-long',
algorithm: 'HS256',
issuer: 'https://your-auth-server.com/',
audience: 'https://yoursite.com/mcp-server.php'
);
$server
->withAuth(
tokenValidator: $tokenValidator,
authorizationServers: 'https://your-auth-server.com/',
resourceId: 'https://yoursite.com/mcp-server.php'
)
->tool('protected-data', 'Access protected data', function (): string {
return 'Authenticated access granted.';
})
->run();Provider configurations can change over time, consult the official documentation from your provider for the latest details.
$tokenValidator = new JwtTokenValidator(
key: '',
algorithm: 'RS256',
issuer: 'https://YOUR_TENANT.auth0.com/',
audience: 'https://yoursite.com/mcp-server.php', // Must match the API Identifier in Auth0
jwksUri: 'https://YOUR_TENANT.auth0.com/.well-known/jwks.json'
);$tokenValidator = new JwtTokenValidator(
key: '',
algorithm: 'RS256',
issuer: 'https://YOUR_ORG.okta.com/oauth2/default',
audience: 'https://yoursite.com/mcp-server.php',
jwksUri: 'https://YOUR_ORG.okta.com/oauth2/default/v1/keys'
);$tokenValidator = new JwtTokenValidator(
key: '',
algorithm: 'RS256',
issuer: 'https://keycloak.example.com/realms/YOUR_REALM',
audience: 'your-mcp-client-id',
jwksUri: 'https://keycloak.example.com/realms/YOUR_REALM/protocol/openid-connect/certs'
);$tokenValidator = new JwtTokenValidator(
key: '',
algorithm: 'RS256',
issuer: 'https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0',
audience: 'api://your-app-client-id',
jwksUri: 'https://login.microsoftonline.com/YOUR_TENANT_ID/discovery/v2.0/keys'
);For providers or flows that don't use standard JWT, implement TokenValidatorInterface:
<?php
// custom-validator-server.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Auth\TokenValidatorInterface;
use Mcp\Server\Auth\TokenValidationResult;
// Example: validate tokens by calling your provider's introspection endpoint
class IntrospectionTokenValidator implements TokenValidatorInterface
{
public function __construct(
private string $introspectionUrl,
private string $clientId,
private string $clientSecret
) {}
public function validate(string $token): TokenValidationResult
{
$ch = curl_init($this->introspectionUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'token' => $token,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || $response === false) {
return new TokenValidationResult(
valid: false,
error: 'Token introspection request failed'
);
}
$data = json_decode($response, true);
if (!($data['active'] ?? false)) {
return new TokenValidationResult(
valid: false,
error: 'Token is not active'
);
}
return new TokenValidationResult(
valid: true,
claims: $data
);
}
}
$server = new McpServer('custom-auth-server');
$validator = new IntrospectionTokenValidator(
introspectionUrl: 'https://your-auth-server.com/oauth2/introspect',
clientId: 'your-client-id',
clientSecret: 'your-client-secret'
);
$server
->withAuth($validator, 'https://your-auth-server.com/', 'https://yoursite.com/mcp-server.php')
->tool('whoami', 'Show the authenticated user info', function (): string {
return 'You are authenticated.';
})
->run();Add the following rules to your .htaccess file in the document root:
# 1. Pass Authorization header to PHP (REQUIRED for MCP)
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
# 2. Route .well-known endpoint to your MCP server
RewriteRule ^\.well-known/oauth-protected-resource(/.*)?$ /server_auth.php [L]Why This Is Necessary:
- Many shared hosting environments strip the
Authorizationheader by default - The first rule ensures OAuth bearer tokens reach your PHP scripts
- The second rule enables OAuth discovery via the well-known endpoint
Tools can define an outputSchema to return machine-readable structured data alongside human-readable text. When an outputSchema is set and the callback returns an array or object, the SDK populates both content (text for the model) and structuredContent (validated JSON for programmatic use).
<?php
// structured_output.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('structured-output');
$server->tool(
name: 'analyze-url',
description: 'Parse and analyze a URL into its components',
callback: function (string $url): array {
$parts = parse_url($url);
if ($parts === false) {
throw new \InvalidArgumentException("Invalid URL: {$url}");
}
return [
'scheme' => $parts['scheme'] ?? '',
'host' => $parts['host'] ?? '',
'port' => $parts['port'] ?? null,
'path' => $parts['path'] ?? '/',
'query' => $parts['query'] ?? null,
'fragment' => $parts['fragment'] ?? null,
'is_secure' => ($parts['scheme'] ?? '') === 'https',
];
},
outputSchema: [
'type' => 'object',
'properties' => [
'scheme' => ['type' => 'string'],
'host' => ['type' => 'string'],
'port' => ['type' => ['integer', 'null']],
'path' => ['type' => 'string'],
'query' => ['type' => ['string', 'null']],
'fragment' => ['type' => ['string', 'null']],
'is_secure' => ['type' => 'boolean'],
],
'required' => ['scheme', 'host', 'path', 'is_secure'],
]
);
$server->tool(
name: 'calculate-statistics',
description: 'Calculate basic statistics for a list of comma-separated numbers',
callback: function (string $numbers): array {
$values = array_map('floatval', explode(',', $numbers));
$count = count($values);
if ($count === 0) {
throw new \InvalidArgumentException('No numbers provided.');
}
sort($values);
$sum = array_sum($values);
$mean = $sum / $count;
$median = ($count % 2 === 0)
? ($values[$count / 2 - 1] + $values[$count / 2]) / 2
: $values[(int) floor($count / 2)];
return [
'count' => $count,
'sum' => $sum,
'mean' => round($mean, 4),
'median' => $median,
'min' => min($values),
'max' => max($values),
];
},
outputSchema: [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
'sum' => ['type' => 'number'],
'mean' => ['type' => 'number'],
'median' => ['type' => 'number'],
'min' => ['type' => 'number'],
'max' => ['type' => 'number'],
],
'required' => ['count', 'sum', 'mean', 'median', 'min', 'max'],
]
);
$server->run();Tool callbacks can return a CallToolResult directly for full control over the response, including images, multiple content items, and error flags.
<?php
// rich_content_image.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
use Mcp\Types\ImageContent;
$server = new McpServer('image-tools');
$server->tool(
'generate-placeholder',
'Generate a placeholder image with specified dimensions and return it',
function (int $width = 200, int $height = 200, string $color = '4A90D9'): CallToolResult {
// Create a simple colored rectangle using GD
$image = imagecreatetruecolor($width, $height);
$r = hexdec(substr($color, 0, 2));
$g = hexdec(substr($color, 2, 2));
$b = hexdec(substr($color, 4, 2));
$fill = imagecolorallocate($image, $r, $g, $b);
imagefill($image, 0, 0, $fill);
// Add dimension text
$white = imagecolorallocate($image, 255, 255, 255);
$text = "{$width}x{$height}";
$fontSize = 4;
$textWidth = imagefontwidth($fontSize) * strlen($text);
$textHeight = imagefontheight($fontSize);
$x = ($width - $textWidth) / 2;
$y = ($height - $textHeight) / 2;
imagestring($image, $fontSize, (int) $x, (int) $y, $text, $white);
// Capture PNG output
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
return new CallToolResult(
content: [
new TextContent(text: "Generated a {$width}x{$height} placeholder image with color #{$color}."),
new ImageContent(
data: base64_encode($imageData),
mimeType: 'image/png'
),
]
);
}
);
$server->run();<?php
// rich_content_multi.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
$server = new McpServer('multi-content');
$server->tool(
'system-report',
'Generate a comprehensive system report with multiple sections',
function (): CallToolResult {
$phpInfo = "PHP Version: " . PHP_VERSION . "\n"
. "SAPI: " . PHP_SAPI . "\n"
. "OS: " . PHP_OS . "\n"
. "Extensions: " . implode(', ', get_loaded_extensions());
$memoryInfo = "Memory Usage: " . round(memory_get_usage(true) / 1024 / 1024, 2) . " MB\n"
. "Peak Memory: " . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . " MB\n"
. "Memory Limit: " . ini_get('memory_limit');
$diskInfo = "Disk Free: " . round(disk_free_space('.') / 1024 / 1024 / 1024, 2) . " GB\n"
. "Disk Total: " . round(disk_total_space('.') / 1024 / 1024 / 1024, 2) . " GB";
return new CallToolResult(
content: [
new TextContent(text: "## PHP Environment\n{$phpInfo}"),
new TextContent(text: "## Memory\n{$memoryInfo}"),
new TextContent(text: "## Disk\n{$diskInfo}"),
]
);
}
);
$server->run();Wrap non-text content in a result object. The convenience wrapper only auto-coerces
stringandarrayreturns into aCallToolResult. Returning a bareAudioContent,ImageContent, orEmbeddedResourcewill throw an invalid-result error -- always wrap them in aCallToolResultas shown here.
AudioContent carries base64-encoded audio just as ImageContent carries images. Return it inside a CallToolResult:
<?php
// rich_content_audio.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
use Mcp\Types\AudioContent;
$server = new McpServer('audio-tools');
$server->tool(
'text-to-speech',
'Synthesize speech for a short phrase and return it as audio',
function (string $text): CallToolResult {
// A real tool would call a TTS engine; you can also set this to a pre-rendered clip.
$wav = file_get_contents(__DIR__ . '/assets/greeting.wav');
return new CallToolResult(
content: [
new TextContent(text: "Synthesized speech for: {$text}"),
new AudioContent(
data: base64_encode($wav),
mimeType: 'audio/wav',
),
]
);
}
);
$server->run();A tool can embed a complete resource -- text or binary -- directly in its result with EmbeddedResource. Unlike a loose TextContent block, an embedded resource keeps its own URI and MIME type, so the model and client can treat the generated artifact as addressable context:
<?php
// rich_content_embedded.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
use Mcp\Types\EmbeddedResource;
use Mcp\Types\TextResourceContents;
$server = new McpServer('embedded-tools');
$server->tool(
'generate-invoice',
'Generate an invoice and return it as an embedded resource',
function (string $customer, float $amount): CallToolResult {
$invoiceId = 'INV-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$body = "Invoice {$invoiceId}\nCustomer: {$customer}\nAmount due: \${$amount}\n";
return new CallToolResult(
content: [
new TextContent(text: "Created invoice {$invoiceId} for {$customer}."),
new EmbeddedResource(
resource: new TextResourceContents(
text: $body,
uri: "invoice://{$invoiceId}",
mimeType: 'text/plain',
),
),
]
);
}
);
$server->run();For binary payloads, swap TextResourceContents for BlobResourceContents(blob: base64_encode($bytes), uri: ..., mimeType: ...).
Elicitation lets a tool pause mid-execution and ask the user (via the MCP client) for additional information. It turns what would otherwise be a rigid one-shot tool call into an interactive workflow -- the tool can collect missing parameters, confirm a destructive action, or kick off an out-of-band flow like OAuth.
Elicitation was introduced in the MCP 2025-06-18 revision and extended with URL mode in 2025-11-25. The SDK supports both modes and the same tool code works across the stdio and HTTP transports.
The SDK automatically injects an ElicitationContext into any tool callback that declares one -- no manual wiring is needed, and the context does not appear in the tool's JSON Schema. From there, two different protocol flows are available, depending on which method you call:
Round-trip flow ($elicit->form(...), $elicit->requiresForm(...), $elicit->url(...)):
- The MCP client advertises an
elicitationcapability during initialization - Your tool calls
form()orurl()on the context - The SDK sends an
elicitation/createrequest to the client - The client presents UI to the user and returns the response
- Your tool receives the result and continues executing in the same tool call
Error-based flow ($elicit->throwUrlRequired(...), $elicit->throwMultipleUrlRequired(...)):
- Your tool discovers it is missing an out-of-band prerequisite (credentials, consent, etc.)
- The tool calls
throwUrlRequired(), which throws a JSON-RPC-32042URLElicitationRequirederror - The current tool call terminates immediately -- no result is returned
- The client presents the URL to the user and opens it in a secure browser context
- The user completes the out-of-band flow (OAuth, API key entry, payment, etc.) directly with your server's web UI
- Later, the client retries the original tool call from scratch; this time your tool finds the credentials present and proceeds normally
The round-trip flow is appropriate when the client can collect everything inline. The error-based flow is the correct pattern whenever the interaction must not pass through the MCP client -- anything involving credentials, OAuth, or payment.
Note: MCP defines two elicitation modes -- form (inline structured data) and url (out-of-band flows). This guide covers both. The
2025-11-25spec also introduces task-augmented elicitation, which is still experimental and intentionally not documented here.
Form mode asks the client to collect one or more values from the user and return them inline. The SDK exposes this via $elicit->form() and the stricter $elicit->requiresForm() helper, which throws an ElicitationDeclinedException when the user declines or cancels.
Schema restrictions: form-mode schemas must be a flat object whose properties are primitives (
string,number,integer,boolean), single-select enums, or multi-select enums expressed as anarrayof enumitems. Nested objects and arrays of objects are not supported by design -- the restriction exists so clients can render a simple form UI. See the spec for the full list of supported keywords.
Security: never use form mode to request passwords, API keys, tokens, or payment credentials. Use URL mode for anything sensitive.
<?php
// elicitation_form_simple.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Elicitation\ElicitationContext;
use Mcp\Server\Elicitation\ElicitationDeclinedException;
$server = new McpServer('elicitation-simple');
$server->tool(
name: 'send-greeting',
description: 'Send a personalized greeting, asking the user for their name if needed',
callback: function (ElicitationContext $elicit, string $name = ''): string {
// If the model didn't supply a name, ask the user directly.
if ($name === '') {
try {
$result = $elicit->requiresForm(
message: 'What name should I use for the greeting?',
requestedSchema: [
'type' => 'object',
'properties' => [
'name' => [
'type' => 'string',
'title' => 'Your name',
'minLength' => 1,
],
],
'required' => ['name'],
],
);
$name = $result->content['name'];
} catch (ElicitationDeclinedException $e) {
return 'No greeting sent -- a name is required.';
}
}
return "Hello, {$name}!";
}
);
$server->run();A few things worth noting:
ElicitationContextcan appear anywhere in the parameter list. The SDK strips it before building the tool's input schema, so the model only seesname.requiresForm()returns anElicitationCreateResultonacceptand throws ondeclineorcancel. Use the looserform()variant if you want to inspect the action yourself.$result->contentis an associative array whose keys match thepropertiesyou requested.
A form can request several primitive fields in a single round-trip. This example confirms a destructive action and collects a reason at the same time:
<?php
// elicitation_form_multi.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Elicitation\ElicitationContext;
use Mcp\Server\Elicitation\ElicitationDeclinedException;
$server = new McpServer('elicitation-multi');
$server->tool(
name: 'archive-project',
description: 'Archive a project after confirming with the user',
callback: function (string $projectId, ElicitationContext $elicit): string {
try {
$result = $elicit->requiresForm(
message: "Archive project '{$projectId}'? This cannot be undone from the client.",
requestedSchema: [
'type' => 'object',
'properties' => [
'confirm' => [
'type' => 'boolean',
'title' => 'Confirm archive',
'description' => 'Must be checked to proceed',
'default' => false,
],
'reason' => [
'type' => 'string',
'title' => 'Reason',
'description' => 'Why are you archiving this project?',
'minLength' => 3,
'maxLength' => 200,
],
'visibility' => [
'type' => 'string',
'title' => 'Post-archive visibility',
'enum' => ['hidden', 'read-only', 'public'],
'default' => 'hidden',
],
],
'required' => ['confirm', 'reason'],
],
);
} catch (ElicitationDeclinedException $e) {
return "Archive cancelled ({$e->action}).";
}
if (!$result->content['confirm']) {
return 'Archive cancelled -- confirmation checkbox was not ticked.';
}
// In a real server, archive the project here.
return sprintf(
"Archived '%s' (visibility: %s). Reason: %s",
$projectId,
$result->content['visibility'],
$result->content['reason'],
);
}
);
$server->run();Not every MCP client supports elicitation. If your tool can still do useful work without it, use supportsForm() to fall back gracefully:
$server->tool(
name: 'suggest-tag',
description: 'Suggest a tag for a note, optionally asking the user to pick one',
callback: function (string $noteText, ElicitationContext $elicit): string {
$candidates = ['work', 'personal', 'ideas', 'todo'];
if (!$elicit->supportsForm()) {
// Client can't elicit -- just return our best guess.
return "Suggested tag: {$candidates[0]}";
}
$result = $elicit->form(
message: 'Which tag best fits this note?',
requestedSchema: [
'type' => 'object',
'properties' => [
'tag' => [
'type' => 'string',
'title' => 'Tag',
'enum' => $candidates,
],
],
'required' => ['tag'],
],
);
if ($result === null || $result->action !== 'accept') {
return "Suggested tag: {$candidates[0]}";
}
return "You picked: {$result->content['tag']}";
}
);Form mode is fine for non-sensitive data, but anything involving credentials, OAuth, or payment must go through URL mode -- the MCP client is never allowed to see the user's secrets. In URL mode the server hands the client a URL, the client opens it in a secure browser context, and the user interacts with the server's own web UI directly.
The recommended pattern is the error-based flow: when your tool discovers it is missing an out-of-band prerequisite, call $elicit->throwUrlRequired(). This throws a JSON-RPC -32042 error that tells the client "retry this tool call once the user has completed the URL interaction."
<?php
// elicitation_url_oauth.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Elicitation\ElicitationContext;
$server = new McpServer('elicitation-oauth');
// Replace this with your real token store (bound to the authenticated MCP user).
function lookup_github_token(): ?string
{
return $_SESSION['github_token'] ?? null;
}
$server->tool(
name: 'list-my-repos',
description: 'List the authenticated user\'s GitHub repositories',
callback: function (ElicitationContext $elicit, int $limit = 10): string {
$token = lookup_github_token();
if ($token === null) {
// No credentials yet -- ask the client to open our connect URL.
// The client retries this tool call once the user finishes the flow.
$elicit->throwUrlRequired(
message: 'Connect your GitHub account to list repositories.',
url: 'https://myserver.example.com/oauth/github/start?state=' . bin2hex(random_bytes(8)),
);
}
// If we reach here, the retry succeeded -- do the real work.
$repos = ['alpha', 'beta', 'gamma']; // real call would use $token
return "Your repos:\n- " . implode("\n- ", array_slice($repos, 0, $limit));
}
);
$server->run();Key points about URL mode:
throwUrlRequired()never returns -- it always throws. Treat it as a terminator.- The URL you provide must be a page on your own server (or a trusted provider). Your server is responsible for authenticating the visiting user before redirecting them to any third-party authorization endpoint -- see the MCP security guidance for details.
- Credentials obtained through the URL flow must be stored server-side, bound to the authenticated MCP user identity, and never sent back to the client.
- If a single call needs multiple out-of-band interactions, use
throwMultipleUrlRequired()with an array of['message' => ..., 'url' => ...]entries.
Clients may choose to wait for a notifications/elicitation/complete notification before retrying the tool call. This notification is a hint, not a requirement -- clients are always expected to provide a manual retry path, so sending it is optional and your tool will still work without it.
The notification can only be sent through a live, connected MCP session (the transport must have an open channel back to the client). In practice that means you can reliably send it from inside a running tool callback, where an ElicitationContext is already in scope:
// Inside a tool callback that has observed completion of its own out-of-band flow:
$elicit->notifyUrlComplete($elicitationId);This is useful for tools that can check their own completion state -- for example, a tool that stores a "pending credential" row when it throws the URL error, and on its next invocation notices the row has been filled in.
Heads up (stateless HTTP hosting): on typical cPanel-style PHP hosting the OAuth redirect handler runs in a completely separate HTTP request from the MCP endpoint. That handler has no live MCP session to write to, so it cannot send this notification directly -- doing so would require additional infrastructure (an SSE connection registry, a shared pub/sub queue, etc.) that is outside the scope of the convenience wrapper. That's fine: the client will let the user retry manually, and the retried tool call can detect the now-present credentials and complete normally. For long-running stdio servers the same process holds the session, so in-tool notifications from background work are trivial.
Elicitation works identically whether you are running over stdio or HTTP, but the mechanics differ under the hood:
- Stdio: the call to
form()blocks until the client responds, then returns normally. - HTTP: the SDK cannot block a PHP request waiting for a human, so it suspends the in-progress tool call, returns the elicitation request to the client, and transparently resumes the tool callback on the next HTTP round-trip. Your tool code is re-entered from the top -- but each completed
form()/url()call returns its previously-collected result instead of firing a new request.
This means you should write elicitation code as if it were straight-line and synchronous, even under HTTP. The only rule is: elicitation calls must happen in a deterministic order. Don't make an elicitation call conditional on data that changes between resumes (e.g. rand(), the current timestamp, or external state that may have shifted), or the resume logic won't be able to match up the preloaded results.
No extra wiring is required in your server file -- McpServer::run() / runHttp() handle the suspend/resume plumbing automatically.
The same suspend/resume mechanism also powers server-initiated LLM sampling (covered in Part 9), and a single tool call can freely mix $elicit->form() and $sampling->prompt() calls. The SDK carries the previously-collected results of both features forward across every HTTP round, so on resume each completed call returns its stored result without re-prompting the user or the LLM.
Sampling lets a tool ask the client's LLM to generate a completion on the server's behalf. It is the agentic mirror of elicitation: elicitation asks a human for input, sampling asks a language model for a response. The server never has to ship its own model or manage inference -- the client routes the request to whatever LLM the user already has configured (Claude Desktop, an IDE assistant, a local model, etc.), so cost, policy, and privacy all stay on the client side.
The core sampling/createMessage primitive has been part of MCP since the base 2024-11-05 revision, so a plain $sampling->prompt(...) works against any client that negotiates 2024-11-05 or newer. What is newer is the SDK support for it: server-initiated sampling now works across both stdio and HTTP using the same suspend/resume plumbing that powers elicitation, and the 2025-11-25 revision also adds tool-enabled sampling (passing tools and toolChoice to createMessage() so the client's LLM can emit tool-use blocks), which the SDK gates on the client's sampling.tools sub-capability.
The SDK injects a SamplingContext into any tool callback that declares one, using the same reflection mechanism as ElicitationContext. From there, your tool calls prompt() or createMessage() on the context and gets a CreateMessageResult back -- or null if the client didn't advertise the sampling capability.
- The MCP client advertises a
samplingcapability during initialization - Your tool calls
$sampling->prompt(...)or$sampling->createMessage(...) - The SDK sends a
sampling/createMessagerequest to the client - The client runs its LLM (with optional user review) and returns the completion
- Your tool receives a
CreateMessageResultand continues
Per the MCP spec, sampling/createMessage may only be sent while the server is processing a client-originated request -- there's no "background sampling." The SDK enforces this structurally: SamplingContext is only ever instantiated inside a tool handler, so there is no way to accidentally sample outside that window.
- Agentic tools that need a follow-up completion to analyze, summarize, or rephrase something the tool just computed.
- Content generation inside a tool where you want the user's own LLM (and their API key / model choice) to produce the text rather than the server shipping its own inference.
- Multi-step reasoning where the server has domain knowledge but wants the client's LLM to stitch the final answer together.
- Keeping policy on the client. Content filtering, audit logging, and rate limiting happen where the user has already set them up.
A tool that forwards a user-supplied prompt to the client's LLM and returns the completion:
<?php
// sampling_basic.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Sampling\SamplingContext;
use Mcp\Types\TextContent;
$server = new McpServer('sampling-basic');
$server->tool(
name: 'summarize',
description: 'Ask the client LLM to summarize a block of text in one sentence',
callback: function (string $text, SamplingContext $sampling): string {
if (!$sampling->supportsSampling()) {
return 'This client does not support sampling -- cannot summarize.';
}
$result = $sampling->prompt(
text: "Summarize the following in one sentence:\n\n{$text}",
maxTokens: 200,
);
if ($result === null) {
return 'Summarization is unavailable right now.';
}
// A plain prompt() returns a single text content block.
if ($result->content instanceof TextContent) {
return $result->content->text;
}
return 'Received an unexpected content type from the LLM.';
}
);
$server->run();Notes:
SamplingContextcan appear anywhere in the parameter list; the SDK strips it from the tool's input schema so the model only seestext.prompt()is a one-shot convenience for single-turn text. It returnsnullwhen the client has not advertisedsampling, when the negotiated protocol version is too old, or when the client returns an error -- always check.CreateMessageResult::$contentis aTextContent|ImageContent|AudioContent|ToolUseContentor an array of those, so handle it withinstanceofrather than assuming a shape.
createMessage() is the full API: multi-turn transcripts, an optional system prompt, temperature control, and ModelPreferences that let the server hint (but not require) properties like cost, speed, or intelligence bias. Here is a tool that asks the client LLM to classify a support ticket against a fixed taxonomy:
<?php
// sampling_classify.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Sampling\SamplingContext;
use Mcp\Types\ModelPreferences;
use Mcp\Types\Role;
use Mcp\Types\SamplingMessage;
use Mcp\Types\TextContent;
$server = new McpServer('sampling-classify');
$server->tool(
name: 'classify-ticket',
description: 'Classify a support ticket as billing, technical, or account',
callback: function (string $ticket, SamplingContext $sampling): string {
$messages = [
new SamplingMessage(
role: Role::USER,
content: new TextContent(text: "Ticket:\n{$ticket}\n\nCategory?"),
),
];
$result = $sampling->createMessage(
messages: $messages,
maxTokens: 10,
systemPrompt: 'Reply with exactly one word: billing, technical, or account.',
temperature: 0.0,
// Hint: cheaper + faster is fine, we don't need a flagship model for a one-word label.
modelPreferences: new ModelPreferences(
costPriority: 0.8,
speedPriority: 0.8,
intelligencePriority: 0.2,
),
);
if ($result === null || !($result->content instanceof TextContent)) {
return 'unclassified';
}
$label = strtolower(trim($result->content->text));
return in_array($label, ['billing', 'technical', 'account'], true)
? $label
: 'unclassified';
}
);
$server->run();ModelPreferences is advisory -- the client decides whether to honor the hints. Always validate the response against what you actually need (here: coerce to the taxonomy, fall back to unclassified) rather than trusting the LLM to comply with the system prompt exactly.
Sampling and elicitation compose. A tool can ask the user for a topic, then ask the client's LLM to draft something based on it, all in a single tool call:
<?php
// sampling_plus_elicitation.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Server\Elicitation\ElicitationContext;
use Mcp\Server\Elicitation\ElicitationDeclinedException;
use Mcp\Server\Sampling\SamplingContext;
use Mcp\Types\TextContent;
$server = new McpServer('draft-helper');
$server->tool(
name: 'draft-tweet',
description: 'Ask the user for a topic, then draft a tweet about it using the client LLM',
callback: function (ElicitationContext $elicit, SamplingContext $sampling): string {
try {
$form = $elicit->requiresForm(
message: 'What should the tweet be about?',
requestedSchema: [
'type' => 'object',
'properties' => [
'topic' => ['type' => 'string', 'title' => 'Topic', 'minLength' => 2],
'tone' => [
'type' => 'string',
'title' => 'Tone',
'enum' => ['serious', 'playful', 'technical'],
'default' => 'playful',
],
],
'required' => ['topic'],
],
);
} catch (ElicitationDeclinedException $e) {
return 'No topic provided -- nothing to draft.';
}
$topic = $form->content['topic'];
$tone = $form->content['tone'] ?? 'playful';
$draft = $sampling->prompt(
text: "Write a single tweet (under 280 chars) about {$topic}. Tone: {$tone}.",
maxTokens: 120,
);
if ($draft === null || !($draft->content instanceof TextContent)) {
return "Drafting failed, but here's the topic you picked: {$topic} ({$tone}).";
}
return $draft->content->text;
}
);
$server->run();Under HTTP this tool suspends twice -- once on the form, once on the sampling request -- so the callback ends up being invoked three times total: the initial invocation plus two resumes. On each resume the SDK re-enters the callback from the top, and every completed form() / prompt() call returns its stored result instead of firing a new request. You never have to think about that -- just write straight-line code and keep the call order deterministic so the stored results match up on every re-entry.
Sampling works identically across stdio and HTTP, with the same mechanics as elicitation:
- Stdio: the call to
prompt()/createMessage()blocks until the client returns the completion. - HTTP: the SDK suspends the tool, emits the
sampling/createMessagerequest to the client, and transparently resumes the tool on the next HTTP round with the preloaded result.
The deterministic-ordering rule from Part 8 applies to sampling as well: don't make a sampling call conditional on non-deterministic data between resumes, or the SDK won't be able to match the stored result back to the call.
Two capability checks to know about:
$sampling->supportsSampling()-- returnstruewhen the client advertisedsamplingduring initialization and the negotiated protocol version covers it. Call this early and short-circuit if the answer isfalse.$sampling->supportsToolsInSampling()-- tool-enabled sampling (passingtools/toolChoicetocreateMessage()) is gated on thesampling.toolssub-capability introduced in2025-11-25. If you plan to pass tools, check this separately; otherwise omit the check.
If the client doesn't support sampling, prompt() and createMessage() both return null without sending any request. Handle null the same way you would handle any optional feature -- fall back, return a useful message, or mark the tool result as an error.
Note: The
2025-11-25spec also introduces task-augmented sampling (linking asampling/createMessageto an asynctasks/createlifecycle). Like task-augmented elicitation, this is still experimental and intentionally not documented here.
When a server advertises completions, clients can ask it to suggest values for a prompt argument or a resource-template variable as the user types -- the same way an IDE autocompletes. You register a provider per (prompt-or-template, argument) pair, and the SDK advertises the completions capability automatically as soon as the first provider is registered. Nothing else to wire up.
Use completionForPrompt(promptName, argumentName, provider). The provider receives the partial value the user has typed so far and returns an array of candidate strings:
<?php
// completion_prompt.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('completion-server');
$server->prompt(
'review-code',
'Review a snippet in a given language',
function (string $language, string $code): string {
return "Review this {$language} code:\n\n{$code}";
}
);
// Suggest values for the prompt's "language" argument. $value is what the
// user has typed so far; return the candidates that match.
$server->completionForPrompt(
'review-code',
'language',
function (string $value): array {
$languages = ['php', 'python', 'javascript', 'rust', 'go'];
return array_values(array_filter(
$languages,
fn (string $lang): bool => str_starts_with($lang, strtolower($value)),
));
}
);
$server->run();Use completionForResourceTemplate(uriTemplate, variableName, provider). The template string must exactly match one you registered with resourceTemplate() (see Part 3):
<?php
// completion_template.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('completion-template-server');
$server->resourceTemplate(
uriTemplate: 'users://{userId}/profile',
name: 'User Profile',
callback: fn (string $userId): string => "Profile for {$userId}",
mimeType: 'application/json',
);
// Suggest values for the {userId} variable. The first argument must be the
// exact template string registered above.
$server->completionForResourceTemplate(
'users://{userId}/profile',
'userId',
function (string $value): array {
$ids = ['1001', '1002', '2001'];
return array_values(array_filter(
$ids,
fn (string $id): bool => str_starts_with($id, $value),
));
}
);
$server->run();A provider may accept a second array $context parameter holding the values the user has already chosen for other arguments of the same prompt. Use it to narrow later suggestions -- for example, only offering frameworks that match the language already picked:
$server->completionForPrompt(
'scaffold',
'framework',
function (string $value, array $context): array {
$byLanguage = [
'php' => ['laravel', 'symfony', 'slim'],
'python' => ['django', 'flask', 'fastapi'],
];
$candidates = $byLanguage[$context['language'] ?? ''] ?? [];
return array_values(array_filter(
$candidates,
fn (string $f): bool => str_starts_with($f, $value),
));
}
);The SDK wraps your array in the protocol's completion response and caps it at 100 suggestions, setting the hasMore flag when it truncates. If you need full control over the total and hasMore fields, return a Mcp\Types\CompletionObject (or a complete Mcp\Types\CompleteResult) instead of a plain array.
Every example so far has returned a single result. MCP also lets a server push out-of-band messages to the client while it works: progress updates during a long tool call, log messages, and "the list changed" hints that tell the client to refetch its tool/prompt/resource catalog. These are one-way notifications -- the server emits them, and the client's notification handler receives them (the client guide covers the receiving side).
Transport note (required reading for HTTP): Server-to-client notifications travel on the open channel back to the client, and the two transports differ in a way that matters for compliance:
- Over stdio the channel is always present -- emit freely.
- Over HTTP the Streamable HTTP spec requires that a plain
application/jsonPOST response contain exactly one JSON object: the result of the request. Notifications can only be delivered on atext/event-stream(SSE) response, interleaved before that result. So to emit notifications over HTTP you must enable SSE with->httpOptions(['enable_sse' => true]). With SSE left at its default (false), there is no spec-compliant way to attach a notification to the response, so a server that emits notifications must run over stdio or over an SSE-enabled HTTP endpoint. Every example in this section enables SSE for that reason.One more HTTP caveat even with SSE on: a stateless shared-hosting request that has already returned cannot push a notification after the fact. Always emit from inside a tool callback, while the request is still being processed.
A tool callback can type-hint ProgressContext to report incremental progress. The SDK injects it only when the client attached a progressToken to the call, so make the parameter nullable with a default of null and guard on it with ?->:
<?php
// emit_progress.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Shared\ProgressContext;
$server = new McpServer('progress-server');
// Required for HTTP: progress notifications can only be delivered on an SSE
// response. Without this, an HTTP server has no compliant way to emit them.
// (No effect over stdio.) See the transport note above.
$server->httpOptions(['enable_sse' => true]);
$server->tool(
'process-batch',
'Process a batch of records, reporting progress as it goes',
function (int $count, ?ProgressContext $progress = null): string {
for ($i = 0; $i < $count; $i++) {
// ... do one unit of work ...
// Report one step. If the client didn't request progress,
// $progress is null and this line is a no-op.
$progress?->progress(1);
}
return "Processed {$count} records.";
}
);
$server->run();ProgressContext::progress($amount) increments a running total and emits a notifications/progress, which is all most tools need. The ProgressContext parameter is stripped from the tool's input schema, so the model never sees it.
If you want to send an explicit total so the client can render a percentage, reach the live session and call sendProgressNotification() with the token directly:
$server->tool(
'export-data',
'Export records, reporting percent complete',
function (?ProgressContext $progress = null) use ($server): string {
$session = $server->getServer()->getSession();
$token = $progress?->getToken();
if ($session !== null && $token !== null) {
$session->sendProgressNotification($token, 0, 100);
// ... first half of the work ...
$session->sendProgressNotification($token, 50, 100);
// ... second half of the work ...
$session->sendProgressNotification($token, 100, 100);
}
return 'Export complete.';
}
);A server can stream structured log messages to the client at the standard syslog severities (DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY). To advertise the logging capability -- which is what tells a spec-compliant client it may set a minimum level and expect log notifications -- register a logging/setLevel handler on the underlying Server. Then call sendLogMessage() from inside your tool:
<?php
// emit_logging.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
use Mcp\Types\LoggingLevel;
use Mcp\Types\EmptyResult;
$server = new McpServer('logging-server');
// Required for HTTP: log notifications ride an SSE response (see transport note).
$server->httpOptions(['enable_sse' => true]);
// Advertise the `logging` capability. McpServer has no high-level wrapper for
// logging/setLevel, so register it on the low-level Server. Accepting the level
// is enough here; a real server could store it and filter its own output.
$server->getServer()->registerHandler('logging/setLevel', fn () => new EmptyResult());
$server->tool(
'reindex',
'Rebuild the search index, logging progress to the client',
function () use ($server): string {
$session = $server->getServer()->getSession();
if ($session !== null) {
// level, data (any JSON-serializable value), and an optional logger name.
$session->sendLogMessage(LoggingLevel::INFO, 'Reindex started', 'search');
// ... work ...
$session->sendLogMessage(LoggingLevel::INFO, 'Reindex finished', 'search');
}
return 'Reindex complete.';
}
);
$server->run();The data argument can be any JSON-serializable value -- a string, or a structured array like ['stage' => 'merge', 'docs' => 1200] -- and the optional third argument is a logger name the client can display or filter on.
If your server adds or removes tools, prompts, or resources at runtime, tell the client its cached catalog is stale so it refetches. This is a two-step feature:
- Advertise the capability with
notifyOnChanges()beforerun(). This sets thelistChangedflags in the initialization handshake so the client knows to listen. - Emit the notification when the list actually changes, via
sendToolListChanged(),sendResourceListChanged(), orsendPromptListChanged()on the session.
<?php
// emit_list_changed.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('dynamic-server');
// Required for HTTP: list-changed notifications ride an SSE response (see transport note).
$server->httpOptions(['enable_sse' => true]);
// Step 1: advertise that this server may send list-changed notifications.
$server->notifyOnChanges(
resourcesChanged: true,
toolsChanged: true,
promptsChanged: true,
);
// Step 2: each tool calls the matching send…ListChanged() method after it
// changes the corresponding catalog. The three methods are independent --
// emit only the one(s) whose list actually changed.
$server->tool('enable-beta-tools', 'Turn on the beta tool set', function () use ($server): string {
// ... register or unregister tools based on application state ...
// The TOOL list changed -> notify so the client refetches tools/list.
$session = $server->getServer()->getSession();
$session?->sendToolListChanged();
return 'Beta tools enabled.';
});
$server->tool('mount-dataset', 'Expose a new dataset as readable resources', function () use ($server): string {
// ... add resources for the newly mounted dataset ...
// The RESOURCE list changed -> notify so the client refetches resources/list.
$session = $server->getServer()->getSession();
$session?->sendResourceListChanged();
return 'Dataset mounted.';
});
$server->tool('install-prompt-pack', 'Add a pack of prompt templates', function () use ($server): string {
// ... register the new prompts ...
// The PROMPT list changed -> notify so the client refetches prompts/list.
$session = $server->getServer()->getSession();
$session?->sendPromptListChanged();
return 'Prompt pack installed.';
});
$server->run();notifyOnChanges() only advertises the capability -- it does not auto-emit when you register a tool, prompt, or resource. You decide when a list has meaningfully changed and call the matching method: sendToolListChanged(), sendResourceListChanged(), or sendPromptListChanged(). The three are independent, so emit only the one whose catalog actually changed. As with all server notifications, delivery is immediate over stdio and, over HTTP, rides the in-flight request's SSE response (hence the enable_sse => true above). A long-running stdio server is the natural home for catalogs that change between calls.
Real-world MCP servers combine tools, prompts, and resources. Here is a complete server that demonstrates all three working together:
<?php
// multi_capability.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Server\McpServer;
$server = new McpServer('project-assistant');
// --- Tools ---
$server
->tool(
'estimate-reading-time',
'Estimate reading time for a given text',
function (string $text, int $wpm = 200): string {
$wordCount = str_word_count($text);
$minutes = ceil($wordCount / $wpm);
return "{$wordCount} words, approximately {$minutes} minute(s) to read at {$wpm} WPM.";
}
)
->tool(
'generate-table-of-contents',
'Extract markdown headings to build a table of contents',
function (string $markdown): string {
preg_match_all('/^(#{1,6})\s+(.+)$/m', $markdown, $matches, PREG_SET_ORDER);
if (empty($matches)) {
return 'No headings found in the provided markdown.';
}
$toc = "## Table of Contents\n\n";
foreach ($matches as $match) {
$level = strlen($match[1]);
$title = trim($match[2]);
$slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $title));
$indent = str_repeat(' ', $level - 1);
$toc .= "{$indent}- [{$title}](#{$slug})\n";
}
return $toc;
}
);
// --- Prompts ---
$server
->prompt(
'write-readme',
'Generate a README.md for a project',
function (string $project_name, string $description, string $language = 'PHP'): string {
return <<<PROMPT
Write a professional README.md for a project with these details:
- **Project Name**: {$project_name}
- **Description**: {$description}
- **Language**: {$language}
Include these sections: Overview, Features, Installation, Usage, Configuration, Contributing, and License (MIT).
Use proper markdown formatting with code examples.
PROMPT;
}
)
->prompt(
'document-function',
'Generate documentation for a function or method',
function (string $function_signature, string $purpose): string {
return <<<PROMPT
Write comprehensive documentation for this function:
```
{$function_signature}
```
Purpose: {$purpose}
Include: description, parameter documentation, return value, usage example, and edge cases.
PROMPT;
}
);
// --- Resources ---
$server
->resource(
uri: 'guide://coding-standards',
name: 'Coding Standards',
description: 'Team coding standards and conventions',
callback: function (): string {
return <<<'STANDARDS'
# Coding Standards
## PHP
- Follow PSR-12 coding style
- Use strict_types=1 in all files
- Type-hint all parameters and return types
- Use named arguments for constructors with 3+ parameters
## Naming
- Classes: PascalCase
- Methods/Functions: camelCase
- Variables: camelCase
- Constants: UPPER_SNAKE_CASE
- Database tables: snake_case (plural)
## Git
- Branch naming: feature/description, fix/description, chore/description
- Commit messages: imperative mood ("Add feature" not "Added feature")
- Squash merge feature branches
STANDARDS;
},
mimeType: 'text/markdown'
)
->resource(
uri: 'guide://project-structure',
name: 'Project Structure',
description: 'Recommended directory layout',
callback: function (): string {
return <<<'STRUCTURE'
project/
├── src/ # Application source code
│ ├── Controllers/ # HTTP controllers
│ ├── Models/ # Database models
│ ├── Services/ # Business logic
│ └── Repositories/ # Data access layer
├── tests/ # Test files
│ ├── Unit/
│ └── Integration/
├── config/ # Configuration files
├── public/ # Web root
│ └── index.php # Entry point
├── storage/ # Generated files, logs, cache
├── composer.json
└── README.md
STRUCTURE;
},
mimeType: 'text/plain'
);
$server->run();| Option | Type | Default | Description |
|---|---|---|---|
session_timeout |
int | 3600 | Session expiry in seconds |
max_queue_size |
int | 1000 | Maximum messages in queue per session |
enable_sse |
bool | false | Master switch for Server-Sent Events. While false every POST gets a plain JSON response. Set to true to let the transport negotiate SSE via the client's Accept header |
sse_mode |
string | 'auto' |
Secondary setting that applies only when enable_sse is true and the transport picks SSE for a request. 'auto' (stream only when the request carries a progressToken), 'streaming' (always stream when the runtime permits), or 'buffered' (single-response SSE -- never mid-response flushing) |
sse_retry_ms |
int | 1500 | Reconnect hint emitted on SSE streams via the WHATWG retry field |
sse_event_log_capacity |
int | 64 | Max events retained per session for resumable replay via Last-Event-ID |
sse_standalone_get_idle_ms |
int | 0 | How long an idle standalone-GET SSE stream stays open; default 0 closes immediately (correct for PHP-FPM, where no background worker exists to push messages) |
shared_hosting |
bool/null | null (auto-detect) | Force shared hosting optimizations |
server_header |
string | MCP-PHP-Server/1.0 |
Server identification header |
allowed_origins |
array/null | null | Allowed hostnames for Origin validation (auto-set for cli-server SAPI) |
auth_enabled |
bool | false | Enable OAuth token validation |
authorization_servers |
array | [] | Authorization server URLs |
resource |
string/null | null | Protected resource identifier |
token_validator |
TokenValidatorInterface/null | null | Token validator instance |
resource_metadata_path |
string | /.well-known/oauth-protected-resource |
OAuth metadata endpoint |
| Parameter | Type | Description |
|---|---|---|
$key |
string | Shared secret (HS256) or PEM public key (RS256) |
$algorithm |
string | 'HS256' or 'RS256' (default: 'HS256') |
$issuer |
string/null | Expected iss claim value |
$audience |
string/null | Expected aud claim value |
$jwksUri |
string/null | JWKS endpoint URL for RS256 key fetching |
| Method | Description |
|---|---|
tool(name, description, callback, title?, icons?, outputSchema?, inputSchema?) |
Register a tool |
prompt(name, description, callback, title?, icons?) |
Register a prompt |
resource(uri, name, callback, description?, mimeType?, title?, icons?, size?) |
Register a resource |
resourceTemplate(uriTemplate, name, callback, description?, mimeType?, title?, icons?) |
Register a resource template (variables passed to the callback by name) |
completionForPrompt(promptName, argumentName, provider) |
Register an argument-completion provider for a prompt |
completionForResourceTemplate(uriTemplate, variableName, provider) |
Register a completion provider for a resource-template variable |
httpOptions(array) |
Set HTTP transport configuration |
sessionStore(SessionStoreInterface) |
Set the session persistence backend |
withAuth(tokenValidator, authorizationServers, resourceId) |
Enable OAuth authentication |
notifyOnChanges(resourcesChanged?, toolsChanged?, promptsChanged?) |
Configure change notifications |
enableTasks(storagePath?) |
Enable experimental task support |
run() |
Auto-detect transport and start |
runStdio() |
Force stdio transport |
runHttp() |
Force HTTP transport |
| Context injection | A tool callback that type-hints ElicitationContext, SamplingContext, or ProgressContext automatically receives that context at call time. The parameter is stripped from the tool's input schema. |
getServer() |
Access the underlying Server instance |
getTaskManager() |
Access the TaskManager instance |
| Primitive | Return Type | SDK Behavior |
|---|---|---|
| Tool | string |
Wrapped in TextContent inside CallToolResult |
| Tool | CallToolResult |
Returned as-is |
| Tool | array (with outputSchema) |
Wrapped with content + structuredContent |
| Prompt | string |
Wrapped as single user-role PromptMessage |
| Prompt | array of strings |
Each string becomes a user-role PromptMessage |
| Prompt | GetPromptResult |
Returned as-is |
| Resource | string |
Wrapped as TextResourceContents |
| Resource | SplFileObject or resource |
Base64-encoded as BlobResourceContents |
| Resource | ReadResourceResult |
Returned as-is |
| Resource template | (same as Resource) | Template variables injected into the callback by name; return value normalized exactly like a resource |
| Completion provider | string[] |
Wrapped as a CompletionObject (capped at 100, sets hasMore) |
| Completion provider | CompletionObject / CompleteResult |
Returned as-is |
The reflection-based schema builder produces JSON Schema draft 2020-12 (the dialect the MCP spec assumes by default when a tool input schema omits $schema) using the following PHP-to-JSON-Schema type table. Pass an explicit $inputSchema to tool() if you need types or constraints this table can't express -- the SDK enforces only the spec-required envelope (top-level type: object, well-formed properties and required) and passes every other 2020-12 keyword through to the wire unchanged. See Custom Input Schemas for the full contract.
| PHP Type | JSON Schema Type |
|---|---|
string |
"string" |
int |
"number" |
float |
"number" |
bool |
"boolean" |
array |
"array" |
object / stdClass |
"object" |
- PHP 8.1+ installed and accessible via
phpcommand -
composer installrun to install dependencies - Server file is executable and paths are correct
- Tested via
php server.php(should hang waiting for input -- that's normal) - MCP client config points to the correct
phpbinary and script path
- PHP 8.1+ selected in cPanel MultiPHP Manager
-
ext-curlandext-jsonenabled - Project uploaded with
vendor/directory - Session storage directory is writable (
mcp_sessions/) -
.htaccessblocks access tovendor/andmcp_sessions/ - Error logging configured (not displayed to stdout)
- HTTPS enabled (required for production, especially with OAuth)
- Tested with a simple POST request to the MCP endpoint
- For OAuth: authorization server URLs, issuer, and audience values are correct
- OAuth enabled for any server accessible over the public internet that handles non-public data
- Database queries use prepared statements (never interpolate user input)
- Tool callbacks validate and sanitize all input
- Read-only tools don't accidentally modify state
- Sensitive directories (vendor, sessions, config) are not web-accessible
- Error messages don't leak internal paths or credentials
This guide covers the logiscape/mcp-sdk-php SDK implementing the MCP specification 2025-11-25. For SDK source code and updates, visit github.com/logiscape/mcp-sdk-php.