165 lines
5.3 KiB
PHP
Executable File
165 lines
5.3 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
$repoRoot = dirname(__DIR__);
|
|
$tmpDir = sys_get_temp_dir() . '/redmcp-http-test-' . getmypid();
|
|
if (!mkdir($tmpDir, 0775, true) && !is_dir($tmpDir)) {
|
|
throw new RuntimeException('Could not create temp test dir.');
|
|
}
|
|
|
|
$router = $tmpDir . '/router.php';
|
|
file_put_contents($router, <<<'PHP'
|
|
<?php
|
|
declare(strict_types=1);
|
|
|
|
use RedMCP\McpDispatcher;
|
|
use RedMCP\McpHttpHandler;
|
|
use RedMCP\RedmineClient;
|
|
|
|
require '%AUTOLOAD%';
|
|
|
|
$handler = new McpHttpHandler(
|
|
new McpDispatcher(RedmineClient::fromCredentials('http://127.0.0.1', 'test-key')),
|
|
'test-token',
|
|
'/mcp'
|
|
);
|
|
$handler->handle();
|
|
PHP);
|
|
file_put_contents($router, str_replace('%AUTOLOAD%', addslashes($repoRoot . '/vendor/autoload.php'), (string) file_get_contents($router)));
|
|
|
|
$port = 18765 + (getmypid() % 1000);
|
|
$command = [PHP_BINARY, '-S', '127.0.0.1:' . $port, $router];
|
|
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $repoRoot);
|
|
if (!is_resource($process)) {
|
|
throw new RuntimeException('Could not start PHP built-in server.');
|
|
}
|
|
|
|
$assertions = 0;
|
|
try {
|
|
waitForServer($port);
|
|
|
|
$sse = httpRequest(
|
|
'POST',
|
|
'http://127.0.0.1:' . $port . '/mcp',
|
|
[
|
|
'Authorization: Bearer test-token',
|
|
'Content-Type: application/json',
|
|
'Accept: application/json, text/event-stream',
|
|
],
|
|
'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'
|
|
);
|
|
assertContains('HTTP/1.1 200 OK', $sse['headers'], 'SSE POST returns 200', $assertions);
|
|
assertContains('text/event-stream', $sse['headers'], 'SSE POST returns event stream content type', $assertions);
|
|
assertContains('X-Accel-Buffering: no', $sse['headers'], 'SSE POST disables proxy buffering', $assertions);
|
|
assertContains("event: message\n", $sse['body'], 'SSE POST emits a message event', $assertions);
|
|
assertContains('data: {"jsonrpc":"2.0","id":1,"result":[]}', $sse['body'], 'SSE POST emits JSON-RPC response data', $assertions);
|
|
|
|
$json = httpRequest(
|
|
'POST',
|
|
'http://127.0.0.1:' . $port . '/mcp',
|
|
[
|
|
'Authorization: Bearer test-token',
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
],
|
|
'{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}'
|
|
);
|
|
assertContains('application/json', $json['headers'], 'JSON POST preserves application/json content type', $assertions);
|
|
assertContains('"id":2', $json['body'], 'JSON POST emits JSON-RPC response body', $assertions);
|
|
|
|
$get = httpRequest(
|
|
'GET',
|
|
'http://127.0.0.1:' . $port . '/mcp',
|
|
[
|
|
'Authorization: Bearer test-token',
|
|
'Accept: text/event-stream',
|
|
],
|
|
null
|
|
);
|
|
assertContains('HTTP/1.1 405 Method Not Allowed', $get['headers'], 'GET returns method-not-allowed until standalone streams exist', $assertions);
|
|
assertContains('Allow: POST', $get['headers'], 'GET advertises supported method', $assertions);
|
|
|
|
$origin = httpRequest(
|
|
'POST',
|
|
'http://127.0.0.1:' . $port . '/mcp',
|
|
[
|
|
'Authorization: Bearer test-token',
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
'Origin: https://example.invalid',
|
|
],
|
|
'{"jsonrpc":"2.0","id":3,"method":"ping","params":{}}'
|
|
);
|
|
assertContains('HTTP/1.1 403 Forbidden', $origin['headers'], 'disallowed browser origin returns forbidden', $assertions);
|
|
|
|
fwrite(STDOUT, "OK {$assertions} assertions\n");
|
|
} finally {
|
|
proc_terminate($process);
|
|
proc_close($process);
|
|
foreach ($pipes as $pipe) {
|
|
if (is_resource($pipe)) {
|
|
fclose($pipe);
|
|
}
|
|
}
|
|
@unlink($router);
|
|
@rmdir($tmpDir);
|
|
}
|
|
|
|
function waitForServer(int $port): void
|
|
{
|
|
$deadline = microtime(true) + 5;
|
|
while (microtime(true) < $deadline) {
|
|
$socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
|
|
if (is_resource($socket)) {
|
|
fclose($socket);
|
|
return;
|
|
}
|
|
usleep(100000);
|
|
}
|
|
|
|
throw new RuntimeException('Timed out waiting for test HTTP server.');
|
|
}
|
|
|
|
/**
|
|
* @param array<int,string> $headers
|
|
* @return array{headers:string,body:string}
|
|
*/
|
|
function httpRequest(string $method, string $url, array $headers, ?string $body): array
|
|
{
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Could not initialize curl.');
|
|
}
|
|
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
|
|
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($curl, CURLOPT_HEADER, true);
|
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
|
if ($body !== null) {
|
|
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
|
}
|
|
$raw = curl_exec($curl);
|
|
if (!is_string($raw)) {
|
|
throw new RuntimeException('curl failed: ' . curl_error($curl));
|
|
}
|
|
$headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
|
|
curl_close($curl);
|
|
|
|
return [
|
|
'headers' => substr($raw, 0, $headerSize),
|
|
'body' => substr($raw, $headerSize),
|
|
];
|
|
}
|
|
|
|
function assertContains(string $needle, string $haystack, string $message, int &$assertions): void
|
|
{
|
|
$assertions++;
|
|
if (strpos($haystack, $needle) !== false) {
|
|
return;
|
|
}
|
|
|
|
fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
|
|
exit(1);
|
|
}
|