Skip to content

Instantly share code, notes, and snippets.

@md-riaz
Last active August 20, 2026 09:09
Show Gist options
  • Select an option

  • Save md-riaz/4e6541aef5b51402b40283a2df685e13 to your computer and use it in GitHub Desktop.

Select an option

Save md-riaz/4e6541aef5b51402b40283a2df685e13 to your computer and use it in GitHub Desktop.
McpServer — A zero-dependency, single-class MCP (Model Context Protocol) server for PHP
<?php
declare(strict_types=1);
/**
* McpServer — A zero-dependency, single-class MCP (Model Context Protocol) server for PHP.
*
* Implements a stateless HTTP-style MCP dispatcher (JSON-RPC 2.0).
* No Composer, no SDK, no frameworks required.
*
* This version follows the newer MCP direction where requests are
* self-contained and the server keeps no per-client session state.
*
* @license MIT
* Requires PHP 7.4 or newer.
*
* ─── USAGE ──────────────────────────────────────────────────────────────────────
*
* Basic setup:
* require 'McpServer.php';
* $server = new McpServer('my-mcp-server', '1.0.0');
*
* Register a tool:
* $server->tool(
* 'ping',
* 'Simple health check',
* [],
* function (array $args) {
* return 'pong';
* }
* );
*
* Register a tool with required input:
* $server->tool(
* 'get_user',
* 'Fetch a user by ID',
* [
* 'id' => ['type' => 'integer', 'description' => 'User ID']
* ],
* function (array $args) {
* return ['id' => $args['id'], 'name' => 'John Doe'];
* },
* ['required' => ['id']]
* );
*
* Handle an HTTP request, stateless:
* $body = file_get_contents('php://input');
* header('Content-Type: application/json');
* echo $server->handleHttpRequest($body);
*
* Handle stdio, one JSON-RPC request per line:
* while (($line = fgets(STDIN)) !== false) {
* echo $server->handleHttpRequest(trim($line)) . PHP_EOL;
* }
*
* Supported MCP methods:
* - initialize: returns server info, minimal and session-less
* - tools/list: lists all registered tools with schemas
* - tools/call: executes a registered tool with argument validation
*
* Tool options:
* - required: string[] list of required parameter names
* - annotations: array of MCP tool annotations, such as readOnlyHint
* - outputSchema: JSON Schema for structured output
*/
class McpServer
{
private const JSONRPC_VERSION = '2.0';
private const ERR_PARSE = -32700;
private const ERR_INVALID_REQUEST = -32600;
private const ERR_METHOD_NOT_FOUND = -32601;
private const ERR_INVALID_PARAMS = -32602;
private const ERR_INTERNAL = -32603;
/** @var string */
private $name;
/** @var string */
private $version;
/** @var array<string, array{description: string, inputSchema: array, handler: callable}> */
private $tools = [];
/** @var int 0=silent, 1=error, 2=info, 3=debug */
private $logLevel = 2;
public function __construct(string $name, string $version = '1.0.0')
{
$this->name = $name;
$this->version = $version;
}
/**
* Register a tool the client/LLM can invoke.
*
* @param string $name Tool name (snake_case recommended)
* @param string $description Human-readable description
* @param array $properties JSON Schema properties map
* @param callable $handler function(array $args): mixed
* @param array $options Optional: 'required' => string[], 'annotations' => array, 'outputSchema' => array
*/
public function tool(string $name, string $description, array $properties, callable $handler, array $options = []): self
{
if ($name === '' || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_\-]*$/', $name)) {
throw new \InvalidArgumentException("Invalid tool name: '{$name}'");
}
$inputSchema = [
'type' => 'object',
'properties' => $properties,
];
if (!empty($options['required'])) {
$inputSchema['required'] = array_values($options['required']);
}
$this->tools[$name] = [
'description' => $description,
'inputSchema' => $inputSchema,
'handler' => $handler,
'annotations' => $options['annotations'] ?? [],
'outputSchema' => $options['outputSchema'] ?? null,
];
return $this;
}
/**
* Handle a single JSON-RPC request (stateless).
*
* @param array $request Decoded JSON request body
*/
public function handle(array $request): array
{
if (($request['jsonrpc'] ?? '') !== self::JSONRPC_VERSION) {
return $this->error($request['id'] ?? null, self::ERR_INVALID_REQUEST, 'Invalid or missing "jsonrpc" field');
}
$method = $request['method'] ?? null;
$params = $request['params'] ?? [];
$id = $request['id'] ?? null;
if ($method === null) {
return $this->error($id, self::ERR_INVALID_REQUEST, 'Missing "method" field');
}
try {
switch ($method) {
case 'initialize':
return $this->result($id, [
'protocolVersion' => '2025-06-18',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => [
'name' => $this->name,
'version' => $this->version,
],
]);
case 'tools/list':
return $this->result($id, ['tools' => $this->toolsList()]);
case 'tools/call':
return $this->handleToolsCall($id, $params);
default:
return $this->error($id, self::ERR_METHOD_NOT_FOUND, "Method not found: {$method}");
}
} catch (\Throwable $e) {
return $this->error($id, self::ERR_INTERNAL, 'Internal error: ' . $e->getMessage());
}
}
/**
* Convenience wrapper for HTTP entrypoints.
*/
public function handleHttpRequest(string $rawBody): string
{
$request = json_decode($rawBody, true);
if (!is_array($request)) {
$response = $this->error(null, self::ERR_PARSE, 'Parse error');
} else {
$response = $this->handle($request);
}
return json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
public function setLogLevel(int $level): self
{
$this->logLevel = max(0, min(3, $level));
return $this;
}
private function toolsList(): array
{
$tools = [];
foreach ($this->tools as $name => $def) {
$entry = [
'name' => $name,
'description' => $def['description'],
'inputSchema' => $def['inputSchema'],
];
if (!empty($def['annotations'])) {
$entry['annotations'] = $def['annotations'];
}
if ($def['outputSchema'] !== null) {
$entry['outputSchema'] = $def['outputSchema'];
}
$tools[] = $entry;
}
return $tools;
}
private function handleToolsCall($id, array $params): array
{
$name = $params['name'] ?? '';
$args = $params['arguments'] ?? [];
if (!isset($this->tools[$name])) {
return $this->error($id, self::ERR_INVALID_PARAMS, "Unknown tool: '{$name}'");
}
$tool = $this->tools[$name];
$validationError = $this->validateArguments($args, $tool['inputSchema']);
if ($validationError !== null) {
return $this->result($id, [
'content' => [['type' => 'text', 'text' => 'Validation error: ' . $validationError]],
'isError' => true,
]);
}
try {
$result = ($tool['handler'])($args);
return $this->result($id, [
'content' => $this->normalizeContent($result),
]);
} catch (\InvalidArgumentException $e) {
return $this->result($id, [
'content' => [['type' => 'text', 'text' => 'Invalid input: ' . $e->getMessage()]],
'isError' => true,
]);
} catch (\Throwable $e) {
return $this->result($id, [
'content' => [['type' => 'text', 'text' => 'Tool execution failed: ' . $e->getMessage()]],
'isError' => true,
]);
}
}
private function validateArguments(array $args, array $schema): ?string
{
foreach ($schema['required'] ?? [] as $field) {
if (!array_key_exists($field, $args)) {
return "Missing required parameter: '{$field}'";
}
}
$properties = $schema['properties'] ?? [];
foreach ($args as $key => $value) {
if (!isset($properties[$key])) {
continue; // tolerate extra params (open world)
}
$expectedType = $properties[$key]['type'] ?? null;
if ($expectedType !== null && !$this->matchesType($value, $expectedType)) {
return "Parameter '{$key}' must be of type '{$expectedType}'";
}
}
return null;
}
private function matchesType($value, string $type): bool
{
switch ($type) {
case 'string': return is_string($value);
case 'integer': return is_int($value);
case 'number': return is_int($value) || is_float($value);
case 'boolean': return is_bool($value);
case 'array': return is_array($value);
case 'object': return is_array($value);
case 'null': return $value === null;
default: return true;
}
}
private function normalizeContent($result): array
{
if (is_array($result) && isset($result[0]['type'])) {
return $result;
}
if (is_array($result) && isset($result['type'])) {
return [$result];
}
if (is_string($result)) {
return [['type' => 'text', 'text' => $result]];
}
return [['type' => 'text', 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)]];
}
private function result($id, $result): array
{
return [
'jsonrpc' => self::JSONRPC_VERSION,
'id' => $id,
'result' => $result,
];
}
private function error($id, int $code, string $message): array
{
return [
'jsonrpc' => self::JSONRPC_VERSION,
'id' => $id,
'error' => [
'code' => $code,
'message' => $message,
],
];
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment