pexels-mcp-server
by VictorNain26
README.md
# pexels-mcp-server
[](https://github.com/VictorNain26/pexels-mcp-server/actions/workflows/ci.yml)
[](LICENSE)
[](pyproject.toml)
[](https://modelcontextprotocol.io/specification/2025-11-25)
A Model Context Protocol (MCP) server that gives AI agents access to free
stock photos and videos from [Pexels](https://www.pexels.com/). Plug it
into claude.ai web, Claude Desktop, Claude Code, Cursor or any MCP-aware
client and the model gains the **three MCP primitives** (tools, resources,
prompts) over the Pexels REST surface.
Built around the [MCP spec 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25)
and Anthropic's [Writing tools for agents](https://www.anthropic.com/engineering/writing-tools-for-agents)
guidance: strict Pydantic input schemas, structured tool output via
`structuredContent` + `outputSchema`, `isError=true` on tool failure per
SEP-1303, OAuth 2.1 + RFC 9728 + RFC 7591 DCR + PKCE for the HTTP transport.
## What the agent gets
### 8 tools (model-controlled)
| Tool | Purpose |
|---|---|
| `pexels_search_photos` | Search photos. Filters: `orientation`, `size`, `color`, `locale`, plus post-hoc `min_width` / `min_height` / `aspect_ratio`. |
| `pexels_get_photo` | Fetch one photo by id. |
| `pexels_search_videos` | Search videos. Same filters minus `color`. |
| `pexels_get_video` | Fetch one video by id. |
| `pexels_get_collection_media` | Read photos + videos in a Pexels collection. |
| `pexels_get_curated_photos` | Pexels' editor-curated daily photo feed. Post-hoc dim/aspect filters. |
| `pexels_get_popular_videos` | Trending video feed. Native `min_width` / `min_height` / `min_duration` / `max_duration` (Pexels-side), post-hoc `aspect_ratio`. |
| `pexels_get_featured_collections` | Discover curated collection ids (metadata only — pipe an id into `pexels_get_collection_media`). |
### 3 resources (app-controlled, URI templates)
| URI template | MIME | Body |
|---|---|---|
| `pexels://photo/{photo_id}` | `application/json` | `SinglePhotoResult` |
| `pexels://video/{video_id}` | `application/json` | `SingleVideoResult` |
| `pexels://collection/{collection_id}` | `application/json` | `CollectionMediaResult` |
A user pasting a `pexels.com` URL into a chat lets the host attach the
content directly without the agent invoking a tool.
### 2 prompts (user-controlled, claude.ai connector menu)
| Prompt | Arguments | Use case |
|---|---|---|
| `find_hero_image` | `topic`, `orientation?`, `brand_color?`, `aspect_ratio?` | Marketing hero with brand fit |
| `find_broll` | `topic`, `orientation?`, `resolution?`, `aspect_ratio?` | B-roll, reels, hero loops |
Each prompt renders a short user-message brief that names the tool, the
filters and the attribution requirement — the agent acts in one turn
instead of asking the user for parameters.
## Token economy
Payloads are kept small, but the tool result is still sent twice. What
the server does:
- **Projected payloads.** Tools return a few fields per item (`id`, `alt`,
`page_url`, credit, dimensions, one `image_url` / `video_url`) instead
of the full Pexels object with its eight `src` renditions.
- **Tool descriptions** trimmed to the minimum LLM-actionable signal
(USE WHEN / DO NOT USE / filters / return shape).
- **Type docstrings** removed from `MediaSize`, `PhotoProjection`,
`VideoProjection`, `FilterDiagnostics` etc.: they leaked as `description`
fields into every tool's `$defs`, duplicated across all tools that
referenced them. Now Python comments only.
- **`serverInfo.instructions`** kept to three short sentences (what the
server is, the attribution requirement, how to use the CDN URLs); the
tool list is already shipped by `tools/list`.
- **SDK patch** (see [`_sdk_patches.py`](src/pexels_mcp_server/_sdk_patches.py)):
- Forces `model_dump(exclude_unset=True)` so unset optional TypedDict
fields don't leak as `"field": null`.
- Writes the text copy in `content[]` as compact JSON instead of the
SDK's `indent=2`. The copy itself stays: claude.ai's custom-connector
path reads only `content`, so the payload is sent both as
`structuredContent` and as text.
Measured on `pexels_search_photos(query="paris", per_page=15)` through an
in-process MCP client session (`mcp.shared.memory`), with the Pexels API
mocked by a 15-photo response of about 20 100 chars:
| | `content[0].text` | `structuredContent` (compact JSON) | total |
|---|---|---|---|
| `indent=2` text (SDK default) | 6 810 chars | 5 668 chars | 12 478 chars |
| This server (compact text) | 5 668 chars | 5 668 chars | **11 336 chars** |
The compact text saves about 17 % of the text copy (9 % of the whole
result). Other sizes from the same run: `serverInfo.instructions` is
298 chars, and `tools/list` for the 8 tools is 24 205 chars of compact
JSON, 4 033 of which are descriptions.
## How the agent picks the best image
Pexels already ranks results by relevance. The tools just let the agent
narrow the field in one shot:
1. **Frame query + filters** — `orientation` for hero banners,
`aspect_ratio` for fixed-frame (Instagram 1:1, Story 9:16, hero 16:9),
`min_width` / `min_height` for hard pixel floors (~4000 for A4 print,
~1920 for hero), `color` for brand fit.
2. **Read alt text** — `pexels_search_photos` returns up to 15 candidates
by default with `alt` text, dimensions and photographer credit. The
agent drops anything off-topic and returns the best `image_url` plus
the mandatory `photographer` / `photographer_url`.
When a post-hoc filter (`aspect_ratio` etc.) wipes the page, the envelope
carries a `filter_diagnostics` block telling the agent how to retry.
## Deployment
Designed for **one hosted HTTPS endpoint** with OAuth 2.1 + RFC 9728.
Stdio is supported for local power-user clients (Cursor, scripts).
### Auth model — bring-your-own-key (BYOK) during the OAuth flow
The Python process is both the Resource Server (holding `/mcp`) and the
Authorization Server. The MCP Python SDK mounts every well-known endpoint
automatically: `/.well-known/oauth-protected-resource` (RFC 9728),
`/.well-known/oauth-authorization-server` (RFC 8414), `/authorize`,
`/token`, `/register` (RFC 7591 DCR), all with PKCE.
`register_client` rejects `redirect_uri` schemes that aren't `https://`
or `http://` loopback (OAuth 2.1 phishing mitigation).
After the standard handshake, the server redirects the user's browser to
`/setup`, a short HTML form asking for a Pexels API key. The user pastes
their free key (from <https://www.pexels.com/api/>), the server validates
it against `api.pexels.com`, then mints the OAuth code with the key bound
to the soon-to-be-issued access token (30-day TTL). Every tool / resource
call resolves the caller's key by Bearer-token lookup.
For per-request clients (Cursor stdio bridges, scripts), the server also
accepts an `X-Pexels-Api-Key` HTTP header as a fallback.
### Environment variables
| Variable | Required | Description |
|---|---|---|
| `TRANSPORT` | yes | `streamable-http` or `stdio` (default). |
| `MCP_SERVER_URL` | yes (HTTP) | Public HTTPS URL of this service. No trailing slash. |
| `MCP_ALLOWED_HOSTS` | no | Comma-separated `Host` allowlist (DNS rebinding protection). Auto-set to `MCP_SERVER_URL`'s hostname if unset. |
| `MCP_RATE_LIMIT_PER_MINUTE` | no (60) | Per-IP rate limit. `/healthz`, `/readyz`, OAuth metadata are exempt. |
| `MCP_TRUSTED_PROXY_HOPS` | no (1) | Proxies in front of the app (Koyeb LB = 1, Cloudflare-then-Koyeb = 2, no proxy = 0). |
| `REDIS_URL` | no | When set, OAuth state lives in Redis and survives restarts. Supports `rediss://` (TLS). |
| `MCP_ENCRYPTION_KEY` | yes if `REDIS_URL` | 32-byte url-safe base64 Fernet key. Pexels keys are encrypted at rest. Generate: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. |
| `HOST` / `PORT` | no | Default `127.0.0.1:8000`. Docker flips host to `0.0.0.0`. |
| `LOG_LEVEL` | no (`INFO`) | Standard Python levels. |
| `LOG_FORMAT` | no | `json` (default in HTTP) or `text` (default in stdio). |
| `PEXELS_API_KEY` | stdio only | Default key for local clients. Ignored in HTTP mode. |
### Persistent sessions (Redis, optional but recommended in prod)
Without `REDIS_URL`, OAuth state is in-memory and every Koyeb deploy
forces users to re-walk `/setup`. With Redis, registered clients, access
tokens and bound keys survive restarts. Short-lived OAuth state (pending
`/setup` sessions, authorization codes) stays in process memory either
way, so Redis does not make multiple replicas safe.
The bound Pexels key is encrypted client-side with Fernet (AES-128-CBC +
HMAC-SHA256) before being written — a leaked Redis dump alone yields
opaque ciphertext.
Compatible providers: [Upstash Redis](https://upstash.com/) (free tier
10k cmd/day, 256 MB, TLS), Redis Cloud, self-hosted. See
[`docker-compose.yml`](docker-compose.yml) for the local dev setup.
### Koyeb (one-command deploy)
```bash
koyeb service create pexels-mcp \
--git github.com/VictorNain26/pexels-mcp-server \
--git-branch main \
--git-builder docker \
--ports 8000:http \
--routes /:8000 \
--checks 8000:http:/healthz \
--env TRANSPORT=streamable-http \
--env "MCP_SERVER_URL=https://{{ KOYEB_PUBLIC_DOMAIN }}" \
--env "MCP_ALLOWED_HOSTS={{ KOYEB_PUBLIC_DOMAIN }}" \
--env LOG_FORMAT=json \
--instance-type nano \
--regions fra
```
Then add `REDIS_URL` + `MCP_ENCRYPTION_KEY` for persistent sessions.
### Smoke test
```bash
URL=https://<your-service>.koyeb.app
curl -s "$URL/healthz" # -> ok
curl -s "$URL/.well-known/oauth-protected-resource" | head -20
curl -i -X POST "$URL/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json,text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{}' | head -10
# -> 401 with WWW-Authenticate: Bearer ... resource_metadata="..."
```
### Connect a client
| Client | Steps |
|---|---|
| **claude.ai web** | Settings → Connectors → Add custom connector → URL `https://<host>/mcp`. Click *Connect*. Paste your Pexels key on the `/setup` page. |
| **Claude Desktop** | Settings → Connectors → Add (remote) → same URL. Same `/setup` flow. |
| **Claude Code** | `claude mcp add pexels --transport http https://<host>/mcp`. |
| **MCP Inspector** | `npx @modelcontextprotocol/inspector` → paste the URL. |
## Install
This package (`pexels-mcp` in `pyproject.toml`) is **not published on
PyPI**; install it from this repository. The `pexels-mcp-server` name on
PyPI belongs to an unrelated project, so do not `pip install` it.
Run the stdio server straight from Git:
```bash
PEXELS_API_KEY=your-key uvx --from git+https://github.com/VictorNain26/pexels-mcp-server pexels-mcp-server
```
## Local development
```bash
git clone https://github.com/VictorNain26/pexels-mcp-server
cd pexels-mcp-server
uv sync --all-extras
```
### HTTP server (prod parity)
```bash
TRANSPORT=streamable-http HOST=127.0.0.1 PORT=8000 \
MCP_SERVER_URL=http://127.0.0.1:8000 \
uv run pexels-mcp-server
```
### Full stack with Redis (Fernet path exercised)
```bash
echo "MCP_ENCRYPTION_KEY=$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')" > .env
docker compose up --build
```
### Stdio (Cursor, local scripts)
```bash
PEXELS_API_KEY=your-key uv run pexels-mcp-server
```
Stdio bypasses OAuth — the key comes from the env var directly.
### Check suite
```bash
uv run ruff check && uv run ruff format --check
uv run mypy src
uv run python -m pytest
```
## Response shape
`pexels_search_photos(query="paris", per_page=1)` ships:
- `structuredContent` (canonical payload, machine-readable, ~600c):
```json
{
"page": 1,
"per_page": 1,
"count": 1,
"has_more": true,
"next_page": 2,
"total_results": 8000,
"photos": [
{
"id": 28448939,
"alt": "Vibrant street view of central Paris ...",
"page_url": "https://www.pexels.com/photo/.../28448939/",
"photographer": "Sergey Guk",
"photographer_url": "https://www.pexels.com/@sergeyguk",
"width": 4000,
"height": 6000,
"image_url": "https://images.pexels.com/photos/28448939/.../original.jpeg"
}
]
}
```
- `content[0]`: the same object as compact JSON text
(`{"page":1,"per_page":1,...}`).
The text copy is there because claude.ai's custom-connector path passes
only `content` to the model. Clients that read `structuredContent` can
ignore it.
## Three usage examples
### 1. Hero image with brand color and aspect ratio
```python
pexels_search_photos(
query="modern open-plan office workspace",
orientation="landscape",
size="large",
color="blue",
aspect_ratio="16:9",
min_width=1920,
per_page=6,
)
```
### 2. 4K B-roll, fixed aspect
```python
pexels_search_videos(
query="aerial drone shot of mountain lake at dawn",
orientation="landscape",
size="large",
aspect_ratio="16:9",
per_page=10,
)
```
`video_url` is the direct MP4 of the top-resolution variant.
### 3. Drill into a Pexels collection
```python
pexels_get_collection_media(collection_id="9j5dhpu", per_page=20)
```
The response splits `photos[]` and `videos[]`. Filter to one type with
`type="photos"` or `type="videos"`.
## Rate limits and attribution
Pexels free tier: **200 requests/hour, 20 000 requests/month** on the
caller's key (per Pexels' [API docs](https://www.pexels.com/api/documentation/)).
The server warns to stderr below 100 remaining; the response envelope
does not carry rate-limit metadata (saves tokens — flip `LOG_LEVEL=DEBUG`
if you need it).
If you publish anything returned by this server you **must** credit the
photographer / videographer and link back to Pexels per the
[Pexels licence](https://www.pexels.com/license/). Every tool, resource
and prompt is shaped so the LLM sees `photographer` / `uploader_name`
and matching URLs and can surface them in the user-facing answer.
## Architecture notes
- **3-of-3 MCP primitives.** Tools (model-controlled), Resources
(app-controlled, URI templates per RFC 6570), Prompts (user-controlled,
surfaced in claude.ai's connector menu).
- **Spec-compliant auth.** OAuth 2.1 Resource Server + Authorization
Server in one process via the MCP Python SDK's
`OAuthAuthorizationServerProvider`. RFC 9728 PRM, RFC 8414 ASM, RFC 7591
DCR, PKCE — all served by the SDK. The only custom routes are
`GET /` (landing) and `GET/POST /setup` (BYOK form).
- **Stateless MCP transport, single-replica OAuth.**
`stateless_http=True, json_response=True`: the MCP transport allocates
no session IDs, so tool calls need no sticky sessions. Trade-off: no
sampling / no `ctx.report_progress` / no resource subscriptions —
documented in [`CLAUDE.md`](CLAUDE.md). The server as a whole is **not**
horizontally scalable yet: pending `/setup` sessions, authorization
codes and the transient code→key binding live in process memory (even
with `REDIS_URL`), so an OAuth flow that hits a second replica fails.
The rate limiter is per-process too. Run one replica.
- **Read-only by construction.** Every tool advertises
`readOnlyHint=true, destructiveHint=false, idempotentHint=true,
openWorldHint=true` plus a `title`.
- **Structured tool output + `isError=true`.** Tools return a `TypedDict`;
the SDK auto-generates `outputSchema`. Errors raise → FastMCP wraps in
`CallToolResult(isError=true)` per SEP-1303.
- **Strict inputs.** Pydantic v2 with `extra="forbid"`; invalid values
come back as `Invalid parameters: <field>: <reason>`.
- **Token-lean payloads.** See the [Token economy](#token-economy)
section above.
- **SDK patches** in [`_sdk_patches.py`](src/pexels_mcp_server/_sdk_patches.py).
The only place in the repo that mutates third-party state.
## Health and probes
`GET /healthz` (liveness) and `GET /readyz` (readiness) return `200 ok`
and bypass auth. The Dockerfile declares `HEALTHCHECK` against `/healthz`.
## Compatibility
- Python 3.10, 3.11, 3.12.
- `mcp` SDK pinned `>=1.25,<2`.
- Transport: stdio + Streamable HTTP. Legacy SSE is not enabled.
- MCP spec 2025-11-25 (SDK negotiates downgrade to 2025-06-18 / 2025-03-26).
See [SECURITY.md](.github/SECURITY.md) to report a vulnerability,
[PRIVACY.md](PRIVACY.md) for what the server does and doesn't store.
## License
MIT. See [LICENSE](LICENSE).
TDQS
A4.7/5.0
Scored across 8 tools
Disambiguation5/5
Each tool targets a distinct resource and action (e.g., search vs. get individual, photos vs. videos, collections vs. curated vs. popular). No overlapping purposes.
Naming Consistency5/5
All tools follow a consistent 'pexels_verb_noun' pattern with clear verbs (get, search) and nouns (photos, videos, collections, media). No mixing of conventions.
Tool Count5/5
8 tools is well-scoped for a stock media API covering both photos and videos with search, individual retrieval, curated feeds, and collections. Each tool earns its place.
Completeness5/5
The tool surface covers all key Pexels features: search, single-item fetch, curated lists, popular feeds, and collection browsing. No gaps for a read-only API; uploads are outside scope.
Maintenance
ActivityMaintained
ResponsivenessUnresponsive