0.11.0 — MCP server ships (30 tools across the 6 native apps) + per-card loading skeletons on Dial home and Templates
The **MCP server** finally ships — an earlier draft existed in the tree but had never been deployed, had no UI to mint a token against, and covered only 4 of the 6 native apps thinly. This is the working suite — **thirty read-only tools across Tables, Calendar, Dial, Inbox, Pages, and Templates**, tokens that bind to `(org_id, user_id)` and query through the owner's own RLS (so isolation is a database property, not a per-tool discipline), a **Settings → MCP access** tab to mint and revoke, and it's live on production. Plus **per-card loading skeletons** on the Dial landing page and the Templates gallery, killing the "0 available now" false zero and the "No templates yet" flash on first paint.
What's new
MCP server: 30 read-only tools, tokens bound to (org_id, user_id), live on prod
An MCP server had lived in the repo for a while but had never
shipped. Four independent checks came back negative — the function
was absent from the deployed edge-function list, its mcp_tokens
migration had never been applied to any environment, no UI existed to
mint a token, and there were no tests, docs, or .mcp.json in the
tree. Nobody could have connected to it. It also covered only four of
the six native apps, thinly.
This ships it: the auth model is rebuilt around per-user identity, the tool surface covers every native app, and the function, migration, and in-app management UI are all live on production.
Endpoint. https://<your-project-ref>.supabase.co/functions/v1/mcp,
JSON-RPC 2.0 over Streamable HTTP, MCP 2025-06-18.
Tools — 30 across six apps + two system tools.
| App | Count | Tools |
|---|---|---|
| System | 2 | whoami, get_current_time |
| Tables | 6 | tables, rows, saved views, row activity timeline |
| Calendar | 4 | calendars, events (window + get + search) |
| Dial | 6 | buckets, stats, live queue health, individual calls, suppressions |
| Inbox | 5 | accounts, list, thread, batch threads, labels |
| Pages | 4 | list, title search, full-text body search, get-as-markdown |
| Templates | 3 | list, get, search |
Read-only by design. No tool writes, sends, or deletes. MCP clients like Claude Desktop call tools without confirming each one; a confirming write-tool surface is on the roadmap, but it isn't in v1.
The load-bearing change is auth. Tokens now bind to
(org_id, user_id). The function exchanges one for a real Supabase
session belonging to that user and queries through it, so RLS is the
enforcement boundary. The prior draft ran every query as
service_role with hand-written .eq('org_id', ...) in each tool — a
security invariant held together by whoever writes the next tool
remembering it. It also made Inbox impossible: mailbox credentials live
in integrations, which is keyed by user_id, not org_id, so an
org-only identity had nothing to resolve against.
Minting a session costs two round trips, so it's cached in-memory per
isolate and in mcp_sessions (with the live session held in Vault) so
cold starts don't pay for it.
Dial calls the existing RPCs. dial_bucket_stats /
dial_buckets_overview / dial_bucket_calls already exist, are
SECURITY INVOKER, and carry pgTAP coverage. The prior JS tally was a
second implementation that had already drifted — no per-user filter,
none of the meetings handling the RPC was fixed for. Reusing the RPCs
means MCP counts match what a rep sees in the app.
Content serialization. Page blocks are converted to markdown, template HTML to text. Unrecognised block types are reported in the response, not dropped — a page can never be silently lossy.
Error handling. Postgres codes and constraint text are mapped to safe plain-language messages that never reach an LLM context; an unmapped failure returns a short correlation id matching a server log line.
Exact counts are opt-in. has_more comes from fetching limit + 1
so pagination doesn't require a scan; include_total: true triggers
the full RLS-scoped count only when a caller actually needs one.
Inbox returns triage-sufficient metadata from list_inbox and
offers a batch get_email_threads, so the model isn't pushed into an
N+1 against a rate-limited provider.
Settings → MCP access. New Settings tab to mint and revoke tokens:
show-once reveal, two-press revoke, built against the design system.
Revocation is immediate — the server also drops its cached session for
the token, so an in-flight client stops working at once. Tokens are
revoked rather than deleted so created_at and last_used_at survive
for audit.
Verified end-to-end against production. initialize, tools/list
(30), one read tool per app returning real data, argument validation,
and error redaction all exercised against the deployed function. The
dial RPC returned real meetings stats, the exact case the old JS
tally got wrong. A temporary token was minted for this and deleted
afterwards; zero tokens exist right now.
See MCP server.
Loading: per-card skeletons on Dial home and Templates
Both apps already had page-level skeletons — the gap was everything after the shell paints. The cards themselves.
Dial cards used "Loading…" text instead of skeletons. The bucket
cards rendered — for available now and leads, plus a literal
"Loading today's calls…" string while dial_buckets_overview was in
flight — the bare-text anti-pattern the design system names
explicitly. The window is longer than it looks: counts can't start
until the view snapshot RPC resolves every bucket's preset, so cards
sit in that state through two serial round trips.
The summary strip reported zeros while counting. summarizeBuckets
summed an empty map to 0, so "0 available now" sat in hero type for
the whole load, with a quiet "still counting N buckets" note pushed
off to the right. That's the same false zero the cards themselves
refuse to show.
Templates flashed "No templates yet" on first paint. The workspace
gated on !loaded && loading, but the store starts loading: false
and only flips inside a passive ensureLoaded() effect — so the first
committed paint fell through to the gallery with zero templates,
rendering the empty state and its two create buttons before the fetch
had begun.
What changed. A new primitive — SkeletonPulse — the pulse
without the ARIA region, for skeleton blocks inside content that has
already rendered. aria-hidden by design (twenty cards must not
become twenty aria-live regions; the container announces once).
Every dial card count slot now has three states: the number, a
skeleton sized to the digits it stands in for, or — once the value
isn't coming. Name and status stay real throughout — only what's
actually pending is replaced.
The summary strip skeletons its four totals until at least one bucket
has landed, then shows running totals plus the existing still-counting
note. A failed fetch drops every card to — and the header keeps
the sole "Couldn't load counts" warning — no card pulses at a number
that will never arrive, and the failure is named once rather than
twenty times.
Templates now gates on !loaded rather than loading, so the first
paint is the skeleton — not the empty state. The error branch is
checked before the skeleton branch, since a failed first load never
becomes loaded and would otherwise pulse forever on a dead network.
Both surfaces run through useSkeletonGate with stale threaded in,
so a warm cache doesn't produce sub-frame flicker and stale reloads
soften rather than snap.
Under the hood
- Migration 177 — MCP server, user-scoped.
mcp_tokensbound to(org_id, user_id),mcp_sessionsfor the per-token session cache (Vault-held),mcp_token_issueandmcp_token_revokeasauthenticated-executableSECURITY DEFINERfunctions that check membership internally. Supersedes the never-applied earlier draft. - Edge function
mcpdeployed withverify_jwt: false— the server authenticates with its ownp88_tokens, not Supabase JWTs. - Two new advisor lints are intentional.
mcp_sessionsisrls_enabled_no_policyby design (service-role only; holds a Vault pointer to a live token), and themcp_token_*RPCs are theauthenticated-executable definers described above. SkeletonPulse. New primitive alongside the existing page-level skeletons — pulse without an aria-live region, takesactiveso a component keeps one piece of JSX for both states rather than forking into a duplicate layout.useSkeletonGateadopted on both Dial home and Templates, withstalethreaded into both skeleton components. Neither needs anidentity: an org switch already flips each store's condition, so adding one would be a second source of truth.- Design philosophy. DESIGN-PHILOSOPHY gains a Partial loads
section, the false-zero anti-pattern, and the gate-on-
!loadedrule, so the next card grid gets the same treatment. .mcp.jsonis checked into the terminal repo, so Claude Code connects automatically from the project root oncePROJECT88_MCP_TOKENis exported.
What's next
- A confirming write-tool surface for MCP. Send an email, log a call, create a page — behind an explicit confirmation policy so a hallucinated call can't land in production data. Deliberately deferred so the auth model could be validated against production before anything could mutate.
- Per-token rate limits. Upstream providers (Gmail, Graph) enforce their own, but MCP itself needs a quota before this surface opens to end-customers — a tight tool loop can otherwise walk into an inbox rate limit.
- Pages surface loading skeletons. Pages is the third tree-app
and its store has the same
loading: falseinit, butPageWorkspaceis tree + editor with no card gallery — a different shape, and a bigger change than this one.
See MCP server and Key concepts → MCP token.
Changelog
What's new in Project88. We ship continuously — this page tracks the user-visible changes that matter.
0.10.0 — Dial home + bucket folders, default-From + scheduled send that ships, and phone/palette search that finds what's there
Dial gets a **card landing page with bucket folders** and stops looping the last lead of an exhausted queue. **Default From account** on every compose window — and **scheduled send** finally runs end-to-end (the worker was never deployed). **Phone search** matches by a canonical digit key instead of stored format, so `(910) 620-0001` and `9106200001` are the same lookup. The command palette stops hiding an entire org behind a legacy `workspaces` row. Clients get a real **source** column backfilled from lineage/phone/email, and the `pitchPrfct` lead-source label is renamed to `selfGen` in place — data, options list, saved views, and conversion snapshots all in one migration.