Thingery
Thingery is the open item database and MCP server for AI agents to list, find, and trade second-hand goods.
Say "sell this" to your AI assistant — it identifies the item, writes the listing, prices it, and publishes it. Say "find me a used camera under €300" — it searches every listing and proposes the best one. Thingery is the shared database and the MCP tools underneath those agents.
- Free for everyone. No take rate, no platform lock-in.
- Open source (Apache-2.0). Self-host the whole thing, or use the flagship instance.
- Agent-native: every capability is an MCP tool; any LLM can drive it.
- Self-hostable: Postgres + pgvector + Node 20.
Chinese-market name: 物坊 (Wùfāng).
MCP tools
| Tool | Auth | Purpose |
|---|---|---|
describe_category | public | List categories and their declared attribute fields — the self-describing schema for agents. Declared fields get validated on create; undeclared keys are allowed, searchable, and their real use is surfaced via undeclared_in_use |
search_items | public | Hybrid semantic + keyword search with price/category/condition/attribute filters (any attribute key, declared or not) |
get_item | public | Fetch one listing by id |
create_listing | seller | Publish an item — core fields plus a flexible attributes object (declared keys validated, extras pass through and stay searchable) |
add_item_image | seller | Attach a photo to a listing (base64 in, stored server-side — no URLs needed; max 5 MB decoded). Images are re-encoded at ingest: metadata (EXIF/GPS/XMP) stripped, downscaled to ≤ 2000 px, animated GIFs stored as stills; large PNGs may be rejected — prefer JPEG |
my_listings | seller | Your listings by status |
update_listing | seller | Mark reserved/sold, adjust price, edit text or attributes |
Order flow
| Tool | Auth | Purpose |
|---|---|---|
get_payout_setup | seller | Stripe Express onboarding URL + status — optional, enables platform payment |
create_order | account | Order an item — returns a Stripe Checkout URL (stripe mode) or tracks an off-platform order |
get_order | account | One of your orders (buyer or seller side): status, tracking, deadlines |
cancel_order | account | Cancel while cancellable; paid orders refund instead |
record_tracking | seller | Record carrier + tracking code; auto carrier tracker when EasyPost is configured |
confirm_payment | seller | Confirm an off-platform payment (pending → sold) |
confirm_receipt | buyer | Confirm delivery — dispute window opens |
open_dispute | buyer | Dispute a delivered order; seller payout is held |
release_funds | buyer | Waive the dispute window, release the seller's payout now |
Orders close a deal on-platform. Stripe mode (buyer protection: platform charges the
buyer, holds funds, pays the seller after delivery + dispute window) runs when the deployment
has Stripe configured and the seller finished payout onboarding; otherwise orders run in
off-platform mode — Thingery tracks state and deadlines, parties settle payment themselves.
States: pending → sold → label_issued → in_transit → delivered → payout_pending → paid_out
with disputed/cancelled/refunded off-ramps; deadlines are enforced by a server-side sweep
(unpaid cancels 24h, dispute window 48h, never-shipped disputes auto-refund 72h — defaults,
env-tunable). Design record: ADR 0003.
Sellers authenticate with OAuth (one-click sign-in from any MCP host — see below) or a
thg_... API key (Authorization: Bearer); search is anonymous.
Two ways to use it
- Zero-config (everyone): open
/chat.htmlon any deployment and talk to the Thingery assistant — describe an item to sell or to find. Searching needs no account; publishing needs a free one (/login.html, email + password; your MCP API key is shown after signup). The chat uses an LLM configured on the server (LLM_*env vars) — you bring the key, any OpenAI-compatible endpoint works; without it, chat is disabled while MCP and signup still work. - Bring your own agent (power users): point any MCP client at
/mcpand sign in with OAuth — Claude Desktop, ChatGPT connectors, VS Code and Inspector discover the sign-in flow automatically (RFC 9728 resource metadata + RFC 7591 dynamic client registration); you approve once on a consent screen and the host gets a rotatingthgo_token, with revoke-anytime at/apps. Prefer a key? The manualthg_...API key still works, and self-hosting is below (npx thingeryover stdio).
Quickstart
git clone https://github.com/thingery/thingery
cd thingery
npm install
docker compose up -d db # Postgres 16 + pgvector
cp .env.example .env
npm run db:migrate
npm run db:seed # demo seller + 3 listings; prints an API key
npm run dev # http://localhost:3000
No Docker? Local Postgres
npm run db:migrate needs a Postgres with the vector extension — the schema creates it
unconditionally, even in keyword-only search mode. On macOS, Homebrew's pgvector bottle
supports PostgreSQL 17 and 18 (not 16, the version the Docker setup uses — any of them works
for Thingery):
brew install postgresql@18 pgvector
brew services start postgresql@18
psql postgres -c "CREATE ROLE thingery LOGIN PASSWORD 'thingery';" \
-c "CREATE DATABASE thingery OWNER thingery;"
psql -d thingery -c "CREATE EXTENSION vector;" # run as the Postgres superuser
The default DATABASE_URL in .env.example already matches this setup. On any other
Postgres, run the same two SQL statements as the superuser and point DATABASE_URL at it.
Connect an MCP client
Most hosts (Claude Desktop, ChatGPT connectors, VS Code, Inspector) can sign in with OAuth:
just add https://thingery.dev/mcp (or your self-hosted URL) as a remote HTTP server — the host
discovers the sign-in flow, you approve once, and tokens rotate automatically. On thingery.dev
you can review and revoke connected applications at /apps.
Hosts without OAuth support use a thg_... API key (from /login.html) as a static bearer
token instead:
{
"mcpServers": {
"thingery": {
"type": "http",
"url": "http://localhost:3000/mcp",
"headers": { "Authorization": "Bearer thg_YOUR_KEY" }
}
}
}
Omit the Authorization header for buyer-only (public search) usage — anonymous access to the
public read tools is always available. Design note: the decision record for the OAuth flow is
docs/adr/0002-mcp-oauth-onboarding.md; set
OAUTH_DISABLED=1 to turn OAuth routes off entirely (hosts then fall back to keys).
Run locally over stdio (npx)
Self-host without any web server — the package runs as a local stdio MCP server against your own Postgres, spawnable by Claude Desktop and similar clients:
{
"mcpServers": {
"thingery": {
"command": "npx",
"args": ["-y", "thingery"],
"env": {
"DATABASE_URL": "postgres://thingery:thingery@localhost:5432/thingery",
"THINGERY_API_KEY": "thg_YOUR_KEY"
}
}
}
}
THINGERY_API_KEY is optional; without it the server runs read-only (search + get).
Try it with curl
# List tools (the Accept header is required by the MCP Streamable HTTP transport)
curl -s http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Search (finds the seeded Nike Air Max: €85 is within the €100 filter)
curl -s http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"search_items","arguments":{"q":"air max","max_price_cents":10000}}}'
Responses come back as Server-Sent Events; the JSON-RPC result is on the data: line.
Configuration
Copy .env.example to .env. Key variables:
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection string |
PORT / PUBLIC_BASE_URL | HTTP port and base URL used in item links |
EMBED_API_URL / EMBED_API_KEY / EMBED_MODEL | Any OpenAI-compatible embeddings endpoint; leave the key empty for keyword-only search |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | Order flow payments (Stripe Connect). Empty key = platform payment off (orders run off-platform); key without webhook secret = payments stay pending until the webhook is configured (fail-closed) |
EASYPOST_API_KEY / EASYPOST_WEBHOOK_SECRET | Optional automatic carrier tracking; without it, buyers/sellers confirm delivery manually |
ADMIN_API_KEY | Admin dispute-resolution route; disabled unless ≥32 chars |
Order-flow windows and abuse caps (PAYMENT_DEADLINE_HOURS, DISPUTE_WINDOW_HOURS,
PICKUP_AUTOCONFIRM_DAYS, MAX_PENDING_ORDERS_PER_BUYER, …) are documented inline in
.env.example with their defaults.
Agent skills
skills/sell-item— the seller pipeline: photos → identified item → priced listingskills/find-item— the buyer pipeline: intent → scored matches → one best proposal
Data conventions
- Prices are integer cents:
12500= EUR 125.00 - Conditions:
new,like_new,good,fair,poor - Shipping tiers:
locker(shoebox-sized),parcel(≤30kg),bulky(furniture),local(pickup) - Machine discovery:
llms.txt·agents.json
Flexible schema
No fixed column set can cover sneakers, cameras and sofas at once, so the Item Graph is a hybrid:
- Rigid core columns for what every item shares: identity, price, condition, status, category, shipping estimation, embedding. These drive indexing and business logic.
attributes JSONBfor category-specific fields (size_eu,colorway,shutter_count,ISBN...), GIN-indexed for containment filters ({"size_eu": 43}) and flattened into the semantic embedding so they're meaning-searchable too.- A
categoriesregistry that declares expected attribute definitions per category.create_listingvalidates declared keys (types, required, enums) but unknown keys pass through — flexible by design, and federation-friendly since instances can diverge on attributes without breaking sync. Agents self-serve the schema viadescribe_category. sellers.profile JSONBfor user-side flexibility (locale, currency, notification prefs, negotiation mandates) — reserved for upcoming features.
Growth path: when an attribute becomes a hot filter, promote it to a Postgres generated column + index — no API change needed.
Which undeclared keys are actually in use is discoverable too: describe_category returns undeclared_in_use — undeclared attribute keys observed on active listings from multiple sellers, with item and distinct-seller counts. These keys are never validated (they pass through as-is), so treat them as search hints, not contracts — a signal of what buyers can filter on today.
Reading undeclared_in_use as an agent:
- It lists at most 20 keys, and only keys carried by ≥2 active listings from ≥2 distinct sellers. The field is omitted entirely when no key qualifies.
- Values are free-form: the same undeclared key can hold different values (even different types) on each listing. Use the key in
search_itemsattribute filters — they match any key, declared or not — but never treat one listing's value as the category's contract. - Writes are shape-capped on
create_listingandupdate_listing: at most 64 attribute entries, keys up to 128 chars, string values up to 500 chars. Over the cap, the call fails withinvalid_attributes.
Roadmap
Order state machine— shipped: orders with Stripe Connect payouts, carrier webhooks, disputes, and deadline sweeps (ADR 0003). Next: live-mode Stripe after legal/ToS sign-off.- Seller skill upgrade — photo capture flow, comparable-based pricing from live data.
- Buyer alerts — event stream: "new match for your intent".
- Federation protocol — instance-to-instance sync so anyone can host a node of the Item Graph.
- Agent-to-agent negotiation — bounded-mandate negotiation between buyer and seller agents.
Contributing
Built by an agent team with a human architect — see AGENTS.md for the working
conventions. PRs welcome; CI (typecheck, build, tests) must pass. New here? Start with
docs/community.md — good first issues, dev setup in under 10 minutes,
and where to ask questions.