feat(mcp): RequestMediaUploadTool issues signed upload URLs

This commit is contained in:
Paulo Castellano 2026-05-15 16:19:03 -03:00
parent d50348562b
commit 7971571dc3
3 changed files with 121 additions and 0 deletions

View file

@ -20,6 +20,7 @@
use App\Mcp\Tools\Post\ListPostsTool;
use App\Mcp\Tools\Post\PreviewPostTool;
use App\Mcp\Tools\Post\PublishPostTool;
use App\Mcp\Tools\Post\RequestMediaUploadTool;
use App\Mcp\Tools\Post\UpdatePostTool;
use App\Mcp\Tools\Signature\CreateSignatureTool;
use App\Mcp\Tools\Signature\DeleteSignatureTool;
@ -50,6 +51,7 @@ class TryPostServer extends Server
PreviewPostTool::class,
DeletePostTool::class,
AttachMediaFromUrlTool::class,
RequestMediaUploadTool::class,
GetPostMetricsTool::class,
// Platforms (read-only metadata)

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image up to 10 MB, video up to 50 MB hard cap) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
class RequestMediaUploadTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$user = $request->user();
$workspaceId = $user->current_workspace_id;
$token = (string) Str::uuid();
$expiresAt = CarbonImmutable::now()->addMinutes(15);
$uploadUrl = URL::temporarySignedRoute(
'api.uploads.store',
$expiresAt,
['token' => $token, 'ws' => $workspaceId],
);
return Response::structured([
'upload_token' => $token,
'upload_url' => $uploadUrl,
'expires_at' => $expiresAt->toIso8601String(),
'max_bytes' => 52428800,
'field_name' => 'media',
]);
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\Post\RequestMediaUploadTool;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\URL;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('returns a single-use signed upload URL', function () {
$response = TryPostServer::actingAs($this->user)
->tool(RequestMediaUploadTool::class, []);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('upload_token')
->has('upload_url')
->has('expires_at')
->where('max_bytes', 52428800)
->where('field_name', 'media')
->etc();
});
});
test('signed URL is valid against the api.uploads.store route', function () {
$uploadUrl = null;
TryPostServer::actingAs($this->user)
->tool(RequestMediaUploadTool::class, [])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) use (&$uploadUrl) {
$json->etc();
$uploadUrl = $json->toArray()['upload_url'];
});
expect(URL::hasValidSignature(
request()->create($uploadUrl, 'POST'),
))->toBeTrue();
});
test('each call returns a distinct upload_token', function () {
$firstToken = null;
$secondToken = null;
TryPostServer::actingAs($this->user)
->tool(RequestMediaUploadTool::class, [])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) use (&$firstToken) {
$json->etc();
$firstToken = $json->toArray()['upload_token'];
});
TryPostServer::actingAs($this->user)
->tool(RequestMediaUploadTool::class, [])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) use (&$secondToken) {
$json->etc();
$secondToken = $json->toArray()['upload_token'];
});
expect($firstToken)->not->toBe($secondToken);
});