Skip to main content
Glama

campos-share

A lightweight Next.js service on Vercel backed by Vercel Blob, serving standalone unlisted share pages and raw files at:

$SHARE_BASE_URL/<slug>
$SHARE_BASE_URL/<slug>/<filename>


Architecture & Principles

  • Blob Storage Only (Zero Database):

    • Standing constraint: There is no database anywhere (no Postgres, SQLite, Redis, or ORM).

    • Storage is partitioned in Vercel Blob by slug:

      <slug>/__meta.json
      <slug>/__page.html       (for kind: "uploaded")
      <slug>/<asset-filename>
    • __meta.json is the sole source of truth. Link expiry is a timestamp comparison at read time; password protection is a cryptographic hash comparison at read time.

  • Repatriation Escape Hatch:

    • @vercel/blob is imported and used in exactly one module (lib/storage.ts).

    • The rest of the application interacts with a standard storage interface. Repatriating to AWS S3, MinIO, or a custom object store requires editing only lib/storage.ts.

    • Standard Node.js runtime only—no edge-runtime exclusives, no proprietary caching.

  • Direct Client Uploads (Bypassing Function Payload Ceilings):

    • Vercel Functions enforce a 4.5 MB payload ceiling (FUNCTION_PAYLOAD_TOO_LARGE).

    • Binary assets bypass function bodies entirely: clients request a scoped upload token (POST /api/uploads) and upload directly to Vercel Blob. POST /api/shares is called only after assets land.

    • Ceiling for all uploads is 20 MB, enforced by token constraints.

  • HTTP Caching that survives an update:

    • A file can be replaced in place and keep its URL, so nothing is cached as immutable. Unprotected, unexpiring shares and files: public, max-age=0, must-revalidate with the store's ETag; a browser's If-None-Match goes through to Blob, which answers 304 without moving the bytes when nothing changed.

    • Password-protected shares: private, no-cache. Shared caches never retain protected content.

    • Expiring shares: private, max-age=0, must-revalidate. Evaluated on every request.

    • Every read of __meta.json and of file bytes bypasses Blob's CDN cache (useCache: false), and every write asks for the shortest cache the store allows. Blob otherwise serves reads from a cache for up to a minute after a delete or an overwrite, which is how a file just deleted read as "already exists" and how a file just added could be dropped by the next write.

  • Template Invariants ("The Validator Ascends into the Template"):

    • Metadata rules hold by construction: <html lang="..."> matching content language, non-empty <title>, <meta name="viewport">, <meta name="description">, <meta name="robots" content="noindex,nofollow">, and Open Graph tags.

    • og:image is generated only for unprotected and unexpiring shares with image assets. Password-protected and expiring shares never publish og:image to avoid leaking content to social crawlers.

  • Security & Privacy:

    • Obscure URLs are not access control.

    • Password hashing uses crypto.scrypt tuned to ~100ms key derivation time to defeat brute-force guessing without needing serverless state.

    • Requests attempting to unlock non-existent or unprotected slugs run dummy scrypt derivations to guarantee constant execution time and prevent slug probing.

    • Successful unlock sets an HTTP-only, SameSite=Lax, Secure cookie scoped to Path=/[slug] signed with SHARE_COOKIE_SECRET (never reusing SHARE_API_TOKEN).


Related MCP server: uploadthing-mcp

API Contract (/api/*)

All /api/* routes require Authorization: Bearer <SHARE_API_TOKEN>. Token validation uses constant-time comparison (crypto.timingSafeEqual).

1. POST /api/uploads

Issues a scoped Blob client upload token before files move.

  • Request:

    {
      "slug": "hotel-comparison",
      "files": [
        { "name": "informe.pdf", "contentType": "application/pdf", "sizeBytes": 481920, "overwrite": false }
      ]
    }
  • Response (200 OK):

    {
      "clientToken": "vercel_blob_client_...",
      "prefix": "hotel-comparison/"
    }
  • Errors: 400 (invalid or reserved slug / missing files / file > 20 MB), 401 (unauthorized), 409 (the slug already holds a file with that name, or is an uploaded page; create never overwrites).

  • An existing slug accepts new file names: that is how a file joins a share. Upload it, then PUT /api/shares/{slug} with the full asset list. The slugs api, mcp and oauth are reserved for application routes.

  • A file declared with "overwrite": true may replace one the share already holds, behind the same URL. After the PUT to the presigned URL, PUT /api/shares/{slug} with the same asset list refreshes its size and etag in the record. Assets in API responses carry etag and, once replaced, updatedAt.

  • Wherever an asset is declared (POST /api/shares, PUT /api/shares/{slug}, POST /api/shares/{slug}/assets, PUT /api/shares/{slug}/assets/{name}) it may carry "sha256", the lowercase hex SHA-256 of its bytes. The service stores and echoes it, computes it itself for content it writes (MCP upload_file, update_file), and drops it when a presigned overwrite changes the bytes without restating it, so a digest in the record always describes the current content. Callers use it to know whether bytes are already published.

2. POST /api/shares

Creates a share record after assets have been uploaded to Blob.

  • Request:

    {
      "slug": "hotel-comparison",
      "title": "Comparativa de hoteles",
      "description": "Precios y ubicación de tres opciones en Lisboa",
      "lang": "es",
      "kind": "generated",
      "html": null,
      "assets": [
        {
          "name": "informe.pdf",
          "originalName": "Informe final (v2).pdf",
          "contentType": "application/pdf"
        }
      ],
      "expiresAt": null,
      "password": null
    }
    • For kind: "uploaded", set html to the inline HTML string and omit assets.

  • Response (201 Created):

    {
      "slug": "hotel-comparison",
      "url": "$SHARE_BASE_URL/hotel-comparison",
      "createdAt": "2026-09-14T18:20:00Z",
      "assets": [
        {
          "name": "informe.pdf",
          "originalName": "Informe final (v2).pdf",
          "contentType": "application/pdf",
          "sizeBytes": 481920,
          "url": "$SHARE_BASE_URL/hotel-comparison/informe.pdf"
        }
      ]
    }
  • Errors: 400 (validation failure or declared asset does not exist in Blob), 401, 409 (slug exists).

3. GET /api/shares

Lists all active shares.

  • Response (200 OK): Array of share summaries (slug, title, description, lang, kind, createdAt, url, assetCount, totalSize, isExpired, isPasswordProtected).

  • Note: passwordHash is never exposed in API responses.

4. GET /api/shares/{slug}

Retrieves full share record and asset URLs.

  • Response (200 OK): Full share metadata (excluding passwordHash).

  • Response (404 Not Found): If share does not exist.

5. PUT /api/shares/{slug}

Replaces metadata and asset set for an existing share, pruning obsolete blobs.

  • Request: Same payload as POST /api/shares. Assets uploaded beforehand via POST /api/uploads.

  • Response (200 OK): Updated share record.

6. DELETE /api/shares/{slug}

Deletes <slug>/__meta.json and all associated blobs under <slug>/.

  • Response (204 No Content).

7. POST /api/shares/{slug}/assets

Adds already-uploaded files to a generated share without restating the rest. The caller names only the files that join; the record is updated with a conditional write, so two callers adding at the same time both end up in it.

  • Request: { "assets": [ { "name": "mapa.png", "originalName": "Mapa (v2).png", "contentType": "image/png" } ] } after the files were PUT to their presigned URLs.

  • Response (200 OK): the full share record, as GET /api/shares/{slug}.

  • Errors: 400 (a declared file is not in storage, or an invalid name), 404 (unknown slug), 409 (a name the share already holds, or the share is an uploaded page).

8. PUT /api/shares/{slug}/assets/{name}

Brings the record of one file in line with the bytes in storage, after an upload with "overwrite": true replaced them. Idempotent.

  • Request: optional { "contentType": "...", "originalName": "..." }; an empty body keeps both.

  • Response (200 OK): the full share record with the file's new sizeBytes, etag and updatedAt.

  • Errors: 404 (unknown slug or file).

9. DELETE /api/shares/{slug}/assets/{name}

Removes one file and prunes its blob. A generated share must keep at least one file, so removing the last one removes the share.

  • Response (200 OK): { "unpublished": false, "share": { ...full record... } }, or { "unpublished": true, "share": null } when the share went away.

  • Errors: 404 (unknown slug or file).

PUT /api/shares/{slug} stays the way to change title, language, expiry, password or the whole asset set at once; the three routes above are the atomic way to touch one file. All record writes that start from a read use the store's ETag as a precondition and retry on conflict, so an update never builds on a copy another caller has already replaced.

10. POST /api/shares/orphans

Sweeps and deletes <slug>/ prefixes older than 24 hours that contain uploaded blobs but no __meta.json.


MCP server

The service exposes its files as tools to any MCP client at:

$SHARE_BASE_URL/mcp

It is a remote, client-agnostic MCP server: Streamable HTTP only (no legacy SSE), stateless so it fits Vercel Functions, serving the 2026-07-28 protocol revision natively and 2025-era clients (those that still send initialize) from the same endpoint. It works unchanged with claude.ai custom connectors, Claude Code, ChatGPT Developer Mode, the Gemini app (Spark connected apps), Gemini CLI, MCP Inspector and any other standard client.

Nothing in it is specific to one vendor: no UI widgets, no vendor _meta, only tools (no resources or prompts, which some clients ignore).

The model the tools present

A folder is a share and its slug is the prefix. A file is an asset in it. A path is <prefix>/<filename>. Every file is public at $SHARE_BASE_URL/<prefix>/<filename> and its folder page at $SHARE_BASE_URL/<prefix>.

A page is the other kind of share (kind: "uploaded"): one HTML document served at $SHARE_BASE_URL/<prefix> with nothing around it. A page holds no files, so the file tools refuse it and say so; publish_page and update_page are its two verbs.

Tool

Does

Scope

Annotations

upload_file

Publishes inline content (text or base64, up to 3 MB decoded) and returns the public URL. Creates the folder or adds to an existing one. Never overwrites.

files:write

writes

update_file

Replaces the content of an existing file (inline, up to 3 MB decoded) and keeps its URL.

files:write

destructive, idempotent

create_upload_url

Returns a presigned PUT URL (15 min, bound to content type and size) for files up to 20 MB, plus the URL the file will have. For large files and for clients that can run curl. overwrite: true replaces an existing file in place.

files:write

writes

complete_upload

Publishes a file uploaded through create_upload_url, or refreshes its record after an overwrite. Idempotent.

files:write

idempotent

publish_page

Publishes an HTML document (inline, up to 3 MB) as a page at $SHARE_BASE_URL/<prefix>. Never overwrites.

files:write

writes

update_page

Replaces the document of an existing page and keeps its URL. Title, description and language keep their stored value unless restated.

files:write

destructive, idempotent

list_files

Name, size, date and URL of every file, optionally under a prefix, 50 per page with a cursor, plus every page matching the prefix (not paginated).

files:read

read-only

get_file_info

Details of one file, one folder or one page.

files:read

read-only

delete_file

Deletes a file, a whole folder, or a page (by its prefix). Deleting the last file deletes its folder.

files:delete

destructive

Every tool returns readable text and the same data as structuredContent with an outputSchema, so clients that use either work. Failures come back as tool results with isError: true and a message that says what to do (auth, size, duplicate name, missing upload), never as exceptions.

Schemas are the least common denominator every host's function calling accepts: flat objects, basic types, string enums, explicit required, a description on every field, no anyOf/oneOf/$ref/additionalProperties/$schema. tests/mcp.test.mjs asserts this on the real tools/list output.

Limits. Vercel Functions reject request bodies above 4.5 MB, so upload_file accepts 3 MB of decoded content and points at create_upload_url above that. publish_page and update_page take the same 3 MB inline, with no presigned path: a document that large should link to its images, styles and scripts as files of their own. Files go up to 20 MB through the presigned URL, which is the share service's ceiling. Tool calls are rate limited per subject (120 per minute per instance), passphrase attempts per address (5 per 15 minutes), and the token and registration endpoints per address. These counters live in the memory of the running instance; a hard ceiling belongs in the Vercel Firewall. File content and secrets are never logged.

Environment variables

Variable

Required

Purpose

MCP_OAUTH_SECRET

yes

Signs every OAuth artefact (client ids, codes, access and refresh tokens). 32+ characters, e.g. openssl rand -base64 48. Rotating it logs every client out.

MCP_LOGIN_PASSPHRASE_HASH

yes

scrypt hash of the passphrase that authorizes clients. Generate with npm run passphrase:hash.

MCP_STATIC_TOKEN

no

Bearer token for terminal clients that cannot run OAuth. Off when empty. 32+ characters.

MCP_STATIC_TOKEN_SCOPES

no

Scopes of the static token. Default files:read files:write, never delete.

MCP_OAUTH_CLIENTS

no

JSON array of pre-registered clients (client_id, client_secret, client_name, redirect_uris) for hosts that take an id and secret instead of registering.

MCP_ALLOWED_ORIGINS

no

Browser origins allowed on /mcp. Default *: the endpoint has no ambient authority, every call carries a bearer token. A comma-separated list turns on strict Origin validation.

SHARE_DEFAULT_LANG

no

Language stamped on folders the tools create. Default es.

The share API's own SHARE_API_TOKEN is not involved: the tools call lib/shares.ts directly, the same module the HTTP API uses.

Authorization

The MCP endpoint is an OAuth 2.1 resource server and the app is its own authorization server, without a database:

  • Discovery. /.well-known/oauth-protected-resource/mcp (and the root variant) names the authorization server; /.well-known/oauth-authorization-server describes it. A request without a token gets 401 with WWW-Authenticate: Bearer resource_metadata="...".

  • Client registration. Three ways, chosen by the client: Client ID Metadata Documents (an https URL as client_id, fetched and validated, never from private addresses), Dynamic Client Registration at /oauth/register (the client_id is the signed registration itself, so nothing is stored), or pre-registered clients from MCP_OAUTH_CLIENTS.

  • Flow. Authorization code with PKCE S256 only, resource bound to $SHARE_BASE_URL/mcp and copied into the token audience, iss in the authorization response. Access tokens last 1 hour, refresh tokens 30 days from the first grant.

  • Login. There is one resource owner. /oauth/authorize shows which client asks for what and takes the passphrase; entering it is the consent. The hash lives in MCP_LOGIN_PASSPHRASE_HASH and is checked with scrypt in constant time.

  • Scopes. files:read, files:write, files:delete. Clients get all three unless they ask for less. Each tool checks its scope and answers with a tool error when it is missing.

  • Static token. With MCP_STATIC_TOKEN set, Authorization: Bearer <token> is accepted with the scopes in MCP_STATIC_TOKEN_SCOPES. Meant for Gemini CLI and scripts; keep it out of shared configs.

Because tokens are stateless, an authorization code is not single-use (its 60-second life and the PKCE verifier bound to it are the protection) and a single refresh token cannot be revoked on its own: rotate MCP_OAUTH_SECRET to revoke everything.

Set it up

openssl rand -base64 48            # -> MCP_OAUTH_SECRET
npm run passphrase:hash            # prompts, prints -> MCP_LOGIN_PASSPHRASE_HASH
openssl rand -base64 36            # -> MCP_STATIC_TOKEN (optional)

Add them in Vercel under Settings → Environment Variables and redeploy. The endpoint is live at $SHARE_BASE_URL/mcp.

Try it with MCP Inspector

Run the app locally against in-memory storage (nothing reaches Vercel Blob):

STORAGE_PROVIDER=memory SHARE_BASE_URL=http://localhost:3000 SHARE_API_TOKEN=dev SHARE_COOKIE_SECRET=dev \
MCP_OAUTH_SECRET=$(openssl rand -base64 48) MCP_LOGIN_PASSPHRASE_HASH="$(npm run -s passphrase:hash -- 'una frase de desarrollo')" \
MCP_STATIC_TOKEN=dev-static-token-0123456789abcdefghij npm run dev

Then either:

  • Web UI with the full OAuth flow: npm run mcp:inspect, open the URL it prints, choose transport Streamable HTTP, URL http://localhost:3000/mcp, click Connect. The Inspector discovers the metadata, registers itself, opens the login page; enter the passphrase. List and call the tools from the UI.

  • CLI with the static token, which also checks schema portability across hosts:

MCP_STATIC_TOKEN=dev-static-token-0123456789abcdefghij npm run mcp:check
npx -y @modelcontextprotocol/inspector --cli --server-url http://localhost:3000/mcp --transport http \
  --header "Authorization: Bearer dev-static-token-0123456789abcdefghij" \
  --method tools/call --tool-name upload_file \
  --tool-args-json '{"filename":"hola.md","content":"# Hola","encoding":"text"}'

Point MCP_URL at production to run the same checks there.

Connect the clients

In every case the server URL is $SHARE_BASE_URL/mcp. The host discovers the OAuth endpoints, registers itself, sends you to the login page, and you enter the passphrase once.

claude.ai (custom connector). Settings → Connectors → Add custom connector → name it and paste the URL → AddConnect. The login page opens; enter the passphrase. Free plans allow one custom connector. Advanced settings takes a client id and secret if you prefer a pre-registered client from MCP_OAUTH_CLIENTS (register https://claude.ai/api/mcp/auth_callback as its redirect URI, or whatever URI the connector dialog shows).

Claude Code.

claude mcp add --transport http share https://share.example.com/mcp
claude mcp login share        # or run /mcp inside a session and choose Authenticate

For a script or CI, the static token instead of OAuth:

claude mcp add --transport http share https://share.example.com/mcp \
  --header "Authorization: Bearer $MCP_STATIC_TOKEN"

ChatGPT (Developer Mode). Needs Plus, Pro, Business, Enterprise or Edu on the web. Settings → Apps → Advanced settings → turn on Developer mode (on some accounts it sits under Settings → Security and login). Then Settings → Apps → Create (or Add app): name, the URL, authentication OAuth, save. ChatGPT registers itself through its Client ID Metadata Document and opens the login page. Tools without readOnlyHint ask for confirmation before each call; list_files and get_file_info do not.

Gemini app (Spark connected apps). Requires a personal Google account, 18+, English, Keep Activity on, and setup on the web at gemini.google.com: Settings → Connected apps → Add custom app → paste the URL. Gemini registers itself dynamically; if its dialog insists on a client id and secret, add an entry to MCP_OAUTH_CLIENTS with the redirect URI the dialog shows and paste those values under Advanced features. Google's help notes that it does not support or secure third-party servers; at the time of writing there is an open report of Spark not calling the token endpoint after the login redirect, which nothing on the server side can fix.

Gemini CLI. In ~/.gemini/settings.json:

{
  "mcpServers": {
    "share": {
      "httpUrl": "https://share.example.com/mcp",
      "oauth": { "enabled": true, "authProviderType": "dynamic_discovery" }
    }
  }
}

Then /mcp auth share inside the CLI. Or with the static token and no browser:

{
  "mcpServers": {
    "share": {
      "httpUrl": "https://share.example.com/mcp",
      "headers": { "Authorization": "Bearer <MCP_STATIC_TOKEN>" }
    }
  }
}

Any other client. Streamable HTTP to $SHARE_BASE_URL/mcp; OAuth discovery does the rest. Clients that cannot run OAuth use the static token in the Authorization header.


Local Development & Testing

# Install dependencies
npm install

# Run automated tests (100% offline using in-memory storage)
npm test

# Run Next.js development server
npm run dev

# Run production build
npm run build

Deploying to Vercel

  1. Import the repository. Vercel Dashboard → Add New → Project → pick this repository. The framework is detected as Next.js; leave Build Command, Output Directory and Root Directory at their defaults.

  2. Create the Blob store. Project → StorageCreateBlob, and connect it to the project. That connection is what supplies BLOB_READ_WRITE_TOKEN; do not paste the token by hand.

  3. Add the environment variables under Settings → Environment Variables. All of them are read at request time and fail fast when missing — there are no fallback defaults.

    Variable

    Required

    What it is

    BLOB_READ_WRITE_TOKEN

    yes

    Comes from the connected Blob store (step 2).

    SHARE_API_TOKEN

    yes

    Bearer token publishing clients send to /api/*.

    SHARE_COOKIE_SECRET

    yes

    Signs unlock cookies. A distinct secret, never SHARE_API_TOKEN.

    SHARE_BASE_URL

    yes

    Canonical base URL of the deployment, e.g. https://share.example.com.

    MCP_OAUTH_SECRET

    for /mcp

    Signs every OAuth artefact. 32+ characters.

    MCP_LOGIN_PASSPHRASE_HASH

    for /mcp

    npm run passphrase:hash output.

    The optional MCP settings are listed in .env.example and in MCP server.

  4. Point SHARE_BASE_URL at the domain you will actually share. Add the custom domain first (Settings → Domains), then set the variable to it. Published links, og: tags and the OAuth metadata are all built from it, so changing it later invalidates links already handed out.

  5. Deploy by pushing to main, or with Redeploy from the dashboard.

Moving an existing project to a different repository

Keep the project rather than creating a new one: the domain, the Blob store and the environment variables stay with it, so already-published links and any connected MCP client keep working.

  1. Settings → Git → Disconnect. Deployments, domains and variables are untouched; only auto-deploy stops.

  2. Make sure Vercel's GitHub App can see the new repository (GitHub → Settings → Applications → Vercel → Repository access).

  3. Settings → Git → Connect Git Repository, pick the new one, and set the production branch to main.

  4. Renaming the project changes its generated *.vercel.app hostname but not custom domains. If SHARE_BASE_URL points at the generated hostname, update both together.


License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    An MCP server that enables users to upload local files to S3-compatible storage and retrieve shareable public URLs. Files are automatically organized into unique UUID-based folders to prevent naming conflicts while preserving original filenames.
    5 npm
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server for UploadThing that lets AI assistants upload, list, and delete files on UploadThing's CDN via natural language. Runs as a Cloudflare Worker for always-on serverless access.
    5 npm
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    MCP server for managing personal assets (images, files) in Vercel Blob storage, enabling upload, list, metadata, delete, and copy operations.
    5
    2 npm
    MIT
  • F
    license
    A
    quality
    B
    maintenance
    Enables AI agents and users to upload and download files via MCP, generating shareable links and identifier codes. Files are automatically deleted after 24 hours.
    9
    -