Skip to content

Architecture

The NEMT MCP Service follows Clean/Hexagonal architecture, mirroring nemt-facility-backend conventions. The service is organized into four layers and now spans eight application modules. 31 tools total after the v2.0 audit remediation (removed get_draft_status, get_cancellation_reasons, create_standing_order; added get_member_phone, get_member_addresses).

graph TD A["🤖 MCP Clients
(Claude, GPT, AI Agents)"] -->|"HTTP POST /mcp"| B["MCP Transport Layer
internal/mcp/"] B --> B1["RequestID → Logging → BearerAuth → AuditLog"] B1 --> B2["StreamableHTTPHandler
(Stateless mode)"] B2 -->|"tool/call"| C1["🔐 verify
3 tools"] B2 -->|"tool/call"| CM["👤 member
4 tools"] B2 -->|"tool/call"| C2["📋 booking
5 tools"] B2 -->|"tool/call"| C3["📍 address
3 tools"] B2 -->|"tool/call"| C4["🚗 trip
11 tools"] B2 -->|"tool/call"| CE["🪪 enrollment
2 tools"] B2 -->|"tool/call"| CP["🗺️ planning
1 tool"] B2 -->|"tool/call"| CN["✉️ notification
1 tool"] C1 --> D["Shared Infrastructure
internal/pkg/ — Pkg facade"] CM --> D C2 --> D C3 --> D C4 --> D CE --> D CP --> D CN --> D D --> E1[("☁️ Spanner
Member, Trip, Facility data")] D --> E2[("⚡ Valkey
Sessions, Drafts, Cache")] D --> E3["🗺️ Google Maps / Directions / Places"] D --> E4["🌤️ Open-Meteo (weather)"] D --> E5["✉️ SendGrid (email)"] D --> E6["📦 nemt-objects
nemt-trip-service
nemt-standing-order"] style A fill:#4a9eff,color:#fff style B fill:#2d3748,color:#fff style B1 fill:#2d3748,color:#a0aec0 style B2 fill:#2d3748,color:#fff style D fill:#1a202c,color:#fff
Module Tools Purpose
verify 3 Member identity verification + session creation
member (v2.0) 6 PII contact CRUD — email/phone/address (read + INTENT-gated write)
booking 4 Incremental draft assembly + commit (no-op set_booking_details returns status)
address 3 Address resolution, facility search (with Places Details enrichment), reverse geocoding
trip 10 Trip read/edit, cancellation (single-tool two-step), will-call, MR driver assignment, TP assignment, standing orders
enrollment (v2.0) 2 Mileage-reimbursement driver onboarding + PCI banking setup
planning (v2.0) 1 Pre-book estimation (route + weather + suggested pickup)
notification (v2.0) 1 Email confirmations (HTML + standing-order PDFs)

Handles HTTP routing, authentication, and middleware. Analogous to internal/grpc/ in facility-backend.

  • server.go – HTTP handler setup, route wiring (/mcp, /health)
  • auth/ – BearerAuth middleware, CachedTokenValidator
  • interceptors/ – RequestID, StructuredLogging, HIPAA audit logging
  • ports/ – TokenCache, TokenDB, HealthChecker interfaces
  • adapters/ – APIKeyTokenDB, health probe implementations

API key mapping: MCP_API_KEY_MAPPINGS maps token:org_id:client_name. No lob_id in the token – it is resolved from member data during verify_member.

Each module is a bounded context following the hexagonal pattern:

internal/app/{module}/
{module}.go # Module factory: New(Params) -> Module
register.go # Tool registration
domain/ # Pure domain logic (no I/O)
ports/ # Interfaces (contracts)
adapters/
repo/ # Data access (wraps nemt-objects)
cache/ # Valkey-backed caches (per-module as needed)
crypto/ # (enrollment only) AES-GCM encryptor
validation/ # (enrollment only) ABA routing validator
geocoding/ directions/ weather/ # (address / planning) external clients
mcp/handlers/ # MCP tool handlers (thin adapters)
usecases/ # Business logic interactors

Key principle: Handlers are thin. They map MCP args to use case input, call the interactor, map output to MCP response. All business logic lives in usecases.

Central dependency injection via Pkg facade. New collaborators were added in v2.0 to back the new modules.

internal/pkg/
pkg.go # New(ctx) initializes all deps in order
types.go # Pkg struct definition
infra/
config/ # tools/config.Load() with secrets
db/ # Spanner client
valkey/ # Valkey client
log/ # Logger
integrations/
directions/ # Google Directions client (planning)
email/ # SendGrid v3 client (notification)
geo/ # Google Geocoding / Places
pdf/ # go-pdf/fpdf generator (notification)
vendors/ # Nemt vendor clients

Pkg fields (v2.0 additions)

  • EmailClient — SendGrid v3 wrapper used by send_confirmation. Disabled when SENDGRID_API_KEY is empty.
  • PDFGenerator — go-pdf/fpdf wrapper used for standing-order trip-schedule PDFs.

Initialization order: env -> config -> logger -> Spanner -> Cache -> nemt-objects -> integrations (directions, email, pdf, geo) -> TripService -> StandingOrder

Close order (reverse): Cache -> Spanner

Types shared across all modules:

  • session.go – Session struct (Valkey, 1h TTL, with Version for optimistic locking). v2.0 added LastConfirmedDraft (by-value snapshot of the draft at confirm_trip success), LastTripIDs, LastFriendlyIDs, IsStandingOrder, StandingOrderID, and StandingOrderMeta — populated by confirm_trip and consumed by send_confirmation to authorize the follow-up email and prevent cross-session PHI leakage.
  • draft.go – BookingDraft struct (Valkey, 24h TTL, typed Address fields)
  • address.go – Typed Address struct (Street, City, State, ZipCode, Lat, Lng)
  • response.go – ToolResponse, Progress, Guidance, MarshalToolResult, MarshalResult[T]
  • errors.go – ErrorCode taxonomy + the v2.0 INTENT primitive: IntentRequiredError carries Action, CurrentValues, NewValue, and Hint; ErrIntentRequiredSentinel plus Unwrap() lets handlers detect it through errors.Is chains; BuildIntentRequiredToolError(err) upgrades it into a typed INTENT_REQUIRED ToolError. ErrSessionMissing sentinel is here too.
  • session_loader.go – LoadSession, LoadSessionAndDraft, ValidateDraftOwnership
  • helpers.go – DerefStr, DerefInt (nil-safe pointer dereference)
  • phone.go – NormalizePhone, ValidatePhone
  • audit.go – AuditEntry, AuditWriter interface
  • context.go – Context key accessors (RequestID, SessionID, OrgID)
  • annotations.go – Tool annotation helpers (BoolPtr)
  • audit/slog_writer.go – Single HIPAA audit logging implementation (shared across all modules)

internal/mcp/instructions.go exposes a ~1100-character ServerInstructions constant wired into the MCP SDK via mcpsdk.ServerOptions{Instructions: ServerInstructions}. The SDK ships these instructions in the initialize result, so every connecting AI agent receives them ONCE per session — they are prepended to the agent’s system prompt and never re-sent on subsequent calls.

This replaces the old pattern of duplicating workflow rules across every tool description. The instructions cover:

  • SESSIONS — 1-hour TTL, session_id is required by all stateful tools.
  • BOOKING FLOW — verify_member → resolve_address (pickup + dropoff) → set_booking_details → review_trip → confirm_trip. Highlights the no-op set_booking_details status-snapshot pattern.
  • INTENT — when update_member_email or update_member_phone returns INTENT_REQUIRED, STOP and ASK the member; never silently overwrite.
  • CANCEL (single-tool two-step) — call cancel_trip / cancel_standing_order WITHOUT reason to receive the list of valid reasons; call again WITH the chosen reason.
  • CODES — use the EXACT treatment_type / service_type codes returned by verify_member.
  • HIPAA — tool responses contain PHI; never echo raw PHI to logs or untrusted clients.

Total token cost is paid once per session, not per call. This is the largest payload reduction in the v2.0 audit (~10-25 KB saved per booking session compared with the prior pattern of repeating workflow guidance in every tool description).

internal/mcp/interceptors/sequence.go implements SequenceMiddleware, a stateful middleware that:

  • Tracks per-session monotonic call sequences. Counters live in a sync.Map keyed by session_id, each entry carries an atomic.Uint64. Safe under concurrent fan-in.
  • Has a 90-minute TTL with a 5-minute janitor sweep. A background goroutine (lifetime tied to the app context, CC-2) prunes idle entries. The TTL exceeds the 1-hour session TTL so the counter outlives any live conversation by a buffer.
  • Emits {tool, sequence, session_id, request_id} on every tools/call. Gives multi-turn AI conversations a unique increasing number for cross-call correlation when debugging.
  • Injects session_id into the request context via domain.WithSessionID BEFORE the next middleware runs.

The middleware sits in the chain as: BearerAuth → SequenceMiddleware → AuditLogging. The bonus payoff of injecting session_id into context is that AuditLogging now reliably picks up session_id from context rather than re-parsing the JSON-RPC body — fixes the silent gap where audit rows used to lack session_id for tools that loaded the session inside the use case rather than the handler.

The domain.IntentRequiredError + ErrIntentRequiredSentinel pair (in internal/domain/errors.go) gives every member-mutation use case a single way to surface the “existing primary would be silently overwritten” condition. The error carries the current values, the new value, and a hint; ClassifyAndBuildError recognizes it through wrap chains via errors.As and emits a typed INTENT_REQUIRED ToolError with a stock recovery suggestion (“Ask the member: replace, add_secondary, or add_as_primary…”). Used by update_member_email and update_member_phone. (update_member_address does not use INTENT — Place rows lack a “primary” concept; dedup + home-exclusivity cover the same ground.)

graph LR V["🔐 verify"] --> M["👤 member"] V --> B["📋 booking"] V --> E["🪪 enrollment"] V --> P["🗺️ planning"] B --> A["📍 address"] B --> T["🚗 trip"] T --> N["✉️ notification"] style V fill:#48bb78,color:#fff style M fill:#38b2ac,color:#fff style B fill:#4299e1,color:#fff style A fill:#ed8936,color:#fff style T fill:#9f7aea,color:#fff style E fill:#d53f8c,color:#fff style P fill:#0bc5ea,color:#fff style N fill:#f6ad55,color:#fff

No module imports another module directly. Shared state (sessions, drafts) is accessed through port interfaces backed by the same Valkey cache.

Integration Location Used By
Google Directions internal/pkg/integrations/directions/ planning (estimate_trip)
Google Places Text Search internal/pkg/integrations/geo/ address (find_place)
Google Reverse Geocoding internal/pkg/integrations/geo/ address (get_location_info, update_member_address)
Open-Meteo internal/app/planning/adapters/weather/ planning (estimate_trip)
SendGrid v3 internal/pkg/integrations/email/ notification (send_confirmation)
go-pdf/fpdf internal/pkg/integrations/pdf/ notification (standing-order attachments)

v2.0 introduced the following secrets (pulled via tools/config/secret, validated at startup):

  • PAYEE_ENCRYPTION_KEY – Base64-encoded 32-byte AES-256 key used by internal/app/enrollment/adapters/crypto/ to encrypt account numbers and tax IDs. Required for setup_payee.
  • SENDGRID_API_KEY – SendGrid v3 API key. When empty, send_confirmation returns a configuration error (fail-safe).
  • EMAIL_FROM_ADDRESS – Sender address for confirmation emails.
  • EMAIL_FROM_NAME – Sender display name.
  1. Create new module directory: internal/app/{name}/
  2. Define domain types in domain/
  3. Define port interfaces in ports/
  4. Implement adapters in adapters/
  5. Write use case interactors in usecases/
  6. Create MCP handler in adapters/mcp/handlers/ (or mcp_handlers.go for small modules)
  7. Wire module in {name}.go / module.go
  8. Register tools in register.go
  9. Add module to app.go and tools.RegisterAll()