Skip to content

Let MCP clients upload files with a short-lived upload grant - #93

Open
josipmrsic wants to merge 2 commits into
plannotator:mainfrom
josipmrsic:DEVPLAT-1369-upload-grant
Open

josipmrsic wants to merge 2 commits into
plannotator:mainfrom
josipmrsic:DEVPLAT-1369-upload-grant

Conversation

@josipmrsic

Copy link
Copy Markdown

After #86, Claude Code signed in through Entra could connect to /mcp, list, and read, but not publish. This PR fixes the publish step for every MCP client, not only Entra ones.

What stood in the way

artifact_create_upload tells the agent to PUT each file "using the same bearer credential" (credential: "reuse_the_mcp_bearer_credential"). That can't work in two ways:

  1. The agent never has that credential. The MCP client keeps its OAuth tokens to itself, and the bundled skill says never to expose the MCP bearer credential to the model.
  2. /api/ takes API keys only. Decision 0028 keeps OAuth off /api/ on purpose, so even the client's own token is refused on the upload URL.

So publishing through MCP only worked when the model happened to know an API key. No test covered an OAuth MCP client uploading; every upload test PUT with an API key.

What changes

artifact_create_upload now returns an upload grant once per upload:

"uploadGrant": { "scheme": "Bearer", "token": "as_upg_…", "expiresAt": "…" },
"files": [{ "authorization": { "scheme": "Bearer", "credential": "upload_grant" }, "uploadUrl": "…", … }]

The agent sends it as Authorization: Bearer on that upload's file PUTs, then commits through MCP as before.

Request with the grant Result
PUT of a file of its own upload, while open stored
the same route after commit or expiry the upload's own UPLOAD_CLOSED / UPLOAD_EXPIRED
any other upload, route, method, path, or project 401 AUTHENTICATION_REQUIRED
  • It works like a presigned URL. Permission is checked when the grant is issued (artifact_create_upload) and again at commit (artifact_commit_upload, on the live MCP credential). The grant only lets the declared bytes in, and every file is already bound to its size and SHA-256.
  • Only the SHA-256 digest of the grant is stored, in a new nullable grant_digest column on staged_uploads. No new server secret.
  • The grant is carried in the header, not the URL, so it stays out of access logs. It expires with its upload (60 minutes).
  • /api/ accepts an as_upg_ bearer only for PUT on the file route; anywhere else it gets 401 AUTHENTICATION_REQUIRED, so no principal-based handler ever sees one.

Unchanged: API keys (also on the file PUT), browser sessions, POST /api/v1/uploads (issues no grant), the commit step, and 0028's rule that /api/ takes no OAuth tokens.

Decision 0031 has the reasoning and the rejected alternatives: accept the MCP OAuth token on the upload route (breaks the audience binding 0028 relies on), OAuth on all of /api/ (excluded by 0028, and the agent still wouldn't hold the token), and a stateless HMAC grant (needs a new secret per deployment).

Storage and migrations
Backend Migration
SQLite schema 13
D1 revision 10
Postgres 0012_staged_upload_grant

Each adds one nullable column with no backfill. Uploads still open during an upgrade have no grant; they keep working with API keys and expire within the hour. #88 and #92 claim the same numbers, so whichever lands later renumbers; happy to rebase.

Spec and tests
  • Spec: decision 0031; PUB-014 (implementing, with its proof gap); PUB-001-F gains the grant exception; MCP baseline and skill wording. The decision number assumes Support Microsoft Entra ID for MCP OAuth #86 (0029) and Add artifact owners and an opt-in restricted access setting #88 (0030).
  • Conformance: tests/conformance/pub-014-upload-grant.test.ts drives the service and a local server whose MCP credential /api/ refuses, like an Entra token. It uploads two files in parallel with the grant and commits through MCP, and refuses the grant on another upload, another route, another method, a changed path, the wrong project, an altered token, after commit, and after expiry. It also checks that the grant never reaches the logs.
  • Runtime: Postgres stores and scopes the digest per installation, and the populated-v1 upgrade still passes. D1 adds the column on upgrade and publishes through MCP with a grant.
  • mcp-modern-http.test.ts now uploads with the grant and keeps one API-key PUT.

Verification

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