feat(api): implement MCP resource reads
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
6ef194754e
commit
3ead3fed2b
2 changed files with 240 additions and 14 deletions
|
|
@ -413,6 +413,8 @@ class McpApiController extends Controller
|
|||
*/
|
||||
public function resource(Request $request, string $uri): JsonResponse
|
||||
{
|
||||
$uri = rawurldecode($uri);
|
||||
|
||||
// Parse URI format: server://resource/path
|
||||
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||
return $this->validationErrorResponse([
|
||||
|
|
@ -428,12 +430,35 @@ class McpApiController extends Controller
|
|||
return $this->notFoundResponse('Server');
|
||||
}
|
||||
|
||||
$resourceDef = $this->findResourceDefinition($server, $uri, $resourcePath);
|
||||
if ($resourceDef !== null && $this->resourceDefinitionHasContent($resourceDef)) {
|
||||
return response()->json([
|
||||
'uri' => $uri,
|
||||
'server' => $serverId,
|
||||
'resource' => $resourcePath,
|
||||
'content' => $this->normaliseResourceContent($resourceDef),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
||||
if ($result === null) {
|
||||
return $this->notFoundResponse('Resource');
|
||||
}
|
||||
|
||||
if (is_array($result) && array_key_exists('content', $result)) {
|
||||
$content = $result['content'];
|
||||
} elseif (is_array($result) && array_key_exists('contents', $result)) {
|
||||
$content = $result['contents'];
|
||||
} else {
|
||||
$content = $result;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'uri' => $uri,
|
||||
'content' => $result,
|
||||
'server' => $serverId,
|
||||
'resource' => $resourcePath,
|
||||
'content' => $content,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->errorResponse(
|
||||
|
|
@ -452,16 +477,7 @@ class McpApiController extends Controller
|
|||
*/
|
||||
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
|
||||
{
|
||||
$commandMap = [
|
||||
'hosthub-agent' => 'mcp:agent-server',
|
||||
'socialhost' => 'mcp:socialhost-server',
|
||||
'biohost' => 'mcp:biohost-server',
|
||||
'commerce' => 'mcp:commerce-server',
|
||||
'supporthost' => 'mcp:support-server',
|
||||
'upstream' => 'mcp:upstream-server',
|
||||
];
|
||||
|
||||
$command = $commandMap[$server] ?? null;
|
||||
$command = $this->resolveMcpServerCommand($server);
|
||||
if (! $command) {
|
||||
throw new \RuntimeException("Unknown server: {$server}");
|
||||
}
|
||||
|
|
@ -516,9 +532,130 @@ class McpApiController extends Controller
|
|||
*/
|
||||
protected function readResourceViaArtisan(string $server, string $path): mixed
|
||||
{
|
||||
// Similar to executeToolViaArtisan but with resources/read method
|
||||
// Simplified for now - can expand later
|
||||
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
||||
$command = $this->resolveMcpServerCommand($server);
|
||||
if (! $command) {
|
||||
throw new \RuntimeException("Unknown server: {$server}");
|
||||
}
|
||||
|
||||
$mcpRequest = [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => uniqid(),
|
||||
'method' => 'resources/read',
|
||||
'params' => [
|
||||
'uri' => "{$server}://{$path}",
|
||||
'path' => $path,
|
||||
],
|
||||
];
|
||||
|
||||
$process = proc_open(
|
||||
['php', 'artisan', $command],
|
||||
[
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
base_path()
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new \RuntimeException('Failed to start MCP server process');
|
||||
}
|
||||
|
||||
fwrite($pipes[0], json_encode($mcpRequest)."\n");
|
||||
fclose($pipes[0]);
|
||||
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
proc_close($process);
|
||||
|
||||
$response = json_decode($output, true);
|
||||
if (! is_array($response)) {
|
||||
throw new \RuntimeException('Invalid MCP resource response');
|
||||
}
|
||||
|
||||
if (isset($response['error'])) {
|
||||
throw new \RuntimeException($response['error']['message'] ?? 'Resource read failed');
|
||||
}
|
||||
|
||||
return $response['result'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the artisan command used for a given MCP server.
|
||||
*/
|
||||
protected function resolveMcpServerCommand(string $server): ?string
|
||||
{
|
||||
$commandMap = [
|
||||
'hosthub-agent' => 'mcp:agent-server',
|
||||
'socialhost' => 'mcp:socialhost-server',
|
||||
'biohost' => 'mcp:biohost-server',
|
||||
'commerce' => 'mcp:commerce-server',
|
||||
'supporthost' => 'mcp:support-server',
|
||||
'upstream' => 'mcp:upstream-server',
|
||||
];
|
||||
|
||||
return $commandMap[$server] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a resource definition within the loaded server config.
|
||||
*/
|
||||
protected function findResourceDefinition(array $server, string $uri, string $path): mixed
|
||||
{
|
||||
foreach ($server['resources'] ?? [] as $resource) {
|
||||
if (! is_array($resource)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resourceUri = $resource['uri'] ?? null;
|
||||
$resourcePath = $resource['path'] ?? null;
|
||||
$resourceName = $resource['name'] ?? null;
|
||||
|
||||
if ($resourceUri === $uri || $resourcePath === $path || $resourceName === basename($path)) {
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a resource definition into a response payload.
|
||||
*/
|
||||
protected function normaliseResourceContent(mixed $resource): mixed
|
||||
{
|
||||
if (! is_array($resource)) {
|
||||
return $resource;
|
||||
}
|
||||
|
||||
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
|
||||
if (array_key_exists($field, $resource)) {
|
||||
return $resource[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a resource definition already carries readable content.
|
||||
*/
|
||||
protected function resourceDefinitionHasContent(mixed $resource): bool
|
||||
{
|
||||
if (! is_array($resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
|
||||
if (array_key_exists($field, $resource)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
89
src/php/src/Api/Tests/Feature/McpResourceTest.php
Normal file
89
src/php/src/Api/Tests/Feature/McpResourceTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'MCP Resource Key',
|
||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||
);
|
||||
|
||||
$this->plainKey = $result['plain_key'];
|
||||
|
||||
$this->serverId = 'test-resource-server';
|
||||
$this->serverDir = resource_path('mcp/servers');
|
||||
$this->serverFile = $this->serverDir.'/'.$this->serverId.'.yaml';
|
||||
|
||||
if (! is_dir($this->serverDir)) {
|
||||
mkdir($this->serverDir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($this->serverFile, <<<YAML
|
||||
id: test-resource-server
|
||||
name: Test Resource Server
|
||||
status: available
|
||||
resources:
|
||||
- uri: test-resource-server://documents/welcome
|
||||
path: documents/welcome
|
||||
name: welcome
|
||||
content:
|
||||
message: Hello from the MCP resource bridge
|
||||
version: 1
|
||||
YAML);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
if (isset($this->serverFile) && is_file($this->serverFile)) {
|
||||
unlink($this->serverFile);
|
||||
}
|
||||
|
||||
if (isset($this->serverDir) && is_dir($this->serverDir)) {
|
||||
@rmdir($this->serverDir);
|
||||
}
|
||||
|
||||
$mcpDir = dirname($this->serverDir ?? '');
|
||||
if (is_dir($mcpDir)) {
|
||||
@rmdir($mcpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a resource from the server definition', function () {
|
||||
$encodedUri = rawurlencode('test-resource-server://documents/welcome');
|
||||
|
||||
$response = $this->getJson("/api/mcp/resources/{$encodedUri}", [
|
||||
'Authorization' => "Bearer {$this->plainKey}",
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'uri' => 'test-resource-server://documents/welcome',
|
||||
'server' => 'test-resource-server',
|
||||
'resource' => 'documents/welcome',
|
||||
]);
|
||||
|
||||
expect($response->json('content'))->toBe([
|
||||
'message' => 'Hello from the MCP resource bridge',
|
||||
'version' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
Loading…
Add table
Reference in a new issue