bookmark-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # Bookmark Bridge
2
+
3
+ Lets an MCP client read and reorganize Chrome bookmarks, with every change
4
+ recorded in git so it can be rolled back.
5
+
6
+ ```
7
+ MCP client --stdio--> bookmark-bridge --ws(127.0.0.1)-- Chrome extension --chrome.bookmarks--> Chrome
8
+ ```
9
+
10
+ The extension is the WebSocket client; it dials out because an extension cannot
11
+ listen on a port. A second MCP process that cannot bind the port joins the first
12
+ as a peer and relays through it, so several sessions share one browser.
13
+
14
+ ## Install
15
+
16
+ Two pieces: this server, and a Chrome extension that dials into it.
17
+
18
+ **1. Register the server with your MCP client.** It is fetched on first run; no
19
+ Rust needed. macOS and Linux only — the server uses `flock` and POSIX file modes.
20
+
21
+ <details open>
22
+ <summary><b>Claude Code</b></summary>
23
+
24
+ ```sh
25
+ claude mcp add bookmark-bridge -- npx -y bookmark-bridge
26
+ ```
27
+
28
+ </details>
29
+
30
+ <details>
31
+ <summary><b>Codex</b> — <code>~/.codex/config.toml</code></summary>
32
+
33
+ ```toml
34
+ [mcp_servers.bookmark-bridge]
35
+ command = "npx"
36
+ args = ["-y", "bookmark-bridge"]
37
+ ```
38
+
39
+ </details>
40
+
41
+ <details>
42
+ <summary><b>Any other client</b> — the standard entry</summary>
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "bookmark-bridge": {
48
+ "command": "npx",
49
+ "args": ["-y", "bookmark-bridge"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Put it wherever your client keeps its MCP servers.
56
+
57
+ </details>
58
+
59
+ <details>
60
+ <summary><b>Or have your agent do it</b> — paste this</summary>
61
+
62
+ ```
63
+ Install the bookmark-bridge MCP server for me.
64
+
65
+ 1. Register it with this client as an MCP server named "bookmark-bridge",
66
+ running: npx -y bookmark-bridge
67
+ Use whatever config file this client uses, and do not disturb the servers
68
+ already there.
69
+ 2. Restart or reload so the server is picked up, then call its bridge_status
70
+ tool and tell me what it reports.
71
+ 3. If it says the extension is not connected, tell me to install the Chrome
72
+ extension from https://github.com/semanticist21/bookmark-bridge and then
73
+ check again.
74
+
75
+ It is macOS and Linux only. Do not build from source; the npx package fetches
76
+ a prebuilt binary.
77
+ ```
78
+
79
+ </details>
80
+
81
+ Your client starts and stops the server for you; there is no daemon to run.
82
+
83
+ **2. Install the extension.** From the Chrome Web Store, or load `extension/`
84
+ at `chrome://extensions` with developer mode on.
85
+
86
+ That is the whole setup. The extension shows a green light in its popup once the
87
+ two find each other.
88
+
89
+ <details>
90
+ <summary>From source instead</summary>
91
+
92
+ ```sh
93
+ cd mcp && cargo build --release
94
+ ```
95
+
96
+ Then point the same config entry at the binary instead:
97
+ `"command": "<repo>/mcp/target/release/bookmark-bridge"`, with no `args`.
98
+
99
+ A source checkout also gets a shared token automatically: the server writes one
100
+ to `~/.config/bookmark-bridge/token` and copies it into `extension/`, so both
101
+ ends have it. A Web Store install is read-only and cannot receive one, so the
102
+ server runs without a token there and relies on the loopback binding — a token
103
+ only one side knows would reject the extension forever. Set
104
+ `BOOKMARK_BRIDGE_TOKEN` to demand one regardless, and put the same value in the
105
+ extension's storage. `BOOKMARK_BRIDGE_PORT` changes the port.
106
+
107
+ </details>
108
+
109
+ ## Safety model
110
+
111
+ Three independent mechanisms, in the order they engage:
112
+
113
+ **Snapshots.** Every mutating call commits the whole tree to a git repository at
114
+ `~/.local/state/bookmark-bridge/history` before and after the change. If the
115
+ before-snapshot fails, the change is refused — a mutation that cannot be undone
116
+ is not worth making. Pass `force: true` to override.
117
+
118
+ **Compare-and-swap.** Any operation may carry `expect: {title?, url?, parentId?}`.
119
+ If the current value differs, that operation alone is skipped and reported with
120
+ the actual value. This is what protects a plan from a user edit that landed
121
+ between planning and applying.
122
+
123
+ **Drift detection.** `history_drift` compares the live tree against `agent-head`,
124
+ the commit marking the agent's last completed operation. The difference is what
125
+ someone else changed. The extension additionally records bookmark events tagged
126
+ `agent` or `user`; that ledger is evidence, and the diff is the authority.
127
+
128
+ ## Known limits
129
+
130
+ These are real. Read them before trusting the output.
131
+
132
+ - **Reading list is invisible.** `chrome.readingList` is a separate API whose
133
+ items never appear in the bookmark tree. Neither layer sees them.
134
+ - **"Remove all user bookmarks" fires no extension event.** Chromium has a
135
+ standing TODO for it. The diff still catches it; the ledger will be empty.
136
+ - **The ledger misses whatever happens while Chrome or the extension is not
137
+ running** — changes synced from another device while the browser was closed,
138
+ an extension reload, an update. The diff catches all of these on the next call.
139
+ - **Sync-originated changes do fire events** and are attributed to the user,
140
+ which is correct. Each sync batch also emits `onImportBegan`/`onImportEnded`,
141
+ so those events cannot distinguish an HTML import from a routine sync.
142
+ - **`dateAdded` cannot be restored.** Anything `history_restore` recreates gets
143
+ today's date; the Chrome API offers no way to set it. Export an HTML backup if
144
+ the original dates matter.
145
+ - **Restore refuses when ids look reassigned.** Signing in or out, profile
146
+ recovery and HTML import all renumber bookmarks; restoring across that boundary
147
+ would build a duplicate tree beside the real one, so it stops instead.
148
+ - **Order within a folder is restored approximately.** Indices are applied
149
+ sequentially, and each move shifts its siblings.
150
+
151
+ ## Tests
152
+
153
+ `cd mcp && cargo test` spawns the real binary, plays the extension with a
154
+ WebSocket client holding an in-memory tree, and drives it over MCP. It covers the
155
+ protocol handshake, compare-and-swap skipping, idempotent create, the batch size
156
+ cap, refusing to mutate without a snapshot, restoring a deleted subtree under new
157
+ ids, drift reporting only the user's changes, and two sessions sharing one
158
+ extension through the peer relay.
159
+
160
+ ## Tools
161
+
162
+ | | |
163
+ | --- | --- |
164
+ | `bookmarks_tree` `_search` `_children` | read |
165
+ | `bookmarks_update` `_move` `_create` `_remove` `_removeTree` | single change, each accepts `expect` |
166
+ | `bookmarks_batch` | up to 200 changes at once, per-item `expect` |
167
+ | `history_log` `_restore` `_drift` | snapshots, rollback, what changed since the agent last ran |
168
+ | `bridge_status` | extension connection |
169
+
170
+ ## Privacy
171
+
172
+ The extension holds `bookmarks`, `storage` and `alarms`. It connects only to
173
+ `127.0.0.1` and rejects WebSocket handshakes carrying an `http(s)` origin, so a
174
+ web page cannot reach it. Nothing is sent anywhere else. The token stops a page
175
+ from connecting; it does not stop another process on the same machine, which can
176
+ read the token file just as easily.
177
+
178
+ ## License
179
+
180
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // Fetches the prebuilt server for this platform on first run, then hands over to
3
+ // it. An MCP client launches this over stdio, so nothing may be written to
4
+ // stdout that is not protocol: progress goes to stderr.
5
+ import {spawn} from 'node:child_process';
6
+ import {createHash} from 'node:crypto';
7
+ import {createWriteStream} from 'node:fs';
8
+ import {chmod, mkdir, mkdtemp, readFile, rename, rm, stat} from 'node:fs/promises';
9
+ import {tmpdir} from 'node:os';
10
+ import {dirname, join} from 'node:path';
11
+ import {pipeline} from 'node:stream/promises';
12
+ import {fileURLToPath} from 'node:url';
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf8'));
16
+ const REPO = 'semanticist21/bookmark-bridge';
17
+
18
+ const TARGETS = {
19
+ 'darwin-arm64': 'aarch64-apple-darwin',
20
+ 'darwin-x64': 'x86_64-apple-darwin',
21
+ 'linux-x64': 'x86_64-unknown-linux-gnu',
22
+ 'linux-arm64': 'aarch64-unknown-linux-gnu',
23
+ };
24
+
25
+ function die(message) {
26
+ process.stderr.write(`bookmark-bridge: ${message}\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ const key = `${process.platform}-${process.arch}`;
31
+ const target = TARGETS[key];
32
+ if (!target) {
33
+ die(`no prebuilt server for ${key}. The server uses flock and POSIX file modes, `
34
+ + `so Windows is not supported. Build from source: `
35
+ + `https://github.com/${REPO}`);
36
+ }
37
+
38
+ // Cached per version, so upgrading the package fetches a matching server rather
39
+ // than silently reusing an old one.
40
+ const cacheDir = join(here, '..', '.bin', pkg.version);
41
+ const binary = join(cacheDir, 'bookmark-bridge');
42
+
43
+ async function exists(path) {
44
+ try { await stat(path); return true; } catch { return false; }
45
+ }
46
+
47
+ async function fetchOrDie(url, what) {
48
+ const res = await fetch(url, {redirect: 'follow'});
49
+ if (!res.ok) die(`could not download ${what} (HTTP ${res.status})\n ${url}`);
50
+ return res;
51
+ }
52
+
53
+ async function install() {
54
+ const tag = `v${pkg.version}`;
55
+ const name = `bookmark-bridge-${target}.tar.gz`;
56
+ const base = `https://github.com/${REPO}/releases/download/${tag}`;
57
+
58
+ process.stderr.write(`bookmark-bridge: fetching ${tag} for ${key}\n`);
59
+ const sums = (await (await fetchOrDie(`${base}/SHA256SUMS`, 'checksums')).text());
60
+ const line = sums.split('\n').find((l) => l.trim().endsWith(name));
61
+ if (!line) die(`no checksum published for ${name} in ${tag}`);
62
+ const expected = line.trim().split(/\s+/)[0];
63
+
64
+ const staging = await mkdtemp(join(tmpdir(), 'bookmark-bridge-'));
65
+ const archive = join(staging, name);
66
+ try {
67
+ await pipeline(
68
+ (await fetchOrDie(`${base}/${name}`, 'the server')).body,
69
+ createWriteStream(archive),
70
+ );
71
+
72
+ // Never run a binary that does not match what the release published.
73
+ const actual = createHash('sha256').update(await readFile(archive)).digest('hex');
74
+ if (actual !== expected) {
75
+ die(`checksum mismatch for ${name}\n expected ${expected}\n got ${actual}`);
76
+ }
77
+
78
+ await new Promise((resolve, reject) => {
79
+ const tar = spawn('tar', ['-xzf', archive, '-C', staging], {stdio: 'inherit'});
80
+ tar.on('error', reject);
81
+ tar.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}`)));
82
+ });
83
+
84
+ await mkdir(dirname(cacheDir), {recursive: true});
85
+ // Rename is atomic, so two clients starting at once cannot see a half-written
86
+ // directory.
87
+ await rm(cacheDir, {recursive: true, force: true});
88
+ await rename(join(staging, `bookmark-bridge-${target}`), cacheDir);
89
+ await chmod(binary, 0o755);
90
+ } finally {
91
+ await rm(staging, {recursive: true, force: true});
92
+ }
93
+ }
94
+
95
+ if (!(await exists(binary))) {
96
+ try { await install(); }
97
+ catch (e) { die(`install failed: ${e.message}`); }
98
+ }
99
+
100
+ const child = spawn(binary, process.argv.slice(2), {stdio: 'inherit'});
101
+ child.on('error', (e) => die(`could not start the server: ${e.message}`));
102
+ child.on('exit', (code, signal) => {
103
+ if (signal) process.kill(process.pid, signal);
104
+ else process.exit(code ?? 0);
105
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "bookmark-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Chrome bookmarks as MCP tools, with every change recorded in git so it can be rolled back",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/semanticist21/bookmark-bridge.git"
9
+ },
10
+ "homepage": "https://github.com/semanticist21/bookmark-bridge",
11
+ "bin": {
12
+ "bookmark-bridge": "bin/bookmark-bridge.mjs"
13
+ },
14
+ "files": ["bin/", "README.md"],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "os": ["darwin", "linux"],
19
+ "keywords": ["mcp", "model-context-protocol", "bookmarks", "chrome"]
20
+ }