MCP server
The MCP server your AI agent connects to, auth-gated with a timing-safe Bearer check.
@caisson-sh/mcp-server is the MCP server your AI agent connects to, to drive generation,
configuration, and the operations the base exposes. It is a first-class surface, not a side door.
The contract
Every tool call is auth-gated: the Bearer token is checked against the issued token set with a
constant-time compare (tokens: readonly ClientToken[] in the server config below). A missing or
wrong token is refused before any tool is listed or dispatched.
// .mcp.json — the agent connects with its issued token.
{
"mcpServers": {
"caisson": {
"url": "https://mcp.example.com",
"headers": { "Authorization": "Bearer ${CAISSON_TOKEN}" },
},
},
}API reference
Building the server
createMcpServer builds a transport-agnostic core. The same McpServerOptions bind to a
stdio connection or an HTTP handler below, each transport constructs its own core instance
from those options (createStdioMcpServer/createHttpMcpHandler both call createMcpServer
internally), so a host runs one or the other from shared config rather than sharing a single
running instance across both. It owns no database transaction: onGenerate is the host's
hook for the actual generation.
function createMcpServer(options: McpServerOptions): McpServer;
interface McpServerOptions {
tokens: readonly ClientToken[]; // issued { token, accountId } pairs (prod: a DB lookup by token hash)
index: RegistryIndex; // the built registry index — generate's allowlist
onGenerate: (ctx: GenerateContext) => Promise<{ generationId: string }>;
coach?: CoachOptions; // opt-in ai-kit setup-coach tools (see below)
checkRateLimit?: (accountId: string) => Promise<void>; // awaited before every dispatch
}
interface McpServer {
authenticate(bearer: string): McpSession;
registerTool(registration: ToolRegistration): void;
retireTool(entry: RetiredTool): void;
listTools(session: McpSession): readonly ToolRegistration[];
handleToolCall(
session: McpSession,
tool: string,
args: unknown,
): Promise<unknown>;
}authenticate compares the bearer against every issued token in constant time before
deciding, no early return on a match. listTools returns every registered tool, sorted.
handleToolCall answers a name that doesn't exist with a 404.
registerTool lets a host add its own tools through the same seam generate uses. It validates the declarative manifest (description, semver version,
audit.logArgs) before the duplicate-name guard, so a bad manifest fails at registration,
not at first call. retireTool marks a name retired rather than deleting it: a call to
a retired tool answers RetiredToolError (410, { reason, retiredAt }) instead of the
generic 404 an unknown name gets, so an integration built against a dropped tool gets an
actionable signal. Retirement is append-only, no unretireTool, and a name is always
exactly one of active / retired / unknown, never two at once.
The tool catalog
Three tools are always registered; the four setup-coach tools exist only when the host passes
coach options.
| Tool | Args | Behavior |
|---|---|---|
list_modules | none | Returns every catalog module id, sorted. |
describe_module | { name } | Returns { name, latest, summary } for one module; NotFoundError (404) for an unknown id. |
generate | { projectName, modules: {id, version}[] } | Validates every {id, version} against the built registry index, then delegates to onGenerate. |
inspect_env | { names: string[] } | Reports which env-var names are set, presence only, never values. |
propose_ai_config | { defaultLane, lanes: [...] } | Turns desired AI lanes into a validated forge.config plus the required env-var names. |
write_forge_config | { settings, approve? } | Fail-closed: without approve: true it returns a preview and never touches the writer port. |
validate_setup | { settings } | Confirms the config parses and every referenced key name is present in the environment. |
generate's registry allowlist check (unknown module or version → ValidationError, 400) runs
before the host hook sees anything. The modules array is capped at 100 entries, rejected on raw
length before the Zod parse runs, a guard against a parse-then-cap ordering that would otherwise
walk an oversized array once before rejecting it.
Transports
The core is transport-agnostic; two bindings ship. Both authenticate before any transport
object is constructed, an invalid token never reaches listTools or handleToolCall.
// stdio — one connection == one session, authenticated once at connect time.
function createStdioMcpServer(deps: StdioServerDeps): Server;
function runStdioServer(
deps: StdioServerDeps,
transport?: Transport,
): Promise<Server>;
interface StdioServerDeps {
mcp: McpServerOptions;
bearer: string;
}// HTTP (Streamable) — stateless, re-authenticates the Bearer on every request.
function createHttpMcpHandler(deps: HttpServerDeps): HttpMcpHandler;
function runHttpServer(
deps: HttpServerDeps,
listen: HttpListenOptions,
): Promise<http.Server>; // the underlying node:http listener, not the MCP-SDK Server —
// a fresh SDK Server is built and bound per request (see below).
interface HttpServerDeps {
mcp: McpServerOptions;
allowedHosts: readonly string[]; // DNS-rebinding protection, required non-empty
allowedOrigins: readonly string[]; // never "*", never reflected
}
interface HttpListenOptions {
port: number;
host: string; // no localhost fallback — the caller supplies a real bind address
}createHttpMcpHandler throws ConfigError at construction if either allowlist is empty, or
if allowedOrigins contains a literal "*": a network-reachable listener never starts with
rebinding protection silently disabled. Each request reads its POST body under a 256 KiB
ceiling before handing it to the SDK transport, rejecting an oversized body (ValidationError, 400) without fully buffering it first. Every response carries X-Content-Type-Options,
X-Frame-Options, and Strict-Transport-Security.
The setup-coach ports
registerCoachTools wires the four setup-coach tools above onto an McpServer (or anything
structurally equal to the minimal CoachToolRegistrar slice). It is secrets-safe by
construction: no tool ever accepts or returns a key value.
function registerCoachTools(
server: CoachToolRegistrar,
options: CoachOptions,
): void;
function presenceEnvPort(
source?: Record<string, string | undefined>,
): CoachEnvPort;
interface CoachOptions {
env: CoachEnvPort; // presence-only: has(name) => boolean, never reads a value
writer: CoachWriterPort; // write(files) — only invoked when a call passes approve: true
configPath?: string; // default "forge.config.json"
envExamplePath?: string; // default ".env.example"
}presenceEnvPort adapts a value-bearing source (default process.env) down to a boolean
port, the coach module never holds a secret value, only whether a name is set. An empty
string counts as unset. write_forge_config's output is names plus config only: a generated
.env.example lists NAME= placeholders, never a real key.