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 +180 -0
- package/bin/bookmark-bridge.mjs +105 -0
- package/package.json +20 -0
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
|
+
}
|