feat(mcp): media upload via signed-URL flow#42
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Security model
APP_KEY?ws=Xsignedreturns 403Cache::addatomic → 409 on second attemptmimetypes:rule reads magic bytes viafinfoMedia::where('mediable_id', $authedWs)What changed
database/migrations/*_add_upload_token_to_media_table.phpmediasapp/Http/Requests/Api/StoreUploadRequest.phpMediaTypeenumapp/Http/Controllers/Api/UploadController.phproutes/api.phpPOST /api/uploads/{token}outsideauth:apigroup, signed middleware onlyapp/Mcp/Tools/Post/RequestMediaUploadTool.phpapp/Mcp/Tools/Post/AttachMediaFromUploadTool.phpapp/Mcp/Servers/TryPostServer.phpconfig/ai.phpai.mcp.upload.{max_size_mb,url_ttl_minutes}config keysapp/Models/Media.phpupload_tokenin$fillableTests (15 new)
UploadControllerTest(7): happy path + unsigned + tampered ws + expired + replay + oversized + bad mimeRequestMediaUploadToolTest(3): structured response + valid signature + distinct tokensAttachMediaFromUploadToolTest(4): happy + foreign ws token + unknown token + foreign postMediaUploadFlowTest(1): end-to-end smoke — agent → real signed URL → POST → attach to postMediaUploadTokenColumnTest(1): column exists with expected shapeThe 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
RequestMediaUploadToolfrom Claude Desktop,curl -F media=@photo.png "{upload_url}", attach to a draft post, publish?ws=in the URL — confirm 403Out of scope (deferred follow-ups)
RequestMediaUploadTool(proposed 30/min/workspace, 200/day/user)Plan::storage_limit_bytesfield exists yet)MediaUploadResourceJsonResource wrapper onUploadControllerresponse (matches the project memory rule — consistent withPostController::storeMedia's inline shape for now)AttachMediaFromUploadTool(code path exists, untested)FILESYSTEM_DISK=s3/r2— backend-mediated remains the default🤖 Generated with Claude Code