diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index 6bd9df35..d61aa92c 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -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) diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php new file mode 100644 index 00000000..e61dcd84 --- /dev/null +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -0,0 +1,47 @@ +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 []; + } +} diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php new file mode 100644 index 00000000..8ce7afbc --- /dev/null +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -0,0 +1,72 @@ +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); +});