Packages

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.

ToolArgsBehavior
list_modulesnoneReturns 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.