Skip to content

feat(mcp): media upload via signed-URL flow#42

Merged
paulocastellano merged 12 commits into
mainfrom
feature/mcp-media-upload
May 15, 2026
Merged

feat(mcp): media upload via signed-URL flow#42
paulocastellano merged 12 commits into
mainfrom
feature/mcp-media-upload

Conversation

@paulocastellano
Copy link
Copy Markdown
Contributor

Summary

Adds an MCP-driven media upload flow so agents (Claude Code, Cursor, etc.) can attach local files to TryPost posts without provisioning an API key. Today the only MCP path is AttachMediaFromUrlTool, which requires a public HTTP URL.

The design is workspace-scoped + storage-driver agnostic: works on local, r2, s3, minio — important because TryPost is open source and self-hosted. No cloud-presigned-URL assumption.

How it works

1. agent  → RequestMediaUploadTool()
            returns { upload_token, upload_url, expires_at, max_bytes, field_name }
            no DB write — URL is the contract, HMAC-signed with APP_KEY

2. client → POST {upload_url} multipart/form-data media=@file
            `signed` middleware verifies HMAC + expires
            Cache::add atomic single-use lock
            Workspace::addMedia stores via Storage::disk() default
            Media row tagged with upload_token (UNIQUE constraint)

3. agent  → AttachMediaFromUploadTool(post_id, upload_token)
            resolves Media by token + workspace, attaches to Post

Security model

Threat Defense
Forge URL HMAC-SHA256 with APP_KEY
Tamper ?ws=X HMAC invalidates → signed returns 403
URL leaks 15 min TTL + single-use cache + DB unique constraint
Replay Cache::add atomic → 409 on second attempt
Oversized upload 3 layers: nginx 51m + PHP 51M + FormRequest max:51200
Mime spoofing mimetypes: rule reads magic bytes via finfo
Cross-workspace attach Media::where('mediable_id', $authedWs)

What changed

File Purpose
database/migrations/*_add_upload_token_to_media_table.php UUID column, nullable + unique on medias
app/Http/Requests/Api/StoreUploadRequest.php 50 MB cap + mime whitelist from MediaType enum
app/Http/Controllers/Api/UploadController.php Transactional store, atomic token claim
routes/api.php POST /api/uploads/{token} outside auth:api group, signed middleware only
app/Mcp/Tools/Post/RequestMediaUploadTool.php Issues signed URLs
app/Mcp/Tools/Post/AttachMediaFromUploadTool.php Resolves by token + workspace scope (using morph alias)
app/Mcp/Servers/TryPostServer.php Registers both new tools
config/ai.php ai.mcp.upload.{max_size_mb,url_ttl_minutes} config keys
app/Models/Media.php upload_token in $fillable

Tests (15 new)

  • UploadControllerTest (7): happy path + unsigned + tampered ws + expired + replay + oversized + bad mime
  • RequestMediaUploadToolTest (3): structured response + valid signature + distinct tokens
  • AttachMediaFromUploadToolTest (4): happy + foreign ws token + unknown token + foreign post
  • MediaUploadFlowTest (1): end-to-end smoke — agent → real signed URL → POST → attach to post
  • MediaUploadTokenColumnTest (1): column exists with expected shape

The smoke test uses the actual signed URL returned by RequestMediaUploadTool (not a reconstructed one), so it catches integration bugs the unit tests can't — that's how the morph alias bug surfaced.

Operator notes

Production nginx already at client_max_body_size 1024m — well above the 50 MB cap. PHP defaults are fine. No infra changes needed.

Tune via env:

  • MCP_UPLOAD_MAX_SIZE_MB (default 50)
  • MCP_UPLOAD_URL_TTL_MINUTES (default 15)

Test plan

  • Full suite: 1558 passing, 2 skipped, 0 failures
  • Pint clean
  • Manual: invoke RequestMediaUploadTool from Claude Desktop, curl -F media=@photo.png "{upload_url}", attach to a draft post, publish
  • Manual: try to replay a used URL — confirm 409
  • Manual: tamper ?ws= in the URL — confirm 403

Out of scope (deferred follow-ups)

  • Rate limit on RequestMediaUploadTool (proposed 30/min/workspace, 200/day/user)
  • Per-workspace storage quota (no Plan::storage_limit_bytes field exists yet)
  • MediaUploadResource JsonResource wrapper on UploadController response (matches the project memory rule — consistent with PostController::storeMedia's inline shape for now)
  • Test for media-type mismatch rejection in AttachMediaFromUploadTool (code path exists, untested)
  • Direct-to-cloud upload (presigned R2) when FILESYSTEM_DISK=s3/r2 — backend-mediated remains the default

🤖 Generated with Claude Code

@paulocastellano paulocastellano merged commit 0277643 into main May 15, 2026
2 checks passed
@paulocastellano paulocastellano deleted the feature/mcp-media-upload branch May 15, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant