Pop Star Pet

Connect an AI agent to Pop Star Pet

Pop Star Pet exposes a Model Context Protocol (MCP) server so assistants like Claude, ChatGPT, Cursor or Lovable can read orders and reviews and move orders through production. Every call runs as a signed-in Pop Star Pet user, so the same admin/customer permissions apply as in the app.

MCP endpoint

https://popstarpet.com/mcp

Transport: Streamable HTTP · Auth: OAuth 2.1 (dynamic client registration) · Consent screen: /.lovable/oauth/consent

1. Quickest test: add it to a client

Most MCP clients only need the URL — they discover the OAuth server, register themselves and open a browser window where you sign in and approve the connection.

  • Claude / ChatGPT: add a custom connector and paste the endpoint above.
  • Cursor / Codex / Windsurf: add an HTTP MCP server entry (see the config below).
  • Lovable: use Add to Lovable from the app's agent integrations panel.
{
  "mcpServers": {
    "pop-star-pet": {
      "type": "http",
      "url": "https://popstarpet.com/mcp"
    }
  }
}

Sign in with the email of the account you want the agent to act as. Use a studio admin account to test update_order_status and list_testimonials.

2. Or test with the MCP Inspector

The official inspector walks the whole OAuth flow for you and lets you fire tool calls by hand — the fastest way to confirm a change.

npx @modelcontextprotocol/inspector
# then set Transport = Streamable HTTP
# and URL = https://popstarpet.com/mcp
# click Connect -> sign in -> Approve

3. The OAuth flow, step by step

Only needed if you are writing a raw client instead of using an MCP SDK.

  1. Call the endpoint with no token. It returns 401 with a WWW-Authenticate header pointing at the protected resource metadata.
  2. Fetch /.well-known/oauth-protected-resource to get the authorization server (the app's auth issuer).
  3. Fetch the issuer's /.well-known/oauth-authorization-server for the authorize / token / register endpoints.
  4. Register your client dynamically (DCR), or reuse a registered client id.
  5. Run authorization code + PKCE. The user signs in and approves on /.lovable/oauth/consent.
  6. Exchange the code for an access token and send it as Authorization: Bearer … on every MCP request.
# 1. discover
curl -i https://popstarpet.com/mcp -X POST \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# -> HTTP/1.1 401  WWW-Authenticate: Bearer resource_metadata="https://popstarpet.com/.well-known/oauth-protected-resource"

curl -s https://popstarpet.com/.well-known/oauth-protected-resource

Session tokens copied out of the web app will not work — the server requires a token minted by the OAuth flow.

4. Calling tools over HTTP

Every request is JSON-RPC over a single POST. Both Accept types are required by the spec.

export TOKEN="<oauth-access-token>"

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

5. Tool reference

create_order

Submits a new personalised song order. Pricing comes from the chosen song length and add-ons; the order starts unpaid with status "new".

Access: Any signed-in user

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "create_order", "arguments": { "pet_name": "Waffles", "species": "border collie", "vibe": "upbeat acoustic pop", "tone": "funny", "song_length": "1.5 minutes — up to 800 words", "story": "Waffles steals socks and howls at the microwave...", "addon_lyric_sheet": true } } }'
Request body (pretty)
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "create_order",
    "arguments": {
      "pet_name": "Waffles",
      "species": "border collie",
      "vibe": "upbeat acoustic pop",
      "tone": "funny",
      "song_length": "1.5 minutes — up to 800 words",
      "story": "Waffles steals socks and howls at the microwave...",
      "addon_lyric_sheet": true
    }
  }
}

list_orders

Newest first. Optional status / payment_status / limit filters.

Access: Admins see all orders, customers see their own

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list_orders", "arguments": { "payment_status": "paid", "limit": 5 } } }'
Request body (pretty)
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "list_orders",
    "arguments": { "payment_status": "paid", "limit": 5 }
  }
}

get_order

Full order record including the pet story, brief and generated lyrics.

Access: Admins, or the customer who placed the order

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "get_order", "arguments": { "order_id": "00000000-0000-0000-0000-000000000000" } } }'
Request body (pretty)
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_order",
    "arguments": { "order_id": "00000000-0000-0000-0000-000000000000" }
  }
}

update_order_status

Moves an order to new | generating | lyrics_ready | lyrics_failed | singing | ready | delivered.

Access: Studio admins only

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "update_order_status", "arguments": { "order_id": "00000000-0000-0000-0000-000000000000", "status": "lyrics_ready" } } }'
Request body (pretty)
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "update_order_status",
    "arguments": {
      "order_id": "00000000-0000-0000-0000-000000000000",
      "status": "lyrics_ready"
    }
  }
}

list_testimonials

Ratings, review text and publish permission, newest first. Optional min_rating.

Access: Studio admins only

curl -s https://popstarpet.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "list_testimonials", "arguments": { "min_rating": 4, "limit": 10 } } }'
Request body (pretty)
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tools/call",
  "params": {
    "name": "list_testimonials",
    "arguments": { "min_rating": 4, "limit": 10 }
  }
}

5b. Permissions and access denied errors

Each tool declares the permission it needs and it is checked before any data is touched, using the signed-in user from the verified access token (never from tool input). Customer tools are list_orders, get_order and create_order; studio-admin tools are update_order_status and list_testimonials. Row level security still scopes every result, so customers only ever see their own orders.

When a check fails the tool returns an error result with structured content you can branch on:

{
  "error": {
    "code": -32003,
    "type": "forbidden",
    "tool": "update_order_status",
    "required_permission": "admin",
    "granted_permission": "customer",
    "retryable": false
  }
}
  • -32001 unauthenticated — no valid OAuth token. Complete the sign-in flow and retry.
  • -32003 forbidden — signed in, but the account lacks the tool's permission. Not retryable; sign in with a studio admin account or use the customer tools.
  • -32004 authorization_unavailable — the permission lookup failed. Retryable after a short backoff.

6. Rate limits, error codes and backoff

Every tool call is counted per signed-in user, per tool, in a rolling 60 second window. Limits are enforced server-side, so they apply no matter which client you use.

ToolLimitWindow
create_order10 calls60 seconds
list_orders30 calls60 seconds
get_order60 calls60 seconds
update_order_status20 calls60 seconds
list_testimonials30 calls60 seconds

When you exceed a limit the tool call returns an MCP tool error (isError: true) rather than failing the transport, so read the structuredContent.error object for machine readable retry guidance.

{
  "isError": true,
  "content": [{ "type": "text", "text": "Rate limit exceeded for \"list_orders\": 30 calls per 60s per user. Retry after 24s ..." }],
  "structuredContent": {
    "error": {
      "code": -32029,
      "type": "rate_limited",
      "tool": "list_orders",
      "limit": 30,
      "window_seconds": 60,
      "retry_after_seconds": 24,
      "reset_at": "2026-01-01T00:01:00.000Z",
      "retryable": true
    }
  }
}

Error codes

  • -32029 (HTTP 429 equivalent) — rate_limited. Retryable: wait retry_after_seconds, then retry.
  • -32001 unauthenticated. Not retryable: reconnect and run the OAuth flow again.
  • 401 / 406 — transport-level HTTP errors (token or headers). Fix the request; do not retry blindly.
  • 5xx — transient. Retry with the same backoff as -32029, capped at a handful of attempts.

Backoff example (TypeScript)

async function callTool(body: unknown, attempt = 0): Promise<any> {
  const res = await fetch("https://popstarpet.com/mcp", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
    },
    body: JSON.stringify(body),
  });

  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 5) throw new Error("gave up after 5 attempts");
    const retryAfter = Number(res.headers.get("retry-after")) || 0;
    await sleep((retryAfter || 2 ** attempt) * 1000 + Math.random() * 500);
    return callTool(body, attempt + 1);
  }

  const json = await res.json();
  const err = json?.result?.structuredContent?.error;
  if (err?.type === "rate_limited") {
    if (attempt >= 5) throw new Error("rate limited: " + err.tool);
    // honour the server's hint, with jitter, and double it on repeats
    const wait = err.retry_after_seconds * 2 ** attempt * 1000 + Math.random() * 500;
    await sleep(wait);
    return callTool(body, attempt + 1);
  }
  return json;
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
# bash: retry a rate-limited call with exponential backoff
for attempt in 0 1 2 3 4; do
  out=$(curl -s https://popstarpet.com/mcp \
    -H "Authorization: Bearer $TOKEN" \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_orders","arguments":{}}}')
  echo "$out" | grep -q '"type":"rate_limited"' || { echo "$out"; break; }
  sleep $(( 2 ** attempt ))
done

Good practice: run tool calls sequentially rather than fanning out, cache get_order results within a session, and always add jitter so retries from multiple agents don't line up.

7. Troubleshooting

  • 401 Unauthorized — missing, expired or app-session token. Reconnect the client so it runs the OAuth flow again.
  • 406 Not Acceptable — add Accept: application/json, text/event-stream.
  • Empty order list or "no order was updated" — the signed-in account is a customer, not a studio admin. Permissions are enforced in the database, not in the tool.
  • Consent page loops back to the homepage — sign in first, then retry the connection from the client.

Back to Pop Star Pet