← Back
MCP Case Study · 04 Published March 2026

Phorest MCP Server, Architecture, Security & Notion Agent Setup

A hosted MCP server exposing 20 Phorest salon API tools to AI agents via Streamable HTTP. Full OAuth 2.0 with PKCE, AES-256-GCM encryption at rest, opaque SHA-256 token hashing, refresh rotation, and session binding, connected directly to Notion's AI agent.

Overview

The Phorest MCP Server is a hosted Model Context Protocol server that exposes the Phorest salon management API as 20 AI-callable tools. It uses Streamable HTTP transport with a full OAuth 2.0 authorization code flow (PKCE), allowing any MCP-compatible client, including Notion's AI agent, to securely authenticate and interact with Phorest data.

Stack: Node.js · TypeScript · Express 5 · PostgreSQL · Heroku
Key dependencies: @modelcontextprotocol/sdk ^1.26.0, express ^5.2.1, pg ^8.20.0, jose ^6, zod ^4.3.6


Architecture

Source files

FileRole
src/index.tsExpress server, OAuth router, MCP session management, 20 tool definitions
src/oauth-provider.tsOAuth server provider (authorize, token exchange, refresh, revocation)
src/oauth-store.tsPostgreSQL state store (clients, connections, tokens, auth codes, pending logins)
src/auth.tsEncryption layer, HKDF key derivation, JWE encrypt/decrypt, opaque token generation
src/phorest-client.tsPhorest API HTTP client with centralized ID validation
src/login-page.tsHTML login form with CSRF protection

Request flow

Notion Agent → POST /mcp (Streamable HTTP)
  ↓
Bearer token extracted from Authorization header
  ↓
Token hash looked up in oauth_tokens table
  ↓
Phorest credentials decrypted from oauth_connections
  ↓
Session created/reused (bound to client + config fingerprint)
  ↓
MCP tool executed against Phorest API
  ↓
JSON response returned to agent

OAuth 2.0 Flow

The server implements the full OAuth 2.0 Authorization Code flow with PKCE (S256), as required by the MCP specification.

  1. Discovery, Client fetches /.well-known/oauth-authorization-server
  2. Authorization, Client redirects user to /authorize with code_challenge (S256)
  3. Login, Server renders an HTML form for Phorest API credentials
  4. Credential validation, Server calls Phorest API (/business/{id}/branch) to verify
  5. Authorization code, On success, server stores an encrypted connection, generates an auth code
  6. Token exchange, Client exchanges code + code_verifier at /token for access (1h) and refresh (30d) tokens
  7. API access, Client includes Bearer <access_token> on every /mcp request
  8. Token refresh, Rotation enforced on every refresh

Key design decisions


Security Architecture

1. Encryption at rest (AES-256-GCM + HKDF)

All sensitive data in PostgreSQL is encrypted using JWE with dir + A256GCM. The key is derived from AUTH_SECRET via HKDF-SHA256, ensuring a cryptographically strong key even from a weak-ish secret.

2. Opaque token design

Tokens are never stored in cleartext. Generate phmcp_atk_<32 random bytes>, hash with SHA-256, store the hash. Even if the database is compromised, tokens cannot be recovered.

3. CSRF protection

The login form uses a stateful double-submit pattern, a 32-byte CSRF token is generated on /authorize, embedded in the form, and compared on POST /login. No cookies required.

4. Rate limiting

EndpointLimitWindow
POST /login5 req/IP15 min
POST /mcp100 req/IP15 min
/authorize100 req/IP15 min

5. Path traversal prevention

All user-supplied IDs are validated against /^[a-zA-Z0-9_-]+$/ before being interpolated into API paths, enforced centrally via validateOpaqueId().

6. Session binding

MCP sessions are bound to a config fingerprint: SHA-256(JSON.stringify(phorestConfig)). Every /mcp request verifies that the token's client ID and config fingerprint match the session owner.

7. Generic error messages

Auth failures on /mcp return a generic "Unauthorized". The real error is logged server-side only, no information leakage about token validity or expiry.

8. HTTPS enforcement

At startup, if NODE_ENV=production and BASE_URL does not start with https://, the server refuses to start.

9. Bounded pending logins & automatic cleanup

oauth_pending_logins is capped at 1,000 entries. A periodic task runs every 60 seconds to clean up expired pending logins, auth codes, access tokens, and refresh tokens. The timer uses .unref() so it doesn't block graceful shutdown.


Database schema

TablePurpose
oauth_clientsRegistered OAuth clients (encrypted metadata)
oauth_connectionsPhorest credential sets (encrypted config)
oauth_pending_loginsIn-progress login flows
oauth_auth_codesAuthorization codes
oauth_tokensAccess & refresh tokens (SHA-256 hashes)

20 MCP Tools

The server exposes branches, staff, services, clients (list / get / create / update), products, appointments and bookings (list / get / create), purchase items, vouchers, rooms, and a get_connection_info helper that lets agents auto-detect their configured branch and region.


Notion-Specific Technical Details


Security checklist