Solution architecture ·

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.

Next.jsReact NativeGraphQL BFFNestJSMCP · Streamable HTTPJWT + token exchangeRedisKafkaOpenTelemetry
Prepared by
Kamal Mohan
Role
Tech Lead
Date
October 2026
Status
Draft for review
← → to move · F for full screen
01 · Ingress

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.

Path 1

Guided form

Next.js / React Native → GraphQL BFF (persisted queries) → command bus. No MCP involved.

POST /graphql · __Host-sid cookie
Path 2

Our 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 = MCP
Guardrail middleware (path 2 only)authn → scope → schema → policy → confirm → idempotency → audit
Application layerCommandHandler / QueryHandler → domain modules → outbox events
Single write path

REST, GraphQL and MCP all dispatch to the same CommandHandlers.

Exactly-once effects

Commands carry an idempotency key. Side effects run from events after commit.

LLM output is untrusted input

Tool arguments get the same validation as an anonymous form post.

02 · Architecture

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.

Web and PWANext.jsMobileReact NativeSalon tabletRN kioskWhatsApp, voicewebhook, SIPCDN, WAFbot score, JA4managed rulesTurnstileTLS 1.3API gatewayJWT verify (JWKS)GCRA rate limitsrouting, mTLSBFFGraphQL, sessionsAgent orchestratortool loop, SSEMCP server/mcp, internal onlyNestJS + MCP SDKWebhook adaptersWhatsApp, paymentsMCP clientGuardrailsauthn, scopezod, policyconfirmidempotencyauditcommand / query buscommands from eventsApplication + domainCommand busCatalogBooking engineSalon opsCustomer profileMembershipNotificationsPostgreSQLsystem of recordRedissessions, holds, limitsKafkaoutbox relay, eventsIdentity, STSOIDC, JWKS, exchangeModel gateway, LLMredaction, egress listOTel, audit logtraces, metricsedge: public ingressapplication: private subnetsplatform: private onlyagent and MCP pathexisting application layer, shared by both ingress paths
03 · Frontend

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} />.

Chat stream contract (SSE)
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.confirm renders <ReadBackCard>. Its button calls the confirmHold(holdId) mutation; the BFF mints the token.
  • SlotPicker and ReadBackCard are the same components the form uses.
04 · Backend

Backend: modular monolith, hexagonal inside

Adapters translate protocols into commands. Handlers orchestrate. Domain code has no framework or I/O imports.

ADAPTERSAPPLICATIONDOMAININFRASTRUCTUREREST controllerszod DTOsGraphQL resolversBFF onlyMCP tool adaptersdefineTool() to commandKafka consumersidempotent by eventIdCommand and query busdispatch by type, unit of workHandlersHoldSlot, ConfirmBooking,SearchAvailability, CancelCross-cuttingidempotency, authorisation policyEvent publisheroutbox row inside the txAggregatesBooking, Slot, Hold, CreditDomain policiescancellation, pricing, credit useDomain eventsBookingConfirmed, HoldExpiredValue objectsMoney, TimeRange, SlotIdBookingRepositorySQL, optimistic lockHoldStoreRedis, Lua scriptsPaymentGatewayRazorpay or StripeOutboxRelaypoll or CDC, to KafkaClock, IdGeneratortestable time and idsportsDependencies point inward. Infrastructure implements ports declared by the application layer; adapters never touch it directly.

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.

05 · Backend

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.

AgentMCP clientMCP serverauthn, guardrailsBooking handlercommandRedisholds, limitsPaymentsproviderPostgreSQLtx + outboxKafkaeventstools/call hold_slotHoldSlotCommandLua: cap check, SET hold NX EX 300holdId, priceMinor, expiresInconfirm_booking + ctok (customer tapped Confirm)SET ctok:{jti} NX EX 120ConfirmBookingauthorize deposit, Idempotency-KeyBEGIN, insert booking + outbox, COMMITDEL holdbookingId BK-20931outbox relay: BookingConfirmed
06 · Backend

Events: transactional outbox to Kafka

Side effects run after commit and never inside the tool call. A failed SMS cannot undo a booking.

Booking txbooking rowoutbox rowone transactionOutbox relaypoll or CDCat-least-oncekey = aggregateIdKafkabooking.events.v1membership.events.v1schema registryNotifications: SMS, WhatsAppMembership credit ledgerAvailability cache invalidationAudit and analytics sinkDead-letter topicafter 5 retriesalert on depth > 0
ConcernDesign
DeliveryAt-least-once from the relay. Ordering per aggregate through the Kafka key.
ConsumersIdempotent: processed_events(eventId) checked in the same transaction as the effect.
SchemasSchema registry, backward-compatible changes only, .v1 topics. Contract tests in CI.
FailuresExponential backoff, 5 tries, then the dead-letter topic. Replays are safe because consumers are idempotent.
07 · Data

Data model: core booking schema

Logical schema only. Booking, payment and ledger rows are the only tables the confirm path writes.

customerid uuid PKphone text UKemail textdisplay_name textconsent_at timestamptzstatus texthair_profilecustomer_id uuid PK, FKshade textgray_pct smallintallergies bytea (encrypted)formula jsonbmembership_ledgerid bigint PKcustomer_id uuid FKbooking_id text FKdelta intreason textbookingid text PKcustomer_id uuid FKlocation_id uuid FKservice_id uuid FKstylist_id uuid FKduring tstzrangestatus booking_statusprice_minor intchannel textversion intpaymentid uuid PKbooking_id text FKprovider_ref text UKamount_minor intstatus textlocationid uuid PKname texttz textgeo geographystylistid uuid PKlocation_id uuid FKname textskills text[]serviceid uuid PKlocation_id uuid FKname textduration_min intprice_minor intshiftid uuid PKstylist_id uuid FKduring tstzrange1:n1:nn:1n:1n:11:n1:nSlot holds are not stored here: they live in Redis for 300 s. The exclusion constraint on booking is the last guard.
08 · Data

Booking table: constraints do the safety work

Correctness for double booking is a database property. Redis holds only reduce contention.

booking
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.

09 · Data

Outbox, idempotency and consumer tables

These make retries, replays and at-least-once delivery safe. They hold no booking data.

events and idempotency
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.

10 · Data

Refresh tokens, denylist and audit

Login refresh tokens, early revocation of agent tokens, and the audit trail behind every tools/call.

tokens
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_at set revokes the whole family_id.
audit
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_denylist is mirrored in Redis, so the per-call jti check 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.
11 · Data

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.

heldRedis, 300 sconfirmedoccupies the slotchecked_inoccupies the slotcompletedterminalcancelledslot freedno_showslot freedconfirmarrivesrecordedcancelgrace overreschedule (same row, new range)Only confirmed and checked_inoccupy time in the exclusionconstraint, so leaving themfrees the slot automatically.
TransitionCommandGuardEvent
held to confirmedConfirmBookinglive hold owned by sub, ctok valid, price unchangedBookingConfirmed
confirmed to confirmedRescheduleBookingstep-up auth, version match, new range freeBookingRescheduled
confirmed to cancelledCancelBookingstep-up auth, policy window sets refund or feeBookingCancelled
confirmed to no_showMarkNoShow (job)start + grace passed, no check-inNoShowRecorded
12 · Mcp

Inside the MCP server

One request path from the in-cluster socket to the application layer. Auth runs before any JSON-RPC is parsed.

TransportPOST /mcpGET /mcp (SSE)DELETE /mcpmTLS, in-cluster onlyAuthverify JWT (JWKS)aud, exp, scopejti denylistinternal issuerSessionMcp-Session-IdRedis mcps:{id}bound to sub30 min idleJSON-RPC routerinitializetools/list, tools/callresources/readping, cancelTool registryscope-filtered listname to handlerzod to JSON SchemaannotationsGuardrail chainvalidate, policyconfirmidempotencyaudit, OTel spanApplication layercommand / query bus401 Unauthorizedbad, expired or wrong audRedissession lookup, 30 min idle

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.

13 · Services to MCP

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)
ServiceToolAnnotations
Catalogfind_salons
list_services
read-only
Bookingsearch_availabilityread-only
hold_slotwrite, idempotent
confirm_bookingwrite, needs ctok
reschedule_booking
cancel_booking
destructive, step-up
Profileget_my_profile
list_my_bookings
read-only, owner
Membershipget_my_creditsread-only, owner
Helpsearch_help
policy://cancellation
read-only, resource
Not mappedpayments, roster, pricing, inventory, staff
14 · Exposure

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

catalog:read · anonymous
find_salonslist_servicessearch_availability
  • s-maxage=60, served from cache
  • 14-day window, stylist IDs omitted
GCRA per IP and device

Customer

booking:read booking:write
hold_slotconfirm_bookinglist_my_bookingsget_my_profileget_my_credits
  • Subject = token sub
  • Every query filtered by owner
GCRA per sub and device

Step-up

booking:modify + acr=otp
reschedule_bookingcancel_booking
  • auth_time < 10 min
  • Else 401 insufficient_user_authentication, acr_values=otp, max_age=600 (RFC 9470)
Same rule on voice and WhatsApp

Never exposed

no scope exists
refundcapture_paymentedit_rosterset_pricelookup_customerexportraw_query
  • Staff operations stay behind staff SSO + MFA
Not registered on the server
15 · Mcp

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.

Customer appbrowser or mobileBFFsession cookieAgentorchestrator, MCP clientIdentitytoken exchange (STS)MCP serverinternal, mTLSPOST /conversations/c_91/turns (cookie)turn + session reference (mTLS)POST /token token-exchange, audience=mcpJWT: aud=mcp, sub, act=agent, 5 mininitialize Authorization: Bearer ...Mcp-Session-Id, capabilitiestools/call search_availabilityJWKS (cached), no per-call round tripstructuredContenttool resultsSSE: delta, ui.slots
16 · Guardrails

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.

Authenticate

JWT signature via JWKS, aud = MCP URL, exp, revocation list.

Authorise

Tool scope ⊆ token scopes. sub → ctx.subject.

Validate

zod .strict(), ISO-8601 dates, ID formats, 14-day window.

Policy

Ownership, live-hold count, cancellation window.

Confirm

Single-use confirmation token (ctok).

Execute

Idempotency key, 2 s timeout, circuit breaker.

Shape, audit

outputSchema check, PII filter, audit row, OTel span.

What the caller receives on failure
StageFailureResponse
Authenticatemissing, expired or wrong audHTTP 401 · token missing, expired or wrong aud
Authorisescope missingHTTP 403 · error="insufficient_scope", scope="booking:write"
Validateschema mismatchisError: true + field errors (model can self-correct)
PolicyHOLD_LIMIT, OUTSIDE_WINDOWisError: true + code + alternatives[]
Confirmno human yesCONFIRMATION_REQUIRED + ui.confirm event
Executetimeout, breaker openisError: true + retryable: true + retryAfterMs
17 · Guardrails

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.

hold_slotSET hold:{slot} NX EX 300
read-backstructuredContent: service, start, priceMinor
human yesUI tap · spoken yes · WhatsApp reply
ctok mintedJWS, 120 s, single use
confirm_bookingverify → commit → BookingConfirmed
Confirm on web and app
// 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…" }
ctok claims and checks
{ "sub": "cus_2041", "hold": "hold_81f", "price_minor": 180000,
  "ch": "web", "jti": "c7f1…", "exp": now+120 }
  • Signature valid, not expired, sub equals the access token's sub
  • Hold still live and owned; price unchanged since read-back
  • jti unused: 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
18 · Security

Threat model for the MCP surface

Threats specific to LLM-driven tool calls, and the control in the server that handles each.

ThreatVectorControl
Prompt injectionInstructions hidden in profile notes, salon text or tool outputTool output is data. No tool executes free text. Writes need a ctok that the model cannot mint.
Tool driftTool descriptions or schemas change unintentionally or through a dependencyStatic descriptions. tools/list snapshot test blocks schema changes in CI. listChanged off.
Agent token theftLeaked agent token reused5 min lifetime, audience-bound, bound to sub, jti denylist, reachable only over mTLS.
Token passthroughToken forwarded downstream or used for another resourceAudience check. Downstream calls use service identity plus signed x-subject.
Session hijackStolen or guessed Mcp-Session-IdRandom 128-bit id, bound to sub. Not an authenticator, the bearer token is still required.
Direct access to /mcpCaller bypassing the agent and its checksNo ingress route. NetworkPolicy admits only agent pods. mTLS client certificate required.
IDORModel passes another customer's idNo customer id in any input schema. Owner filter in the repository.
Data exfiltrationOver-broad tool outputoutputSchema with minimal fields, PII filter, max 20 items per result.
SSRFTool that fetches a URLTools take ids, never URLs. Egress allow-list at NAT.
19 · Abuse

Bot and abuse controls

Request-rate limits stop fast bots. Atomic business invariants stop slow ones that stay under the rate limits.

Edge

WAF managed rules, bot score < 30 → challenge, JA4 TLS fingerprint, ASN reputation. Turnstile token verified server-side on BFF mutations.

Agent

One service identity acts for the customer (act claim). Limits apply to the customer and the conversation, never to the agent as a whole.

Accounts

Writes require a phone-verified sub. Accounts under 24 h old get 1 live hold.

Algorithm

GCRA in Redis, one Lua script per check, keys rl:{dim}:{id}. Fail open for reads, fail closed for writes.

Invariants

≤ 2 live holds per sub, checked and set atomically in Lua. ≤ 4 upcoming bookings. Deposit flag after 2 no-shows.

KeyReadsWrites
ip120 / min10 / min
sub60 / min6 holds / 10 min
device120 / min10 / min
phone (OTP)—3 / 15 min, 10 / day
conversation30 turns40k 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 hHold TTL 60 s, then hold ban 1 h
> 3 accounts per deviceStep-up OTP, manual review
agent tool error rate > 20%Trip breaker, fall back to form, page on-call
20 · Abuse

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.

GCRA rate limit, one script per check
-- 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, or RATE_LIMITED inside MCP.
Hold with per-customer cap
-- 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.
21 · Sessions

Session model

Identity, agent tokens, transport and conversation state are stored separately. A session ID never authorises a request.

SessionCredential / keyStoreTTLRevocation
Web login__Host-sid → sess:{sid}Redis30 min idle, 30 d absoluteLogout; refresh reuse revokes the token family
Mobile loginJWT 10 min + rotating refreshMemory / Keychain30 dSame family revocation
Agent tokenJWT aud=MCP, act=agent, jtiAgent memory only5 minNot refreshable: re-exchange from the live session; jti denylist
MCP transportMcp-Session-Id → mcps:{id} {sub}Redis30 min idleDELETE /mcp; sub mismatch → 404, re-initialize
Conversationconv:{sub}Redis24 hBooked or handed off; summary persisted
Slot holdhold:{slot} = sub · holds:{sub}Redis300 sConfirm 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.

22 · Sessions

How the sessions relate

Each session answers one question. Authority comes from the identity provider and flows downward. Nothing flows back up.

Identity providerSSO session at the STSWeb / app sessionsess:{sid}, __Host-sidChannel identityverified phone, WhatsApp idAgent tokenJWT aud=MCP, act=agent5 min, exchanged per turnConversationconv:{sub}, 24 hkeyed by customer, not channelMCP transportmcps:{id}{sub}, 30 min idleSlot holdshold:{slot}, 300 sholds:{sub} (cap 2)authorisestool calls read and write statehold_slotshared by chat, WhatsApp, voiceRevocation fans out from the identity provider: logout or refresh-token reuse revokes refresh families, denies agent tokens, drops transports, clears conversations and releases holds.
23 · Operations

Tracing, audit and metrics

One trace id runs from the MCP request through guardrails, handler, Redis, payments and the outbox event.

Audit record, one per tools/call
{ "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.
MetricAlert
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_ratioabove 60%, a hoarding signal
confirm_decline_ratio{channel}sudden rise, possible injection
outbox_lag_seconds, dlq_depthlag above 30 s, any DLQ depth
24 · Operations

Deployment and scaling

The MCP server scales on in-flight requests and open SSE streams. State lives in Redis, so pods are disposable.

Internetclients, hostsCDN, WAFALB, TLS 1.3idle timeout 120 sKUBERNETES, private subnets, mTLS meshbffHPA 3 to 20 podsagent-orchestratorHPA 3 to 20, SSEmcp-serverHPA 3 to 30, statelesswebhookssignature verify, enqueueapp-modulesmodular monolithbooking, catalog, salonprofile, membershipworkersoutbox relayKafka consumers, remindersotel-collectortraces, metrics, logsPostgreSQLmanaged, multi-AZRedis clusterreplicas, AOFKafkamanaged, 3 brokersNAT egress allow-listLLM provider, paymentsWhatsApp, SMS, speech
  • Long-lived streams: ALB idle timeout 120 s, SSE heartbeat every 25 s. On SIGTERM stop accepting, drain in-flight tools/call for 30 s, then close streams.
  • Release gate: blue-green, with a tools/list contract snapshot test that fails the build on any schema or description change not in the changelog.
25 · End to end

Booking through the agent: protocol trace

From login to the committed booking.

01 Login
Session

OIDC + phone OTP. The BFF sets __Host-sid.

02 Exchange
Agent token

RFC 8693 exchange: 5 min JWT with aud = MCP, act = agent.

03 Initialise
Session

initialize returns Mcp-Session-Id; tools/list filtered by scope.

04 Hold
Read-back

search_availability, then hold_slot. BFF emits ui.confirm.

05 Confirm
ctok

Customer taps Confirm, BFF mints ctok, confirm_booking consumes it.

06 Commit
Outbox

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"}}}
26 · Summary

Key technical decisions

Each one narrows what a compromised prompt or bot can do.

The agent calls our own MCP server

Token exchange (RFC 8693) from the customer's session. One guardrail chain on every channel.

Tools wrap command handlers; schemas shared via zod

REST, GraphQL and MCP cannot drift apart.

ctok minted outside the model

Bound to sub + hold + price, single use. Prompt injection cannot commit a write.

Subject from the sub claim only

No input schema contains a customer ID, which rules out IDOR through prompts.

GCRA limits plus atomic invariants in Redis Lua

Catches bots that stay under request-rate limits.

Stateless MCP pods with no ingress route

Scale out freely. A stolen Mcp-Session-Id is useless without the token and the network position.

Open questions
  1. Run the MCP server as its own service from day one, or in-process with the agent until load justifies it?
  2. Sender-constrained agent tokens (mTLS-bound or DPoP) from day one, or after launch?
  3. Which actions need OTP step-up beyond cancel and reschedule?