feat(mcp): RequestMediaUploadTool issues signed upload URLs
This commit is contained in:
parent
d50348562b
commit
7971571dc3
3 changed files with 121 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
47
app/Mcp/Tools/Post/RequestMediaUploadTool.php
Normal file
47
app/Mcp/Tools/Post/RequestMediaUploadTool.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
72
tests/Feature/Mcp/RequestMediaUploadToolTest.php
Normal file
72
tests/Feature/Mcp/RequestMediaUploadToolTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue