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:
parent
596ad491ef
commit
564f157e44
27 changed files with 143 additions and 32 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'ريل',
|
||||
'description' => 'فيديو قصير حتى 90 ثانية',
|
||||
'description' => 'فيديو قصير حتى 15 دقيقة',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'قصة',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reel',
|
||||
'description' => 'Σύντομο βίντεο έως 90 δευτερόλεπτα',
|
||||
'description' => 'Σύντομο βίντεο έως 15 λεπτά',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'Story',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'リール',
|
||||
'description' => '最大 90 秒のショート動画',
|
||||
'description' => '最大 15 分のショート動画',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'ストーリー',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => '릴스',
|
||||
'description' => '최대 90초 짧은 동영상',
|
||||
'description' => '최대 15분 짧은 동영상',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => '스토리',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reel',
|
||||
'description' => 'Korte video tot 90 seconden',
|
||||
'description' => 'Korte video tot 15 minuten',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'Story',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reels',
|
||||
'description' => 'Короткое видео до 90 секунд',
|
||||
'description' => 'Короткое видео до 15 минут',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'История',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reel',
|
||||
'description' => '最长 90 秒的短视频',
|
||||
'description' => '最长 15 分钟的短视频',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => '快拍',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue