Skip to main content
Glama
arielshad
by arielshad

3d-asset-server searches 25 asset sites at once and downloads what you pick, ready to drop into a game or a website. Use it from a browser, from curl, from the command line, or let your AI assistant drive it over MCP.

"I need a low-poly tree pack, a mossy rock material and a sunset HDRI for my Three.js scene."

Your assistant calls search_assets three times, shows you the options with their licences, and download_asset puts glTF, textures and the HDRI into your project's assets/ folder.

  • One query, many sources. Results are merged and ranked, with a status line for every site.

  • Real downloads. From free sources you get files directly: glTF with its .bin and textures, PBR maps at the resolution you ask for, HDRIs, or zipped packs (extracted for you).

  • Licence first. Every result says what licence it has, whether it is free, and whether you must credit the author.

  • Honest about what's blocked. Sites that block bots come back as links to their own search, so you still know where else to look.


Contents


Related MCP server: Blendkit MCP Server

How it works

Architecture: the web UI, curl, AI assistants (MCP) and the CLI all reach one server. Its REST API and MCP server call AssetService, which searches 20 sources through a caching HTTP client: 11 via APIs, 6 scraped, 3 link-only.

What a single search does:

One search: pick the sources that fit, search them in parallel with a timeout each, rank and merge the results, and return them with a status report for every site.

A slow or broken site never fails the search. It shows up in the per-site report with its error and a link to the same search on that site.


Sources

Source

Best for

Search

Direct download

Licence

Poly Haven

HDRIs, PBR textures, scanned models

API

yes: glTF / blend / fbx, maps, HDR / EXR

CC0

ambientCG

Realistic materials, HDRIs, models

API

yes: per-resolution zips

CC0

CGBookcase

PBR textures

API (catalogue)

no: hotlink-protected CDN, links to the download page

CC0

ShareTextures

Textures and realistic models

API (tag search)

no: its licence forbids automated downloads

CC0 + site terms

BlenderKit

Blender assets of every kind

API

yes for free assets (GLB / blend); paid ones need BLENDERKIT_API_KEY

CC0 / royalty free

Fab

Game assets, environments, characters

link only (bot wall)

no

per listing

Kenney

Low-poly 3D, 2D, UI and audio packs

scrape

yes: pack zips, auto-extracted

CC0

Poliigon

Premium materials and models

link only (bot wall)

no

per listing

Quaternius

Low-poly models, animated characters

scrape

no: Google Drive / itch.io links

CC0 / QAL

Polyfork

Low-poly models and themed kits for web and games

API

yes for free assets (GLB from Polyfork's CDN); FBX / USDZ / OBJ need an account

royalty free, no redistribution

3DAssets.dev

Web-optimised GLB models: props, nature, buildings, vehicles

API

yes: GLB from its CDN

CC0

Grab3D

Free models with PBR textures: props, characters, vehicles, nature, buildings

scrape (sitemap)

yes: GLB, FBX, Blend, USDZ, OBJ

CC0

Sketchfab

Community 3D models: scans, props, characters (CC0 subset)

API

no: downloads need a Sketchfab login

CC0

Wikimedia Commons

STL scans, anatomy, reconstructions and printable parts

API

yes: STL from Wikimedia's CDN

per file (CC0, public domain, CC-BY, CC-BY-SA)

3DTextures.me

Realistic and stylized PBR

WordPress API

no: Google Drive folders

CC0

FreeStylized

Stylized models, materials, skyboxes and asset packs

WordPress API

no: Google Drive links

royalty free (custom CC0)

3DTexel

PBR materials, HDRIs, decals, 3D assets

API

no: downloads need a free account

CC0

TextureCan

PBR materials and a few models

scrape

yes: 1K-4K zips

CC0

OpenGameArt

Community 3D models, textures and 2D sprites

scrape

yes: plain zip / file links

per asset (mostly CC0, CC-BY)

Textures.com

Photo textures, 3D foliage, decals, skies

JSON API

no: credit system

Textures.com licence

HDRMaps

HDRIs and backplates

WooCommerce API

yes for free HDRIs (EXR)

royalty free

HDRI Hub

HDRI environments

scrape

no: checkout

royalty free

CGTrader

Free and paid models

JSON listing

no: login

per listing

TurboSquid

Free and paid models

link only (bot wall)

no

per listing

itch.io

Indie art, 3D packs, UI, audio

scrape

no: itch's download flow

per listing

Sources by what they give you: 11 with search and direct download, 11 with search and a link to the asset page, 3 linked to their own search.

Notes:

  • Results without direct downloads still have full metadata and a link to the asset page.

  • CGTrader sits behind an IP-based bot wall that often blocks cloud and datacenter IPs. It works from home connections. On a server, run behind a proxy with NODE_USE_ENV_PROXY=1. When it is blocked, the report says so and links to CGTrader's own search.

  • Scraped sites can change their HTML. If one breaks, only that source shows as failed; the others keep working. npm run test:live checks every site.


Quick start

Needs Node.js 20 or newer.

git clone https://github.com/arielshad/3d-asset-server.git
cd 3d-asset-server
npm install
npm run build

npm start                    # web UI + HTTP API + MCP on http://localhost:8787
node dist/cli.js mcp         # MCP over stdio, for Claude Desktop / Claude Code / Cursor
node dist/cli.js search "low poly tree" --type model --free

With Docker, using the published image (no build needed):

docker run -p 8787:8787 ghcr.io/arielshad/3d-asset-server

Or build it yourself with docker build -t 3d-asset-server .


Web UI

Live at https://3d.shep.bot, or run npm start and open http://localhost:8787.

The website lives in web/: Astro with React islands, Tailwind CSS 4, shadcn/ui and Magic UI components. Every page is pre-rendered HTML, so search engines and AI crawlers see the full content; only the interactive parts hydrate.

Page

What it is

/

Landing page: hero search, sources, agent setup, FAQ

/search

The search app: type chips, free/direct-download switches, source picker, per-site status, detail sheet with format/resolution picker, download, curl and agent snippets

/docs

Quick start

/docs/mcp

Setup for Claude Code, Cursor, VS Code, Windsurf, Codex CLI, Gemini CLI, the Claude apps and any stdio client

/docs/api

REST API guide with curl, JavaScript and Python examples

/docs/api/reference

Interactive OpenAPI reference (Scalar, self-hosted)

/docs/sources

All sources with licences, generated from the provider registry

/assets

Curated asset collections (see below): an index, four hubs (/assets/hdris, /assets/textures, /assets/3d-models, /assets/game-assets) and one page per topic, such as /assets/free-sunset-hdris

/sources/<id>

One page per source: licence, pricing, how it is searched, census numbers and its assets in the collections

/docs/self-hosting

Docker, Node, configuration, metrics

  • Search state in the URL. /search?q=sunset&type=hdri&free=true can be bookmarked or shared.

  • Detail sheet. Licence, author, formats, polygon count and tags; pick a format and resolution to see exactly which files you'll get and their size; download the file or one zip; copy a curl command or a prompt for your agent.

  • API key. If ASSET_SERVER_API_KEY is set, the page asks for the key once and remembers it.

For AI agents

Point an agent at https://3d.shep.bot and it finds its way:

URL

For

/AGENTS.md

The agent guide: MCP setup, REST workflow, licensing rules, an example

/llms.txt, /llms-full.txt

llms.txt index and every guide in one file

/docs/*.md

Markdown twin of every docs page

/skill/SKILL.md

A Claude Code skill (~/.claude/skills/3d-assets/SKILL.md)

/openapi.json

OpenAPI 3.1

Requests with Accept: text/markdown get markdown instead of HTML (/ → /AGENTS.md, sent with Vary: Accept), unknown paths answer agents with a Markdown 404 that links to the docs, llms.txt and the sitemap, every page advertises its twin with Link: <…>; rel="alternate"; type="text/markdown", and curl https://3d.shep.bot returns a JSON index that points at all of the above. /about, /contact and /privacy describe who runs the service and what it records; /docs/api/versioning is the versioning, deprecation and rate-limit policy.


MCP: use it from an AI assistant

An assistant calls search_assets, then get_asset, then download_asset, and tells you the licence and where the files were saved.

Claude Code

claude mcp add 3d-assets -e ASSET_DOWNLOAD_DIR="$PWD/assets" -- node /path/to/3d-asset-server/dist/cli.js mcp

Claude Desktop, Cursor, or any stdio MCP client

{
  "mcpServers": {
    "3d-assets": {
      "command": "node",
      "args": ["/path/to/3d-asset-server/dist/cli.js", "mcp"],
      "env": { "ASSET_DOWNLOAD_DIR": "/path/to/your/game/assets" }
    }
  }
}

Remote (Streamable HTTP)

The public server needs no setup or key:

claude mcp add --transport http 3d-assets https://3d.shep.bot/mcp

Other clients: see https://3d.shep.bot/docs/mcp. For your own server, point the client at http://<host>:8787/mcp. If you set ASSET_SERVER_API_KEY, send Authorization: Bearer <key>.

Over HTTP, download_asset is off by default because it would write to the server's disk, not yours. Instead, get_asset returns direct file URLs plus a one-click bundleUrl zip. Set ASSET_SERVER_HTTP_DOWNLOADS=true if the server and the files should live on the same machine.

Tools

Tool

What it does

search_assets

query, plus optional types (model, texture, material, hdri, sprite, ui, audio, font, pack), providers, free_only, downloadable_only, limit, offset. Returns ranked results, also_search_on links for link-only sites, and any sites that failed.

get_asset

Details for an id such as polyhaven:ArmChair_01: description, licence, available formats and resolutions, and exactly which files a download would fetch for a given format / resolution.

download_asset

Saves into <dest_dir>/<provider>-<id>/ (default ./assets), keeps companion files in place and extracts zips. Returns the paths, licence and an attribution line when credit is required.

list_providers

Every source: what it's best for, asset types, pricing, licence, and whether it supports direct downloads.


HTTP API

Endpoint

GET /v1/search?q=&type=&providers=&free=&downloadable=&limit=&offset=

Ranked, merged results plus a per-site report (ok, error, timeout, skipped, link).

GET /v1/providers

The source catalogue.

GET /og/query.png?q=&type=&free= · GET /og/asset.png?id=

1200×630 share cards for search and asset links (see below).

GET /v1/catalog

Catalog census, counted daily: listings per source, by asset type, licence and category, free and CC0 counts, the last 30 days' releases, most downloaded assets. Same as /catalog.json; daily history at /catalog-history.json.

GET /v1/stats

Usage totals (searches by surface, downloads, MCP tool calls, top clients and asset types) and per-source health (success rate, p50/p95 latency). Shown on /stats.

GET /v1/assets/{provider}:{id}

Full details, including every file.

GET /v1/assets/{id}/files?format=&resolution=&maps=&all=

The files a download would fetch.

GET /v1/assets/{id}/download?format=&resolution=

302 to the file when it is one self-contained file; otherwise a streamed zip with its companions.

POST /mcp

MCP endpoint (Streamable HTTP, stateless).

GET /openapi.json

OpenAPI 3.1 description.

GET /

Web UI in a browser; a JSON index of endpoints for API clients.

GET /health

Liveness check.

curl 'localhost:8787/v1/search?q=brick+wall&type=material&free=true&limit=5'
curl -OJ 'localhost:8787/v1/assets/polyhaven:WoodenChair_01/download?format=gltf&resolution=1k'

Search response (shortened):

{
  "query": "brick wall",
  "results": [
    {
      "id": "polyhaven:brick_wall_001",
      "title": "Brick Wall 001",
      "type": "material",
      "url": "https://polyhaven.com/a/brick_wall_001",
      "license": { "name": "CC0", "commercialUse": true, "attributionRequired": false },
      "price": { "free": true },
      "resolutions": ["1k", "2k", "4k", "8k"],
      "downloadable": true,
      "score": 1
    }
  ],
  "providers": [
    { "provider": "polyhaven", "status": "ok", "count": 5, "tookMs": 210 },
    { "provider": "fab", "status": "link", "searchUrl": "https://www.fab.com/search?q=brick+wall&is_free=1" }
  ]
}

CLI

$ node dist/cli.js search "low poly tree" --type model --free --limit 5

1.00  model    free   blenderkit:72fac4cb-901c-4a48-8fc8-84a0baa0bd21
      Low poly tree — https://www.blendkit.com/asset-gallery-detail/72fac4cb-.../
0.93  model    free   cgtrader:lowpoly-tree-collection-01-200-trees-lowpoly-collection
      Low Poly Trees Mega Pack - 200 Trees — https://www.cgtrader.com/free-3d-models/...
0.93  model    free   itchio:brokenvector/low-poly-tree-pack
      Low Poly Tree Pack — https://brokenvector.itch.io/low-poly-tree-pack
0.91  model    free   blenderkit:346d99d8-36fb-4f9d-a4a1-2f9ad9233e79
      Low poly tree — https://www.blendkit.com/asset-gallery-detail/346d99d8-.../
0.84  model    free   itchio:mark-auman/low-poly-trees-asset-pack
      Low-Poly Trees Asset Pack — https://mark-auman.itch.io/low-poly-trees-asset-pack

Sources:
  polyhaven        ok       1
  blenderkit       ok       5
  fab              link     0  https://www.fab.com/search?q=low+poly+tree&is_free=1
  kenney           ok       2
  quaternius       ok       5
  cgtrader         ok       5
  turbosquid       link     0  https://www.turbosquid.com/Search/3D-Models/free/low-poly-tree
  itchio           ok       5
  ...
3d-asset-server serve                    HTTP API + web UI + MCP at /mcp
3d-asset-server mcp                      MCP over stdio
3d-asset-server search <query> [--type model,hdri] [--providers a,b] [--free] [--limit N]

Downloads

Unless you say otherwise, a download picks game- and web-friendly files:

Asset type

Preferred format, in order

Resolution

model

glb → gltf → fbx → obj → blend

closest to 2k

hdri

hdr → exr

closest to 2k

material

jpg maps → png maps → zip

closest to 2k

texture

jpg → png → zip → exr

closest to 2k

pack

zip (extracted)

–

Companion files keep their relative paths, so a glTF loads straight away:

  assets/
  └── polyhaven-WoodenChair_01/
      ├── WoodenChair_01_2k.gltf
      ├── WoodenChair_01.bin
      └── textures/
          ├── WoodenChair_01_diff_2k.jpg
          ├── WoodenChair_01_nor_gl_2k.jpg
          └── WoodenChair_01_arm_2k.jpg

  assets/
  └── kenney-nature-kit/              ← zip downloaded and extracted
      └── kenney_nature-kit/
          ├── Models/GLTF format/...
          └── License.txt

Safety:

  • Only http(s) URLs to public hosts are fetched. Private and loopback addresses are refused, including host names that resolve to one and IPv4-mapped IPv6 literals. Redirects are followed by hand and every hop is checked again.

  • Paths that try to escape the target folder (zip-slip, ../) are rejected or skipped.

  • Each download has a size guard (ASSET_SERVER_MAX_DOWNLOAD_BYTES, default 2 GiB), also applied to zip bundles streamed by /download and to what an extracted zip expands to. Zips are extracted by streaming, with at most ASSET_SERVER_MAX_ZIP_ENTRIES files.

  • Downloads are not cut off after a fixed time; they abort when no bytes arrive for ASSET_SERVER_DOWNLOAD_IDLE_MS (default 30 s).

  • Login, checkout and hotlink protection are never bypassed.


Configuration

Variable

Default

PORT / HOST

8787 / 0.0.0.0

HTTP bind address

ASSET_SERVER_API_KEY

–

Require Authorization: Bearer <key> or x-api-key: <key> on /v1/* and /mcp (headers only; see the note below)

ASSET_SERVER_PUBLIC_URL

request origin

Base URL used in links handed to MCP clients

ASSET_SERVER_PROVIDERS

all

Comma list to enable only some sources, e.g. polyhaven,ambientcg,kenney

ASSET_DOWNLOAD_DIR

./assets

Default download folder for MCP

ASSET_SERVER_HTTP_DOWNLOADS

false

Expose download_asset on the HTTP MCP endpoint (writes to the server's disk)

ASSET_SERVER_HTTP_MCP

true

Set to false to turn off MCP over HTTP at /mcp. The website and /v1 API keep working, and the stdio MCP server is unaffected

ASSET_SERVER_MAX_DOWNLOAD_BYTES

2 GiB

Per-download size guard (also caps zip bundles and zip extraction)

ASSET_SERVER_MAX_ZIP_ENTRIES

20000

Most files extracted from one zip

ASSET_SERVER_DOWNLOAD_IDLE_MS

30000

Abort a download when no bytes arrive for this long

ASSET_SERVER_PROVIDER_TIMEOUT_MS

12000

Per-site search timeout

ASSET_SERVER_HTTP_TIMEOUT_MS

15000

Per-request HTTP timeout

ASSET_SERVER_CACHE_TTL_MS

10 min

In-memory HTTP cache TTL

ASSET_SERVER_USER_AGENT

3d-asset-server/0.1

User-Agent sent to the sites

BLENDERKIT_API_KEY

–

Optional; unlocks plan and purchased BlenderKit assets

NODE_USE_ENV_PROXY

–

Set to 1 so Node's fetch uses HTTPS_PROXY

ASSET_SERVER_RATE_LIMIT

120

Requests per client per window on /v1/* and /mcp; responses carry RateLimit-Policy / RateLimit (+ RateLimit-Limit/Remaining/Reset), and 429 adds Retry-After. 0 = off

ASSET_SERVER_RATE_LIMIT_WINDOW

60

Rate-limit window in seconds

ASSET_SERVER_TRUSTED_PROXIES

–

How the client address (rate limits, network labels) is read. Unset: the leftmost X-Forwarded-For entry, which is only right behind an edge proxy that replaces the header (as on 3d.shep.bot). 0: ignore forwarding headers and use the socket address (server exposed directly). N: N proxies append to X-Forwarded-For, so the client is the Nth entry from the right. Set it when self-hosting, or clients can pick their own rate-limit bucket

METRICS_PORT

–

Serve Prometheus metrics on this port at /metrics and log one JSON line per search, download and MCP tool call (see below)

PROMETHEUS_URL

–

Prometheus that scrapes METRICS_PORT. /v1/stats (and the /stats page) then report the last 24 hours and 7 days across replicas; without it they count this process since it started

MAXMIND_ACCOUNT_ID, MAXMIND_LICENSE_KEY

–

With METRICS_PORT: download MaxMind's free GeoLite2-ASN database (refreshed weekly) to label MCP callers by network; see MCP client attribution

ASSET_SERVER_ASN_DB

–

Path to a GeoLite2-ASN .mmdb file to use instead of downloading one

ASSET_SERVER_SESSION_SECRET

random

Signs the MCP session IDs that carry the client's declared name. Set it so sessions keep their label across restarts

ASSET_SERVER_TELEMETRY

on

0 (or false/off) turns off anonymous usage telemetry; see Telemetry

DO_NOT_TRACK

–

1 also turns telemetry off

UMAMI_WEBSITE_ID

–

With UMAMI_HOST: send web page views to your own Umami instead (cookieless; honours Do Not Track; only utm_* query parameters are sent, never search text). The page CSP allows that one origin

UMAMI_HOST

–

Origin serving the Umami tracker (/script.js) and its collect endpoint (/api/send)

The API key is accepted in headers only. The ?api_key= query parameter was removed because keys in URLs end up in browser history, server logs and Referer headers; requests that use it now get 401. The web UI sends the key as a header for downloads too. The key is compared in constant time.

Analytics

With METRICS_PORT set (src/core/analytics.ts), metrics are served on that separate port (never on the public listener), with bounded labels only:

Metric

Labels

asset_server_searches_total

surface (web, api, mcp), client family, type, free_only, has_results

asset_server_search_duration_seconds, asset_server_search_results

surface

asset_server_provider_requests_total, asset_server_provider_duration_seconds

provider, status

asset_server_downloads_total, asset_server_asset_views_total

surface, client, provider

asset_server_mcp_tool_calls_total

tool, client (claude-code, cursor, vscode, …), outcome, network, consistency

asset_server_mcp_tool_duration_seconds

tool

asset_server_mcp_connects_total

declared client, client_version (major, or 0.<minor>), network

asset_server_mcp_new_callers_total

client, network (first tool call of the day per caller; increase(...[1d]) ≈ distinct callers)

asset_server_http_requests_total, asset_server_page_views_total

route / page

Each search also logs {"event":"search","query":…,"results":…,"surface":…} to stdout for top-query and zero-result analysis in a log store, and each MCP connect logs {"event":"mcp_connect","client_name":…}. No IPs, keys or cookies are recorded. In the shep.bot cluster a ServiceMonitor (deploy/servicemonitor.yaml) feeds Prometheus, Loki collects the event lines, and the 3D Asset Server Grafana dashboard shows both.

The public /stats page reads the same counters back through GET /v1/stats (src/core/stats.ts): from Prometheus when PROMETHEUS_URL is set (cached for a minute), otherwise from in-process tallies since the last restart. It shows aggregate counts only.

MCP client attribution

Which AI tool is calling /mcp, and from where (src/mcp/attribution.ts, src/core/network.ts). Everything here is for metrics only and never changes a response.

  • Client. MCP clients declare their name and version in initialize. The endpoint is stateless, so the reply hands them back in a signed Mcp-Session-Id, which clients send on every later request. Tool calls are labelled with that declared client, or with the User-Agent family when no session is sent. claude-ai covers claude.ai and Claude Desktop connectors, which both call from Anthropic's cloud.

  • Network. The caller's address is sorted into anthropic and openai (their published egress ranges; OpenAI's are fetched daily), aws / gcp / azure / cloud (other hosting) / isp (home, office or mobile) from MaxMind's GeoLite2-ASN database, private, or unknown. Without a MaxMind account only the first two and private are labelled. The address is looked up, never stored.

  • Consistency. consistent; mismatch when the signals disagree (the User-Agent and the declared client name different products, or a claude.ai / ChatGPT User-Agent calls from outside that vendor's network); no_session (no session ID sent: curl, scripts, older clients); bad_session (a session ID this server did not sign, for example from before a restart without ASSET_SERVER_SESSION_SECRET). A mismatch is a hint, not proof: only signed requests can prove who a caller is.

  • Distinct callers. The first tool call of the day from each caller increments asset_server_mcp_new_callers_total. A caller is a hash of address and User-Agent with a random salt that changes every UTC day and is never stored. Callers behind one vendor egress address count once.

This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.

Telemetry

serve and mcp send anonymous usage telemetry by default, so we can see how the project is used outside 3d.shep.bot. It goes to the project's self-hosted Umami at stats.shep.bot (src/core/telemetry.ts), and the server prints a one-line notice to stderr when it starts.

  • Events: one per search, asset lookup, download and MCP tool call, one when an MCP client connects, plus one at start. Each carries only bounded labels: interface (web, api, mcp), client family (claude-code, cursor, …) and its major version, source, asset-type filter, whether anything was found, outcome, version, mode and OS.

  • Web UI page views, when you open the bundled website: page path and title, screen size, browser and language. The host name is reported as self-hosted, and no referrer is sent.

  • Never sent: search text, asset IDs, file paths, URLs, API keys or your machine's name. Umami derives a country from your IP address and does not store the address. No cookies.

Turn it off with ASSET_SERVER_TELEMETRY=0 or DO_NOT_TRACK=1. Sending never blocks a request, and the process stops trying after a few failures in a row (for example when offline).

Catalog census

.github/workflows/catalog-census.yml runs scripts/census.mjs daily: every provider's census() counts what its site holds in a few requests (API totals, catalogues, WordPress X-WP-Total, result counts or sitemaps; see src/core/census.ts). The result is committed to web/src/data/catalog.json with one point per day in catalog-history.json, then CI deploys it. It feeds the /stats page, the homepage numbers, llms.txt, /v1/catalog and a schema.org Dataset.

  • Counts the source caps (BlenderKit stops at 10,000 per type) are marked as lower bounds.

  • A source whose count fails keeps its last good numbers, marked stale.

The same workflow then refreshes the asset collections (scripts/snapshot-collections.mjs) and commits both.

Asset collections

Search pages (/search?q=…) are noindex: they are share links, not landing pages. The pages that rank are the collections: one page per thing people search for ("free sunset HDRIs", "free brick textures", "free low-poly trees"), pre-rendered with real results, editorial copy, an FAQ and CollectionPage / ItemList / FAQPage JSON-LD, plus a Markdown twin for agents (/assets/<slug>.md, with asset ids).

  • Topic: web/src/content/collections/<slug>.md. The frontmatter holds the title, description, hub, the search (q, types, free, and an exclude list of title phrases or asset ids that never belong on the page), aliases, related pages and the FAQ. The body is the copy. scripts/lib/topics.mjs holds the editorial rules (lengths, links, FAQ), and the tests apply them to every topic.

  • Results: web/src/data/collections/<slug>.json, written by scripts/snapshot-collections.mjs (logic in src/core/collections.ts). It keeps relevant, free results that have a working thumbnail, at most 40% from one source. Assets already on a page keep their place, so pages don't reshuffle daily, and updatedAt (the sitemap's lastmod) moves only when the list changes. A page with fewer than 12 assets or a single source is noindex and left out of the sitemap.

  • Adding one by hand: write the topic file, run npm run build:server && node scripts/snapshot-collections.mjs <slug>, read the titles in the snapshot, add exclude phrases for anything off-topic, and commit both files.

Weekly drafting

.github/workflows/draft-collections.yml proposes up to 3 new collections every Monday:

  1. scripts/collection-ideas.mjs reads the last week's searches from Loki through Grafana. It keeps queries made at least 3 times that look like plain topics and that no collection or open pull request covers.

  2. A Claude Code agent (prompts/draft-collections.md) writes the topic files and checks their results. It works in a read-only checkout, and its transcript stays out of the public log because it reads search terms.

  3. A job without Claude credentials gates the patch (scripts/collection-gate.mjs: new topic files only, editorial rules, no duplicates). It re-runs each search itself, withdraws topics with too few results, runs the build, typecheck and tests, and opens a pull request labelled collections.

  4. A second Claude Code run reviews the pages (prompts/review-collections.md). Approved pages are squash-merged and deployed. Otherwise the pull request is labelled needs-editor and left for a human.

It needs the same Claude secret as source discovery. It also needs the repository setting Allow GitHub Actions to create and approve pull requests (Settings → Actions → General). The optional GRAFANA_TOKEN secret is a Viewer service-account token for grafana.shep.bot; without it, the agent picks topics from its own research.

Share previews (Open Graph)

Every page has its own 1200×630 card (og:image = /og/<page>.png), rendered at build time by web/integrations/og-images.mjs from the page's title and description, with matching og:image:alt, twitter:* tags and a primaryImageOfPage ImageObject in the JSON-LD. Share links get cards rendered on request (src/og/render.ts: satori + resvg, Geist font; src/api/og.ts: LRU cache, two renders at a time, rate-limited):

  • /search?q=brick+wall&type=material: title, description, og:url and a card for the query (/og/query.png?…).

  • /search?asset=polyhaven:ArmChair_01: opens that asset in the search app (the detail panel's Share button copies this link) and unfurls with the asset's title, licence, formats and thumbnail (/og/asset.png?id=…; the thumbnail is fetched from public hosts only and re-encoded with sharp).

Daily source discovery

.github/workflows/discover-integrations.yml runs once a day (and on demand from the Actions tab):

  1. A Claude Code agent follows prompts/discover-integrations.md. It searches the web for 3D asset sites that are not yet in integrations/registry.json, checks their robots.txt and terms, integrates the best one as a provider with offline and live tests, and records the others as rejected with a reason. It works in a read-only checkout and hands over a patch.

  2. A separate job without Claude credentials applies the patch and gates it (scripts/integration-gate.mjs):

    • only provider, test, fixture, registry and docs files may change;

    • nothing may be deleted, and existing tests may not be edited;

    • at most one new source per run.

    It then runs the build, typecheck, the full test suite and the new source's live test.

  3. If everything passes, it commits to main and dispatches CI, which builds the image and pins it in deploy/, so ArgoCD rolls the new source out to 3d.shep.bot. If a check fails, the site is recorded as rejected with a 30-day recheck instead.

It needs one repository secret: CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token) or ANTHROPIC_API_KEY.


Project layout

src/
├── cli.ts               serve | mcp | search
├── index.ts             library exports
├── core/
│   ├── types.ts         Asset, AssetFile, Provider, SearchQuery ...
│   ├── http.ts          fetch wrapper: UA, timeouts, LRU cache, in-flight dedupe
│   ├── service.ts       fan-out search, ranking, per-site reports
│   ├── download.ts      file selection, safe downloads, zip extraction, zip streaming
│   └── util.ts          matching/scoring, type inference, helpers
├── providers/           one adapter per site, plus index.ts (the registry)
│   ├── polyhaven.ts  ambientcg.ts  blenderkit.ts  kenney.ts  quaternius.ts
│   ├── itchio.ts  cgtrader.ts  texturescom.ts  threedtextures.ts  hdrmaps.ts
│   ├── sharetextures.ts  cgbookcase.ts  texturecan.ts  hdrihub.ts
│   └── linked.ts        Fab, Poliigon, TurboSquid (link-only)
├── api/
│   ├── app.ts           Hono REST API, website, MCP Streamable HTTP mount, request metrics
│   ├── site.ts          serves the pre-rendered site: caching, 404, markdown twins for agents
│   └── openapi.ts       OpenAPI 3.1 with full schemas (rendered at /docs/api/reference)
├── core/analytics.ts    Prometheus metrics + JSON event log
├── core/network.ts      caller network label: vendor ranges + GeoLite2-ASN
├── core/collections.ts  collection snapshots: relevance, stable order, thumbnail checks
├── core/stats.ts        /v1/stats: Prometheus queries or in-process tallies
└── mcp/
    ├── server.ts        MCP tool definitions (shared by stdio and HTTP)
    └── attribution.ts   signed session IDs, consistency check, distinct callers
web/                     the website (Astro + React + Tailwind + shadcn/ui)
├── src/pages/           index, search, stats, assets (collections), sources, docs (markdown), API reference, 404
├── src/components/      ui/ (shadcn + Magic UI), search/ (the search app), home/
├── src/content/         AGENTS.md, the Claude Code skill and collections/ (one topic per page)
├── src/data/            generated data: providers, census, collections/ (saved results)
├── src/lib/             site constants, schema.org JSON-LD, agent client configs, FAQ
└── integrations/        emits AGENTS.md, llms.txt, llms-full.txt, markdown twins, Scalar bundle
deploy/                  Kubernetes manifests for 3d.shep.bot (synced by ArgoCD)
integrations/            registry.json: live sources (with date added) and evaluated/rejected sites
prompts/                 instructions for the source-discovery, collection-drafting and review agents
scripts/                 site data export, census, collection snapshots/ideas, integration and collection gates
test/
├── providers/           offline tests per site, against trimmed fixtures
├── live/                live smoke tests (LIVE=1)
└── service.test.ts      ranking, downloads, API, MCP

Adding a source: implement the Provider interface (see docs/PROVIDERS_GUIDE.md), add it to src/providers/index.ts, and add an offline test with fixtures.

interface Provider {
  id, name, homepage, description, assetTypes
  access: "api" | "scrape" | "link"
  pricing, license
  search(query, ctx): { assets, total?, searchUrl? }
  getAsset?(id, ctx): { ...asset, files[] } | null
  buildSearchUrl(query): string   // the same search on the site
}

Development

npm ci && npm --prefix web ci
npm test            # offline tests (fixtures, no network)
npm run test:live   # live smoke tests against the real sites
npm run typecheck   # server + website (astro check)
npm run build       # server to dist/, then the website to dist/web
npm run dev         # watch-mode server
npm --prefix web run dev   # website dev server; proxies /v1 and /mcp to `npm start` on :8787

CI (.github/workflows/ci.yml) runs build, typecheck and tests on every push and pull request; on main it also publishes the image and pins it in deploy/, which ArgoCD rolls out to 3d.shep.bot.


Licences & etiquette

IMPORTANT

This server finds and fetches assets. It does not own them. Every result shows its licence: respect it, and credit authors when it says so.

  • Sites are queried gently: responses are cached, there are a few requests per search, and the User-Agent identifies the server.

  • Paid, login-gated and hotlink-protected content is never bypassed; those results link to the source site.

  • ShareTextures' terms forbid automated downloads, so it is search-only.

The server's own code is licensed under Apache-2.0.

Related MCP Connectors

Related MCP Servers