Files
bDS/MCP_STANDALONE_PLAN.md

24 KiB

MCP Standalone CLI Plan

Goal

Ship a bundled CLI tool (bds-mcp) alongside the Electron app that:

  • Runs as a self-contained MCP server over stdio (for Claude Desktop, VS Code, etc.)
  • Works entirely against the SQLite database — no Electron process required
  • Signals the running app of changes via a db_notifications table
  • Is installed into agent configs by the app's existing agent-config UI (with add and remove)

Architecture

┌─────────────────────────────────┐     ┌────────────────────────────────────┐
│  bds-mcp  (bundled CLI binary)  │     │  bDS Electron app                  │
│                                 │     │                                    │
│  StdioServerTransport ──────┐   │     │  HTTP MCPServer :4124              │
│                             │   │     │  ├── same engines                  │
│                             │   │     │  └── NotificationWatcher           │
│  PostEngine                 ├───┼─────┼──► SQLite (WAL mode, shared)       │
│  MediaEngine                │   │     │      posts / media / scripts …     │
│  ScriptEngine               │   │     │      db_notifications  ← new       │
│  TemplateEngine             │   │     └────────────────────────────────────┘
│  MCPServer (tools/res)      │   │                    ▲
│  ProposalStore (in-memory)  │   │           chokidar(-wal) + 100 ms debounce
└─────────────────────────────┘   │           invalidate engines + IPC to renderer
                                  │
agent (Claude Desktop, VS Code…) ─┘
(stdio)

Port mapping

Context Port Transport
bDS app running :4124 HTTP (existing, unchanged)
bds-mcp CLI stdio

Required Changes

A — db_notifications table (new Drizzle migration)

CREATE TABLE db_notifications (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  entity    TEXT NOT NULL,    -- 'post' | 'media' | 'script' | 'template'
  entityId  TEXT NOT NULL,
  action    TEXT NOT NULL,    -- 'created' | 'updated' | 'deleted'
  fromCli   INTEGER NOT NULL DEFAULT 1,  -- 1 = written by CLI; reserved for future app→CLI signalling
  seenAt    INTEGER,          -- NULL = unprocessed by app
  createdAt INTEGER NOT NULL
);

The CLI writes a row after every mutation. The app's NotificationWatcher queries for seenAt IS NULL AND fromCli = 1, invalidates the relevant engine cache, emits IPC events to the renderer, stamps seenAt, then prunes rows whose seenAt is older than 1 hour to prevent unbounded table growth.


B — Draft mode for scripts and templates (new Drizzle migration)

Scripts and templates currently write the file immediately on create. To allow CLI-created proposals to be durable (survive CLI restarts) without polluting the filesystem until accepted, add the same draft pattern that posts already use.

-- scripts
ALTER TABLE scripts ADD COLUMN status TEXT NOT NULL DEFAULT 'published'
  CHECK (status IN ('draft', 'published'));
ALTER TABLE scripts ADD COLUMN content TEXT;  -- draft body; NULL when on-disk

-- templates
ALTER TABLE templates ADD COLUMN status TEXT NOT NULL DEFAULT 'published'
  CHECK (status IN ('draft', 'published'));
ALTER TABLE templates ADD COLUMN content TEXT;  -- draft body; NULL when on-disk

Behaviour:

  • propose_script / propose_templateINSERT with status: 'draft', body in content, filePath: '' — no file written yet; ProposalStore maps proposalId → rowId
  • accept_proposal → write file to disk, set status: 'published', clear content
  • discard_proposalDELETE the row (never touched the filesystem)

C0 — Decouple DatabaseConnection from Electron

src/main/database/connection.ts currently imports { app } from 'electron' for three things: app.getPath('userData'), app.isPackaged, and process.resourcesPath. The CLI cannot load this file at all without Electron present.

Refactor DatabaseConnection to accept explicit constructor arguments:

interface DatabaseConnectionConfig {
  dbPath: string;           // absolute path to bds.db
  migrationsFolder: string; // absolute path to the drizzle/ migrations dir
  dataDirs?: string[];      // extra directories to mkdir at startup (posts/, media/, etc.)
}

Four things must move out of connection.ts into main.ts (or equivalent callers):

  1. app.getPath('userData') in the constructor — replaced by the explicit dbPath
  2. app.isPackaged + process.resourcesPath in runMigrations() — replaced by the explicit migrationsFolder
  3. The posts/ and media/ mkdirSync calls in the constructor — absorbed into
    the new dataDirs option (caller supplies the list)
  4. getDataPaths() — this method also calls app.getPath('userData') directly and must be removed from DatabaseConnection entirely. Move callers to compute data paths themselves (they already have userData in scope in main.ts).

The Electron main.ts constructs it as today, computing paths via app.getPath before passing them in. The CLI uses platformConfigPath() and __dirname-relative resolution. All Electron imports are removed from connection.ts itself.

WAL mode and synchronous pragma — add to initializeLocal() immediately after creating the client, before running migrations:

await this.localClient.execute('PRAGMA journal_mode=WAL');
await this.localClient.execute('PRAGMA synchronous=NORMAL');

This must be done for both the app and CLI connections. It ensures the -wal file exists (enabling chokidar to watch it) and gives consistent write performance.

This is a prerequisite for every CLI section that follows.


C — platformConfigPath() — userData without Electron (src/cli/platform.ts)

Pure-Node helper to resolve the same path as Electron's app.getPath('userData'):

Platform Path
macOS ~/Library/Application Support/Blogging Desktop Server
Windows %APPDATA%\Blogging Desktop Server
Linux ~/.config/Blogging Desktop Server

Used by the CLI entrypoint to find the SQLite database without loading Electron.


D — CliNotifier interface + injected DB writer

interface CliNotifier {
  notify(entity: string, id: string, action: 'created' | 'updated' | 'deleted'): Promise<void>;
}
  • Electron app passes a no-op implementation (keeps engines clean, no code change needed in the app path)
  • CLI passes a DB writer implementation that inserts into db_notifications

Injected as an optional constructor argument on PostEngine, ScriptEngine, TemplateEngine, MediaEngine.


E — Engine invalidate() API

No engine currently exposes a way to signal that its data is stale. Before NotificationWatcher can be implemented, each engine that holds any in-memory state needs an invalidate(entityId?: string): void method that clears the relevant cached entries (or the whole cache when entityId is omitted), forcing a fresh DB read on the next access. Engines that are already fully stateless (query DB on every call) get a no-op stub so NotificationWatcher can call a uniform API.

Engines to update: PostEngine, MediaEngine, ScriptEngine, TemplateEngine.


F — NotificationWatcher (new engine, app-side only)

src/main/engine/NotificationWatcher.ts

Uses chokidar (already in dependencies) to watch both bds.db and bds.db-wal. This gives sub-100 ms latency with zero boilerplate: chokidar handles missing files (the -wal file doesn't exist until the first write after WAL mode is enabled), platform differences (FSEvents on macOS, inotify on Linux, FSWatch on Windows), and add events for when the WAL file is created for the first time.

import chokidar, { FSWatcher } from 'chokidar';

export class NotificationWatcher {
  private watcher: FSWatcher | null = null;
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;

  constructor(
    private readonly dbPath: string,           // absolute path to bds.db
    private readonly db: DrizzleDB,
    private readonly engines: WatchableEngines,
    private readonly mainWindow: BrowserWindow,
    private readonly debounceMs = 100,
  ) {}

  start(): void {
    // Watch both the main db file (catches checkpoints) and the WAL file
    // (catches every write before checkpoint). chokidar handles the case
    // where bds.db-wal does not yet exist — it fires 'add' when it appears.
    this.watcher = chokidar.watch(
      [this.dbPath, `${this.dbPath}-wal`],
      {
        persistent: false,
        ignoreInitial: true,   // don't fire on startup for pre-existing files
        usePolling: false,     // rely on native FSEvents/inotify/kqueue
        awaitWriteFinish: false,
      },
    );
    this.watcher.on('change', () => this.schedule());
    this.watcher.on('add',    () => this.schedule()); // WAL created for first time
  }

  private schedule(): void {
    if (this.debounceTimer) clearTimeout(this.debounceTimer);
    this.debounceTimer = setTimeout(() => this.process(), this.debounceMs);
  }

  private async process(): Promise<void> {
    const rows = await this.db
      .select()
      .from(dbNotifications)
      .where(and(
        isNull(dbNotifications.seenAt),
        eq(dbNotifications.fromCli, 1),
      ));

    for (const row of rows) {
      this.engines[row.entity]?.invalidate(row.entityId);
      this.mainWindow.webContents.send(
        'entity:changed',
        { entity: row.entity, entityId: row.entityId, action: row.action },
      );
      await this.db
        .update(dbNotifications)
        .set({ seenAt: Date.now() })
        .where(eq(dbNotifications.id, row.id));
    }

    // Prune rows processed more than 1 hour ago to prevent unbounded growth.
    await this.db
      .delete(dbNotifications)
      .where(lt(dbNotifications.seenAt, Date.now() - 3_600_000));
  }

  stop(): void {
    if (this.debounceTimer) clearTimeout(this.debounceTimer);
    this.watcher?.close().catch(() => {});
  }
}

WatchableEngines is a plain record keyed by entity name (post, media, script, template) mapping to objects that expose invalidate(). Started in main.ts alongside PreviewServer and MCPServer; stopped in the app.on('before-quit') handler.


G — MCPServer.startCli() (extend existing MCPServer.ts)

The App View's app.callServerTool() routes through the MCP host (Claude Desktop, VS Code, etc.) over the same connection the server is on — it does not make a direct HTTP call to a secondary endpoint. The CLI therefore needs exactly one server and one transport:

async startCli(): Promise<void> {
  const server = this.createMcpServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);

  // Block until the MCP host closes stdin (normal session end).
  // Signal-based shutdown is the caller's responsibility: do NOT register
  // signal handlers here or they will race with the bds-mcp.ts handlers.
  await new Promise<void>((resolve) => {
    process.stdin.on('close', resolve);
  });

  await server.close();
}

No secondary HTTP port. No bds-mcp-base-url meta tag. The ProposalStore is owned by the single MCPServer instance as before.


H — src/cli/bds-mcp.ts — CLI entrypoint (no Electron imports)

1. platformConfigPath() → userData dir
2. dbPath = path.join(userData, 'bds.db')
3. migrationsFolder = path.join(__dirname, 'drizzle')  // Contents/Resources/drizzle/
4. new DatabaseConnection({ dbPath, migrationsFolder, dataDirs: [postsDir, mediaDir] })
5. await db.initializeLocal()  // runs WAL + synchronous pragmas, then migrations
6. SELECT active project (isActive = 1); exit with message if none
7. instantiate engines with CliNotifier DB writer
8. new MCPServer({ postEngine, mediaEngine, … }, { proposalTtlMs: 8 * 60 * 60 * 1000 })
9. await mcpServer.startCli()   // blocks until stdin closes
10. await shutdown()            // graceful post-stdin exit

MCPServer should accept an optional proposalTtlMs to override the default 30-minute TTL. CLI sessions can last hours (overnight agent runs), so 8 hours is the appropriate default. Pass proposalTtlMs through to the ProposalStore constructor.

No electron imports. Fully standalone Node process.

Graceful shutdown — two paths, no racing:

// Called both by signal handlers (forced) and after normal stdin-close (graceful).
// process.exit() makes it non-reentrant even without the once guards.
async function shutdown(): Promise<never> {
  mcpServer.proposalStore.destroy();
  await db.close();   // flushes any in-flight @libsql writes
  process.exit(0);
}

// Signal handlers own interruption; registered before startCli() so they are
// active during the entire session.  Do NOT register signals inside startCli().
process.once('SIGTERM', shutdown);
process.once('SIGINT',  shutdown);

// Normal path: stdin closes → startCli() resolves → server.close() runs → shutdown()
await mcpServer.startCli();
await shutdown();

When SIGTERM fires, shutdown() runs and calls process.exit(0)startCli() never resumes, server.close() is skipped (safe: the process is exiting anyway). When stdin closes cleanly, startCli() resumes, server.close() runs first, then shutdown() exits. The once guards and process.exit together make the function non-reentrant.

Native module resolution — When launched as ELECTRON_RUN_AS_NODE=1, Electron's ASAR patching is still active, so require('@libsql/client') resolves into app.asar/node_modules. This is a documented side-effect of ELECTRON_RUN_AS_NODE that has been stable since Electron 20. Add an integration test that verifies the module resolves and the DB opens successfully from the bundled path; if a future Electron major breaks this, the test will catch it early.


I — esbuild/Vite build target for CLI bundle

New build target in vite.config.ts (or package.json scripts):

src/cli/bds-mcp.ts → dist/cli/bds-mcp.cjs
target: node, format: cjs, bundle: true

The project uses @libsql/client with platform-specific native binaries (e.g. @libsql/darwin-arm64). These cannot be bundled by esbuild. Externalize all @libsql/* and chokidar-related native packages, and ensure they resolve at runtime from the app bundle (see the ASAR resolution note in section H):

externals: ['@libsql/client', '@libsql/darwin-arm64', '@libsql/linux-x64-gnu',
            'chokidar', 'fsevents', …]

The drizzle/ migrations folder does not need to be re-bundled alongside the .cjs file — it is already included in extraResources (see J). The path resolution path.join(__dirname, 'drizzle') resolves correctly when bds-mcp.cjs sits at Contents/Resources/bds-mcp.cjs and drizzle/ is at Contents/Resources/drizzle/.

Runs as part of npm run build before the Electron build.


J — electron-builder extraResources

The { "from": "drizzle", "to": "drizzle" } entry already exists in package.json. Only the CLI bundle itself needs to be added:

"build": {
  "extraResources": [
    { "from": "dist/cli/bds-mcp.cjs",  "to": "bds-mcp.cjs" }
    // { "from": "drizzle", "to": "drizzle" }  ← already present, do not duplicate
  ]
}

bds-mcp.cjs lands at Contents/Resources/bds-mcp.cjs; drizzle/ is already at Contents/Resources/drizzle/. The CLI migration resolver path.join(__dirname, 'drizzle') resolves Contents/Resources/drizzle/ correctly from that location.


K — MCPAgentConfigEngine — add Claude Desktop + stdio support + remove

New agent type

export type MCPAgentId =
  | 'claude-code'
  | 'claude-desktop'       // ← NEW (stdio)
  | 'github-copilot'
  | 'gemini-cli'
  | 'opencode';

Extended options

export interface MCPAgentConfigOptions {
  homeDir: string;
  platform: NodeJS.Platform;
  mcpUrl: string;
  // stdio agents (set from app at runtime)
  execPath: string;    // process.execPath (packaged) or 'node' (dev)
  scriptPath: string;  // path to bds-mcp.cjs
}

Claude Desktop entry

case 'claude-desktop':
  return {
    command: this.execPath,
    args: [this.scriptPath],
    env: { ELECTRON_RUN_AS_NODE: '1' },
  };

Config file path:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

Note: Verify the current OpenCode config schema before implementing the opencode entry — the format has changed between releases. Check the live spec at https://opencode.ai/docs before writing the buildEntry case.

Remove from config (new method)

removeFromConfig(agentId: MCPAgentId): AgentConfigResult
  • Reads the config file
  • Deletes config[serversKey][SERVER_NAME]
  • If serversKey object is now empty, removes the key entirely
  • Writes back; returns { success, configPath }
  • No-ops gracefully if file or entry does not exist

This powers Remove buttons in the agent-config UI alongside the existing Add buttons, so users never have to hunt down config files manually.


L — ScriptEngine / TemplateEngine — draft lifecycle methods

New methods on each engine:

// ScriptEngine
createDraftScript(data: CreateScriptInput): Promise<ScriptData>   // status: 'draft', no file
publishScript(id: string): Promise<ScriptData | null>              // write file, status: 'published'
deleteDraftScript(id: string): Promise<boolean>                    // only if status: 'draft'

// TemplateEngine
createDraftTemplate(data: CreateTemplateInput): Promise<TemplateData>
publishTemplate(id: string): Promise<TemplateData | null>
deleteDraftTemplate(id: string): Promise<boolean>

M — ProposalStore — map proposals to DB row IDs

For draftPost proposals the store already maps proposalId → postId (the DB row). Extend this consistently:

  • proposeScript → maps proposalId → scriptId (DB row, status: 'draft')
  • proposeTemplate → maps proposalId → templateId (DB row, status: 'draft')
  • proposeMediaMetadata → stays in-memory (no draft DB row needed, metadata diff only)
  • proposePostMetadata → stays in-memory (same reason)

accept_proposal dispatches to publishScript / publishTemplate / publishPost etc. discard_proposal dispatches to deleteDraftScript / deleteDraftTemplate / deletePost.

Known failure mode for in-memory metadata proposals: if the CLI process is killed after a propose_media_metadata or propose_post_metadata call but before accept/discard, the proposalId no longer exists in any ProposalStore. The App View buttons will call accept_proposal and receive { success: false, message: "Proposal … not found" } — this is already user-visible in the review UI. No silent corruption occurs; the post or media item is simply unchanged. This is accepted as the trade-off for keeping metadata proposals lightweight (no DB rows, no filesystem side effects).


N — Renderer: subscribe to entity:changed IPC events

The app's UI must reflect changes the CLI makes while the app is open. NotificationWatcher fires entity:changed IPC events (section F), but nothing currently handles them in the renderer.

preload.ts — expose the channel alongside existing IPC listeners:

onEntityChanged: (cb: (payload: EntityChangedPayload) => void) =>
  ipcRenderer.on('entity:changed', (_, payload) => cb(payload)),

Renderer subscription — in the top-level app component (or a dedicated IpcListener component that mounts once), subscribe on mount and unsubscribe on unmount:

useEffect(() => {
  const unsub = window.api.onEntityChanged(({ entity, entityId, action }) => {
    // Dispatch store invalidation so the next render fetches fresh data
    if (entity === 'post')     postsStore.getState().invalidate(entityId);
    if (entity === 'media')    mediaStore.getState().invalidate(entityId);
    if (entity === 'script')   scriptsStore.getState().invalidate(entityId);
    if (entity === 'template') templatesStore.getState().invalidate(entityId);
  });
  return () => unsub();
}, []);

Store invalidate() methods should clear the cached entry and, if the entity is currently displayed, trigger an immediate refetch — consistent with how other IPC-driven refreshes work in the app.


Design Decisions

Question Decision
Active project in CLI Use isActive = 1 from DB; fail fast if none
Proposal durability Scripts/templates use DB draft rows; metadata proposals stay in-memory (failure mode documented in M)
Proposal TTL 30 min default in app; 8 hours in CLI (overnight agent sessions)
App View tool calls in CLI mode app.callServerTool() routes through the MCP host, not a direct HTTP call — no secondary port needed
Claude Desktop support stdio via bundled bds-mcp.cjs + ELECTRON_RUN_AS_NODE=1 (entitlements already set)
Remove from config New removeFromConfig() method + Remove buttons in UI
DB change detection chokidar on both bds.db and bds.db-wal; 100 ms debounce; handles missing WAL via add events
WAL mode Explicit PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL in initializeLocal() for both app and CLI
db_notifications growth Rows pruned after seenAt is 1 hour old, inside each process() call
DB coupling to Electron DatabaseConnection accepts explicit paths; getDataPaths() removed; Electron path resolution stays in main.ts
Native module resolution ELECTRON_RUN_AS_NODE inherits Electron ASAR require-hooking; integration test guards against regression
Signal handling Signal handlers in bds-mcp.ts only; startCli() only listens to stdin-close — no competing handlers
Renderer live updates Section N: entity:changed IPC → store invalidate() → refetch

Implementation Order (TDD per AGENTS.md)

  1. Migrations (A + B) — schema changes first, all other work builds on them
  2. Decouple DatabaseConnection from Electron (C0) — remove app import, move getDataPaths() to callers, add WAL pragmas; update existing DB tests to pass explicit paths
  3. platformConfigPath() (C) — pure function, easy to test
  4. CliNotifier + DB writer (D) — unit tests with mock DB
  5. Engine invalidate() API (E) — add and test on each engine before NotificationWatcher
  6. ScriptEngine/TemplateEngine draft lifecycle (L) — tests first
  7. ProposalStore TTL option + DB mapping (M) — extend existing tests; add proposalTtlMs constructor arg
  8. NotificationWatcher (F) — test with mock DB rows + mock chokidar emitter (emit 'change' / 'add' events on a fake FSWatcher)
  9. MCPAgentConfigEngineclaude-desktop + removeFromConfig (K) — extend existing tests; verify opencode format before writing its case
  10. MCPServer.startCli() (G) — test with in-process StdioServerTransport
  11. src/cli/bds-mcp.ts (H) — integration test: spawn process, send initialize, assert response, assert clean shutdown on SIGTERM and on stdin-close
  12. Build target + extraResources (I + J) — verify npm run build produces dist/cli/bds-mcp.cjs; drizzle/ entry already in extraResources, only add bds-mcp.cjs
  13. UI: Remove buttons — renderer component changes, follows K
  14. NotificationWatcher wired in main.ts (F, app side) + stopped in before-quit
  15. Renderer entity:changed subscription (N) — preload.ts exposure + store invalidate() hookup + component subscription

Each step: write failing test → implement → green → next.