From 564f157e44a214df1c0df78f4847aca9236399b2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:21:39 -0300 Subject: [PATCH] Fix MCP upload rate limits and Instagram Reel duration caps. Key signed uploads by workspace so ChatGPT's shared egress IPs don't throttle tenants together, raise the MCP upload cap to 300MB, and expose accurate Reel max durations via API/MCP. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 26 +++++++++++++ .../Api/PlatformContentTypesResource.php | 1 + .../Tools/Platform/ListContentTypesTool.php | 3 +- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 2 +- app/Providers/AppServiceProvider.php | 11 ++++++ config/ai.php | 2 +- lang/ar/posts.php | 2 +- lang/de/posts.php | 2 +- lang/el/posts.php | 2 +- lang/en/posts.php | 2 +- lang/es/posts.php | 2 +- lang/fr/posts.php | 2 +- lang/it/posts.php | 2 +- lang/ja/posts.php | 2 +- lang/ko/posts.php | 2 +- lang/nl/posts.php | 2 +- lang/pl/posts.php | 2 +- lang/pt-BR/posts.php | 2 +- lang/ru/posts.php | 2 +- lang/tr/posts.php | 2 +- lang/zh/posts.php | 2 +- routes/api.php | 2 +- tests/Feature/Api/PlatformApiTest.php | 18 ++++++++- tests/Feature/Api/UploadControllerTest.php | 39 ++++++++++++++----- tests/Feature/Mcp/PlatformToolTest.php | 29 ++++++++++++++ .../Mcp/RequestMediaUploadToolTest.php | 2 +- tests/Unit/Enums/ContentTypeTest.php | 10 ++++- 27 files changed, 143 insertions(+), 32 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index b9338bc9..936337cc 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -179,6 +179,32 @@ public function maxMediaCount(): int }; } + /** + * Maximum video duration in seconds for this content type, when the + * platform publishes a hard cap via API. Null when unlimited, unknown, + * or enforced dynamically (e.g. TikTok creator_info). + * + * Mirrors resources/js/composables/useMediaRules.ts. + */ + public function maxVideoDurationSec(): ?int + { + return match ($this) { + self::InstagramFeed => 60, + self::InstagramReel => 15 * 60, + self::InstagramStory => 60, + self::FacebookPost => 240 * 60, + self::FacebookReel => 90, + self::FacebookStory => 60, + self::LinkedInPost, self::LinkedInPagePost => 10 * 60, + self::YouTubeShort => 3 * 60, + self::PinterestVideoPin => 15 * 60, + self::XPost => 140, + self::ThreadsPost => 5 * 60, + self::BlueskyPost => 60, + default => null, + }; + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 1082de9f..0d9132cb 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -42,6 +42,7 @@ public function toArray(Request $request): array 'description' => $type->description(), 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), + 'max_video_duration_sec' => $type->maxVideoDurationSec(), ], array_values(ContentType::forPlatform($platform)), ), diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index a7b3b632..e240f3a2 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -29,6 +29,7 @@ public function handle(Request $request): ResponseFactory 'description' => $type->description(), 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), + 'max_video_duration_sec' => $type->maxVideoDurationSec(), ], array_values(ContentType::forPlatform($platform)), ); diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 2b380e50..889f120f 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -14,7 +14,7 @@ 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, video, or PDF document, up to the workspace upload cap — 50 MB by default) 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.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 300 MB by default) 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 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 960c0244..10c739ad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -159,6 +159,17 @@ protected function configureRateLimiting(): void return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); }); + + // MCP signed uploads arrive from ChatGPT's shared egress IPs. Key by + // workspace_id (bound into the signed URL) so tenants don't share a bucket. + RateLimiter::for('mcp-uploads', function (Request $request) { + $workspaceId = (string) $request->query('workspace_id'); + + return [ + Limit::perMinute(60)->by('workspace:'.$workspaceId), + Limit::perMinute(600)->by('ip:'.$request->ip()), + ]; + }); } protected function configureStripeWebhooks(): void diff --git a/config/ai.php b/config/ai.php index c28a3160..0034ca58 100644 --- a/config/ai.php +++ b/config/ai.php @@ -53,7 +53,7 @@ 'mcp' => [ 'upload' => [ - 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), + 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 300), 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), ], ], diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 03d07a14..8d5901e5 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'ريل', - 'description' => 'فيديو قصير حتى 90 ثانية', + 'description' => 'فيديو قصير حتى 15 دقيقة', ], 'instagram_story' => [ 'label' => 'قصة', diff --git a/lang/de/posts.php b/lang/de/posts.php index f876ef0e..0aeef2b4 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -466,7 +466,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Kurzes Video bis zu 90 Sekunden', + 'description' => 'Kurzes Video bis zu 15 Minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/el/posts.php b/lang/el/posts.php index 975fbdae..470ff022 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Σύντομο βίντεο έως 90 δευτερόλεπτα', + 'description' => 'Σύντομο βίντεο έως 15 λεπτά', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/en/posts.php b/lang/en/posts.php index dde76013..f15f8f15 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Short video up to 90 seconds', + 'description' => 'Short video up to 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/es/posts.php b/lang/es/posts.php index 6f53b10f..61b04344 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video corto de hasta 90 segundos', + 'description' => 'Video corto de hasta 15 minutos', ], 'instagram_story' => [ 'label' => 'Historia', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 2887da40..7cbd880e 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Courte vidéo jusqu\'à 90 secondes', + 'description' => 'Courte vidéo jusqu\'à 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/it/posts.php b/lang/it/posts.php index 6baf9996..ea9ca8de 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video breve fino a 90 secondi', + 'description' => 'Video breve fino a 15 minuti', ], 'instagram_story' => [ 'label' => 'Storia', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 352e6ddd..4c205ec4 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'リール', - 'description' => '最大 90 秒のショート動画', + 'description' => '最大 15 分のショート動画', ], 'instagram_story' => [ 'label' => 'ストーリー', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 86f1f1ab..2e58a2e7 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => '릴스', - 'description' => '최대 90초 짧은 동영상', + 'description' => '최대 15분 짧은 동영상', ], 'instagram_story' => [ 'label' => '스토리', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 75a6af89..f13035b5 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Korte video tot 90 seconden', + 'description' => 'Korte video tot 15 minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index c30419ec..3af64e34 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Rolka', - 'description' => 'Krótki film do 90 sekund', + 'description' => 'Krótki film do 15 minut', ], 'instagram_story' => [ 'label' => 'Relacja', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 51359954..d7d8856a 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Vídeo curto de até 90 segundos', + 'description' => 'Vídeo curto de até 15 minutos', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index c97df56c..c180e074 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Короткое видео до 90 секунд', + 'description' => 'Короткое видео до 15 минут', ], 'instagram_story' => [ 'label' => 'История', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 59007cc6..2babf59f 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -466,7 +466,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '90 saniyeye kadar kısa video', + 'description' => '15 dakikaya kadar kısa video', ], 'instagram_story' => [ 'label' => 'Hikaye', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index fc5d3cb3..0fec3de5 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '最长 90 秒的短视频', + 'description' => '最长 15 分钟的短视频', ], 'instagram_story' => [ 'label' => '快拍', diff --git a/routes/api.php b/routes/api.php index fe3c48f1..9c603e79 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Route; Route::post('/uploads/{token}', [UploadController::class, 'store']) - ->middleware(['signed', 'throttle:10,1']) + ->middleware(['signed', 'throttle:mcp-uploads']) ->where('token', '[0-9a-f-]{36}') ->name('api.uploads.store'); diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 204253c6..954229b7 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -10,7 +10,7 @@ }); it('lists content types per platform', function () { - $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->getJson(route('api.content-types')) ->assertOk() ->assertJsonStructure([ @@ -23,11 +23,25 @@ 'allowed_media_types', 'default_content_type', 'content_types' => [ - '*' => ['value', 'label', 'description', 'max_media_count', 'requires_media'], + '*' => [ + 'value', + 'label', + 'description', + 'max_media_count', + 'requires_media', + 'max_video_duration_sec', + ], ], ], ], ]); + + $platforms = collect($response->json('platforms')); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); it('rejects content-types without auth', function () { diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index ac69900e..fc0969d1 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -102,9 +102,11 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->count())->toBe(1); }); -test('rejects file larger than 50MB', function () { +test('rejects file larger than the MCP upload cap', function () { + config(['ai.mcp.upload.max_size_mb' => 1]); + $token = (string) Str::uuid(); - $file = UploadedFile::fake()->create('huge.mp4', 51 * 1024 + 1, 'video/mp4'); + $file = UploadedFile::fake()->create('huge.mp4', 1024 + 1, 'video/mp4'); $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); @@ -122,18 +124,37 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); -test('rate limits floods from the same IP', function () { - for ($i = 0; $i < 10; $i++) { +test('rate limits floods from the same workspace', function () { + for ($i = 0; $i < 60; $i++) { $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image("f{$i}.png", 16, 16)], - ); + )->assertSuccessful(); } - $response = $this->postJson( + $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image('over.png', 16, 16)], - ); - - $response->assertStatus(429); + )->assertStatus(429); +}); + +test('different workspaces on the same IP do not share the upload rate limit', function () { + $otherWorkspace = Workspace::factory()->create(); + + for ($i = 0; $i < 60; $i++) { + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image("a{$i}.png", 16, 16)], + )->assertSuccessful(); + } + + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('blocked.png', 16, 16)], + )->assertStatus(429); + + $this->postJson( + signedUploadUrl($otherWorkspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('other.png', 16, 16)], + )->assertSuccessful(); }); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index e603f7cf..e8828b20 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -33,6 +33,18 @@ 'default_content_type', 'content_types', ]) + ->has('content_types', fn (AssertableJson $types) => $types + ->each(fn (AssertableJson $type) => $type + ->hasAll([ + 'value', + 'label', + 'description', + 'max_media_count', + 'requires_media', + 'max_video_duration_sec', + ]) + ) + ) ) ); }); @@ -45,3 +57,20 @@ $response->assertOk() ->assertSee(['linkedin', 'linkedin_post', 'x_post', 'instagram_feed', 'mastodon_post']); }); + +test('list content types exposes reel max video durations', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListContentTypesTool::class, []); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->etc(); + + $platforms = collect($json->toArray()['platforms']); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + }); +}); diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php index 8ce7afbc..72041356 100644 --- a/tests/Feature/Mcp/RequestMediaUploadToolTest.php +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -26,7 +26,7 @@ $json->has('upload_token') ->has('upload_url') ->has('expires_at') - ->where('max_bytes', 52428800) + ->where('max_bytes', 300 * 1024 * 1024) ->where('field_name', 'media') ->etc(); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 6dd1ba83..d37ac5e2 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -21,11 +21,19 @@ test('content type has correct descriptions', function () { expect(ContentType::InstagramFeed->description())->toContain('feed'); - expect(ContentType::InstagramReel->description())->toContain('90 seconds'); + expect(ContentType::InstagramReel->description())->toContain('15 minutes'); + expect(ContentType::FacebookReel->description())->toContain('90 seconds'); expect(ContentType::InstagramStory->description())->toContain('24 hours'); expect(ContentType::YouTubeShort->description())->toContain('3 minutes'); }); +test('content type exposes max video duration in seconds', function () { + expect(ContentType::InstagramReel->maxVideoDurationSec())->toBe(15 * 60); + expect(ContentType::FacebookReel->maxVideoDurationSec())->toBe(90); + expect(ContentType::YouTubeShort->maxVideoDurationSec())->toBe(3 * 60); + expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); +}); + test('content type maps to correct platform', function () { expect(ContentType::InstagramFeed->platform())->toBe(Platform::Instagram); expect(ContentType::InstagramReel->platform())->toBe(Platform::Instagram);