Expand redMCP safe issue operations and HTTP handling
This commit is contained in:
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user