yoke-mcp 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/LICENSE +21 -0
- package/MOTIVATION.md +73 -0
- package/README.md +207 -0
- package/ROADMAP.md +111 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +178 -0
- package/dist/doctor.js.map +1 -0
- package/dist/install.d.ts +47 -0
- package/dist/install.js +165 -0
- package/dist/install.js.map +1 -0
- package/dist/mcp-server.d.ts +370 -0
- package/dist/mcp-server.js +606 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/native-host.d.ts +2 -0
- package/dist/native-host.js +170 -0
- package/dist/native-host.js.map +1 -0
- package/dist/protocol.d.ts +339 -0
- package/dist/protocol.js +9 -0
- package/dist/protocol.js.map +1 -0
- package/dist/socket-client.d.ts +16 -0
- package/dist/socket-client.js +89 -0
- package/dist/socket-client.js.map +1 -0
- package/dist/socket-path.d.ts +5 -0
- package/dist/socket-path.js +19 -0
- package/dist/socket-path.js.map +1 -0
- package/extension/browser/background.js +371 -0
- package/extension/browser/cdp.js +259 -0
- package/extension/browser/snapshot.js +154 -0
- package/extension/icons/128.png +0 -0
- package/extension/icons/16.png +0 -0
- package/extension/icons/32.png +0 -0
- package/extension/icons/48.png +0 -0
- package/extension/icons/icon.svg +8 -0
- package/extension/manifest.json +28 -0
- package/extension/protocol.js +8 -0
- package/package.json +53 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects interactive elements, tags each with a stable reference, and stashes
|
|
3
|
+
* the mapping on the page so a later click can resolve it.
|
|
4
|
+
*
|
|
5
|
+
* Runs in the page. Everything it needs is defined inside it.
|
|
6
|
+
*/
|
|
7
|
+
export function collectSnapshot(maxElements) {
|
|
8
|
+
const SELECTOR = [
|
|
9
|
+
'a[href]', 'button', 'input', 'select', 'textarea', 'summary',
|
|
10
|
+
'[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]',
|
|
11
|
+
'[role="tab"]', '[role="menuitem"]', '[role="option"]', '[contenteditable="true"]',
|
|
12
|
+
'[onclick]', '[tabindex]:not([tabindex="-1"])',
|
|
13
|
+
].join(',');
|
|
14
|
+
const visible = (element) => {
|
|
15
|
+
const rect = element.getBoundingClientRect();
|
|
16
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const style = getComputedStyle(element);
|
|
20
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0';
|
|
21
|
+
};
|
|
22
|
+
const nameOf = (element) => {
|
|
23
|
+
const aria = element.getAttribute('aria-label');
|
|
24
|
+
if (aria) {
|
|
25
|
+
return aria.trim();
|
|
26
|
+
}
|
|
27
|
+
const input = element;
|
|
28
|
+
if (input.labels && input.labels.length > 0) {
|
|
29
|
+
return (input.labels[0]?.innerText ?? '').trim();
|
|
30
|
+
}
|
|
31
|
+
if (input.placeholder) {
|
|
32
|
+
return input.placeholder.trim();
|
|
33
|
+
}
|
|
34
|
+
const title = element.getAttribute('title');
|
|
35
|
+
if (title) {
|
|
36
|
+
return title.trim();
|
|
37
|
+
}
|
|
38
|
+
const alt = element.getAttribute('alt');
|
|
39
|
+
if (alt) {
|
|
40
|
+
return alt.trim();
|
|
41
|
+
}
|
|
42
|
+
return (element.innerText ?? element.textContent ?? '').trim().slice(0, 120);
|
|
43
|
+
};
|
|
44
|
+
const roleOf = (element) => {
|
|
45
|
+
const explicit = element.getAttribute('role');
|
|
46
|
+
if (explicit) {
|
|
47
|
+
return explicit;
|
|
48
|
+
}
|
|
49
|
+
const tag = element.tagName.toLowerCase();
|
|
50
|
+
if (tag === 'a') {
|
|
51
|
+
return 'link';
|
|
52
|
+
}
|
|
53
|
+
if (tag === 'button' || tag === 'summary') {
|
|
54
|
+
return 'button';
|
|
55
|
+
}
|
|
56
|
+
if (tag === 'select') {
|
|
57
|
+
return 'combobox';
|
|
58
|
+
}
|
|
59
|
+
if (tag === 'textarea') {
|
|
60
|
+
return 'textbox';
|
|
61
|
+
}
|
|
62
|
+
if (tag === 'input') {
|
|
63
|
+
const type = element.type;
|
|
64
|
+
if (type === 'checkbox' || type === 'radio' || type === 'submit' || type === 'button') {
|
|
65
|
+
return type;
|
|
66
|
+
}
|
|
67
|
+
// Its own role rather than textbox, so a password field stays visible to a
|
|
68
|
+
// caller that has to type into it while its value can be withheld.
|
|
69
|
+
// Collapsing it into textbox is what made the value impossible to redact.
|
|
70
|
+
if (type === 'password') {
|
|
71
|
+
return 'password';
|
|
72
|
+
}
|
|
73
|
+
return 'textbox';
|
|
74
|
+
}
|
|
75
|
+
return 'generic';
|
|
76
|
+
};
|
|
77
|
+
const isSecret = (element) => element.tagName.toLowerCase() === 'input' && element.type === 'password';
|
|
78
|
+
const registry = new Map();
|
|
79
|
+
const elements = [];
|
|
80
|
+
let index = 0;
|
|
81
|
+
for (const element of Array.from(document.querySelectorAll(SELECTOR))) {
|
|
82
|
+
if (elements.length >= maxElements) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (!visible(element)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
index += 1;
|
|
89
|
+
const ref = `e${index}`;
|
|
90
|
+
registry.set(ref, element);
|
|
91
|
+
const rect = element.getBoundingClientRect();
|
|
92
|
+
const input = element;
|
|
93
|
+
elements.push({
|
|
94
|
+
ref,
|
|
95
|
+
role: roleOf(element),
|
|
96
|
+
name: nameOf(element),
|
|
97
|
+
tag: element.tagName.toLowerCase(),
|
|
98
|
+
...(input.value && !isSecret(element) ? { value: String(input.value).slice(0, 80) } : {}),
|
|
99
|
+
...(input.disabled ? { disabled: true } : {}),
|
|
100
|
+
// Centre point, kept for the click path and never shown to a caller.
|
|
101
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
102
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Parked on the window so a later resolve can find the same elements without
|
|
106
|
+
// re-walking the DOM, which would renumber everything.
|
|
107
|
+
window.__yokeRefs = registry;
|
|
108
|
+
return { url: location.href, title: document.title, elements };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Where a reference is now, resolved in the page, and what is on top of it.
|
|
112
|
+
*
|
|
113
|
+
* Re-read at click time rather than trusted from the snapshot, because a page
|
|
114
|
+
* that scrolled or reflowed since would otherwise be clicked in the wrong place.
|
|
115
|
+
* The hit test is here rather than in the caller because it has to happen at the
|
|
116
|
+
* same moment as the measurement: anything later is a different page.
|
|
117
|
+
*/
|
|
118
|
+
export function locateRef(ref) {
|
|
119
|
+
const registry = window.__yokeRefs;
|
|
120
|
+
const element = registry?.get(ref);
|
|
121
|
+
if (!element) {
|
|
122
|
+
return { x: 0, y: 0, found: false };
|
|
123
|
+
}
|
|
124
|
+
element.scrollIntoView({ block: 'center', inline: 'center' });
|
|
125
|
+
const rect = element.getBoundingClientRect();
|
|
126
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
127
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
128
|
+
const top = document.elementFromPoint(x, y);
|
|
129
|
+
const describe = (node) => {
|
|
130
|
+
const id = node.id ? `#${node.id}` : '';
|
|
131
|
+
const first = node.classList.length > 0 ? `.${node.classList[0]}` : '';
|
|
132
|
+
return `${node.tagName.toLowerCase()}${id}${first}`;
|
|
133
|
+
};
|
|
134
|
+
let hit;
|
|
135
|
+
if (!top) {
|
|
136
|
+
hit = 'nothing';
|
|
137
|
+
}
|
|
138
|
+
else if (top === element) {
|
|
139
|
+
hit = 'self';
|
|
140
|
+
}
|
|
141
|
+
else if (element.contains(top) || top.contains(element)) {
|
|
142
|
+
hit = 'nested';
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
hit = 'covered';
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
x,
|
|
149
|
+
y,
|
|
150
|
+
found: true,
|
|
151
|
+
hit,
|
|
152
|
+
...(hit === 'covered' && top ? { topmost: describe(top) } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"
|
|
2
|
+
stroke="#1098ad" stroke-width="16" stroke-linecap="round" stroke-linejoin="round">
|
|
3
|
+
<!-- The grip bar with a horn turning up at each end, and the column below it.
|
|
4
|
+
Every centreline is a multiple of 8 and the stroke is 16, so at 16px each
|
|
5
|
+
line lands on a pixel boundary instead of straddling two. -->
|
|
6
|
+
<path d="M24 24V64h80V24"/>
|
|
7
|
+
<path d="M64 64v40"/>
|
|
8
|
+
</svg>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Yoke",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Let MCP clients drive your signed in Chrome: tabs, page text, trusted input, screenshots, console, and network.",
|
|
6
|
+
"icons": {
|
|
7
|
+
"16": "icons/16.png",
|
|
8
|
+
"32": "icons/32.png",
|
|
9
|
+
"48": "icons/48.png",
|
|
10
|
+
"128": "icons/128.png"
|
|
11
|
+
},
|
|
12
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyt9Vs20B0gvtrAGrHnM1tCwXLa8mtRp/GLfYqMwwIAg1osvEAzk+aAhBam4wYrVI0p6y97qgcp0diwi6zHx/9z5feuklqRtPbI+JJSdJIIUZfJTs06HPfdKvMFUyLeyTVeIIqOPqkNPNUrRRa7QrZ3iAPE3+zpBUbR/t9EeW5VPFW7pM+PPZ0Jnv7GyyrTWRQ5veA5Y0N0PTJK8uBUu8uVa863inuVyTxUCxslD76whBuR5GqjTkzl9T0CzZ0Oo5aTJm+0ni+LT4g6sQIEKRukAvL1rj+Y5tTD4K5hh/4+d/u2t07uDlE5iymW3xdiYJ+hBuXPSz2nj+OWV19iJcLwIDAQAB",
|
|
13
|
+
"minimum_chrome_version": "116",
|
|
14
|
+
"permissions": [
|
|
15
|
+
"tabs",
|
|
16
|
+
"tabGroups",
|
|
17
|
+
"nativeMessaging",
|
|
18
|
+
"scripting",
|
|
19
|
+
"debugger"
|
|
20
|
+
],
|
|
21
|
+
"background": {
|
|
22
|
+
"service_worker": "browser/background.js",
|
|
23
|
+
"type": "module"
|
|
24
|
+
},
|
|
25
|
+
"host_permissions": [
|
|
26
|
+
"<all_urls>"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The shapes the three processes agree on.
|
|
2
|
+
//
|
|
3
|
+
// One file so the extension, the host and the server cannot drift: a request the
|
|
4
|
+
// server can send is exactly a request the extension knows how to answer, and
|
|
5
|
+
// the compiler is what enforces that rather than a comment asking nicely.
|
|
6
|
+
/** Bumped when a message shape changes in a way an older peer cannot read. */
|
|
7
|
+
export const PROTOCOL = 1;
|
|
8
|
+
export const isResponse = (value) => typeof value === 'object' && value !== null && 'id' in value && 'ok' in value;
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yoke-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for browser automation in the Chrome you are already signed in to, with no tab-group boundary.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"yoke": "dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/",
|
|
10
|
+
"!dist/*.sh",
|
|
11
|
+
"!dist/*.bat",
|
|
12
|
+
"extension/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"ROADMAP.md",
|
|
16
|
+
"MOTIVATION.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc && tsc -p tsconfig.extension.json",
|
|
23
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.extension.json --noEmit",
|
|
24
|
+
"test": "npm run build && node --test --experimental-strip-types test/*.test.ts",
|
|
25
|
+
"lint": "tsc --noEmit",
|
|
26
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
27
|
+
"check:versions": "node scripts/check-versions.mjs",
|
|
28
|
+
"prepublishOnly": "npm run clean && npm run build && npm run check:versions"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"chrome",
|
|
33
|
+
"browser-automation",
|
|
34
|
+
"extension",
|
|
35
|
+
"tab-groups"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/hamzahamidi/yoke.git"
|
|
41
|
+
},
|
|
42
|
+
"main": "dist/mcp-server.js",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/chrome": "^0.0.270",
|
|
45
|
+
"@types/node": "^22.0.0",
|
|
46
|
+
"typescript": "^5.6.0"
|
|
47
|
+
},
|
|
48
|
+
"type": "module",
|
|
49
|
+
"homepage": "https://github.com/hamzahamidi/yoke#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/hamzahamidi/yoke/issues"
|
|
52
|
+
}
|
|
53
|
+
}
|