MCP Image Library

Images for
Claude,
on demand.

A remote MCP server that gives Claude access to a curated image library. Claude searches by tag or purpose, gets back public URLs, and uses them anywhere — landing pages, docs, PDFs, slide decks.

Railway · hosted OAuth 2.1 · Clerk Cloudflare R2 · storage Supabase · metadata pixlib.app · live
Live images · Cloudflare R2 · pixlib.app
9
Images in library
2
MCP tools exposed
5
Infrastructure pieces
2.1
OAuth version
3
CLI utilities built

Architecture

how it connects
Claude Desktop
Local stdio transport. No auth required. Connects directly to index.js via node process.
Railway MCP Server
StreamableHTTP transport at pixlib.app. Stateless — new McpServer per POST request.
Supabase + R2
Postgres stores metadata. R2 stores image files. Only connect at write time (bulk.js).
Management layer (your machine only)
bulk.js — bulk import + R2 upload + auto-tag manage.js — add/remove individual images migrate-to-r2.js — one-time Supabase → R2 migration

Data Layer

two databases, one purpose
Supabase Postgres metadata

Stores everything Claude needs: name, URL, purpose, tags, description. Tag filtering uses Postgres array operators so Claude can ask for images with ALL specified tags.

-- images table
id         uuid     primary key
name       text     not null
file_path   text     R2 public URL
purpose     text     hero | landing-page | ...
tags       text[]   GIN indexed array
description text     AI generated
created_at timestamptz default now()
Cloudflare R2 file store

Stores the actual image files. No egress fees, global CDN, public bucket. Migrated from Supabase Storage because Supabase's CDN was blocking Anthropic's server IPs.

// bulk.js upload flow
await r2.send(new PutObjectCommand({
  Bucket: "pixlib-images",
  Key: filename,
  Body: fileBuffer,
  ContentType: contentType
}));
Known issue: Cloudflare R2 also blocks Anthropic's IP range (160.79.104.0/21). Images load in browsers but Claude cannot fetch them server-side. PDF generation with embedded images is currently broken.

Stack

6 pieces
Supabase
Postgres for image metadata
ohdrnymwmhdtqfvaboxe.supabase.co
Cloudflare R2
File store, migrated from Supabase Storage
pub-ec825bb937cf408697bc337c32cd668b.r2.dev
Railway
Hosts the MCP server at pixlib.app
StreamableHTTP transport · port 8080
Clerk
OAuth 2.1 provider for remote connections
clerk.pixlib.app · production instance
MCP SDK
StreamableHTTP remote, stdio local
@modelcontextprotocol/sdk v1.29.0
Porkbun
Domain registrar for pixlib.app
DNS via Cloudflare · SSL via Railway

Status

what works, what doesn't
Working
Local MCP via Claude Desktop (stdio)
Remote MCP via Claude.ai (StreamableHTTP + OAuth)
OAuth 2.1 flow via Clerk production instance
Auto-tagging with Claude Haiku on bulk import
search_images and list_images tools verified in MCP Inspector
In Progress
Server-side image fetching — blocked by R2/Anthropic IP issue
PDF generation with embedded images — same IP blocker
Anthropic Connectors Directory submission — pending
Landing page, pricing, socials (Step 4)
CDN that doesn't block Anthropic's IP range (160.79.104.0/21)

Library

9 images · Cloudflare R2

Code

index.js — move mouse to scroll
index.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createClerkClient } from "@clerk/backend";

// Opaque token store — avoids exposing Clerk JWTs to Claude.ai
const tokenStore = new Map();

async function validateToken(req) {
  const token = req.headers["authorization"]?.slice(7);
  if (!token) return null;
  if (token === process.env.INSPECTOR_KEY) return { sub: "inspector" };
  const entry = tokenStore.get(token);
  if (!entry || Date.now() > entry.expiresAt) return null;
  return { sub: entry.sub };
}

// MCP tools — read-only, annotated
server.tool("search_images", "Search by purpose and tags...", {
  purpose: z.enum(["hero", "landing-page", "..."]).optional(),
  tags: z.array(z.string()).default([]),
  limit: z.number().default(10),
}, { title: "Search Images", readOnlyHint: true }, async ({ purpose, tags, limit }) => {
  let query = supabase.from("images").select("*").limit(limit);
  if (purpose) query = query.eq("purpose", purpose);
  if (tags.length) query = query.contains("tags", tags);
  const { data } = await query;
  return { content: [{ type: "text", text: formatResults(data) }] };
});

// Stateless StreamableHTTP — new server per POST request
if (req.method === "POST") {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless
  });
  const mcpServer = createMcpServer();
  await mcpServer.connect(transport);
  await transport.handleRequest(req, res);
}

Problems

01
Problem
Node.js 18 on Railway — no native WebSocket support, Supabase client crashed on startup
Resolved
Added "engines": {"node": ">=20"} to package.json, forced Railway to use Node 24
02
Problem
GitHub authentication failing — password auth is not supported for Git operations
Resolved
Generated SSH key, added public key to GitHub, switched remote URL to git@ format
03
Problem
Images over 5MB failing Claude Haiku vision — base64 encoding exceeded API limit
Resolved
Upload image to Supabase first, then pass public URL to Claude instead of base64 bytes
04
Problem
Tool annotations breaking handler — "typedHandler is not a function" error in MCP Inspector
Resolved
Wrong SDK API — annotations must be the 4th argument, handler the 5th, not merged into description object
05
Problem
Claude Desktop rejected remote MCP — "url field not a valid MCP server configuration"
Resolved
Claude Desktop doesn't support URL-based remote MCPs yet. Kept local stdio for Desktop, Railway URL for Claude.ai
06
Problem
OAuth redirect hitting Clerk dev domain — SSL error on accounts.*.clerk.accounts.dev
Resolved
Created Clerk production instance, bought pixlib.app domain, added DNS records, hardcoded clerk.pixlib.app
07
Problem
Claude.ai rejected Clerk JWT — token missing standard "aud" claim, Clerk uses "client_id" instead
Resolved
Switched to opaque token store — exchange Clerk JWT internally, issue a random hex token to Claude.ai
08
Problem
MCP messages posting to "/" not "/message" — 404 on every tool call, tools appeared connected but silent
Resolved
Updated message handler to accept POST to both "/" and "/message", switched SSE endpoint path to "/"
09
Problem
Tools connected but not discoverable — Claude.ai showed "no tools available" after OAuth succeeded
Resolved
Claude.ai uses StreamableHTTP not SSE. Switched transport. Stateless mode (new server per POST) fixed tool discovery
10
Problem — Unresolved
Supabase Storage CDN blocking Anthropic's IP range (160.79.104.0/21) — Claude gets 403 on image fetch
Attempted Fix
Migrated to Cloudflare R2. R2 also blocks Anthropic's IPs. Images load in browsers but Claude cannot fetch server-side. PDF generation broken.

Development

local → hosted
Local MCP via stdio
Built index.js with two read-only tools — search_images and list_images. Connected to Claude Desktop via node process in claude_desktop_config.json. No auth, no server, just a local pipe. First proof that Claude could query a Supabase image database.
Supabase Storage + bulk.js
Added bulk.js to import entire folders at once. Switched from manual tagging to Claude Haiku vision — images are uploaded then analyzed via their public URL. Auto-generates purpose, tags, and description for each image.
Railway deployment + SSE transport
Pushed to GitHub, connected Railway. Switched from stdio to SSE transport so the server could run remotely. Added health check, OAuth metadata discovery, and the first attempt at Clerk OAuth — hit multiple issues with JWT format, wrong transport, and IP blocks.
Custom domain + Clerk production
Bought pixlib.app via Porkbun, pointed DNS to Railway, provisioned SSL. Migrated Clerk from dev to production instance — required 5 DNS records and a separate Cloudflare setup. OAuth login finally worked end-to-end.
StreamableHTTP + opaque tokens
Discovered Claude.ai uses StreamableHTTP, not SSE. Switched transport. Clerk JWTs were missing the "aud" claim so Claude rejected them — replaced with an opaque token store. Tools finally appeared in Claude.ai settings.
Live on Claude.ai · Submitted to Anthropic
Connector live at pixlib.app with full OAuth 2.1 flow. Migrated images to Cloudflare R2. Remaining blocker: both Supabase and R2 block Anthropic's server IPs for direct image fetching.

Proof of Work

local MCP · Claude Desktop · stdio

These are real tool call responses captured from Claude Desktop during local development. Three calls to list_images showing the library growing from 0 → 4 → 8 images as bulk.js imports ran.

list_images
Request
{}
Response
No images in library yet.
Before first bulk import
list_images
Request
{}
Response
Showing 1–4 of 4 image(s):

• ykaiavu-field...
  Path: [truncated]
After first batch import
list_images
Request
{}
Response
Showing 1–8 of 8 image(s):

• wolfgang_hasselmann...
  Path: [truncated]
After second batch import

Privacy Policy

Last updated June 2026

Data collected

Email address and name when you authenticate via Clerk. No other personal data is collected or stored.

How it's used

Authentication only. No marketing, no analytics, no tracking. Your credentials are never shared.

Third-party services

Auth via Clerk. Metadata via Supabase. Files via Cloudflare R2.

Contact

Data deletion requests: ninavolu7@gmail.com. Data retained while account is active.

v1 — local stdio v2 — own database · current v3 — Unsplash integration · in progress