Skip to content

Instantly share code, notes, and snippets.

@md-riaz
Last active July 13, 2026 04:04
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 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