Servers
Source: packages/keryx/classes/Server.ts, packages/keryx/servers/web.ts, packages/keryx/initializers/mcp.ts
Servers are the transport layer — they accept incoming connections and route them to actions. The framework ships with a web server (HTTP + WebSocket via Bun.serve), a CLI entry point, and an MCP server for AI agents. You could add others (gRPC, raw TCP, etc.) by extending the Server base class.
Server Base Class
abstract class Server<T> {
name: string;
/** The underlying server object (e.g., Bun.Server) */
server?: T;
abstract initialize(): Promise<void>;
abstract start(): Promise<void>;
abstract stop(): Promise<void>;
}Servers are auto-discovered from the servers/ directory, just like actions and initializers.
WebServer
The built-in web server uses Bun.serve to handle HTTP requests and WebSocket connections on the same port. It's configured via config.server.web.
HTTP Request Flow
When an HTTP request comes in, the server:
- Checks for a WebSocket upgrade — if the client is requesting a WebSocket connection, it upgrades transparently
- Tries to serve a static file (if
staticFiles.enabledistrueand the path matches) - Matches the request path and method against registered action routes
- Extracts params from path segments (
:param), query string, and request body - Creates a
Connection, callsconnection.act()with the action name and params - Returns the JSON response with appropriate headers and status codes
Param loading order matters — later sources override earlier ones:
- Path params (e.g.,
/user/:id→{ id: "123" }) - Query params (e.g.,
?limit=10) - Body params (JSON or FormData)
WebSocket Message Flow
WebSocket connections are long-lived. After the initial HTTP upgrade, the client sends JSON messages with a messageType field:
| messageType | What it does |
|---|---|
"action" | Execute an action — same validation and middleware as HTTP |
"subscribe" | Subscribe to a PubSub channel (with middleware authorization) |
"unsubscribe" | Unsubscribe from a channel |
Action messages include action, params, and an optional messageId that's echoed back in the response so the client can correlate requests.
Static Files
The web server can serve static files from a configured directory (default: assets/). This is useful for serving the frontend build output or other static assets alongside the API.
HTTP Compression
The web server compresses responses using Brotli or gzip when the client supports it (via Accept-Encoding). Compression is enabled by default for responses larger than 1024 bytes. The server prefers Brotli when available, falling back to gzip.
Configure via config.server.web.compression:
| Key | Env Var | Default | What it does |
|---|---|---|---|
enabled | WEB_COMPRESSION_ENABLED | true | Enable/disable HTTP compression |
threshold | WEB_COMPRESSION_THRESHOLD | 1024 | Minimum response size (bytes) to compress |
encodings | — | ["br","gzip"] | Supported encodings (preference order) |
Static File Caching
When static file serving is enabled, the server supports ETag-based caching and Cache-Control headers. If a client sends If-None-Match with a matching ETag, the server returns 304 Not Modified instead of the full file.
| Key | Env Var | Default | What it does |
|---|---|---|---|
staticFiles.cacheControl | WEB_SERVER_STATIC_CACHE_CONTROL | "public, max-age=3600" | Cache-Control header for static files |
staticFiles.etag | WEB_SERVER_STATIC_ETAG | true | Enable ETag generation and 304s |
Correlation IDs
Every request is assigned a correlation ID (UUID) for distributed tracing. The ID is available on connection.correlationId and returned in the X-Request-Id response header. If a reverse proxy or upstream service sends an X-Request-Id header, the server can trust and propagate it instead of generating a new one.
| Key | Env Var | Default | What it does |
|---|---|---|---|
correlationId.header | WEB_CORRELATION_ID_HEADER | "X-Request-Id" | Header name for correlation IDs |
correlationId.trustProxy | WEB_CORRELATION_ID_TRUST_PROXY | false | Trust incoming correlation ID headers |
WebSocket Configuration
| Key | Env Var | Default | What it does |
|---|---|---|---|
websocket.maxPayloadSize | WS_MAX_PAYLOAD_SIZE | 65536 | Max message size in bytes |
websocket.maxMessagesPerSecond | WS_MAX_MESSAGES_PER_SECOND | 20 | Rate limit per connection |
websocket.maxSubscriptions | WS_MAX_SUBSCRIPTIONS | 100 | Max channel subscriptions per connection |
websocket.drainTimeout | WS_DRAIN_TIMEOUT | 5000 | Ms to wait for pending messages during shutdown |
Configuration
All web server settings are in config.server.web:
| Key | Default | What it does |
|---|---|---|
enabled | true | Enable/disable the web server |
port | 8080 | Listen port |
host | "localhost" | Bind address |
apiRoute | "/api" | URL prefix for action routes |
allowedOrigins | "*" | CORS allowed origins |
staticFiles.enabled | true | Serve static files |
staticFiles.directory | "assets" | Directory for static files |
CLI "Server"
The CLI isn't technically a server — it's a separate entry point (keryx.ts) that uses Commander to register every action as a CLI command. But it goes through the same Connection → act() pipeline as HTTP and WebSocket.
The server boots in RUN_MODE.CLI, which tells initializers to skip transport-specific setup (like binding to a port). After the action executes, the process exits with the appropriate exit code.
# List all available actions
./keryx.ts actions
# Run an action
./keryx.ts "user:create" --name Evan --email evan@example.com --password secret -q | jq
# Start the full server
./keryx.ts startMCP Server
Source: packages/keryx/initializers/mcp.ts, packages/keryx/initializers/oauth.ts
The MCP (Model Context Protocol) server exposes actions as tools for AI agents. Unlike the web server and CLI, MCP is implemented as an initializer rather than a Server subclass — but it follows the same pattern of accepting requests and routing them through Connection → act().
When enabled (MCP_SERVER_ENABLED=true), the MCP initializer:
- Registers every action (where
mcp.tool !== false) as an MCP tool - Registers actions with
mcp.resourceas MCP resources (static URI or URI template) - Registers actions with
mcp.promptas MCP prompts - Converts action names from
:to-format (e.g.,user:create→user-create) - Converts Zod input schemas to JSON Schema for tool/prompt parameter definitions
- Handles Streamable HTTP transport at the configured route (default
/mcp)
Each authenticated client gets its own McpServer instance, tracked by the mcp-session-id header.
Authentication
MCP uses OAuth 2.1 with PKCE for authentication. The OAuth initializer (packages/keryx/initializers/oauth.ts) provides the required endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/oauth-protected-resource | GET | Resource metadata (RFC 9728) |
/.well-known/oauth-authorization-server | GET | Authorization server metadata |
/oauth/register | POST | Dynamic client registration |
/oauth/authorize | GET | Authorization page (login/signup) |
/oauth/authorize | POST | Process login/signup |
/oauth/token | POST | Exchange code for access token |
The authorization page is rendered from Mustache templates in packages/keryx/templates/. Actions tagged with mcp.isLoginAction or mcp.isSignupAction handle the actual authentication during the OAuth flow.
Configuration
| Key | Env Var | Default |
|---|---|---|
enabled | MCP_SERVER_ENABLED | false |
route | MCP_SERVER_ROUTE | "/mcp" |
instructions | MCP_SERVER_INSTRUCTIONS | package description |
oauthClientTtl | MCP_OAUTH_CLIENT_TTL | 2592000 |
oauthCodeTtl | MCP_OAUTH_CODE_TTL | 300 |
Request Flow
- MCP client sends a POST to
/mcpwithAuthorization: Bearer <token> - The initializer verifies the token against Redis (
oauth:token:{token}) - A new
Connectionis created with type"mcp"and the authenticated user's session - Action params are extracted from the MCP tool call arguments
connection.act()executes the action through the standard middleware pipeline- The result is returned as an MCP tool response
See the MCP guide for full usage details.