syncme-mcp 1.0.1 → 1.0.2

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  copies your logins for a site from your normal chrome into a separate chrome that ai agents and scripts can drive. so jev, browser-use or playwright can use your accounts without touching your real browser.
4
4
 
5
+ syncme comes in two parts: the [chrome extension](https://chromewebstore.google.com/detail/dcgmgkdgmbjgnchjdmokimppnhfinpfp) and [syncme-mcp](https://www.npmjs.com/package/syncme-mcp), the npm bridge it talks to. the source for both is on [github](https://github.com/levinkin/syncme).
6
+
5
7
  ## how it works
6
8
 
7
9
  1. when you're on a site you want, click the syncme icon and it asks if you want to copy it over
@@ -18,14 +20,14 @@ needs chrome and node 20 or newer. works on mac and windows.
18
20
 
19
21
  there's nothing to install: the extension talks to the bridge over a websocket on 127.0.0.1, and any command starts the bridge itself.
20
22
 
21
- 1. install the extension (chrome web store, or load this folder unpacked from `chrome://extensions` with developer mode on)
22
- 2. click the syncme icon and use one of the connect buttons, or run `npx -y syncme-mcp setup` to wire up claude code, codex, cursor, windsurf and claude desktop
23
+ 1. install the extension from the [chrome web store](https://chromewebstore.google.com/detail/dcgmgkdgmbjgnchjdmokimppnhfinpfp), or load this folder unpacked from `chrome://extensions` with developer mode on
24
+ 2. click the syncme icon, hit the gear, and use one of the connect buttons, or run `npx -y syncme-mcp setup` to wire up claude code, codex, cursor, windsurf and claude desktop
23
25
 
24
26
  ## commands
25
27
 
26
28
  ```
27
29
  npx -y syncme-mcp open # start the ai browser, prints {"cdp": "http://127.0.0.1:<port>"}
28
- npx -y syncme-mcp sync https://example.com # copy one site, asks you in chrome first if it's not saved yet
30
+ npx -y syncme-mcp sync https://example.com # copy one site, asks you in chrome first unless it's saved or yolo is on
29
31
  npx -y syncme-mcp stop # close the ai browser
30
32
  npx -y syncme-mcp shutdown # close it and stop the bridge
31
33
  npx -y syncme-mcp setup # add the mcp server to the ai apps you have installed
@@ -46,7 +48,7 @@ await (await browser.contexts()[0].newPage()).goto('https://example.com');
46
48
 
47
49
  ## mcp
48
50
 
49
- you can also hook it up as an mcp server so your ai can ask for a site itself. it calls `copy_site` with something like `https://github.com`, you get a popup in chrome asking if it can have that login, and if you say yes it copies it and gets back the cdp url. there's also `open_browser` and `close_browser`.
51
+ you can also hook it up as an mcp server so your ai can ask for a site itself. it calls `copy_site` with something like `https://github.com`, you get a popup in chrome asking if it can have that login, and if you say yes it copies it and gets back the cdp url. turn on yolo mode in the syncme options (the gear in the popup) to skip that popup and let your ai copy any site it asks for. there's also `open_browser` and `close_browser`.
50
52
 
51
53
  ```
52
54
  claude mcp add --scope user syncme -- npx -y syncme-mcp
package/approve.html CHANGED
@@ -1,17 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="en"><meta charset="utf-8"><title>syncme</title>
3
- <style>body{font:15px system-ui;width:340px;margin:16px}button{padding:8px 14px}li{margin:6px 0}li button{padding:2px 8px;margin-left:6px}label{display:block;margin:14px 0}#question{font-weight:600}</style>
3
+ <style>:root{color-scheme:light dark}body{font:15px system-ui;width:340px;margin:16px}button{padding:8px 14px}#question{font-weight:600}footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #8884;padding-top:10px}#options{background:none;border:none;padding:2px;font-size:20px;line-height:1;cursor:pointer;opacity:.7}#options:hover{opacity:1}</style>
4
4
  <p id="question"></p>
5
- <button id="copy">Copy</button> <button id="deny" hidden>No</button>
6
5
  <p id="status" role="status"></p>
7
- <div id="settings">
8
- <label><input type="checkbox" id="auto"> Copy all saved sites when the AI browser starts</label>
9
- <h2>Saved sites</h2>
10
- <ul id="approved"></ul>
11
- <h2>Connect your ai</h2>
12
- <p id="waiting" hidden>connect an ai app first.</p>
13
- <p id="pairing" hidden><span></span> <button id="repair">Re-pair</button></p>
14
- <p><a id="cursor" hidden>Add to Cursor</a> <a id="vscode" hidden>Add to VS Code</a></p>
15
- <button id="claude">Copy Claude Code command</button> <button id="setup">Copy setup command</button>
16
- </div>
6
+ <footer><span><button id="copy">Copy</button> <button id="deny" hidden>No</button></span><button id="options" title="Options" aria-label="Options">&#9881;&#xFE0E;</button></footer>
17
7
  <script src="approve.js"></script></html>
package/approve.js CHANGED
@@ -1,76 +1,37 @@
1
1
  const status = document.querySelector('#status');
2
2
  const question = document.querySelector('#question');
3
3
  const copyButton = document.querySelector('#copy');
4
- const auto = document.querySelector('#auto');
5
4
  let site;
6
- async function render() {
7
- const {approved = []} = await chrome.storage.local.get('approved');
8
- const list = document.querySelector('#approved');
9
- list.replaceChildren();
10
- for (const value of approved) {
11
- const li = document.createElement('li');
12
- li.append(document.createTextNode(new URL(value).host));
13
- const button = document.createElement('button');
14
- button.textContent = 'Remove';
15
- button.onclick = async () => {
16
- const {approved = []} = await chrome.storage.local.get('approved');
17
- await chrome.storage.local.set({approved: approved.filter(x => x !== value)});
18
- status.textContent = 'Removed. The AI browser keeps cookies it already has.';
19
- await render();
20
- };
21
- li.append(button); list.append(li);
22
- }
23
- }
24
5
  const requested = new URLSearchParams(location.search).get('site');
25
6
  if (requested) {
26
- question.textContent = `Your AI wants your ${new URL(requested).host} login. Copy it?`;
27
- document.querySelector('#settings').hidden = true;
7
+ question.textContent = `Your AI needs your ${new URL(requested).host} login to continue. Copy it?`;
8
+ document.querySelector('#options').hidden = true;
28
9
  const deny = document.querySelector('#deny');
29
10
  deny.hidden = false;
30
11
  copyButton.onclick = () => chrome.runtime.sendMessage({op: 'allow', answer: true});
31
12
  deny.onclick = () => chrome.runtime.sendMessage({op: 'allow', answer: false});
32
- } else chrome.tabs.query({active: true, currentWindow: true}).then(([tab]) => {
33
- const u = tab?.url ? new URL(tab.url) : null;
34
- if (u && ['http:', 'https:'].includes(u.protocol)) {
35
- site = u.origin;
36
- question.textContent = `Copy ${u.host} to your AI browser?`;
37
- } else {
38
- question.textContent = 'Open a website to copy it.';
39
- copyButton.hidden = true;
40
- }
41
- });
42
- if (!requested) copyButton.onclick = async () => {
43
- copyButton.disabled = true;
44
- status.textContent = 'Copying...';
45
- try {
46
- const result = await chrome.runtime.sendMessage({op: 'copy', origin: site});
47
- status.textContent = result?.ok ? `Copied ${result.count} cookies.` : result?.error === 'Busy' ? 'Busy, try again in a moment.' : result?.error === 'Not connected' ? 'Connect an ai app first.' : 'Copy failed.';
48
- } catch (_) {
49
- status.textContent = 'Copy failed.';
50
- } finally {
51
- copyButton.disabled = false;
52
- }
53
- await render();
54
- };
55
- chrome.storage.local.get('autoSync').then(({autoSync}) => { auto.checked = autoSync === true; });
56
- auto.onchange = () => chrome.storage.local.set({autoSync: auto.checked});
57
- const server = {name: 'syncme', command: 'npx', args: ['-y', 'syncme-mcp']};
58
- const links = {
59
- cursor: 'cursor://anysphere.cursor-deeplink/mcp/install?name=syncme&config=' + btoa(JSON.stringify({command: server.command, args: server.args})),
60
- vscode: 'vscode:mcp/install?' + encodeURIComponent(JSON.stringify(server)),
61
- };
62
- for (const [id, href] of Object.entries(links)) {
63
- const link = document.querySelector('#' + id);
64
- link.href = href;
65
- link.hidden = false;
13
+ } else {
14
+ chrome.tabs.query({active: true, currentWindow: true}).then(([tab]) => {
15
+ const u = tab?.url ? new URL(tab.url) : null;
16
+ if (u && ['http:', 'https:'].includes(u.protocol)) {
17
+ site = u.origin;
18
+ question.textContent = `Copy ${u.host} to your AI browser?`;
19
+ } else {
20
+ question.textContent = 'Open a website to copy it.';
21
+ copyButton.hidden = true;
22
+ }
23
+ });
24
+ copyButton.onclick = async () => {
25
+ copyButton.disabled = true;
26
+ status.textContent = 'Copying...';
27
+ try {
28
+ const result = await chrome.runtime.sendMessage({op: 'copy', origin: site});
29
+ status.textContent = result?.ok ? `Copied ${result.count} cookies.` : result?.error === 'Busy' ? 'Busy, try again in a moment.' : result?.error === 'Not connected' ? 'Connect an ai app first.' : 'Copy failed.';
30
+ } catch (_) {
31
+ status.textContent = 'Copy failed.';
32
+ } finally {
33
+ copyButton.disabled = false;
34
+ }
35
+ };
36
+ document.querySelector('#options').onclick = () => chrome.runtime.openOptionsPage();
66
37
  }
67
- document.querySelector('#claude').onclick = () => navigator.clipboard.writeText('claude mcp add syncme -- npx -y syncme-mcp');
68
- document.querySelector('#setup').onclick = () => navigator.clipboard.writeText('npx -y syncme-mcp setup');
69
- chrome.storage.session.get(['connected', 'pairing']).then(({connected, pairing}) => {
70
- document.querySelector('#waiting').hidden = connected === true || Boolean(pairing);
71
- const box = document.querySelector('#pairing');
72
- box.hidden = !pairing;
73
- box.firstChild.textContent = pairing === 'lost' ? 'syncme lost its pairing with this extension.' : 'syncme is paired with a different extension. Run npx -y syncme-mcp unpair, then hit Re-pair.';
74
- });
75
- document.querySelector('#repair').onclick = () => { chrome.runtime.sendMessage({op: 'repair'}); document.querySelector('#pairing').hidden = true; };
76
- render();
package/background.js CHANGED
@@ -59,19 +59,19 @@ function connect() {
59
59
  return;
60
60
  }
61
61
  if (message.op === 'copied') { pending.get(message.id)?.(message); pending.delete(message.id); return; }
62
- const {approved = [], autoSync = false} = await chrome.storage.local.get(['approved', 'autoSync']);
62
+ const {approved = [], autoSync = false, yolo = false} = await chrome.storage.local.get(['approved', 'autoSync', 'yolo']);
63
63
  if (message.op === 'list') { send({id: message.id, origins: autoSync ? approved : []}); return; }
64
64
  let stage = 'request';
65
65
  try {
66
66
  if (message.op !== 'sync' || typeof message.id !== 'string') throw Error('Invalid request');
67
67
  const u = origin(message.origin);
68
68
  if (!approved.includes(u.origin)) {
69
- if (!await allow(u.origin)) throw Error('Origin not approved');
69
+ if (!yolo && !await allow(u.origin)) throw Error('Origin not approved');
70
70
  const {approved: now = []} = await chrome.storage.local.get('approved');
71
71
  await chrome.storage.local.set({approved: [...new Set([...now, u.origin])]});
72
72
  }
73
73
  stage = 'cookies';
74
- const cookies = (await chrome.cookies.getAll({domain: u.hostname})).filter(c => c.domain.replace(/^\./, '') === u.hostname && !c.partitionKey && (!c.secure || u.protocol === 'https:'));
74
+ const cookies = (await chrome.cookies.getAll({})).filter(c => (c.hostOnly ? c.domain === u.hostname : ('.' + u.hostname).endsWith(c.domain)) && !c.partitionKey && (!c.secure || u.protocol === 'https:'));
75
75
  send({id: message.id, origin: u.origin, cookies});
76
76
  } catch (_) { try { send({id: message.id, error: stage === 'cookies' ? 'Cookie API refused' : 'Origin unavailable or not approved'}); } catch (_) {} }
77
77
  };
package/manifest.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "48": "icons/48.png",
10
10
  "128": "icons/128.png"
11
11
  },
12
- "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyDi/Cstz6nR487vgWA2QTykf48Svj3DFUeSMva5uZdxoYIJSxKW1S9d+YGFFM0osrctIUWL6YbeGQYxWcnakI4oItc9x0oKO9w5lLYm1Azzn3zy/Pj/8dN647SyBjeyjW5EDAs2eNwWj15qjOy+y1u3Oqep/8kx0eVHwoHWwojH2trvOIBUwa1zd9eJ8fV4lcrEUr9okjlaZEB3s+qYR/D/ezvOlP3VMHztCL1dptv/Y8DtVVFqC+AkfaOqLEGYfaXufF+cdG/V6DBu6YZ1mww1VET/rjcGKCl+OsEFIwIOU0usOWbLB1JnspVupeq8CDe+baHBU24qQZc3U7UpaUQIDAQAB",
12
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7Ya2I48aElXGpM5ww2OWgBOa/qtRPz8dFJ80ny2yVo8QbNt2hFwfl9KpHOC93Ik/rgzDRQStH7X37kFV9TedCv+vIq5oYe9GOCzLtYRZZzQI1P8zF1znXzuWsTusWaXpvkKAo0stzRBSlEFI166kUeEHhPw3TreKQl2kFy0IT9ht9pHBkwKS77Yy4NbNogkdZPPBuAjB1OdL/Rl9epUtagtCHuBYemMLhdaa8UJxDynGLxuqeIsxK8kXtb4WTWeFjA13mlNSiGNXb8bIcu0HOYV1d9m/bVaosENQEuAJHw8NRmhbSjQKp7/2Dflphb50ZoE9yiazAhIZ4DGcCQt/QIDAQAB",
13
13
  "permissions": [
14
14
  "alarms",
15
15
  "cookies",
@@ -29,5 +29,8 @@
29
29
  "32": "icons/32.png"
30
30
  }
31
31
  },
32
- "options_page": "approve.html"
32
+ "options_ui": {
33
+ "page": "options.html",
34
+ "open_in_tab": false
35
+ }
33
36
  }
package/options.html ADDED
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en"><meta charset="utf-8"><title>syncme options</title>
3
+ <style>:root{color-scheme:light dark}body{font:14px system-ui;min-width:420px;margin:16px}h2{font-size:15px;margin:20px 0 8px}label{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:14px 0 2px;font-weight:500}.note{opacity:.7;font-size:12px;margin:0 0 10px}ul{padding:0;list-style:none}li{display:flex;justify-content:space-between;align-items:center;margin:6px 0}li button{padding:2px 8px}</style>
4
+ <h2>Options</h2>
5
+ <label for="auto">Copy saved sites on start <input type="checkbox" id="auto"></label>
6
+ <p class="note">Copies every saved site when the AI browser starts.</p>
7
+ <label for="yolo">Yolo mode <input type="checkbox" id="yolo"></label>
8
+ <p class="note">Copies any site your AI asks for without a popup. Installing syncme already means you're ok with that.</p>
9
+ <h2>Saved sites</h2>
10
+ <ul id="approved"></ul>
11
+ <p id="status" class="note" role="status"></p>
12
+ <h2>Connect your AI</h2>
13
+ <p id="waiting" class="note" hidden>Connect an AI app first.</p>
14
+ <p id="pairing" class="note" hidden><span></span> <button id="repair">Re-pair</button></p>
15
+ <button id="claude">Copy Claude Code command</button> <button id="setup">Copy setup command</button>
16
+ <script src="options.js"></script></html>
package/options.js ADDED
@@ -0,0 +1,35 @@
1
+ const status = document.querySelector('#status');
2
+ async function render() {
3
+ const {approved = []} = await chrome.storage.local.get('approved');
4
+ const list = document.querySelector('#approved');
5
+ list.replaceChildren();
6
+ for (const value of approved) {
7
+ const li = document.createElement('li');
8
+ li.append(document.createTextNode(new URL(value).host));
9
+ const button = document.createElement('button');
10
+ button.textContent = 'Remove';
11
+ button.onclick = async () => {
12
+ const {approved = []} = await chrome.storage.local.get('approved');
13
+ await chrome.storage.local.set({approved: approved.filter(x => x !== value)});
14
+ status.textContent = 'Removed. The AI browser keeps cookies it already has.';
15
+ await render();
16
+ };
17
+ li.append(button); list.append(li);
18
+ }
19
+ }
20
+ for (const [id, key] of [['auto', 'autoSync'], ['yolo', 'yolo']]) {
21
+ const box = document.querySelector('#' + id);
22
+ chrome.storage.local.get(key).then(stored => { box.checked = stored[key] === true; });
23
+ box.onchange = () => chrome.storage.local.set({[key]: box.checked});
24
+ }
25
+ document.querySelector('#claude').onclick = () => navigator.clipboard.writeText('claude mcp add syncme -- npx -y syncme-mcp');
26
+ document.querySelector('#setup').onclick = () => navigator.clipboard.writeText('npx -y syncme-mcp setup');
27
+ chrome.storage.session.get(['connected', 'pairing']).then(({connected, pairing}) => {
28
+ document.querySelector('#waiting').hidden = connected === true || Boolean(pairing);
29
+ const box = document.querySelector('#pairing');
30
+ box.hidden = !pairing;
31
+ box.firstChild.textContent = pairing === 'lost' ? 'syncme lost its pairing with this extension.' : 'syncme is paired with a different extension. Run npx -y syncme-mcp unpair, then hit Re-pair.';
32
+ });
33
+ document.querySelector('#repair').onclick = () => { chrome.runtime.sendMessage({op: 'repair'}); document.querySelector('#pairing').hidden = true; };
34
+ chrome.storage.onChanged.addListener(changes => { if (changes.approved) render(); });
35
+ render();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syncme-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "copy your logins for a site into a separate chrome that your ai agents and scripts can use",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "playwright-core": "^1.49.0",
14
+ "tldts": "^7.4.19",
14
15
  "ws": "^8.18.0"
15
16
  },
16
17
  "license": "PolyForm-Noncommercial-1.0.0",
package/syncme.js CHANGED
@@ -7,6 +7,7 @@ import {dirname, join} from 'node:path';
7
7
  import {createInterface} from 'node:readline';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {chromium} from 'playwright-core';
10
+ import {getDomain} from 'tldts';
10
11
  import {WebSocket, WebSocketServer} from 'ws';
11
12
 
12
13
  const ROOT = dirname(fileURLToPath(import.meta.url));
@@ -115,13 +116,15 @@ function origin(value) {
115
116
 
116
117
  function cookiesFor(site, cookies) {
117
118
  if (!Array.isArray(cookies) || cookies.length > 3000) throw rejected('cookies');
119
+ const registrable = getDomain(site.hostname, {allowPrivateDomains: true});
118
120
  const result = [];
119
121
  for (const c of cookies) {
120
- if (!c || typeof c !== 'object' || typeof c.domain !== 'string' || c.domain.replace(/^\./, '') !== site.hostname || c.partitionKey) throw rejected('cookies');
122
+ if (!c || typeof c !== 'object' || typeof c.domain !== 'string' || c.partitionKey) throw rejected('cookies');
121
123
  for (const field of ['name', 'value', 'domain', 'path']) {
122
124
  if (typeof c[field] !== 'string' || c[field].length > 16384) throw rejected('cookies');
123
125
  }
124
126
  if (!c.path.startsWith('/') || ['secure', 'httpOnly', 'hostOnly', 'session'].some(field => typeof c[field] !== 'boolean')) throw rejected('cookies');
127
+ if (c.hostOnly ? c.domain !== site.hostname : !registrable || !c.domain.startsWith('.') || c.domain.length <= registrable.length || !('.' + site.hostname).endsWith(c.domain)) throw rejected('cookies');
125
128
  if (c.secure && site.protocol !== 'https:') throw rejected('cookies');
126
129
  const item = {name: c.name, value: c.value, path: c.path, secure: c.secure, httpOnly: c.httpOnly};
127
130
  item.domain = c.hostOnly ? site.hostname : c.domain;
@@ -260,7 +263,7 @@ async function serve() {
260
263
  if (response.origin !== value || response.error) throw rejected('bridge');
261
264
  const cookies = cookiesFor(site, response.cookies);
262
265
  for (const cookie of await context.cookies()) {
263
- if (cookie.domain.replace(/^\./, '') === site.hostname && (site.protocol === 'https:' || !cookie.secure)) await context.clearCookies({name: cookie.name, domain: cookie.domain, path: cookie.path});
266
+ if ((cookie.domain === site.hostname || cookie.domain.startsWith('.') && ('.' + site.hostname).endsWith(cookie.domain)) && (site.protocol === 'https:' || !cookie.secure)) await context.clearCookies({name: cookie.name, domain: cookie.domain, path: cookie.path});
264
267
  }
265
268
  await context.addCookies(cookies);
266
269
  count += cookies.length;
@@ -386,12 +389,12 @@ function pack() {
386
389
  try {
387
390
  const {key, ...store} = MANIFEST;
388
391
  writeFileSync(join(stage, 'manifest.json'), JSON.stringify(store, null, 2));
389
- for (const name of ['background.js', 'approve.html', 'approve.js']) copyFileSync(join(ROOT, name), join(stage, name));
392
+ for (const name of ['background.js', 'approve.html', 'approve.js', 'options.html', 'options.js']) copyFileSync(join(ROOT, name), join(stage, name));
390
393
  cpSync(join(ROOT, 'icons'), join(stage, 'icons'), {recursive: true});
391
394
  rmSync(zip, {force: true});
392
395
  const result = process.platform === 'win32'
393
396
  ? spawnSync('powershell', ['-NoProfile', '-Command', `Compress-Archive -Path '${join(stage, '*')}' -DestinationPath '${zip}' -Force`], {stdio: 'ignore'})
394
- : spawnSync('zip', ['-r', '-X', '-q', zip, 'manifest.json', 'background.js', 'approve.html', 'approve.js', 'icons'], {cwd: stage, stdio: 'ignore'});
397
+ : spawnSync('zip', ['-r', '-X', '-q', zip, 'manifest.json', 'background.js', 'approve.html', 'approve.js', 'options.html', 'options.js', 'icons'], {cwd: stage, stdio: 'ignore'});
395
398
  if (result.status !== 0) throw Error('Pack failed');
396
399
  } finally {
397
400
  rmSync(stage, {recursive: true, force: true});