← Index

Pixlib

Pixlib is a remote MCP server that gives Claude a curated image library. Claude searches by tag or purpose, gets a public URL, and uses it in a landing page, a doc, a PDF, a deck.

I built it because I kept watching Claude skip the picture or invent a placeholder. I wanted to ask for a sunflower and get a real file.

The whole thing is one Node file and a handful of scripts. No framework, no build step.

The shape of it

Two read-only tools. search_images filters on purpose and tags, list_images pages through everything. Neither one writes.

Claude Desktop stdio, no auth Claude.ai HTTP, bearer token index.js one file, two transports /health · /.well-known · /oauth/* · POST /mcp Clerk OAuth identity Postgres images table Object store public URLs
The server never sends image bytes. It sends rows that point at a bucket.

The same file boots two ways. With no port set it speaks stdio, which is what Claude Desktop spawns. With a port it comes up as an HTTP server and carries the whole OAuth surface.

Writing happens somewhere else. bulk.js and manage.js hold the service key and run on my laptop. The deployed server has no code path that writes. That split is the decision the rest of the repo hangs off.

What's in it

  • index.js the server, both tools, all the OAuth
  • bulk.js import a folder, upload, auto-tag
  • manage.js add, remove, list, one at a time
  • migrate-to-r2.js point every file_path at R2
  • migrate-back-to-supabase.js the undo
  • setup.sql one table, two indexes
  • railway.json, netlify.toml deploy config

One table holds all of it: name, URL, purpose, tags, description. Tags are a Postgres array with a GIN index on it, so a search for dark and minimal is one query that matches both, not a pile of LIKEs.

bulk.js is the one I like. It uploads the file first, then hands Claude Haiku the resulting public URL instead of the bytes. Anything over 5MB used to blow past the vision limit once it was base64. Pointing at a URL sidesteps that, and Haiku writes back the purpose, three to six tags, and a sentence.

Signing in

Claude.ai wants OAuth 2.1. Clerk handles the identity part, but its tokens carry a client_id where the spec wants an aud, and Claude rejected them on that alone.

So the server stopped passing Clerk's token through at all.

Claude.ai Pixlib Clerk /.well-known discovery GET /oauth/authorize 302, PKCE forwarded callback with code exchange, read userinfo opaque token, not the JWT
The last hop hands back 32 random bytes. Clerk's own token stays on the server, in a Map.

That map lives in memory, which has two consequences I have not fixed. A redeploy logs everyone out, and I cannot run a second instance. Moving it into the same Postgres the images already sit in is the next real piece of work.

The IP problem

Anthropic's servers sit in 160.79.104.0/21. Both Supabase Storage and R2 refuse that range.

Supabase Storage where bulk.js writes migrate-to-r2.js Cloudflare R2 no egress fees, global CDN migrate-back-to-supabase.js Supabase Storage where file_path points now
Both buckets block the same range, so the round trip changed nothing.

Images load fine in a browser. Claude cannot fetch them server-side, so a PDF with embedded pictures still comes out broken. Each migration script skips any row already pointing at its destination, which is the only reason running them twice is safe.

The fix is a proxy in front of the bucket, not a fourth storage provider.

What's live

Local stdio works in Claude Desktop. The remote server is up with StreamableHTTP and OAuth, and both tools show up in the connector list. Bulk import auto-tags on the way in.

Everything on the read path works. The write path is mine and stays mine.