ifPNG

MCP server — image conversion for AI agents

ifPNG exposes its conversion pipeline to Claude and other AI agents through a Model Context Protocol server at POST /api/mcp. It is available to active Pro subscribers only.

Endpointhttps://ifpng.com/api/mcp
TransportStreamable HTTP, stateless (POST only, no SSE, no session ids)
Protocol revision2025-06-18 (also answers 2025-03-26, 2024-11-05)
AuthOAuth 2.1 (PKCE + dynamic client registration), or Authorization: Bearer ifpng_mcp_… API key
Toolsconvert_image, inspect_image, list_formats, get_usage
Quota5,000 conversions per calendar month, 30 requests/min per credential
Public page/mcp

For subscribers: connecting an agent

The short way — no API key

Paste https://ifpng.com/api/mcp into your client as a custom connector and sign in. Claude's apps, Claude Code, Cursor and VS Code all discover the authorization server from the endpoint itself and run the OAuth flow for you: you land on an ifPNG consent screen, approve, and you are connected.

# Claude Code
claude mcp add --transport http ifpng https://ifpng.com/api/mcp
// Cursor, VS Code, Claude Agent SDK
{
  "mcpServers": {
    "ifpng": { "type": "http", "url": "https://ifpng.com/api/mcp" }
  }
}

Manage connected clients — and disconnect any of them — in Dashboard → MCP.

The API-key way — for CLI, CI and headless boxes

Where no browser can complete a sign-in, mint a key in Dashboard → MCP (shown once, five active per account) and send it as a bearer token:

claude mcp add --transport http ifpng https://ifpng.com/api/mcp \
  --header "Authorization: Bearer ifpng_mcp_YOURKEY"

Use one key per machine, so revoking one does not disconnect everything.

Hosts that only launch local stdio servers — use the bridge in mcp-bridge/, a dependency-free script that forwards stdio JSON-RPC to the same endpoint.

From the terminal

cli/ifpng.mjs is a zero-dependency CLI over the same endpoint — no second conversion path, so it cannot drift from what agents get:

node cli/ifpng.mjs login ifpng_mcp_YOURKEY
node cli/ifpng.mjs convert shot.png --to webp --width 1200
node cli/ifpng.mjs info photo.jpg
node cli/ifpng.mjs usage

Verifying by hand

curl -s https://ifpng.com/api/mcp \
  -H "Authorization: Bearer ifpng_mcp_YOURKEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq

Tools

convert_image

Converts to PNG, JPEG, WebP or AVIF, with optional resizing. Counts one conversion against the monthly quota.

ArgumentTypeNotes
image_base64stringThe bytes, base64. A data: prefix is stripped. Max 25 MB decoded.
image_urlstringPublic https URL. Exactly one of this or image_base64.
toenumRequired. png | jpeg | webp | avif
quality1–100Defaults: jpeg/webp 82, avif 50. For png, enables palette quantisation.
width, heightintTarget box.
fitenumcover | contain | inside (default) | outside | fill
allow_enlargementboolDefault false — images are never upscaled unless asked.
backgroundhexFill when flattening alpha into JPEG, or padding with contain. Default #ffffff.
keep_metadataboolDefault false. See Privacy.
filenamestringPreferred output name; the extension comes from to.
outputenumauto (default) | inline | url

Delivery. Results at or under ~1.5 MB come back as an MCP resource blob the host can write to disk. Larger ones are uploaded to R2 and returned as a signed URL valid for one hour. auto picks between them; inline on an oversized result returns an error rather than silently switching, so a client that needs bytes never receives a link by surprise.

A resource blob is used rather than an image content block on purpose: an image block pushes the whole picture into the model's context window, which is expensive and almost never what you want from a file conversion.

inspect_image

Format, dimensions, byte size, alpha, frame count, colour space, density, orientation, and whether metadata is present — without converting. Free.

list_formats

Supported input/output formats and every enforced limit. Free, no arguments.

get_usage

Conversions used this month, remaining, and the reset time. Free.


Authorization (OAuth 2.1)

ifPNG is both the resource server and its own authorization server. Running our own rather than delegating to an identity vendor keeps the consent screen honest — the user is already signed in with NextAuth — and puts no third party between a subscriber and the tools they pay for.

The flow

  1. The client calls /api/mcp with no token → 401 carrying WWW-Authenticate: Bearer resource_metadata="https://ifpng.com/.well-known/oauth-protected-resource".
  2. It fetches that document (RFC 9728) and learns the authorization server.
  3. It fetches /.well-known/oauth-authorization-server (RFC 8414) for the endpoints.
  4. It registers itself at /api/mcp/oauth/register (RFC 7591) — no developer portal, no manual client id.
  5. It opens /api/mcp/oauth/authorize with PKCE S256 and a resource parameter. We validate, park the request server-side, and redirect to the consent screen at /mcp/authorize.
  6. The user signs in (if needed) and approves. /api/mcp/oauth/consent mints a single-use code and returns them to the client.
  7. The client exchanges the code at /api/mcp/oauth/token for a 1-hour access token and a 30-day refresh token.

Both .well-known documents are also served with the resource path appended (/.well-known/oauth-protected-resource/api/mcp), because RFC 9728 builds the URL that way and clients differ on which they try first.

Scopes

ScopeGrants
image.convertconvert_image, inspect_image
account.usageget_usage

list_formats needs none. Tools outside a connection's scopes are omitted from tools/list rather than listed and refused — a model that cannot see a tool will not plan around it. API keys carry every scope, since they are minted by the account owner for their own use.

Hard choices worth knowing

  • PKCE S256 only. plain is not advertised and is rejected at /authorize.
  • Exact redirect URI matching. No prefixes, no wildcards. Registration accepts https, or http on loopback for local clients.
  • Errors are only redirected once the client and redirect URI are validated. An unknown client gets a rendered error page, never a bounce to an unvalidated URL.
  • Refresh tokens rotate. Reusing a rotated refresh token, or replaying an authorization code, is treated as theft: every live token for that client+user pair is revoked. The victim reconnects; the thief keeps nothing.
  • Audience binding is enforced twice — at issuance and again on every MCP call. A token minted for another resource is rejected outright.
  • Consent is required for every client, every time it is registered. A dynamically registered client is inert until a signed-in subscriber approves it.

Security model

The threat model starts from the assumption that arguments are chosen by a language model that may be acting on text from an untrusted source.

Credentials. Both credential types are 256 bits of CSPRNG output. Only a SHA-256 hash is stored, so a database leak cannot be replayed; the hash doubles as the lookup index. Plain SHA-256 rather than bcrypt is correct here — there is no low-entropy guess space for a slow KDF to defend, and an unindexable column would force a full scan plus N bcrypt compares on every call. Keys are accepted only in the Authorization header, never a query parameter, which keeps them out of access logs and browser history.

Entitlement. Re-derived from users.subscription_status on every request rather than baked into the key. A cancelled subscription, a failed renewal, an admin suspension or a self-service deletion all cut off agent access on the next call, with no cache to expire and no revocation sweep to run.

SSRF. image_url is the sharpest edge in the system, so lib/mcp/safe-fetch.ts applies: an https-only scheme check (http is allowed off-production), a port allowlist of 80/443, a private/reserved IP blocklist covering RFC1918, loopback, link-local (including 169.254.169.254), CGNAT, ULA and IPv4-mapped/NAT64 IPv6 forms, connection pinning to an already-validated address via a custom DNS lookup so rebinding cannot win the race, manual redirect following with every hop re-validated, a streamed byte cap, Accept-Encoding: identity, and a 10-second deadline. Fetched bodies are only ever decoded as images, never echoed back, so failures cannot be used to read internal content.

Decoding. The container format comes from sharp's probe of the bytes, never from a filename or Content-Type, and must be on the allowlist. SVG and PDF are excluded because their renderers resolve external references — that would turn a conversion into a file-read primitive. limitInputPixels caps the decoded surface at 50 MP so a compressed decompression bomb fails at decode rather than exhausting memory.

Abuse. 30 requests/minute per key (bucketed on a hash of the key, so no credential material reaches Redis) and 5,000 conversions/month per account. Rate limiting keys on the credential rather than the IP because agent traffic arrives from shared egress, where per-IP limits would have one subscriber throttling another.

Browser isolation. The endpoint emits no CORS headers at all, so a malicious page cannot ride along on a user's stored credentials.

Privacy

EXIF, GPS, IPTC and XMP are stripped by default. Orientation is baked into the pixels first, so stripping never silently rotates a portrait photo. Setting keep_metadata: true preserves everything, including location — the tool description says so explicitly so a model does not enable it casually.

Large outputs land in R2 under mcp/<userId>/ and are removed after 24 hours by scripts/prune-mcp-outputs.js. Inline results are never persisted at all.


Implementation

PathRole
src/pages/api/mcp/index.tsHTTP shell: method handling, rate limit, auth, dispatch
src/lib/mcp/protocol.tsJSON-RPC 2.0 / MCP dispatch
src/lib/mcp/tools.tsTool definitions, schemas and handlers
src/lib/mcp/image.tssharp pipeline, R2 storage of large outputs
src/lib/mcp/safe-fetch.tsSSRF-hardened fetcher
src/lib/mcp/auth.tsBearer auth + subscriber gate
src/lib/mcp/keys.tsKey mint/hash/list/revoke
src/lib/mcp/usage.tsMonthly quota accounting
src/lib/mcp/config.tsEvery tunable limit
src/lib/mcp/oauth/OAuth 2.1 server: metadata, DCR, codes, tokens
src/pages/api/mcp/oauth/authorize, consent, token, register, revoke, metadata
src/pages/mcp/index.tsxPublic landing page (/mcp)
src/pages/mcp/authorize.tsxConsent screen
src/pages/api/user/mcp-keys/Dashboard key management
src/pages/api/user/mcp-connections/Disconnect a connected AI client
cli/ifpng.mjsTerminal client over the same endpoint
src/components/dashboard/McpKeys.tsxDashboard MCP tab
mcp-bridge/stdio → HTTP bridge for stdio-only hosts
migrations/0019_mcp-api-keys.cjsmcp_api_keys, mcp_usage_daily
migrations/0020_mcp-oauth.cjsmcp_oauth_clients, _requests, _codes, _tokens

The protocol layer is hand-written rather than built on @modelcontextprotocol/sdk. The server offers four tools over stateless request/response with no sampling, roots or server-initiated messages; that subset is a few hundred readable lines, which beats adding a dependency tree to a payment-handling app. Nothing else in the repo depends on that choice — the tools and the transport are separate modules.

Limits

All in src/lib/mcp/config.ts. The MCP ceilings are deliberately stricter than the browser upload limits in lib/plans.ts: the web converter streams 100 MB through chunked uploads to R2, while an MCP call carries the bytes inline in a JSON body the route must hold in memory. Do not "align" the two.

Deployment

  1. pnpm db:migrate — creates the mcp_api_keys, mcp_usage_daily and mcp_oauth_* tables, and grants the prune role DELETE on the OAuth ones.
  2. No new environment variables. NEXT_PUBLIC_SITE_URL matters more than before: it is the OAuth issuer and the audience clients bind tokens to. It must be the real public origin, with no trailing slash — if it is wrong, discovery advertises endpoints nobody can reach. R2 credentials and REDIS_URL are used as before.
  3. Schedule node scripts/prune-mcp-outputs.js daily, alongside the other retention jobs in scripts/systemd/. Optional MCP_OUTPUT_RETENTION_HOURS (default 24).

Tests

pnpm test src/lib/mcp — protocol conformance, the SSRF blocklist, key handling, the auth/subscriber gate, the sharp pipeline (including a decompression-bomb case), and an end-to-end pass through the real route with a genuine PNG → WebP conversion.

Possible next steps

  • Submit to the Claude connector directory, which requires a stable privacy policy URL (we have one) and a review pass.
  • Batch conversion in one call, if usage shows agents looping over many files.
  • Per-key scopes for API keys, matching what OAuth connections already have.
  • Publish the CLI to npm so it installs with npx.