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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 21:21:39 -03:00
parent 596ad491ef
commit 564f157e44
27 changed files with 143 additions and 32 deletions

View file

@ -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) {

View file

@ -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)),
),

View file

@ -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)),
);

View file

@ -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

View file

@ -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

View file

@ -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),
],
],

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'ريل',
'description' => 'فيديو قصير حتى 90 ثانية',
'description' => 'فيديو قصير حتى 15 دقيقة',
],
'instagram_story' => [
'label' => 'قصة',

View file

@ -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',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'Reel',
'description' => 'Σύντομο βίντεο έως 90 δευτερόλεπτα',
'description' => 'Σύντομο βίντεο έως 15 λεπτά',
],
'instagram_story' => [
'label' => 'Story',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'リール',
'description' => '最大 90 秒のショート動画',
'description' => '最大 15 分のショート動画',
],
'instagram_story' => [
'label' => 'ストーリー',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => '릴스',
'description' => '최대 90초 짧은 동영상',
'description' => '최대 15분 짧은 동영상',
],
'instagram_story' => [
'label' => '스토리',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'Reel',
'description' => 'Korte video tot 90 seconden',
'description' => 'Korte video tot 15 minuten',
],
'instagram_story' => [
'label' => 'Story',

View file

@ -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',

View file

@ -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',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'Reels',
'description' => 'Короткое видео до 90 секунд',
'description' => 'Короткое видео до 15 минут',
],
'instagram_story' => [
'label' => 'История',

View file

@ -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',

View file

@ -464,7 +464,7 @@
],
'instagram_reel' => [
'label' => 'Reel',
'description' => '最长 90 秒的短视频',
'description' => '最长 15 分钟的短视频',
],
'instagram_story' => [
'label' => '快拍',

View file

@ -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');

View file

@ -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 () {

View file

@ -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();
});

View file

@ -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);
});
});

View file

@ -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();
});

View file

@ -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);