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_notificationstable - 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_template→INSERTwithstatus: 'draft', body incontent,filePath: ''— no file written yet;ProposalStoremapsproposalId → rowIdaccept_proposal→ write file to disk, setstatus: 'published', clearcontentdiscard_proposal→DELETEthe 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):
app.getPath('userData')in the constructor — replaced by the explicitdbPathapp.isPackaged+process.resourcesPathinrunMigrations()— replaced by the explicitmigrationsFolder- The
posts/andmedia/mkdirSynccalls in the constructor — absorbed into
the newdataDirsoption (caller supplies the list) getDataPaths()— this method also callsapp.getPath('userData')directly and must be removed fromDatabaseConnectionentirely. Move callers to compute data paths themselves (they already haveuserDatain scope inmain.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
opencodeentry — the format has changed between releases. Check the live spec at https://opencode.ai/docs before writing thebuildEntrycase.
Remove from config (new method)
removeFromConfig(agentId: MCPAgentId): AgentConfigResult
- Reads the config file
- Deletes
config[serversKey][SERVER_NAME] - If
serversKeyobject 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→ mapsproposalId → scriptId(DB row,status: 'draft')proposeTemplate→ mapsproposalId → 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)
- Migrations (A + B) — schema changes first, all other work builds on them
- Decouple
DatabaseConnectionfrom Electron (C0) — removeappimport, movegetDataPaths()to callers, add WAL pragmas; update existing DB tests to pass explicit paths platformConfigPath()(C) — pure function, easy to testCliNotifier+ DB writer (D) — unit tests with mock DB- Engine
invalidate()API (E) — add and test on each engine before NotificationWatcher ScriptEngine/TemplateEnginedraft lifecycle (L) — tests firstProposalStoreTTL option + DB mapping (M) — extend existing tests; addproposalTtlMsconstructor argNotificationWatcher(F) — test with mock DB rows + mock chokidar emitter (emit'change'/'add'events on a fakeFSWatcher)MCPAgentConfigEngine—claude-desktop+removeFromConfig(K) — extend existing tests; verify opencode format before writing its caseMCPServer.startCli()(G) — test with in-processStdioServerTransportsrc/cli/bds-mcp.ts(H) — integration test: spawn process, sendinitialize, assert response, assert clean shutdown on SIGTERM and on stdin-close- Build target +
extraResources(I + J) — verifynpm run buildproducesdist/cli/bds-mcp.cjs;drizzle/entry already inextraResources, only addbds-mcp.cjs - UI: Remove buttons — renderer component changes, follows K
NotificationWatcherwired inmain.ts(F, app side) + stopped inbefore-quit- Renderer
entity:changedsubscription (N) —preload.tsexposure + storeinvalidate()hookup + component subscription
Each step: write failing test → implement → green → next.