Last active
August 20, 2026 09:09
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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