194 lines
5.5 KiB
PHP
194 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace RedMCP;
|
|
|
|
final class McpHttpHandler
|
|
{
|
|
private McpDispatcher $dispatcher;
|
|
private string $token;
|
|
private string $path;
|
|
|
|
public function __construct(McpDispatcher $dispatcher, string $token, string $path = '/mcp')
|
|
{
|
|
$this->dispatcher = $dispatcher;
|
|
$this->token = $token;
|
|
$this->path = '/' . trim($path, '/');
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== $this->path) {
|
|
$this->sendJson(404, ['error' => 'not found']);
|
|
return;
|
|
}
|
|
if (!$this->originAllowed()) {
|
|
$this->sendJson(403, ['error' => 'origin not allowed']);
|
|
return;
|
|
}
|
|
if (!$this->authorized()) {
|
|
header('WWW-Authenticate: Bearer');
|
|
$this->sendJson(401, ['error' => 'unauthorized']);
|
|
return;
|
|
}
|
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
|
|
header('Allow: POST');
|
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
|
return;
|
|
}
|
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
|
header('Allow: POST');
|
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
|
return;
|
|
}
|
|
|
|
$raw = file_get_contents('php://input');
|
|
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
|
if (!is_array($decoded)) {
|
|
$this->sendJson(400, $this->errorResponse(null, -32700, 'Invalid JSON.'));
|
|
return;
|
|
}
|
|
|
|
if (array_is_list($decoded)) {
|
|
$responses = [];
|
|
foreach ($decoded as $message) {
|
|
if (!is_array($message)) {
|
|
$responses[] = $this->errorResponse(null, -32600, 'Invalid request.');
|
|
continue;
|
|
}
|
|
$response = $this->dispatcher->handleMessage($message, $this->logContext());
|
|
if ($response !== null) {
|
|
$responses[] = $response;
|
|
}
|
|
}
|
|
if ($responses === []) {
|
|
http_response_code(202);
|
|
return;
|
|
}
|
|
if ($this->acceptsEventStream()) {
|
|
$this->sendEventStream($responses);
|
|
return;
|
|
}
|
|
$this->sendJson(200, $responses);
|
|
return;
|
|
}
|
|
|
|
$response = $this->dispatcher->handleMessage($decoded, $this->logContext());
|
|
if ($response === null) {
|
|
http_response_code(202);
|
|
return;
|
|
}
|
|
|
|
if ($this->acceptsEventStream()) {
|
|
$this->sendEventStream([$response]);
|
|
return;
|
|
}
|
|
$this->sendJson(200, $response);
|
|
}
|
|
|
|
private function authorized(): bool
|
|
{
|
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
|
if (!is_string($header) || !str_starts_with($header, 'Bearer ')) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($this->token, substr($header, 7));
|
|
}
|
|
|
|
private function originAllowed(): bool
|
|
{
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
if (!is_string($origin) || trim($origin) === '') {
|
|
return true;
|
|
}
|
|
|
|
$origin = trim($origin);
|
|
foreach ($this->allowedOrigins() as $allowedOrigin) {
|
|
if (hash_equals($allowedOrigin, $origin)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$host = parse_url($origin, PHP_URL_HOST);
|
|
return is_string($host) && in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true);
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
private function allowedOrigins(): array
|
|
{
|
|
$raw = getenv('MCP_ALLOWED_ORIGINS') ?: '';
|
|
if (!is_string($raw) || trim($raw) === '') {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
|
}
|
|
|
|
private function acceptsEventStream(): bool
|
|
{
|
|
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
|
return is_string($accept) && stripos($accept, 'text/event-stream') !== false;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function logContext(): array
|
|
{
|
|
return [
|
|
'transport' => 'http',
|
|
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param mixed $payload
|
|
*/
|
|
private function sendJson(int $status, $payload): void
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $messages
|
|
*/
|
|
private function sendEventStream(array $messages): void
|
|
{
|
|
http_response_code(200);
|
|
header('Content-Type: text/event-stream');
|
|
header('Cache-Control: no-cache');
|
|
header('X-Accel-Buffering: no');
|
|
|
|
foreach ($messages as $message) {
|
|
$encoded = json_encode($message, JSON_UNESCAPED_SLASHES);
|
|
if ($encoded === false) {
|
|
continue;
|
|
}
|
|
echo "event: message\n";
|
|
echo 'data: ' . $encoded . "\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function errorResponse(mixed $id, int $code, string $message): array
|
|
{
|
|
return [
|
|
'jsonrpc' => '2.0',
|
|
'id' => $id,
|
|
'error' => [
|
|
'code' => $code,
|
|
'message' => $message,
|
|
],
|
|
];
|
|
}
|
|
}
|