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
| File | Role |
|---|---|
src/index.ts | Express server, OAuth router, MCP session management, 20 tool definitions |
src/oauth-provider.ts | OAuth server provider (authorize, token exchange, refresh, revocation) |
src/oauth-store.ts | PostgreSQL state store (clients, connections, tokens, auth codes, pending logins) |
src/auth.ts | Encryption layer, HKDF key derivation, JWE encrypt/decrypt, opaque token generation |
src/phorest-client.ts | Phorest API HTTP client with centralized ID validation |
src/login-page.ts | HTML 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.
- Discovery, Client fetches
/.well-known/oauth-authorization-server - Authorization, Client redirects user to
/authorizewithcode_challenge(S256) - Login, Server renders an HTML form for Phorest API credentials
- Credential validation, Server calls Phorest API (
/business/{id}/branch) to verify - Authorization code, On success, server stores an encrypted connection, generates an auth code
- Token exchange, Client exchanges code +
code_verifierat/tokenfor access (1h) and refresh (30d) tokens - API access, Client includes
Bearer <access_token>on every/mcprequest - Token refresh, Rotation enforced on every refresh
Key design decisions
- No stored passwords, Phorest credentials are encrypted at rest with AES-256-GCM; only decrypted in-memory when making API calls
- Opaque tokens,
phmcp_atk_<random>/phmcp_rtk_<random>, stored as SHA-256 hashes - Refresh token rotation, every refresh issues a new pair and invalidates the old refresh token
- Connection replacement, re-authentication revokes old tokens in a single transaction
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
| Endpoint | Limit | Window |
|---|---|---|
POST /login | 5 req/IP | 15 min |
POST /mcp | 100 req/IP | 15 min |
/authorize | 100 req/IP | 15 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
| Table | Purpose |
|---|---|
oauth_clients | Registered OAuth clients (encrypted metadata) |
oauth_connections | Phorest credential sets (encrypted config) |
oauth_pending_logins | In-progress login flows |
oauth_auth_codes | Authorization codes |
oauth_tokens | Access & 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
- Redirect URI matching, Notion appends dynamic
spaceId/userIdquery parameters. The server uses aProxy-based override onredirect_uristhat compares only origin + pathname for configured relaxed-redirect hosts. - Connection ownership, derived from the redirect URI's
spaceId+userId. Re-authenticating replaces the connection and revokes old tokens. - CSP compatibility,
form-actionis intentionally omitted because Notion opens the login in a popup where'self'resolves to Notion's origin, not ours.
Security checklist
- ✓ All credentials encrypted at rest (AES-256-GCM via JWE)
- ✓ Encryption key derived via HKDF (not raw truncation)
- ✓ Tokens stored as SHA-256 hashes (never cleartext)
- ✓ Refresh token rotation enforced
- ✓ CSRF protection on login form
- ✓ Rate limiting on login, MCP, authorize
- ✓ Path traversal prevention via centralized ID validation
- ✓ Session binding (client ID + config fingerprint)
- ✓ Content Security Policy on login page
- ✓ Generic error messages (no information leakage)
- ✓ HTTPS enforcement in production
- ✓ Bounded pending logins (max 1,000)
- ✓ Automatic cleanup of expired state