Skip to content

About

Hosted MCP server for wishlist.fit: connect Claude, ChatGPT, or Codex to your wishlist. Gift giving without the guesswork.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wishlist-mcp

The hosted MCP server for wishlist.fit: gift giving without the guesswork. It lets Claude, ChatGPT, or Codex read and update your wishlist for you.

https://mcp.wishlist.fit/mcp

Connect it from app.wishlist.fit/connect, which has per-client setup steps. You need a wishlist account first; this server never creates one.

docs/connecting.md has the longer version: exact commands for each client, what has actually been verified against production, and the two Codex flags that waste an afternoon if you get them wrong.

What it is

A pure OAuth 2.1 resource server in front of the wishlist REST API. It holds no database and no business rules. Visibility, rate limits, and invite handling all live in the wishlist API, and this server inherits them by calling that API with the user's own access token.

That split is deliberate. The alternative, a second service with its own copy of the rules, is how two surfaces quietly start disagreeing about who may see what.

Claude / ChatGPT / Codex
        │  MCP over HTTP, bearer token
        ▼
   this server ──── verifies the token (issuer + audience)
        │
        │  the same token, forwarded
        ▼
   api.wishlist.fit ──── applies visibility, rate limits, invite rules
        │
        ▼
     Postgres

WorkOS AuthKit is the authorization server. This server issues nothing and shows no consent screen; it only verifies what AuthKit signed.

The tools

Thirty, one per thing you can already do by hand in the web app. Parity is the rule: no agent-only privileges, and nothing the app itself cannot do.

Tool Kind
wishlist_search_people read
wishlist_get_gift_guide read
wishlist_get_profile read
wishlist_get_wishlist read
wishlist_get_my_profile read
wishlist_get_my_wishlist read
wishlist_list_circle read
wishlist_list_circle_requests read
wishlist_list_nudge_prompts read
wishlist_list_nudges read
wishlist_preview_invite read
wishlist_upcoming_occasions read
wishlist_list_notifications read
wishlist_get_reminder_preferences read
wishlist_add_item write
wishlist_update_item write
wishlist_upload_image write
wishlist_update_my_profile write
wishlist_request_circle write
wishlist_accept_circle_request write
wishlist_decline_circle_request write
wishlist_accept_invite write
wishlist_update_reminder_preferences write
wishlist_mark_notifications_read write
wishlist_send_nudge write
wishlist_answer_nudge write
wishlist_dismiss_nudge write
wishlist_create_invite destructive
wishlist_delete_item destructive
wishlist_remove_circle_member destructive

wishlist_get_gift_guide returns a person's profile and wishlist together. It is the reason anyone connects this server, and without it the gift-giver journey costs three round trips. It carries the fields that actually rule a gift in or out: dietary rules and allergies, interests, price comfort, what they already own, and their birthday without the year. Each item in it also carries effective_size, the size to buy: the person's profile size when the item's size_source (shirt, shoe, pants, or ring) points at one the profile shows you, otherwise the item's own size. The raw size and size_source stay on the item. wishlist_get_wishlist and wishlist_get_my_wishlist have no profile in hand, so they return the raw fields only.

wishlist_add_item and wishlist_update_item take size, size_source, color, and brand. size_source means "use my profile size of that kind" and never copies a number onto the item; size stays as the fallback and is not cleared by it. wishlist_update_item removes a size_source with clear_size_source=true, and a color or brand with an empty string. color and brand are at most 64 characters.

An item has a cover photo, image_url, and up to four more in extra_image_urls. Every item read carries the list, [] when there are none. wishlist_add_item and wishlist_update_item take extra_image_urls as a list of http or https links, usually the links wishlist_upload_image returned. On update the list replaces all the extras, [] removes them, and leaving it out changes nothing. More than four is refused before the API is called.

wishlist_upcoming_occasions answers "whose birthday is next" in one call, and pairs with the gift guide: it hands back a username and a date, and the guide turns that into something to buy. Birthdays in it are only people whose circle the caller is in, and only those who filled one in, so an absent birthday is not evidence that someone has none.

The three destructive tools carry destructiveHint: true, so clients that confirm irreversible actions will ask first. Sending an invite emails a real person and cannot be unsent. Accepting a request or an invite is reciprocal: the other person gains access to your circle-only fields as well as you gaining access to theirs. Removing a member cuts both ways at once, and getting back in needs a fresh request they have to accept.

Photos

wishlist_upload_image takes a photo as base64 and returns the link the API stored it under. It saves nothing on its own: the model passes that link to wishlist_add_item, wishlist_update_item, or wishlist_update_my_profile, which is the same two steps the web app's photo picker takes. On an item the link is the cover (image_url) or one of the extra_image_urls. Photos already online skip the upload and go in as image_url, extra_image_urls, or avatar_url directly; the API refuses anything but an http or https link.

The tool refuses more than 2 MB before it calls the API, a quarter of what the API accepts. The API fits every photo inside 1024 px and re-encodes it as WebP, so a larger input spends the calling model's tokens on pixels that are thrown away.

There is deliberately no tool that takes a link and stores a copy. That would have the API fetch whatever URL a model read in someone else's item notes.

Nudges

A nudge is a short question between two people already in the same circle: is your wishlist still current, do you still want this item, are your sizes still right, could you add a few ideas.

It is anonymous by default. wishlist_send_nudge takes signed, which defaults to false; leave it there unless the user asks to be named, because asking whether someone still wants an item, under your user's name, tells that person who is buying it. On the receiving side an incoming nudge with a null username was sent anonymously: report it as "someone in your circle" and do not try to work out who from wishlist_list_circle. The server withheld the name deliberately, and naming a guess is worse than naming nobody.

The wording is not the caller's. wishlist_list_nudge_prompts serves the catalogue, wishlist_send_nudge takes a prompt key, and nothing an agent writes is delivered to the recipient. That is the point: with no free-text field there is nothing to moderate and nothing a model can be argued into sending on someone's behalf.

Sending one emails a real person, so confirm the question and the name first. The API refuses a nudge to anyone outside your circle, to anyone who has nudges off, more than once a day per person, for a week after they dismiss one, and after ten in a day. Each refusal names which and when to try again; report it rather than retrying.

Answering still_current records that your wishlist or profile was confirmed today, which everyone in your circle sees on wishlist_reviewed_at and profile_reviewed_at. The answer is the user's to give: ask which option they want rather than inferring one.

Two ways into a circle

wishlist_create_invite takes an email address and wishlist_request_circle takes a username, but the API decides which path an address takes, not the caller. An address with no account behind it gets a tokenized email; one that already belongs to someone gets an in-app request and no email at all. wishlist_create_invite reports which happened in its outcome field, and a caller that assumes an email went out will tell the user something untrue.

One connection, everything

There are no scopes. WorkOS cannot express custom ones, so a connection carries the whole tool surface. Protection on writes is behavioural rather than structural: the annotations above, the tool descriptions, and the rate limits the API applies.

Running it locally

uv sync
cp .env.example .env      # point it at a local API and the Local WorkOS environment
uv run python -m wishlist_mcp.main
uv run pytest
uv run ruff format --check . && uv run ruff check .

Tests stub the wishlist API with respx. What they check is this server's own job: verifying tokens, shaping requests, and turning API errors into sentences a model can act on. The rules themselves belong to the API and are tested there.

Keeping parity

The wishlist API ships from another repo on another deploy, so an endpoint or a field can change there, reach the website, and leave this surface where it was. Nothing here detects that.

GiftProfile and MyProfile in schemas.py list readable fields explicitly, and wishlist_update_my_profile lists writable ones as arguments, so a new column stops at this boundary until someone moves it. The Dietary, GiftFormat, PriceComfort, and InterestCategory literals are copies of the API's enums for the same reason, and so is SizeSource, which has to name the same four profile sizes as the item's size_source.

Watch for a second failure mode that does not look like a gap: a tool reads one key out of a response body, and an endpoint that starts answering with a second shape makes it report the wrong thing confidently rather than fail. POST /invites did exactly that.

The workspace repo tracks the ledger in docs/mcp-parity.md.

Configuration

Every variable is prefixed WISHLIST_MCP_. See .env.example.

Variable Purpose
API_BASE_URL The wishlist REST API to call
AUTHKIT_DOMAIN WorkOS AuthKit issuer. Empty means the server refuses to start
RESOURCE_URI Canonical URI. Every token's audience must match it exactly
HOST The server 404s on any other host
API_TIMEOUT Seconds to wait on the API
REPORT_USAGE Send one usage event per tool call to the API. Default true
USAGE_TIMEOUT Seconds a slow API may delay a finished result while the event is sent. Default 2

RESOURCE_URI must match the resource indicator configured in WorkOS character for character, including the /mcp path. A mismatch is the most common reason a client refuses to connect.

Usage counts

After each tool call the server posts one event to the API's POST /api/v1/events, with the caller's own token: the tool's name, whether it worked, the API's error code if it did not, and how long it took. It never sends an argument or a result, and the API drops any other field. The counts feed the app's own improvement planning, which otherwise sees the web app's numbers and nothing for this server. usage.py has the reasoning, and a failure to report can never fail a tool.

Things worth knowing before you change this

Each of these is a bug that reached production once.

  • get_http_headers() strips authorization. Ask for it explicitly. Without that, every tool call reports "no access token" while initialize and tools/list still succeed, because those are answered before any tool body runs. A green handshake is not evidence the tools work.
  • The MCP app is mounted at the root and owns its own path. Mounting it at /mcp makes Starlette redirect /mcp to /mcp/, and the address every client is handed has no trailing slash.
  • X-Forwarded-Proto is trusted. Cloud Run terminates TLS, so without it every generated URL claims http://. Combined with the redirect above, that once meant a client would have sent its bearer token in the clear.
  • FastMCP's lifespan is chained into the app's. Mounting an ASGI app does not start its lifespan, and without it every tool call fails with "Task group is not initialized" while unit tests still pass.
  • TestClient follows redirects by default. Pass follow_redirects=False when the point is that a path answers directly.
  • Every path segment is encoded with segment(). httpx applies RFC 3986 dot-segment removal before a request goes out, so an unencoded username or invite_token containing ../ is not a 404: it is a different endpoint, called with the user's own token and reported to the model under the name of the tool that was invoked. A ? does the same by starting a query string. These arguments are chosen by a model that has read item names and bios other people wrote, so treat them as hostile.
  • FastMCP matches Accept by substring. It answers 406 Client must accept application/json to a client sending */*, which already does, and to one sending no header at all, which under RFC 9110 also does. WildcardAccept spells those out before FastMCP sees them. Plain curl sends */*, so this was the first thing anyone hit.

Deployment

Cloud Run, asia-southeast1, in the same project as the rest of wishlist. main deploys on push. Infrastructure lives in wishlist-infrastructure.

Licence

MIT. See LICENSE.

About

Hosted MCP server for wishlist.fit: connect Claude, ChatGPT, or Codex to your wishlist. Gift giving without the guesswork.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages