Skip to content
7 changes: 7 additions & 0 deletions .changeset/quiet-rockets-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"caplets": minor
---

# Hot reload serve config

Add default hot reload for `caplets serve`, including live config and Caplet file reconciliation without restarting the MCP process.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ caplets config paths
caplets config paths --json
```

Caplets validates this file at startup. Config changes take effect after restarting the
Caplets MCP server.
Caplets validates this file at startup and hot reloads config changes while `caplets serve`
is running. Invalid edits are ignored until fixed, so the MCP server keeps serving the last
known-good config instead of dropping every tool because of a transient JSON or validation
error.

The optional `$schema` field points editors at the generated JSON Schema in
[`schemas/caplets-config.schema.json`](schemas/caplets-config.schema.json). CI verifies that
Expand Down Expand Up @@ -458,6 +460,12 @@ Configure your MCP client to run Caplets as a stdio server:
If your client starts the configured command directly, `caplets` without arguments also
starts the MCP server. `serve` is explicit and recommended for clarity.

`caplets serve` watches the effective user config, project config, user Caplet files, and
trusted project Caplet files. Adding, editing, disabling, or removing a Caplet updates the
top-level MCP tool list without restarting Caplets. When an MCP-backed Caplet changes or is
removed, Caplets closes only that affected downstream connection; unrelated Caplets and
their downstream connections keep running.

## How Agents Use It

Caplets initially exposes one MCP tool per enabled Caplet. If the config has `filesystem`,
Expand Down Expand Up @@ -536,9 +544,10 @@ pnpm schema:check
pnpm verify
```

`pnpm dev` rebuilds on source changes and restarts the local stdio MCP server from
`pnpm dev` rebuilds Caplets source changes and restarts the local stdio MCP server from
`dist/index.js`. Use it for local development, not as the command configured in an MCP
client, because build logs are written to stdout.
client, because build logs are written to stdout. Runtime config hot reload is built into
normal `caplets serve` and does not require `pnpm dev`.

## Product Notes

Expand Down
81 changes: 81 additions & 0 deletions docs/plans/2026-05-12-hot-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Hot Reload Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make normal `caplets serve` reload config and Caplet file changes without restarting the Caplets MCP process.

**Architecture:** Introduce an in-process runtime controller that owns the MCP server, registered top-level tool handles, the current registry, backend managers, and filesystem watchers. Reloads parse the effective config exactly as startup does, update registered MCP tools through `RegisteredTool` handles, invalidate only affected backend caches, and keep the last known-good config active when a reload fails.

**Tech Stack:** TypeScript, Node.js `fs.watch`, `@modelcontextprotocol/sdk` `McpServer.registerTool`, Vitest.

---

## Task 1: Runtime Controller

**Files:**

- Create: `src/runtime.ts`
- Modify: `src/index.ts`
- Test: `test/runtime.test.ts`

- [ ] Add `CapletsRuntime` that creates `McpServer`, `ServerRegistry`, `DownstreamManager`, `OpenApiManager`, and `GraphQLManager`.
- [ ] Track registered top-level Caplet tools in a `Map<string, RegisteredTool>`.
- [ ] Register initial enabled Caplets during startup.
- [ ] Keep `src/index.ts` as a small CLI/server entrypoint.

## Task 2: Reload Reconciliation

**Files:**

- Modify: `src/runtime.ts`
- Modify: `src/downstream.ts`
- Modify: `src/openapi.ts`
- Modify: `src/graphql.ts`
- Test: `test/runtime.test.ts`, `test/downstream.test.ts`, `test/openapi.test.ts`, `test/graphql.test.ts`

- [ ] On reload, call existing `loadConfig()` with the same resolved paths used at startup.
- [ ] Add new Caplets with `registerTool`.
- [ ] Update existing Caplets with `RegisteredTool.update`.
- [ ] Remove missing or disabled Caplets with `RegisteredTool.remove`.
- [ ] Add `DownstreamManager.closeServer(serverId)`.
- [ ] Add `OpenApiManager.invalidate(serverId)` and `GraphQLManager.invalidate(serverId)`.
- [ ] Invalidate or close only backends whose normalized config changed.
- [ ] Keep serving the previous registry and tools if reload parsing or validation fails.

## Task 3: Filesystem Watching

**Files:**

- Modify: `src/runtime.ts`
- Test: `test/runtime.test.ts`

- [ ] Watch the effective user config file, project config file, user Caplets root, and trusted project Caplets root.
- [ ] Use dependency-free `fs.watch` with a 250 ms debounce.
- [ ] Watch directories so new, renamed, and deleted Markdown Caplet files are detected.
- [ ] Recreate watchers after every successful reload because roots can change with `CAPLETS_CONFIG`.
- [ ] Close all watchers during runtime shutdown.

## Task 4: Documentation

**Files:**

- Modify: `README.md`

- [ ] Document that `caplets serve` hot reloads config and Caplet file changes by default.
- [ ] Document last known-good behavior for invalid edits.
- [ ] Document that changed or removed MCP-backed Caplets close only their affected downstream connection.
- [ ] Keep `pnpm dev` documented as source rebuild/restart tooling, separate from runtime config hot reload.

## Task 5: Verification

**Files:**

- Modify tests as needed.

- [ ] Run targeted tests for runtime reload and manager invalidation.
- [ ] Run `pnpm format:check`.
- [ ] Run `pnpm lint`.
- [ ] Run `pnpm typecheck`.
- [ ] Run `pnpm schema:check`.
- [ ] Run `pnpm test`.
- [ ] Run `pnpm build`.
117 changes: 110 additions & 7 deletions src/downstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,46 @@ export type CompactTool = {
type ManagedConnection = {
client: Client;
transport: { close(): Promise<void>; onclose?: () => void; onerror?: (error: Error) => void };
configFingerprint: string;
tools?: Tool[];
toolsFetchedAt?: number;
restartingAfterDeath?: boolean;
closing?: boolean;
};

export class DownstreamManager {
private readonly connections = new Map<string, ManagedConnection>();
private readonly connecting = new Map<string, ManagedConnection>();
private readonly restartState = new Map<string, { restartUsed: boolean; backoffUntil: number }>();

constructor(
private readonly registry: ServerRegistry,
private registry: ServerRegistry,
private readonly options: { authDir?: string } = {},
) {}

updateRegistry(registry: ServerRegistry): void {
this.registry = registry;
}

async close(): Promise<void> {
await Promise.allSettled(
[...this.connections.values()].map((connection) => connection.transport.close()),
);
const connections = [...this.connections.values(), ...this.connecting.values()];
for (const connection of connections) {
connection.closing = true;
}
await Promise.allSettled(connections.map((connection) => connection.transport.close()));
this.connections.clear();
this.connecting.clear();
}

async closeServer(serverId: string): Promise<void> {
const connection = this.connections.get(serverId) ?? this.connecting.get(serverId);
this.connections.delete(serverId);
this.connecting.delete(serverId);
this.restartState.delete(serverId);
if (connection) {
connection.closing = true;
await connection.transport.close();
}
}

async checkServer(server: CapletServerConfig): Promise<{
Expand Down Expand Up @@ -186,21 +207,52 @@ export class DownstreamManager {
}

private async connect(server: CapletServerConfig): Promise<ManagedConnection> {
const expectedFingerprint = this.currentServerFingerprint(server);
const existing = this.connections.get(server.server);
if (existing) {
return existing;
if (existing.configFingerprint !== expectedFingerprint) {
this.connections.delete(server.server);
existing.closing = true;
await existing.transport.close();
} else {
return existing;
}
}
if (this.currentServerFingerprint(server) !== expectedFingerprint) {
throw staleServerConfigError(server.server);
}
const currentServer = this.currentServer(server.server);
if (!sameServerConfig(currentServer, server)) {
throw staleServerConfigError(server.server);
}
const restart = this.restartState.get(server.server);
if (restart && restart.restartUsed && Date.now() < restart.backoffUntil) {
throw new CapletsError("SERVER_UNAVAILABLE", `${server.server} is in restart backoff`);
}

this.registry.setStatus(server.server, "starting");
let pendingConnection: ManagedConnection | undefined;
try {
const client = new Client({ name: "caplets", version: "1.0.0" }, { capabilities: {} });
const transport = this.createTransport(server);
const connection: ManagedConnection = {
client,
transport,
configFingerprint: expectedFingerprint,
};
pendingConnection = connection;
this.connecting.set(server.server, connection);
transport.onclose = () => {
this.connections.delete(server.server);
const current = this.connections.get(server.server);
if (current === connection) {
this.connections.delete(server.server);
}
if (connection.closing) {
return;
}
if (current !== connection) {
return;
}
this.restartState.set(server.server, {
restartUsed: true,
backoffUntil: Date.now() + 1_000,
Expand All @@ -212,18 +264,41 @@ export class DownstreamManager {
);
};
transport.onerror = (error: Error) => {
if (connection.closing) {
return;
}
if (this.connections.get(server.server) !== connection) {
return;
}
this.registry.setStatus(
server.server,
"unavailable",
toSafeError(error, "SERVER_UNAVAILABLE"),
);
};
await client.connect(transport, { timeout: server.startupTimeoutMs });
const connection: ManagedConnection = { client, transport };
if (connection.closing) {
await transport.close();
throw new CapletsError("SERVER_UNAVAILABLE", `${server.server} connection was closed`);
}
if (this.currentServerFingerprint(server) !== expectedFingerprint) {
connection.closing = true;
await transport.close();
throw staleServerConfigError(server.server);
}
if (this.connecting.get(server.server) !== connection) {
connection.closing = true;
await transport.close();
throw new CapletsError("SERVER_UNAVAILABLE", `${server.server} connection was replaced`);
}
this.connecting.delete(server.server);
this.connections.set(server.server, connection);
this.registry.setStatus(server.server, "available");
return connection;
} catch (error) {
if (pendingConnection && this.connecting.get(server.server) === pendingConnection) {
this.connecting.delete(server.server);
}
const code = isTimeoutLike(error) ? "SERVER_START_TIMEOUT" : "SERVER_UNAVAILABLE";
const safe = toSafeError(error, code);
this.registry.setStatus(server.server, "unavailable", safe);
Expand Down Expand Up @@ -327,6 +402,34 @@ export class DownstreamManager {
this.options.authDir,
);
}

private currentServer(serverId: string): CapletServerConfig {
const current = this.registry.require(serverId);
if (current.backend !== "mcp") {
throw staleServerConfigError(serverId);
}
return current;
}

private currentServerFingerprint(server: CapletServerConfig): string {
const current = this.currentServer(server.server);
if (!sameServerConfig(current, server)) {
throw staleServerConfigError(server.server);
}
return serializeServerConfig(current);
}
}

function sameServerConfig(left: CapletServerConfig, right: CapletServerConfig): boolean {
return serializeServerConfig(left) === serializeServerConfig(right);
}

function serializeServerConfig(server: CapletServerConfig): string {
return JSON.stringify(server);
}

function staleServerConfigError(serverId: string): CapletsError {
return new CapletsError("SERVER_UNAVAILABLE", `${serverId} configuration changed; retry request`);
}

function nearbyToolNames(tools: Tool[], needle: string): string[] {
Expand Down
10 changes: 9 additions & 1 deletion src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,18 @@ export class GraphQLManager {
private readonly cache = new Map<string, ManagedGraphQl>();

constructor(
private readonly registry: ServerRegistry,
private registry: ServerRegistry,
private readonly options: { authDir?: string } = {},
) {}

updateRegistry(registry: ServerRegistry): void {
this.registry = registry;
}

invalidate(serverId: string): void {
this.cache.delete(serverId);
}

async checkEndpoint(endpoint: GraphQlEndpointConfig): Promise<{
server: string;
status: string;
Expand Down
Loading
Loading