ShadeStudio: frontend, backend and MCP architecture
How the booking domain is exposed to our own agent over MCP: backend structure, tool surface, token exchange, the guardrail chain, abuse controls and sessions.
- Prepared by
- Kamal Mohan
- Role
- Tech Lead
- Date
- October 2026
- Status
- Draft for review
Two ingress paths, one write path
Every path resolves to the same application-layer command handlers. The MCP server is internal: only our own agent calls it.
Guided form
Next.js / React Native → GraphQL BFF (persisted queries) → command bus. No MCP involved.
POST /graphql · __Host-sid cookieOur agent (chat, WhatsApp, voice)
SSE chat → agent orchestrator → MCP client (mTLS) → MCP server → command bus. Token from an RFC 8693 exchange.
tools/call · Bearer, aud = MCPREST, GraphQL and MCP all dispatch to the same CommandHandlers.
Commands carry an idempotency key. Side effects run from events after commit.
Tool arguments get the same validation as an anonymous form post.
End-to-end architecture
Two ingress paths meet at one application layer. The MCP server adds a guardrail stage and has no route from the internet.
Frontend: typed stream, typed components
Chat UI renders ui.* events produced from tool results. Free model text never carries a price or a slot.
Web
Next.js App Router. Catalog as RSC with ISR, booking flow as client components, TanStack Query, types generated from the GraphQL schema.
Mobile
React Native sharing @shade/booking-ui and design tokens. Refresh token in Keychain / Keystore, access token in memory only.
BFF
GraphQL, persisted queries only (hash allow-list). Web session in __Host-sid: HttpOnly, Secure, SameSite=Lax. CSRF token on mutations.
Degradation
Agent circuit open → render <BookingForm prefill={conversation.slots} />.
POST /bff/conversations/c_91/turns Accept: text/event-stream event: delta data: {"text":"Indiranagar has "} event: ui.slots // from search_availability data: {"slots":[{"id":"slt_1715","start":"17:15"}]} event: ui.confirm // from hold_slot data: {"holdId":"hold_81f","priceMinor":180000, "expiresAt":"2026-10-10T11:50:00Z"} event: done data: {"turnId":"t_42","usage":{"in":812,"out":64}}
ui.confirmrenders<ReadBackCard>. Its button calls theconfirmHold(holdId)mutation; the BFF mints the token.SlotPickerandReadBackCardare the same components the form uses.
Backend: modular monolith, hexagonal inside
Adapters translate protocols into commands. Handlers orchestrate. Domain code has no framework or I/O imports.
Module boundaries
booking, catalog, salon, profile, membership, notifications. Cross-module calls go through each module's public API only, enforced by dependency-cruiser in CI.
CQRS-lite
Commands run in a unit of work and return ids. Queries read denormalised views, so search_availability never takes a write lock.
Split later
Modules talk through the bus and events, so any one can become a service. Today it is one deployable, one transaction scope.
Booking write path: hold, confirm, commit
Redis is the fast path for holds. The commit is a single transaction that also writes the outbox row.
Events: transactional outbox to Kafka
Side effects run after commit and never inside the tool call. A failed SMS cannot undo a booking.
| Concern | Design |
|---|---|
| Delivery | At-least-once from the relay. Ordering per aggregate through the Kafka key. |
| Consumers | Idempotent: processed_events(eventId) checked in the same transaction as the effect. |
| Schemas | Schema registry, backward-compatible changes only, .v1 topics. Contract tests in CI. |
| Failures | Exponential backoff, 5 tries, then the dead-letter topic. Replays are safe because consumers are idempotent. |
Data model: core booking schema
Logical schema only. Booking, payment and ledger rows are the only tables the confirm path writes.
Booking table: constraints do the safety work
Correctness for double booking is a database property. Redis holds only reduce contention.
CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TYPE booking_status AS ENUM ('confirmed','checked_in','completed','cancelled','no_show'); CREATE TABLE booking ( id text PRIMARY KEY, -- BK-20931 customer_id uuid NOT NULL REFERENCES customer(id), location_id uuid NOT NULL REFERENCES location(id), service_id uuid NOT NULL REFERENCES service(id), stylist_id uuid NOT NULL REFERENCES stylist(id), during tstzrange NOT NULL, status booking_status NOT NULL DEFAULT 'confirmed', price_minor integer NOT NULL CHECK (price_minor >= 0), channel text NOT NULL, -- form|chat|voice|whatsapp version integer NOT NULL DEFAULT 1, -- optimistic lock EXCLUDE USING gist (stylist_id WITH =, during WITH &&) WHERE (status IN ('confirmed','checked_in')) ); CREATE INDEX booking_customer_upcoming ON booking (customer_id, lower(during)) WHERE status = 'confirmed';
No double booking
The gist exclusion constraint rejects any overlapping range for the same stylist while status is confirmed or checked_in. A violation maps to SLOT_TAKEN and returns alternatives.
Optimistic locking
Reschedule and cancel run UPDATE ... WHERE id = $1 AND version = $2. Zero rows updated means a concurrent change, so the command retries once.
Policy queries
The partial index serves the "max 4 upcoming bookings" check and list_my_bookings without scanning history.
Ledger rules
membership_ledger is append-only. Balance is SUM(delta). Unique (booking_id, reason) makes credit redemption idempotent.
Outbox, idempotency and consumer tables
These make retries, replays and at-least-once delivery safe. They hold no booking data.
CREATE TABLE outbox ( id bigserial PRIMARY KEY, aggregate_id text NOT NULL, type text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz ); CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL; CREATE TABLE idempotency_key ( scope text NOT NULL, -- sub (customer) key text NOT NULL, request_hash bytea NOT NULL, -- same key, new body = 422 status smallint NOT NULL, response jsonb, expires_at timestamptz NOT NULL, PRIMARY KEY (scope, key) ); CREATE TABLE processed_event ( consumer text NOT NULL, event_id uuid NOT NULL, PRIMARY KEY (consumer, event_id) );
outbox
Written in the same transaction as the booking. The relay reads the partial index of unpublished rows in id order and sets published_at after Kafka acks.
idempotency_key
Looked up before executing a write. Same scope and key with the same request_hash replays the stored response. A different hash returns 422. Rows expire after 24 h.
processed_event
Consumers insert (consumer, event_id) in the same transaction as their effect. A duplicate insert means the event was already handled, so it is skipped.
Refresh tokens, denylist and audit
Login refresh tokens, early revocation of agent tokens, and the audit trail behind every tools/call.
CREATE TABLE refresh_token ( id uuid PRIMARY KEY, family_id uuid NOT NULL, customer_id uuid NOT NULL REFERENCES customer(id), token_hash bytea NOT NULL UNIQUE, -- never the raw token used_at timestamptz, revoked_at timestamptz, expires_at timestamptz NOT NULL ); CREATE INDEX refresh_family ON refresh_token (family_id); CREATE TABLE token_denylist ( jti uuid PRIMARY KEY, expires_at timestamptz NOT NULL -- row ends with the token );
- Presenting a token that has
used_atset revokes the wholefamily_id.
CREATE TABLE audit_log ( ts timestamptz NOT NULL, trace_id text, sub text, channel text, tool text, stage text, decision text, args_hash bytea, latency_ms integer ) PARTITION BY RANGE (ts); -- monthly
token_denylistis mirrored in Redis, so the per-calljticheck never touches PostgreSQL. Rows expire with the token.audit_log: monthly partitions, insert-only role (REVOKE UPDATE, DELETE), arguments stored only as a hash.- Agent tokens are never stored: they live in agent memory for 5 minutes.
Booking lifecycle and its guards
Status is the only column that decides whether a booking occupies time. Every transition is a command with its own guard.
| Transition | Command | Guard | Event |
|---|---|---|---|
| held to confirmed | ConfirmBooking | live hold owned by sub, ctok valid, price unchanged | BookingConfirmed |
| confirmed to confirmed | RescheduleBooking | step-up auth, version match, new range free | BookingRescheduled |
| confirmed to cancelled | CancelBooking | step-up auth, policy window sets refund or fee | BookingCancelled |
| confirmed to no_show | MarkNoShow (job) | start + grace passed, no check-in | NoShowRecorded |
Inside the MCP server
One request path from the in-cluster socket to the application layer. Auth runs before any JSON-RPC is parsed.
Transport
Streamable HTTP over mTLS. POST for requests, GET opens an SSE stream for server-to-client messages, DELETE ends the session. A NetworkPolicy admits only the agent-orchestrator pods.
Capabilities
Server advertises tools (listChanged off), resources and logging. Protocol version is negotiated in initialize. Callers with the same scopes see the same tools.
Stateless pods
No in-memory session state. A request may land on any pod, so the session lookup is one Redis GET and every write goes through the same chain.
Domain services as MCP tools
Tools are thin adapters over existing handlers. One zod schema drives REST validation, inputSchema and outputSchema.
export const holdSlot = defineTool({ name: "hold_slot", title: "Hold an open slot for 5 minutes", scope: "booking:write", inputSchema: HoldSlotInput, // zod, shared with the REST DTO outputSchema: HoldReadBack, // returned as structuredContent annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async handler({ slotId }, ctx) { const customerId = ctx.subject; // token `sub`, never an argument const hold = await ctx.commands.execute( new HoldSlotCommand(customerId, slotId), { idempotencyKey: `hold:${customerId}:${slotId}` }); return toReadBack(hold); // price from the domain }, }); // defineTool() calls McpServer.registerTool() and wraps // handler in the guardrail chain (slide 17)
| Service | Tool | Annotations |
|---|---|---|
| Catalog | find_salons list_services | read-only |
| Booking | search_availability | read-only |
| hold_slot | write, idempotent | |
| confirm_booking | write, needs ctok | |
| reschedule_booking cancel_booking | destructive, step-up | |
| Profile | get_my_profile list_my_bookings | read-only, owner |
| Membership | get_my_credits | read-only, owner |
| Help | search_help policy://cancellation | read-only, resource |
| Not mapped | payments, roster, pricing, inventory, staff | |
Scopes and exposure tiers
tools/list is computed from the token's scopes. tools/call checks again, because a client can call a tool it was never shown.
Public
s-maxage=60, served from cache- 14-day window, stylist IDs omitted
Customer
- Subject = token
sub - Every query filtered by owner
Step-up
auth_time< 10 min- Else 401
insufficient_user_authentication,acr_values=otp,max_age=600(RFC 9470)
Never exposed
- Staff operations stay behind staff SSO + MFA
Authentication: token exchange for the agent
The agent holds no long-lived credential. Each turn it exchanges the customer's session for a short token scoped to the MCP server.
The guardrail chain on every tools/call
Middleware in the MCP server, not instructions in a prompt. Every call from the agent runs through this chain, whatever the channel.
JWT signature via JWKS, aud = MCP URL, exp, revocation list.
Tool scope ⊆ token scopes. sub → ctx.subject.
zod .strict(), ISO-8601 dates, ID formats, 14-day window.
Ownership, live-hold count, cancellation window.
Single-use confirmation token (ctok).
Idempotency key, 2 s timeout, circuit breaker.
outputSchema check, PII filter, audit row, OTel span.
| Stage | Failure | Response |
|---|---|---|
| Authenticate | missing, expired or wrong aud | HTTP 401 · token missing, expired or wrong aud |
| Authorise | scope missing | HTTP 403 · error="insufficient_scope", scope="booking:write" |
| Validate | schema mismatch | isError: true + field errors (model can self-correct) |
| Policy | HOLD_LIMIT, OUTSIDE_WINDOW | isError: true + code + alternatives[] |
| Confirm | no human yes | CONFIRMATION_REQUIRED + ui.confirm event |
| Execute | timeout, breaker open | isError: true + retryable: true + retryAfterMs |
Two-phase writes and the confirmation token
The BFF or channel adapter mints the token after a human confirms. The model cannot mint it. It is bound to one customer, one hold and one price.
// 1. hold_slot result becomes a UI event event: ui.confirm data: {"holdId":"hold_81f","priceMinor":180000, "expiresAt":"2026-10-10T11:50:00Z"} // 2. customer taps Confirm: browser to BFF (cookie + CSRF) mutation { confirmHold(holdId: "hold_81f") { token } } // 3. agent to MCP server tools/call confirm_booking { "holdId": "hold_81f", "confirmationToken": "eyJ…" }
{ "sub": "cus_2041", "hold": "hold_81f", "price_minor": 180000,
"ch": "web", "jti": "c7f1…", "exp": now+120 }
- Signature valid, not expired,
subequals the access token'ssub - Hold still live and owned; price unchanged since read-back
jtiunused:SET ctok:{jti} 1 NX EX 120- Voice and WhatsApp: the channel adapter mints it after a full spoken read-back and a clear yes, or a quick-reply bound to the hold id
Threat model for the MCP surface
Threats specific to LLM-driven tool calls, and the control in the server that handles each.
| Threat | Vector | Control |
|---|---|---|
| Prompt injection | Instructions hidden in profile notes, salon text or tool output | Tool output is data. No tool executes free text. Writes need a ctok that the model cannot mint. |
| Tool drift | Tool descriptions or schemas change unintentionally or through a dependency | Static descriptions. tools/list snapshot test blocks schema changes in CI. listChanged off. |
| Agent token theft | Leaked agent token reused | 5 min lifetime, audience-bound, bound to sub, jti denylist, reachable only over mTLS. |
| Token passthrough | Token forwarded downstream or used for another resource | Audience check. Downstream calls use service identity plus signed x-subject. |
| Session hijack | Stolen or guessed Mcp-Session-Id | Random 128-bit id, bound to sub. Not an authenticator, the bearer token is still required. |
| Direct access to /mcp | Caller bypassing the agent and its checks | No ingress route. NetworkPolicy admits only agent pods. mTLS client certificate required. |
| IDOR | Model passes another customer's id | No customer id in any input schema. Owner filter in the repository. |
| Data exfiltration | Over-broad tool output | outputSchema with minimal fields, PII filter, max 20 items per result. |
| SSRF | Tool that fetches a URL | Tools take ids, never URLs. Egress allow-list at NAT. |
Bot and abuse controls
Request-rate limits stop fast bots. Atomic business invariants stop slow ones that stay under the rate limits.
WAF managed rules, bot score < 30 → challenge, JA4 TLS fingerprint, ASN reputation. Turnstile token verified server-side on BFF mutations.
One service identity acts for the customer (act claim). Limits apply to the customer and the conversation, never to the agent as a whole.
Writes require a phone-verified sub. Accounts under 24 h old get 1 live hold.
GCRA in Redis, one Lua script per check, keys rl:{dim}:{id}. Fail open for reads, fail closed for writes.
≤ 2 live holds per sub, checked and set atomically in Lua. ≤ 4 upcoming bookings. Deposit flag after 2 no-shows.
| Key | Reads | Writes |
|---|---|---|
| ip | 120 / min | 10 / min |
| sub | 60 / min | 6 holds / 10 min |
| device | 120 / min | 10 / min |
| phone (OTP) | — | 3 / 15 min, 10 / day |
| conversation | 30 turns | 40k LLM tokens |
Gateway returns 429 + Retry-After. Inside MCP: isError with RATE_LIMITED and retryAfterMs, so the agent backs off.
| Anomaly rule (stream job) | Action |
|---|---|
| hold:confirm > 5 over 1 h | Hold TTL 60 s, then hold ban 1 h |
| > 3 accounts per device | Step-up OTP, manual review |
| agent tool error rate > 20% | Trip breaker, fall back to form, page on-call |
Rate limiting and hold invariants in Redis
Both checks are single Lua scripts, so they are atomic. The database exclusion constraint is still the last guard against double booking.
-- KEYS[1] = rl:{dim}:{id} ARGV: now_ms, emission_ms, burst_ms local tat = tonumber(redis.call('GET', KEYS[1]) or ARGV[1]) tat = math.max(tat, tonumber(ARGV[1])) local new = tat + tonumber(ARGV[2]) local over = new - tonumber(ARGV[1]) - tonumber(ARGV[3]) if over > 0 then return {0, over} end -- limited, retry in `over` ms redis.call('SET', KEYS[1], new, 'PX', ARGV[3] + ARGV[2]) return {1, 0}
- Dimensions:
ip,sub,device,phone,conversation - Reads fail open on Redis error. Writes fail closed.
- Result surfaces as 429 +
Retry-After, orRATE_LIMITEDinside MCP.
-- KEYS: hold:{slot}, holds:{sub} -- ARGV: sub, ttl_s, cap, now_ms, exp_ms redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) -- prune if redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[3]) then return 'HOLD_LIMIT' end if not redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 'SLOT_TAKEN' end redis.call('ZADD', KEYS[2], ARGV[5], KEYS[1]) return 'OK'
- Cap and claim happen together, so parallel tool calls cannot exceed 2 holds.
- Redis down: holds are refused, confirms still check the database constraint.
Session model
Identity, agent tokens, transport and conversation state are stored separately. A session ID never authorises a request.
| Session | Credential / key | Store | TTL | Revocation |
|---|---|---|---|---|
| Web login | __Host-sid → sess:{sid} | Redis | 30 min idle, 30 d absolute | Logout; refresh reuse revokes the token family |
| Mobile login | JWT 10 min + rotating refresh | Memory / Keychain | 30 d | Same family revocation |
| Agent token | JWT aud=MCP, act=agent, jti | Agent memory only | 5 min | Not refreshable: re-exchange from the live session; jti denylist |
| MCP transport | Mcp-Session-Id → mcps:{id} {sub} | Redis | 30 min idle | DELETE /mcp; sub mismatch → 404, re-initialize |
| Conversation | conv:{sub} | Redis | 24 h | Booked or handed off; summary persisted |
| Slot hold | hold:{slot} = sub · holds:{sub} | Redis | 300 s | Confirm or expiry |
Validate every request
Each MCP request re-verifies the JWT. Writes also check the jti denylist. Mcp-Session-Id only routes.
No token passthrough
MCP server calls the domain with its own mTLS identity and a signed x-subject claim. The agent token is never forwarded.
Logout cascade
Revoke refresh families, drop mcps:* for the sub, DEL conv:{sub}, release holds, publish SessionRevoked.
How the sessions relate
Each session answers one question. Authority comes from the identity provider and flows downward. Nothing flows back up.
Tracing, audit and metrics
One trace id runs from the MCP request through guardrails, handler, Redis, payments and the outbox event.
{ "ts": "2026-10-10T11:44:02Z", "trace_id": "4bf92f35…",
"actor": { "sub": "cus_2041", "channel": "web",
"session": "mcps:5c0e9a", "jti": "a91b…" },
"tool": "confirm_booking",
"args_hash": "sha256:9d1c…", // args are hashed, not stored
"stage": "execute", "decision": "allow",
"idempotency_key": "7c1e…",
"result": { "booking_id": "BK-20931" }, "latency_ms": 412 }
- Span per stage:
mcp.auth,mcp.policy,mcp.confirm,handler,redis,payments - Audit rows are append-only and shipped to immutable storage.
| Metric | Alert |
|---|---|
| mcp_tool_calls_total{tool,outcome} | error ratio per tool above 20% |
| mcp_guardrail_denials_total{stage,code} | spike in authn or policy denials |
| mcp_tool_latency_seconds{tool} | confirm_booking p95 above 800 ms |
| rate_limit_rejections_total{dim} | sustained on one sub or device |
| hold_expiry_ratio | above 60%, a hoarding signal |
| confirm_decline_ratio{channel} | sudden rise, possible injection |
| outbox_lag_seconds, dlq_depth | lag above 30 s, any DLQ depth |
Deployment and scaling
The MCP server scales on in-flight requests and open SSE streams. State lives in Redis, so pods are disposable.
- Long-lived streams: ALB idle timeout 120 s, SSE heartbeat every 25 s. On SIGTERM stop accepting, drain in-flight
tools/callfor 30 s, then close streams. - Release gate: blue-green, with a
tools/listcontract snapshot test that fails the build on any schema or description change not in the changelog.
Booking through the agent: protocol trace
From login to the committed booking.
OIDC + phone OTP. The BFF sets __Host-sid.
RFC 8693 exchange: 5 min JWT with aud = MCP, act = agent.
initialize returns Mcp-Session-Id; tools/list filtered by scope.
search_availability, then hold_slot. BFF emits ui.confirm.
Customer taps Confirm, BFF mints ctok, confirm_booking consumes it.
Booking + outbox row in one transaction. BookingConfirmed → notifications, audit.
→ POST /mcp Authorization: Bearer eyJhbGciOiJFUzI1NiJ9… Mcp-Session-Id: 5c0e9a… {"method":"tools/call","params":{"name":"hold_slot","arguments":{"slotId":"slt_1715"}}} ← {"result":{"structuredContent":{"holdId":"hold_81f","service":"Roots","start":"2026-10-10T17:15+05:30","priceMinor":180000,"expiresIn":300}}} BFF to browser: event: ui.confirm data: {"holdId":"hold_81f","priceMinor":180000} browser to BFF: confirmHold(holdId: "hold_81f") returns the confirmation token → {"method":"tools/call","params":{"name":"confirm_booking","arguments":{"holdId":"hold_81f","confirmationToken":"eyJ…"}}} ← {"result":{"structuredContent":{"bookingId":"BK-20931","status":"confirmed"}}}
Key technical decisions
Each one narrows what a compromised prompt or bot can do.
Token exchange (RFC 8693) from the customer's session. One guardrail chain on every channel.
REST, GraphQL and MCP cannot drift apart.
Bound to sub + hold + price, single use. Prompt injection cannot commit a write.
sub claim onlyNo input schema contains a customer ID, which rules out IDOR through prompts.
Catches bots that stay under request-rate limits.
Scale out freely. A stolen Mcp-Session-Id is useless without the token and the network position.
- Run the MCP server as its own service from day one, or in-process with the agent until load justifies it?
- Sender-constrained agent tokens (mTLS-bound or DPoP) from day one, or after launch?
- Which actions need OTP step-up beyond cancel and reschedule?