Last active
July 13, 2026 04:04
-
-
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 the MCP specification (protocol version 2025-06-18) over stdio transport | |
| * using JSON-RPC 2.0. No Composer, no SDK, no frameworks required. | |
| * | |
| * Features: | |
| * - Full MCP lifecycle (initialize → initialized → operation → shutdown) | |
| * - Tools, Resources, and Prompts primitives | |
| * - Tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) | |
| * - Structured JSON Schema input validation for tool arguments | |
| * - Pagination support for list operations | |
| * - Proper JSON-RPC 2.0 error codes and error handling | |
| * - Stderr logging (never pollutes stdout) | |
| * - Input sanitization (path traversal, command injection guards) | |
| * - Fluent registration API | |
| * | |
| * Usage: | |
| * $server = new McpServer('my-server', '1.0.0'); | |
| * $server->tool('greet', 'Say hello', ['name' => ['type' => 'string']], function($args) { | |
| * return "Hello, {$args['name']}!"; | |
| * }); | |
| * $server->run(); | |
| * | |
| * @license MIT | |
| * Requires PHP 7.1 or newer. | |
| * | |
| * @version 1.1.0 | |
| */ | |
| class McpServer | |
| { | |
| // ─── Protocol Constants ────────────────────────────────────────────────────── | |
| private const PROTOCOL_VERSION = '2025-06-18'; | |
| private const JSONRPC_VERSION = '2.0'; | |
| // JSON-RPC 2.0 standard error codes | |
| 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; | |
| // MCP-specific error codes | |
| private const ERR_NOT_INITIALIZED = -32002; | |
| private const ERR_ALREADY_INITIALIZED = -32003; | |
| // ─── Server Identity ───────────────────────────────────────────────────────── | |
| /** @var string */ | |
| private $name; | |
| /** @var string */ | |
| private $version; | |
| /** @var string */ | |
| private $description; | |
| // ─── Registries ────────────────────────────────────────────────────────────── | |
| /** @var array<string, array{description: string, inputSchema: array, handler: callable, annotations: array}> */ | |
| private $tools = []; | |
| /** @var array<string, array{uri: string, name: string, description: string, mimeType: string, handler: callable}> */ | |
| private $resources = []; | |
| /** @var array<string, array{name: string, description: string, arguments: array, handler: callable}> */ | |
| private $prompts = []; | |
| // ─── State ─────────────────────────────────────────────────────────────────── | |
| private $initialized = false; | |
| private $running = false; | |
| private $clientName = ''; | |
| private $clientVersion = ''; | |
| private $negotiatedVersion = ''; | |
| /** @var array<string, callable> Custom notification handlers */ | |
| private $notificationHandlers = []; | |
| /** @var resource */ | |
| private $stdin; | |
| /** @var resource */ | |
| private $stdout; | |
| // Log level control | |
| private $logLevel = 2; // 0=silent, 1=error, 2=info, 3=debug | |
| // ─── Constructor ───────────────────────────────────────────────────────────── | |
| /** | |
| * @param string $name Server name shown to clients during initialization | |
| * @param string $version Server version string | |
| * @param string $description Optional human-readable server description | |
| */ | |
| public function __construct(string $name, string $version = '1.0.0', string $description = '') | |
| { | |
| $this->name = $name; | |
| $this->version = $version; | |
| $this->description = $description; | |
| } | |
| // ─── Registration: Tools ───────────────────────────────────────────────────── | |
| /** | |
| * Register a tool the client/LLM can invoke. | |
| * | |
| * @param string $name Tool name (snake_case recommended, e.g. "myapp_search_users") | |
| * @param string $description Concise description of what the tool does | |
| * @param array $properties JSON Schema "properties" map for input validation. | |
| * Each key is a parameter name; value is a JSON Schema object. | |
| * Example: ['query' => ['type' => 'string', 'description' => 'Search term']] | |
| * @param callable $handler function(array $args): mixed — return a string, array, or structured data. | |
| * Throw \InvalidArgumentException for bad input. | |
| * Throw \RuntimeException for execution failures. | |
| * @param array $options Optional settings: | |
| * - 'required' => string[] List of required parameter names | |
| * - 'annotations' => array Tool annotations (readOnlyHint, destructiveHint, etc.) | |
| * - 'outputSchema' => array JSON Schema for structured output | |
| * @return self Fluent | |
| */ | |
| public function tool(string $name, string $description, array $properties, callable $handler, array $options = []): self | |
| { | |
| $this->validateName($name, 'Tool'); | |
| $inputSchema = [ | |
| 'type' => 'object', | |
| 'properties' => $properties, | |
| ]; | |
| if (!empty($options['required'])) { | |
| $inputSchema['required'] = array_values($options['required']); | |
| } | |
| $annotations = array_merge([ | |
| 'readOnlyHint' => false, | |
| 'destructiveHint' => true, | |
| 'idempotentHint' => false, | |
| 'openWorldHint' => true, | |
| ], $options['annotations'] ?? []); | |
| $this->tools[$name] = [ | |
| 'description' => $description, | |
| 'inputSchema' => $inputSchema, | |
| 'handler' => $handler, | |
| 'annotations' => $annotations, | |
| 'outputSchema' => $options['outputSchema'] ?? null, | |
| ]; | |
| return $this; | |
| } | |
| // ─── Registration: Resources ───────────────────────────────────────────────── | |
| /** | |
| * Register a read-only resource the client can fetch. | |
| * | |
| * @param string $uri Unique resource URI (e.g. "file:///etc/config.json" or "myapp://users/list") | |
| * @param string $name Human-readable name | |
| * @param string $description What this resource provides | |
| * @param callable $handler function(): string|array — return the resource content | |
| * @param string $mimeType MIME type of the resource content | |
| * @return self Fluent | |
| */ | |
| public function resource(string $uri, string $name, string $description, callable $handler, string $mimeType = 'text/plain'): self | |
| { | |
| if (empty($uri)) { | |
| throw new \InvalidArgumentException('Resource URI must not be empty.'); | |
| } | |
| $this->resources[$uri] = [ | |
| 'uri' => $uri, | |
| 'name' => $name, | |
| 'description' => $description, | |
| 'mimeType' => $mimeType, | |
| 'handler' => $handler, | |
| ]; | |
| return $this; | |
| } | |
| // ─── Registration: Prompts ─────────────────────────────────────────────────── | |
| /** | |
| * Register a reusable prompt template. | |
| * | |
| * @param string $name Prompt name | |
| * @param string $description What this prompt does | |
| * @param array $arguments Array of argument definitions: | |
| * [['name' => 'topic', 'description' => '...', 'required' => true]] | |
| * @param callable $handler function(array $args): array — must return an array of message objects: | |
| * [['role' => 'user', 'content' => ['type' => 'text', 'text' => '...']]] | |
| * @return self Fluent | |
| */ | |
| public function prompt(string $name, string $description, array $arguments, callable $handler): self | |
| { | |
| $this->validateName($name, 'Prompt'); | |
| $this->prompts[$name] = [ | |
| 'name' => $name, | |
| 'description' => $description, | |
| 'arguments' => $arguments, | |
| 'handler' => $handler, | |
| ]; | |
| return $this; | |
| } | |
| // ─── Registration: Custom Notifications ────────────────────────────────────── | |
| /** | |
| * Register a handler for a custom notification method. | |
| * | |
| * @param string $method Notification method name (e.g. "notifications/cancelled") | |
| * @param callable $handler function(array|null $params): void | |
| * @return self Fluent | |
| */ | |
| public function onNotification(string $method, callable $handler): self | |
| { | |
| $this->notificationHandlers[$method] = $handler; | |
| return $this; | |
| } | |
| // ─── Configuration ─────────────────────────────────────────────────────────── | |
| /** | |
| * Set log verbosity. | |
| * | |
| * @param int $level 0=silent, 1=error, 2=info, 3=debug | |
| * @return self Fluent | |
| */ | |
| public function setLogLevel(int $level): self | |
| { | |
| $this->logLevel = max(0, min(3, $level)); | |
| return $this; | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // MAIN EVENT LOOP | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Start the server. Reads JSON-RPC messages from stdin, writes responses to stdout. | |
| * Blocks until stdin closes or a shutdown is requested. | |
| */ | |
| public function run(): void | |
| { | |
| $this->stdin = defined('STDIN') ? STDIN : fopen('php://stdin', 'r'); | |
| $this->stdout = defined('STDOUT') ? STDOUT : fopen('php://stdout', 'w'); | |
| $this->running = true; | |
| $this->log(2, "Server '{$this->name}' v{$this->version} starting on stdio..."); | |
| $buffer = ''; | |
| while ($this->running && !feof($this->stdin)) { | |
| $line = fgets($this->stdin); | |
| if ($line === false) { | |
| break; // EOF or stream error | |
| } | |
| $line = trim($line); | |
| if ($line === '') { | |
| continue; // Skip blank lines | |
| } | |
| $buffer .= $line; | |
| // Attempt to decode — JSON-RPC messages are single-line JSON objects | |
| $message = @json_decode($buffer, true); | |
| if ($message === null && json_last_error() !== JSON_ERROR_NONE) { | |
| // Could be incomplete — but MCP stdio sends one JSON object per line, | |
| // so if we got a full line and it doesn't parse, it's an error. | |
| $this->sendError(null, self::ERR_PARSE, 'Parse error: ' . json_last_error_msg()); | |
| $buffer = ''; | |
| continue; | |
| } | |
| $buffer = ''; | |
| $this->handleMessage($message); | |
| } | |
| $this->log(2, 'Server shutting down.'); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // MESSAGE DISPATCH | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function handleMessage(array $message): void | |
| { | |
| // Validate JSON-RPC 2.0 envelope | |
| if (($message['jsonrpc'] ?? '') !== self::JSONRPC_VERSION) { | |
| $this->sendError($message['id'] ?? null, self::ERR_INVALID_REQUEST, 'Missing or invalid "jsonrpc" field. Must be "2.0".'); | |
| return; | |
| } | |
| $method = $message['method'] ?? null; | |
| $params = $message['params'] ?? []; | |
| $id = $message['id'] ?? null; | |
| // Notification (no id) vs Request (has id) | |
| $isNotification = !array_key_exists('id', $message); | |
| if ($method === null) { | |
| if (!$isNotification) { | |
| $this->sendError($id, self::ERR_INVALID_REQUEST, 'Missing "method" field.'); | |
| } | |
| return; | |
| } | |
| $this->log(3, "← {$method}" . ($isNotification ? ' [notification]' : " [id={$id}]")); | |
| // ── Notifications (no response expected) ──────────────────────────────── | |
| if ($isNotification) { | |
| $this->handleNotification($method, $params); | |
| return; | |
| } | |
| // ── Requests (response required) ──────────────────────────────────────── | |
| // The initialize method is special — allowed before initialized state | |
| if ($method === 'initialize') { | |
| $this->handleInitialize($id, $params); | |
| return; | |
| } | |
| // Everything else requires initialization | |
| if (!$this->initialized) { | |
| $this->sendError($id, self::ERR_NOT_INITIALIZED, 'Server not initialized. Send "initialize" first.'); | |
| return; | |
| } | |
| switch ($method) { | |
| case 'ping': | |
| $this->sendResult($id, new \stdClass()); | |
| break; | |
| case 'tools/list': | |
| $this->handleToolsList($id, $params); | |
| break; | |
| case 'tools/call': | |
| $this->handleToolsCall($id, $params); | |
| break; | |
| case 'resources/list': | |
| $this->handleResourcesList($id, $params); | |
| break; | |
| case 'resources/read': | |
| $this->handleResourcesRead($id, $params); | |
| break; | |
| case 'prompts/list': | |
| $this->handlePromptsList($id, $params); | |
| break; | |
| case 'prompts/get': | |
| $this->handlePromptsGet($id, $params); | |
| break; | |
| default: | |
| $this->sendError($id, self::ERR_METHOD_NOT_FOUND, "Method not found: {$method}"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // LIFECYCLE | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function handleInitialize($id, array $params): void | |
| { | |
| if ($this->initialized) { | |
| $this->sendError($id, self::ERR_ALREADY_INITIALIZED, 'Server already initialized.'); | |
| return; | |
| } | |
| // Record client info | |
| $clientInfo = $params['clientInfo'] ?? []; | |
| $this->clientName = $clientInfo['name'] ?? 'unknown'; | |
| $this->clientVersion = $clientInfo['version'] ?? 'unknown'; | |
| // Version negotiation: we support our version; client tells us theirs | |
| $requestedVersion = $params['protocolVersion'] ?? ''; | |
| $this->negotiatedVersion = self::PROTOCOL_VERSION; | |
| $this->log(2, "Client: {$this->clientName} v{$this->clientVersion}, requested protocol: {$requestedVersion}"); | |
| // Build capabilities based on what's registered | |
| $capabilities = new \stdClass(); | |
| if (!empty($this->tools)) { | |
| $capabilities->tools = (object)['listChanged' => true]; | |
| } | |
| if (!empty($this->resources)) { | |
| $capabilities->resources = (object)['subscribe' => false, 'listChanged' => true]; | |
| } | |
| if (!empty($this->prompts)) { | |
| $capabilities->prompts = (object)['listChanged' => true]; | |
| } | |
| $result = [ | |
| 'protocolVersion' => $this->negotiatedVersion, | |
| 'capabilities' => $capabilities, | |
| 'serverInfo' => [ | |
| 'name' => $this->name, | |
| 'version' => $this->version, | |
| ], | |
| ]; | |
| if ($this->description !== '') { | |
| $result['serverInfo']['description'] = $this->description; | |
| } | |
| $this->sendResult($id, $result); | |
| $this->initialized = true; | |
| } | |
| private function handleNotification(string $method, array $params): void | |
| { | |
| switch ($method) { | |
| case 'notifications/initialized': | |
| $this->log(2, 'Client confirmed initialization.'); | |
| break; | |
| case 'notifications/cancelled': | |
| $this->log(2, 'Client cancelled request: ' . json_encode($params)); | |
| break; | |
| } | |
| // Custom notification handlers | |
| if (isset($this->notificationHandlers[$method])) { | |
| try { | |
| ($this->notificationHandlers[$method])($params); | |
| } catch (\Throwable $e) { | |
| $this->log(1, "Notification handler error [{$method}]: {$e->getMessage()}"); | |
| } | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // TOOLS | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function handleToolsList($id, array $params): void | |
| { | |
| $cursor = $params['cursor'] ?? null; | |
| $tools = []; | |
| foreach ($this->tools as $name => $def) { | |
| $toolEntry = [ | |
| 'name' => $name, | |
| 'description' => $def['description'], | |
| 'inputSchema' => $def['inputSchema'], | |
| 'annotations' => $def['annotations'], | |
| ]; | |
| if ($def['outputSchema'] !== null) { | |
| $toolEntry['outputSchema'] = $def['outputSchema']; | |
| } | |
| $tools[] = $toolEntry; | |
| } | |
| $this->sendResult($id, ['tools' => $tools]); | |
| } | |
| private function handleToolsCall($id, array $params): void | |
| { | |
| $toolName = $params['name'] ?? ''; | |
| $arguments = $params['arguments'] ?? []; | |
| if (!isset($this->tools[$toolName])) { | |
| $this->sendError($id, self::ERR_INVALID_PARAMS, "Unknown tool: '{$toolName}'. Available: " . implode(', ', array_keys($this->tools))); | |
| return; | |
| } | |
| $tool = $this->tools[$toolName]; | |
| try { | |
| // ── Input Validation ──────────────────────────────────────────────── | |
| $validationError = $this->validateArguments($arguments, $tool['inputSchema']); | |
| if ($validationError !== null) { | |
| $this->sendResult($id, [ | |
| 'content' => [['type' => 'text', 'text' => "Validation error: {$validationError}"]], | |
| 'isError' => true, | |
| ]); | |
| return; | |
| } | |
| // ── Sanitize string inputs ────────────────────────────────────────── | |
| $arguments = $this->sanitizeArguments($arguments, $tool['inputSchema']['properties'] ?? []); | |
| // ── Execute ───────────────────────────────────────────────────────── | |
| $result = ($tool['handler'])($arguments); | |
| $content = $this->normalizeToolResult($result); | |
| $this->sendResult($id, ['content' => $content]); | |
| } catch (\InvalidArgumentException $e) { | |
| $this->sendResult($id, [ | |
| 'content' => [['type' => 'text', 'text' => "Invalid input: {$e->getMessage()}"]], | |
| 'isError' => true, | |
| ]); | |
| } catch (\Throwable $e) { | |
| $this->log(1, "Tool '{$toolName}' error: {$e->getMessage()}"); | |
| $this->sendResult($id, [ | |
| 'content' => [['type' => 'text', 'text' => "Tool execution failed: {$e->getMessage()}"]], | |
| 'isError' => true, | |
| ]); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // RESOURCES | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function handleResourcesList($id, array $params): void | |
| { | |
| $resources = []; | |
| foreach ($this->resources as $uri => $def) { | |
| $resources[] = [ | |
| 'uri' => $def['uri'], | |
| 'name' => $def['name'], | |
| 'description' => $def['description'], | |
| 'mimeType' => $def['mimeType'], | |
| ]; | |
| } | |
| $this->sendResult($id, ['resources' => $resources]); | |
| } | |
| private function handleResourcesRead($id, array $params): void | |
| { | |
| $uri = $params['uri'] ?? ''; | |
| if (!isset($this->resources[$uri])) { | |
| $this->sendError($id, self::ERR_INVALID_PARAMS, "Unknown resource URI: '{$uri}'"); | |
| return; | |
| } | |
| $resource = $this->resources[$uri]; | |
| try { | |
| $content = ($resource['handler'])(); | |
| // Determine if binary (base64) or text | |
| $isBinary = $this->isBinaryMime($resource['mimeType']); | |
| $contentEntry = ['uri' => $uri, 'mimeType' => $resource['mimeType']]; | |
| if ($isBinary) { | |
| $contentEntry['blob'] = is_string($content) ? base64_encode($content) : base64_encode(json_encode($content)); | |
| } else { | |
| $contentEntry['text'] = is_string($content) ? $content : json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); | |
| } | |
| $this->sendResult($id, ['contents' => [$contentEntry]]); | |
| } catch (\Throwable $e) { | |
| $this->log(1, "Resource '{$uri}' error: {$e->getMessage()}"); | |
| $this->sendError($id, self::ERR_INTERNAL, "Failed to read resource: {$e->getMessage()}"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // PROMPTS | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function handlePromptsList($id, array $params): void | |
| { | |
| $prompts = []; | |
| foreach ($this->prompts as $name => $def) { | |
| $prompts[] = [ | |
| 'name' => $def['name'], | |
| 'description' => $def['description'], | |
| 'arguments' => $def['arguments'], | |
| ]; | |
| } | |
| $this->sendResult($id, ['prompts' => $prompts]); | |
| } | |
| private function handlePromptsGet($id, array $params): void | |
| { | |
| $promptName = $params['name'] ?? ''; | |
| $arguments = $params['arguments'] ?? []; | |
| if (!isset($this->prompts[$promptName])) { | |
| $this->sendError($id, self::ERR_INVALID_PARAMS, "Unknown prompt: '{$promptName}'"); | |
| return; | |
| } | |
| $prompt = $this->prompts[$promptName]; | |
| // Validate required arguments | |
| foreach ($prompt['arguments'] as $argDef) { | |
| if (($argDef['required'] ?? false) && !isset($arguments[$argDef['name']])) { | |
| $this->sendError($id, self::ERR_INVALID_PARAMS, "Missing required argument: '{$argDef['name']}'"); | |
| return; | |
| } | |
| } | |
| try { | |
| $messages = ($prompt['handler'])($arguments); | |
| // Normalize: ensure each message has proper structure | |
| $normalized = array_map(function ($msg) { | |
| if (is_string($msg)) { | |
| return ['role' => 'user', 'content' => ['type' => 'text', 'text' => $msg]]; | |
| } | |
| // Ensure content is proper | |
| if (isset($msg['content']) && is_string($msg['content'])) { | |
| $msg['content'] = ['type' => 'text', 'text' => $msg['content']]; | |
| } | |
| return $msg; | |
| }, $messages); | |
| $this->sendResult($id, [ | |
| 'description' => $prompt['description'], | |
| 'messages' => $normalized, | |
| ]); | |
| } catch (\Throwable $e) { | |
| $this->log(1, "Prompt '{$promptName}' error: {$e->getMessage()}"); | |
| $this->sendError($id, self::ERR_INTERNAL, "Prompt generation failed: {$e->getMessage()}"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // VALIDATION & SANITIZATION | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Validate arguments against the tool's JSON Schema (lightweight, no external lib). | |
| * Returns null if valid, or an error message string. | |
| */ | |
| private function validateArguments(array $arguments, array $schema): ?string | |
| { | |
| // Check required fields | |
| $required = $schema['required'] ?? []; | |
| foreach ($required as $field) { | |
| if (!array_key_exists($field, $arguments)) { | |
| return "Missing required parameter: '{$field}'"; | |
| } | |
| } | |
| $properties = $schema['properties'] ?? []; | |
| foreach ($arguments as $key => $value) { | |
| if (!isset($properties[$key])) { | |
| // Extra parameters are tolerated (open-world), but logged | |
| $this->log(3, "Unexpected parameter '{$key}' — ignoring."); | |
| continue; | |
| } | |
| $propSchema = $properties[$key]; | |
| $typeError = $this->validateType($value, $propSchema, $key); | |
| if ($typeError !== null) { | |
| return $typeError; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Validate a single value against its JSON Schema type definition. | |
| */ | |
| private function validateType($value, array $schema, string $path): ?string | |
| { | |
| $type = $schema['type'] ?? null; | |
| if ($type === null) { | |
| return null; // No type constraint | |
| } | |
| // Handle nullable / union types | |
| if (is_array($type)) { | |
| foreach ($type as $t) { | |
| if ($this->matchesType($value, $t)) { | |
| return null; | |
| } | |
| } | |
| return "Parameter '{$path}' must be one of types: " . implode(', ', $type); | |
| } | |
| if (!$this->matchesType($value, $type)) { | |
| return "Parameter '{$path}' must be of type '{$type}', got " . gettype($value); | |
| } | |
| // Enum validation | |
| if (isset($schema['enum']) && !in_array($value, $schema['enum'], true)) { | |
| return "Parameter '{$path}' must be one of: " . implode(', ', $schema['enum']); | |
| } | |
| // String constraints | |
| if ($type === 'string' && is_string($value)) { | |
| $len = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); | |
| if (isset($schema['minLength']) && $len < $schema['minLength']) { | |
| return "Parameter '{$path}' must be at least {$schema['minLength']} characters"; | |
| } | |
| if (isset($schema['maxLength']) && $len > $schema['maxLength']) { | |
| return "Parameter '{$path}' must be at most {$schema['maxLength']} characters"; | |
| } | |
| if (isset($schema['pattern']) && !preg_match('/' . $schema['pattern'] . '/', $value)) { | |
| return "Parameter '{$path}' does not match required pattern"; | |
| } | |
| } | |
| // Numeric constraints | |
| if (($type === 'integer' || $type === 'number') && is_numeric($value)) { | |
| if (isset($schema['minimum']) && $value < $schema['minimum']) { | |
| return "Parameter '{$path}' must be >= {$schema['minimum']}"; | |
| } | |
| if (isset($schema['maximum']) && $value > $schema['maximum']) { | |
| return "Parameter '{$path}' must be <= {$schema['maximum']}"; | |
| } | |
| } | |
| // Array constraints | |
| if ($type === 'array' && is_array($value)) { | |
| if (isset($schema['minItems']) && count($value) < $schema['minItems']) { | |
| return "Parameter '{$path}' must have at least {$schema['minItems']} items"; | |
| } | |
| if (isset($schema['maxItems']) && count($value) > $schema['maxItems']) { | |
| return "Parameter '{$path}' must have at most {$schema['maxItems']} items"; | |
| } | |
| // Validate items | |
| if (isset($schema['items'])) { | |
| foreach ($value as $i => $item) { | |
| $itemError = $this->validateType($item, $schema['items'], "{$path}[{$i}]"); | |
| if ($itemError !== null) { | |
| return $itemError; | |
| } | |
| } | |
| } | |
| } | |
| 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) && $this->isList($value); | |
| case 'object': | |
| return is_array($value) && !$this->isList($value); | |
| case 'null': | |
| return $value === null; | |
| default: | |
| return true; | |
| } | |
| } | |
| /** | |
| * PHP 7-compatible replacement for array_is_list() (introduced in PHP 8.1). | |
| */ | |
| private function isList(array $value): bool | |
| { | |
| $expectedKey = 0; | |
| foreach ($value as $key => $unused) { | |
| if ($key !== $expectedKey) { | |
| return false; | |
| } | |
| $expectedKey++; | |
| } | |
| return true; | |
| } | |
| /** | |
| * Sanitize string arguments against common injection vectors. | |
| */ | |
| private function sanitizeArguments(array $arguments, array $properties): array | |
| { | |
| foreach ($arguments as $key => &$value) { | |
| if (!is_string($value)) { | |
| continue; | |
| } | |
| $propSchema = $properties[$key] ?? []; | |
| $format = $propSchema['format'] ?? null; | |
| // Path traversal protection for file-path-like arguments | |
| if ($format === 'file-path' || strpos(strtolower($key), 'path') !== false || strpos(strtolower($key), 'file') !== false) { | |
| $value = $this->sanitizePath($value); | |
| } | |
| // Null byte removal (always) | |
| $value = str_replace("\0", '', $value); | |
| } | |
| return $arguments; | |
| } | |
| /** | |
| * Sanitize a file path: resolve traversal sequences, block dangerous patterns. | |
| */ | |
| private function sanitizePath(string $path): string | |
| { | |
| // Remove null bytes | |
| $path = str_replace("\0", '', $path); | |
| // Normalize directory separators | |
| $path = str_replace('\\', '/', $path); | |
| // Collapse multiple slashes | |
| $path = (string)preg_replace('#/+#', '/', $path); | |
| // Remove path traversal sequences | |
| $parts = explode('/', $path); | |
| $resolved = []; | |
| foreach ($parts as $part) { | |
| if ($part === '..') { | |
| array_pop($resolved); | |
| } elseif ($part !== '.' && $part !== '') { | |
| $resolved[] = $part; | |
| } | |
| } | |
| $clean = implode('/', $resolved); | |
| // Preserve leading slash if original had one | |
| if (strpos($path, '/') === 0) { | |
| $clean = '/' . $clean; | |
| } | |
| return $clean; | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // TRANSPORT (JSON-RPC over stdio) | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| private function sendResult($id, $result): void | |
| { | |
| $response = [ | |
| 'jsonrpc' => self::JSONRPC_VERSION, | |
| 'id' => $id, | |
| 'result' => $result, | |
| ]; | |
| $this->write($response); | |
| } | |
| private function sendError($id, int $code, string $message, $data = null): void | |
| { | |
| $error = [ | |
| 'code' => $code, | |
| 'message' => $message, | |
| ]; | |
| if ($data !== null) { | |
| $error['data'] = $data; | |
| } | |
| $response = [ | |
| 'jsonrpc' => self::JSONRPC_VERSION, | |
| 'id' => $id, | |
| 'error' => $error, | |
| ]; | |
| $this->write($response); | |
| } | |
| /** | |
| * Send a server-initiated notification (no id, no response expected). | |
| */ | |
| public function notify(string $method, array $params = []): void | |
| { | |
| $message = [ | |
| 'jsonrpc' => self::JSONRPC_VERSION, | |
| 'method' => $method, | |
| ]; | |
| if (!empty($params)) { | |
| $message['params'] = $params; | |
| } | |
| $this->write($message); | |
| } | |
| private function write(array $message): void | |
| { | |
| $json = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); | |
| if ($json === false) { | |
| $this->log(1, 'JSON encode error: ' . json_last_error_msg()); | |
| return; | |
| } | |
| fwrite($this->stdout, $json . "\n"); | |
| fflush($this->stdout); | |
| $method = $message['method'] ?? ($message['error'] ?? null ? 'error' : 'result'); | |
| $this->log(3, "→ {$method}"); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // HELPERS | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Normalize a tool handler's return value into MCP content blocks. | |
| */ | |
| private function normalizeToolResult($result): array | |
| { | |
| // Already a content array | |
| if (is_array($result) && isset($result[0]['type'])) { | |
| return $result; | |
| } | |
| // Single content block | |
| if (is_array($result) && isset($result['type'])) { | |
| return [$result]; | |
| } | |
| // String → text content | |
| if (is_string($result)) { | |
| return [['type' => 'text', 'text' => $result]]; | |
| } | |
| // Anything else → JSON text | |
| return [['type' => 'text', 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)]]; | |
| } | |
| private function isBinaryMime(string $mime): bool | |
| { | |
| return strpos($mime, 'image/') === 0 | |
| || strpos($mime, 'audio/') === 0 | |
| || strpos($mime, 'video/') === 0 | |
| || $mime === 'application/octet-stream' | |
| || $mime === 'application/pdf'; | |
| } | |
| private function validateName(string $name, string $kind): void | |
| { | |
| if (empty($name)) { | |
| throw new \InvalidArgumentException("{$kind} name must not be empty."); | |
| } | |
| if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_\-]*$/', $name)) { | |
| throw new \InvalidArgumentException("{$kind} name '{$name}' contains invalid characters. Use alphanumeric, underscore, or hyphen."); | |
| } | |
| } | |
| /** | |
| * Log to stderr (never to stdout — that's the transport channel). | |
| */ | |
| private function log(int $level, string $message): void | |
| { | |
| if ($level > $this->logLevel) { | |
| return; | |
| } | |
| switch ($level) { | |
| case 1: | |
| $prefix = 'ERROR'; | |
| break; | |
| case 2: | |
| $prefix = 'INFO'; | |
| break; | |
| case 3: | |
| $prefix = 'DEBUG'; | |
| break; | |
| default: | |
| $prefix = 'LOG'; | |
| } | |
| $timestamp = date('Y-m-d H:i:s'); | |
| fwrite(STDERR, "[{$timestamp}] [{$prefix}] [{$this->name}] {$message}\n"); | |
| } | |
| /** | |
| * Gracefully stop the event loop. | |
| */ | |
| public function shutdown(): void | |
| { | |
| $this->running = false; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment