npc-agent 1.0.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/README.md +119 -0
- package/dist/cdp.d.ts +53 -0
- package/dist/cdp.js +231 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +66 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +173 -0
- package/dist/relay.d.ts +23 -0
- package/dist/relay.js +259 -0
- package/extension/README.md +20 -0
- package/extension/background.js +451 -0
- package/extension/icons/icon.svg +154 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +33 -0
- package/extension/offscreen.html +7 -0
- package/extension/offscreen.js +7 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Freya Zou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# NPC
|
|
4
|
+
|
|
5
|
+
*Your browser's NPC. Handles the side quests.*
|
|
6
|
+
*Control your real browser from any IDE - no context switching.*
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i -g npc-agent
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
Your IDE agent says what to do ("message Anna on Slack"). NPC does it in your real, logged-in browser. No API keys per service, no OAuth, no bot accounts. Works with any MCP IDE - Cursor, VS Code, Windsurf.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
| You want to | Your IDE says |
|
|
22
|
+
| ------------------------ | ------------------------------------------------------------ |
|
|
23
|
+
| Message someone on Slack | "go to slack and message #general: deploy is done" |
|
|
24
|
+
| Reply on Messenger | "open messenger and reply to Tim: sounds good" |
|
|
25
|
+
| Check Gmail | "take a screenshot of my gmail inbox" |
|
|
26
|
+
| Fill out a form | "find the email field, click it, type my address, press Tab" |
|
|
27
|
+
| Do it all in one shot | use `npc_batch` with an array of actions |
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
1. Install: `npm i -g npc-agent`
|
|
35
|
+
2. Load the extension: `chrome://extensions` > Developer mode > Load unpacked > select `extension/`
|
|
36
|
+
3. Add to `.cursor/mcp.json` or `.vscode/mcp.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"npc": {
|
|
42
|
+
"command": "node",
|
|
43
|
+
"args": ["/path/to/npc/dist/index.js"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Click the NPC icon on any tab. Green badge means connected.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
IDE (Cursor / VS Code) NPC Server Browser
|
|
59
|
+
| | |
|
|
60
|
+
|--- MCP stdio ------------->| |
|
|
61
|
+
| "click Send button" |--- WebSocket :7221 -->|
|
|
62
|
+
| | |--- CDP (chrome.debugger)
|
|
63
|
+
| | |--- clicks in real tab
|
|
64
|
+
| |<-- result ------------|
|
|
65
|
+
|<-- tool response ----------| |
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The IDE handles reasoning. NPC just executes browser actions via Chrome DevTools Protocol. No LLM inside NPC.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## MCP tools
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
| Tool | What it does |
|
|
76
|
+
| ------------------ | ----------------------------------------------------------- |
|
|
77
|
+
| `npc_screenshot` | Capture tab as PNG |
|
|
78
|
+
| `npc_navigate` | Go to a URL |
|
|
79
|
+
| `npc_click` | Click at (x, y) |
|
|
80
|
+
| `npc_type` | Type text into focused element |
|
|
81
|
+
| `npc_press_key` | Press Enter, Tab, Escape, arrows |
|
|
82
|
+
| `npc_scroll` | Scroll up/down/left/right |
|
|
83
|
+
| `npc_find` | Find element by CSS selector or text, returns (x, y) center |
|
|
84
|
+
| `npc_batch` | Run multiple actions in one call |
|
|
85
|
+
| `npc_evaluate` | Run JavaScript in page context |
|
|
86
|
+
| `npc_extract_text` | Get all text from the page |
|
|
87
|
+
| `npc_extract_html` | Get full page HTML |
|
|
88
|
+
| `npc_current_url` | Get current tab URL |
|
|
89
|
+
| `npc_page_title` | Get current tab title |
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
### Batch example
|
|
93
|
+
|
|
94
|
+
One MCP call instead of four:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
[
|
|
98
|
+
{"action": "find", "selector": "Message Tim"},
|
|
99
|
+
{"action": "click", "x": 450, "y": 320},
|
|
100
|
+
{"action": "type", "text": "hey, deploy is done"},
|
|
101
|
+
{"action": "key", "key": "Enter"}
|
|
102
|
+
]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Limitations
|
|
108
|
+
|
|
109
|
+
Chrome and Brave only (uses `chrome.debugger` API). One active tab at a time per extension instance. Cannot attach to `chrome://`, `brave://`, or extension pages. Screenshot coordinates are at device pixel ratio - divide by DPR before clicking on HiDPI displays.
|
|
110
|
+
|
|
111
|
+
Requires Node.js >= 18.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Contact
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
package/dist/cdp.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NPCRelay } from './relay.js';
|
|
2
|
+
export interface BatchAction {
|
|
3
|
+
action: 'click' | 'type' | 'key' | 'scroll' | 'navigate' | 'screenshot' | 'wait' | 'evaluate' | 'find';
|
|
4
|
+
x?: number;
|
|
5
|
+
y?: number;
|
|
6
|
+
text?: string;
|
|
7
|
+
key?: string;
|
|
8
|
+
direction?: 'up' | 'down';
|
|
9
|
+
amount?: number;
|
|
10
|
+
url?: string;
|
|
11
|
+
expression?: string;
|
|
12
|
+
selector?: string;
|
|
13
|
+
ms?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface BatchResult {
|
|
16
|
+
action: string;
|
|
17
|
+
ok: boolean;
|
|
18
|
+
result?: any;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class CDP {
|
|
22
|
+
private relay;
|
|
23
|
+
private sessionId;
|
|
24
|
+
constructor(relay: NPCRelay, sessionId: string);
|
|
25
|
+
screenshot(): Promise<{
|
|
26
|
+
data: string;
|
|
27
|
+
dpr: number;
|
|
28
|
+
}>;
|
|
29
|
+
getDevicePixelRatio(): Promise<number>;
|
|
30
|
+
navigate(url: string): Promise<void>;
|
|
31
|
+
waitForLoad(timeoutMs?: number): Promise<void>;
|
|
32
|
+
click(x: number, y: number): Promise<void>;
|
|
33
|
+
type(text: string): Promise<void>;
|
|
34
|
+
pressKey(key: string): Promise<void>;
|
|
35
|
+
pressEnter(): Promise<void>;
|
|
36
|
+
scroll(direction: 'up' | 'down', amount?: number): Promise<void>;
|
|
37
|
+
findElement(selector: string): Promise<{
|
|
38
|
+
found: boolean;
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
text: string;
|
|
42
|
+
}>;
|
|
43
|
+
executeBatch(actions: BatchAction[]): Promise<BatchResult[]>;
|
|
44
|
+
evaluate<T = any>(expression: string): Promise<T>;
|
|
45
|
+
extractText(): Promise<string>;
|
|
46
|
+
extractHTML(): Promise<string>;
|
|
47
|
+
currentUrl(): Promise<string>;
|
|
48
|
+
pageTitle(): Promise<string>;
|
|
49
|
+
viewportSize(): Promise<{
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
package/dist/cdp.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
export class CDP {
|
|
2
|
+
relay;
|
|
3
|
+
sessionId;
|
|
4
|
+
constructor(relay, sessionId) {
|
|
5
|
+
this.relay = relay;
|
|
6
|
+
this.sessionId = sessionId;
|
|
7
|
+
}
|
|
8
|
+
// ─── Page ───────────────────────────────────────────────────────────────────
|
|
9
|
+
async screenshot() {
|
|
10
|
+
const [{ data }, dpr] = await Promise.all([
|
|
11
|
+
this.relay.cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, this.sessionId),
|
|
12
|
+
this.getDevicePixelRatio()
|
|
13
|
+
]);
|
|
14
|
+
return { data: data, dpr };
|
|
15
|
+
}
|
|
16
|
+
async getDevicePixelRatio() {
|
|
17
|
+
try {
|
|
18
|
+
return await this.evaluate('window.devicePixelRatio') || 1;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async navigate(url) {
|
|
25
|
+
await this.relay.cdp('Page.navigate', { url }, this.sessionId);
|
|
26
|
+
await this.waitForLoad();
|
|
27
|
+
}
|
|
28
|
+
async waitForLoad(timeoutMs = 10_000) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
this.relay.removeListener('cdpEvent', onEvent);
|
|
32
|
+
resolve();
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
const onEvent = (evt) => {
|
|
35
|
+
if (evt.method === 'Page.loadEventFired') {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
this.relay.removeListener('cdpEvent', onEvent);
|
|
38
|
+
resolve();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
this.relay.on('cdpEvent', onEvent);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// ─── Input (no artificial sleeps) ──────────────────────────────────────────
|
|
45
|
+
async click(x, y) {
|
|
46
|
+
const base = { x, y, button: 'left', clickCount: 1, modifiers: 0 };
|
|
47
|
+
await this.relay.cdp('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, this.sessionId);
|
|
48
|
+
await this.relay.cdp('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, this.sessionId);
|
|
49
|
+
}
|
|
50
|
+
async type(text) {
|
|
51
|
+
await this.relay.cdp('Input.insertText', { text }, this.sessionId);
|
|
52
|
+
}
|
|
53
|
+
async pressKey(key) {
|
|
54
|
+
const mapped = resolveKey(key);
|
|
55
|
+
await this.relay.cdp('Input.dispatchKeyEvent', { ...mapped, type: 'keyDown' }, this.sessionId);
|
|
56
|
+
await this.relay.cdp('Input.dispatchKeyEvent', { ...mapped, type: 'keyUp' }, this.sessionId);
|
|
57
|
+
}
|
|
58
|
+
async pressEnter() {
|
|
59
|
+
await this.pressKey('Enter');
|
|
60
|
+
}
|
|
61
|
+
async scroll(direction, amount = 500) {
|
|
62
|
+
await this.relay.cdp('Input.dispatchMouseEvent', {
|
|
63
|
+
type: 'mouseWheel',
|
|
64
|
+
x: 640,
|
|
65
|
+
y: 400,
|
|
66
|
+
deltaX: 0,
|
|
67
|
+
deltaY: direction === 'down' ? amount : -amount
|
|
68
|
+
}, this.sessionId);
|
|
69
|
+
}
|
|
70
|
+
// ─── Semantic element finding ──────────────────────────────────────────────
|
|
71
|
+
async findElement(selector) {
|
|
72
|
+
const result = await this.evaluate(`
|
|
73
|
+
(() => {
|
|
74
|
+
// Try CSS selector first
|
|
75
|
+
let el = document.querySelector(${JSON.stringify(selector)});
|
|
76
|
+
|
|
77
|
+
// If no CSS match, search by text content
|
|
78
|
+
if (!el) {
|
|
79
|
+
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
80
|
+
const target = ${JSON.stringify(selector.toLowerCase())};
|
|
81
|
+
while (walk.nextNode()) {
|
|
82
|
+
const node = walk.currentNode;
|
|
83
|
+
const txt = (node.textContent || '').trim().toLowerCase();
|
|
84
|
+
const aria = (node.getAttribute('aria-label') || '').toLowerCase();
|
|
85
|
+
const placeholder = (node.getAttribute('placeholder') || '').toLowerCase();
|
|
86
|
+
if (txt === target || txt.includes(target) || aria.includes(target) || placeholder.includes(target)) {
|
|
87
|
+
el = node;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!el) return { found: false, x: 0, y: 0, text: '' };
|
|
94
|
+
const rect = el.getBoundingClientRect();
|
|
95
|
+
return {
|
|
96
|
+
found: true,
|
|
97
|
+
x: Math.round(rect.x + rect.width / 2),
|
|
98
|
+
y: Math.round(rect.y + rect.height / 2),
|
|
99
|
+
text: (el.textContent || '').trim().slice(0, 200)
|
|
100
|
+
};
|
|
101
|
+
})()
|
|
102
|
+
`);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
// ─── Batch execution ───────────────────────────────────────────────────────
|
|
106
|
+
async executeBatch(actions) {
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const act of actions) {
|
|
109
|
+
try {
|
|
110
|
+
let result = null;
|
|
111
|
+
switch (act.action) {
|
|
112
|
+
case 'click':
|
|
113
|
+
await this.click(act.x ?? 0, act.y ?? 0);
|
|
114
|
+
result = `Clicked (${act.x}, ${act.y})`;
|
|
115
|
+
break;
|
|
116
|
+
case 'type':
|
|
117
|
+
await this.type(act.text ?? '');
|
|
118
|
+
result = `Typed "${act.text}"`;
|
|
119
|
+
break;
|
|
120
|
+
case 'key':
|
|
121
|
+
if ((act.key ?? '').toLowerCase() === 'enter' || (act.key ?? '').toLowerCase() === 'return') {
|
|
122
|
+
await this.pressEnter();
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
await this.pressKey(act.key ?? '');
|
|
126
|
+
}
|
|
127
|
+
result = `Pressed ${act.key}`;
|
|
128
|
+
break;
|
|
129
|
+
case 'scroll':
|
|
130
|
+
await this.scroll(act.direction ?? 'down', act.amount ?? 500);
|
|
131
|
+
result = `Scrolled ${act.direction} ${act.amount ?? 500}px`;
|
|
132
|
+
break;
|
|
133
|
+
case 'navigate':
|
|
134
|
+
await this.navigate(act.url ?? '');
|
|
135
|
+
result = `Navigated to ${act.url}`;
|
|
136
|
+
break;
|
|
137
|
+
case 'screenshot': {
|
|
138
|
+
const shot = await this.screenshot();
|
|
139
|
+
result = { data: shot.data, dpr: shot.dpr };
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'wait':
|
|
143
|
+
await new Promise(r => setTimeout(r, act.ms ?? 100));
|
|
144
|
+
result = `Waited ${act.ms ?? 100}ms`;
|
|
145
|
+
break;
|
|
146
|
+
case 'evaluate':
|
|
147
|
+
result = await this.evaluate(act.expression ?? '');
|
|
148
|
+
break;
|
|
149
|
+
case 'find': {
|
|
150
|
+
const found = await this.findElement(act.selector ?? act.text ?? '');
|
|
151
|
+
result = found;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`Unknown action: ${act.action}`);
|
|
156
|
+
}
|
|
157
|
+
results.push({ action: act.action, ok: true, result });
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
results.push({ action: act.action, ok: false, error: e.message });
|
|
161
|
+
break; // stop batch on first error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
// ─── Runtime ────────────────────────────────────────────────────────────────
|
|
167
|
+
async evaluate(expression) {
|
|
168
|
+
const { result, exceptionDetails } = await this.relay.cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, this.sessionId);
|
|
169
|
+
if (exceptionDetails) {
|
|
170
|
+
throw new Error(`JS eval error: ${exceptionDetails.text}`);
|
|
171
|
+
}
|
|
172
|
+
return result?.value;
|
|
173
|
+
}
|
|
174
|
+
async extractText() {
|
|
175
|
+
return this.evaluate('document.body.innerText');
|
|
176
|
+
}
|
|
177
|
+
async extractHTML() {
|
|
178
|
+
return this.evaluate('document.documentElement.outerHTML');
|
|
179
|
+
}
|
|
180
|
+
async currentUrl() {
|
|
181
|
+
return this.evaluate('window.location.href');
|
|
182
|
+
}
|
|
183
|
+
async pageTitle() {
|
|
184
|
+
return this.evaluate('document.title');
|
|
185
|
+
}
|
|
186
|
+
async viewportSize() {
|
|
187
|
+
return this.evaluate('({ width: window.innerWidth, height: window.innerHeight })');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const KEY_MAP = {
|
|
191
|
+
enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
|
|
192
|
+
return: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
|
|
193
|
+
tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 },
|
|
194
|
+
escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
|
|
195
|
+
backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
|
|
196
|
+
delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
|
|
197
|
+
space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 },
|
|
198
|
+
arrowup: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
|
|
199
|
+
arrowdown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
|
|
200
|
+
arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
|
|
201
|
+
arrowright: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
|
|
202
|
+
home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
|
|
203
|
+
end: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
|
|
204
|
+
pageup: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
|
|
205
|
+
pagedown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
|
|
206
|
+
};
|
|
207
|
+
function resolveKey(key) {
|
|
208
|
+
const lower = key.toLowerCase();
|
|
209
|
+
// Check named keys
|
|
210
|
+
const mapped = KEY_MAP[lower];
|
|
211
|
+
if (mapped)
|
|
212
|
+
return mapped;
|
|
213
|
+
// Single letter a-z
|
|
214
|
+
if (lower.length === 1 && lower >= 'a' && lower <= 'z') {
|
|
215
|
+
return { key: lower, code: `Key${lower.toUpperCase()}`, windowsVirtualKeyCode: lower.toUpperCase().charCodeAt(0) };
|
|
216
|
+
}
|
|
217
|
+
// Single digit 0-9
|
|
218
|
+
if (lower.length === 1 && lower >= '0' && lower <= '9') {
|
|
219
|
+
return { key: lower, code: `Digit${lower}`, windowsVirtualKeyCode: lower.charCodeAt(0) };
|
|
220
|
+
}
|
|
221
|
+
// F1-F12
|
|
222
|
+
const fMatch = lower.match(/^f(\d{1,2})$/);
|
|
223
|
+
if (fMatch) {
|
|
224
|
+
const n = parseInt(fMatch[1]);
|
|
225
|
+
if (n >= 1 && n <= 12) {
|
|
226
|
+
return { key: `F${n}`, code: `F${n}`, windowsVirtualKeyCode: 111 + n };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Fallback - pass through, let Chrome infer
|
|
230
|
+
return { key, code: key, windowsVirtualKeyCode: 0 };
|
|
231
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { NPCRelay } from './relay.js';
|
|
4
|
+
import { createMCPServer } from './mcp.js';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname, resolve } from 'path';
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
10
|
+
process.stderr.write(`NPC - Your browser's NPC. Handles the side quests.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
npc Start MCP server (IDE connects via stdio)
|
|
14
|
+
npc --relay-only Extension bridge without MCP
|
|
15
|
+
npc --help Show this help
|
|
16
|
+
npc --version Show version
|
|
17
|
+
|
|
18
|
+
Configure your IDE:
|
|
19
|
+
Add to .cursor/mcp.json or .vscode/mcp.json:
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"npc": { "command": "npc", "args": [] }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Environment:
|
|
27
|
+
NPC_PORT Override relay port (default: 7221)
|
|
28
|
+
|
|
29
|
+
Docs: https://github.com/freyzo/npc
|
|
30
|
+
`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
36
|
+
process.stdout.write(pkg.version + '\n');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
process.stdout.write('unknown\n');
|
|
40
|
+
}
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
const PORT = parseInt(process.env.NPC_PORT || '7221', 10);
|
|
44
|
+
const relayOnly = process.argv.includes('--relay-only');
|
|
45
|
+
const relay = new NPCRelay(PORT);
|
|
46
|
+
relay.on('extensionConnected', () => {
|
|
47
|
+
process.stderr.write('[npc] browser extension connected\n');
|
|
48
|
+
});
|
|
49
|
+
relay.on('sessionReady', (sid) => {
|
|
50
|
+
process.stderr.write(`[npc] tab session ready (${sid})\n`);
|
|
51
|
+
});
|
|
52
|
+
relay.on('extensionDisconnected', () => {
|
|
53
|
+
process.stderr.write('[npc] browser extension disconnected\n');
|
|
54
|
+
});
|
|
55
|
+
process.on('SIGINT', () => { relay.close(); process.exit(0); });
|
|
56
|
+
process.on('SIGTERM', () => { relay.close(); process.exit(0); });
|
|
57
|
+
process.stderr.write(`[npc] relay listening on ws://localhost:${PORT}\n`);
|
|
58
|
+
if (relayOnly) {
|
|
59
|
+
process.stderr.write('[npc] relay-only mode - load NPC extension in Brave, click icon on a tab\n');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const mcpServer = createMCPServer(relay);
|
|
63
|
+
const transport = new StdioServerTransport();
|
|
64
|
+
await mcpServer.connect(transport);
|
|
65
|
+
process.stderr.write('[npc] MCP server ready - waiting for Brave extension\n');
|
|
66
|
+
}
|
package/dist/mcp.d.ts
ADDED
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { CDP } from './cdp.js';
|
|
4
|
+
import { writeFile } from 'fs/promises';
|
|
5
|
+
import { resolve, dirname } from 'path';
|
|
6
|
+
import { mkdirSync } from 'fs';
|
|
7
|
+
export function createMCPServer(relay) {
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: 'npc',
|
|
10
|
+
version: '0.2.0'
|
|
11
|
+
});
|
|
12
|
+
function getCDP() {
|
|
13
|
+
const sid = relay.activeSession;
|
|
14
|
+
if (!sid)
|
|
15
|
+
throw new Error('No active browser session. Is the Chrome extension connected?');
|
|
16
|
+
return new CDP(relay, sid);
|
|
17
|
+
}
|
|
18
|
+
// ── npc_screenshot ──────────────────────────────────────────────────────────
|
|
19
|
+
server.tool('npc_screenshot', 'Take a screenshot of the real Chrome browser tab (via NPC Chrome extension + CDP). Returns base64 PNG.', {
|
|
20
|
+
savePath: z.string().optional().describe('Optional absolute file path to save the PNG to disk')
|
|
21
|
+
}, async ({ savePath }) => {
|
|
22
|
+
const cdp = getCDP();
|
|
23
|
+
const { data, dpr } = await cdp.screenshot();
|
|
24
|
+
if (savePath) {
|
|
25
|
+
const abs = resolve(savePath);
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
await writeFile(abs, Buffer.from(data, 'base64'));
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
content: [
|
|
34
|
+
{ type: 'image', data, mimeType: 'image/png' },
|
|
35
|
+
{ type: 'text', text: `Device pixel ratio: ${dpr}. Divide image coords by ${dpr} before passing to click/scroll.${savePath ? ` Saved to ${savePath}` : ''}` }
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
// ── npc_navigate ────────────────────────────────────────────────────────────
|
|
40
|
+
server.tool('npc_navigate', 'Navigate the real Chrome browser to a URL (via NPC Chrome extension + CDP)', { url: z.string().describe('The URL to navigate to') }, async ({ url }) => {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(url);
|
|
43
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
44
|
+
throw new Error(`Blocked navigation to ${parsed.protocol} URL - only http: and https: are allowed`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e instanceof Error && e.message.startsWith('Blocked'))
|
|
49
|
+
throw e;
|
|
50
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
51
|
+
}
|
|
52
|
+
const cdp = getCDP();
|
|
53
|
+
await cdp.navigate(url);
|
|
54
|
+
const title = await cdp.pageTitle().catch(() => '');
|
|
55
|
+
return { content: [{ type: 'text', text: `Navigated to ${url} - "${title}"` }] };
|
|
56
|
+
});
|
|
57
|
+
// ── npc_click ───────────────────────────────────────────────────────────────
|
|
58
|
+
server.tool('npc_click', 'Click at pixel coordinates in the real Chrome browser viewport', {
|
|
59
|
+
x: z.number().describe('X coordinate (pixels from left)'),
|
|
60
|
+
y: z.number().describe('Y coordinate (pixels from top)')
|
|
61
|
+
}, async ({ x, y }) => {
|
|
62
|
+
const cdp = getCDP();
|
|
63
|
+
await cdp.click(x, y);
|
|
64
|
+
return { content: [{ type: 'text', text: `Clicked at (${x}, ${y})` }] };
|
|
65
|
+
});
|
|
66
|
+
// ── npc_type ────────────────────────────────────────────────────────────────
|
|
67
|
+
server.tool('npc_type', 'Type text into the focused element in the real Chrome browser', { text: z.string().describe('Text to type') }, async ({ text }) => {
|
|
68
|
+
const cdp = getCDP();
|
|
69
|
+
await cdp.type(text);
|
|
70
|
+
return { content: [{ type: 'text', text: `Typed "${text}"` }] };
|
|
71
|
+
});
|
|
72
|
+
// ── npc_press_key ───────────────────────────────────────────────────────────
|
|
73
|
+
server.tool('npc_press_key', 'Press a keyboard key in the real Chrome browser (Enter, Tab, Escape, ArrowDown, etc.)', { key: z.string().describe('Key name, e.g. "Return", "Tab", "Escape"') }, async ({ key }) => {
|
|
74
|
+
const cdp = getCDP();
|
|
75
|
+
await cdp.pressKey(key);
|
|
76
|
+
return { content: [{ type: 'text', text: `Pressed ${key}` }] };
|
|
77
|
+
});
|
|
78
|
+
// ── npc_scroll ──────────────────────────────────────────────────────────────
|
|
79
|
+
server.tool('npc_scroll', 'Scroll the page in the real Chrome browser', {
|
|
80
|
+
direction: z.enum(['up', 'down']).describe('Scroll direction'),
|
|
81
|
+
amount: z.number().optional().default(500).describe('Pixels to scroll (default 500)')
|
|
82
|
+
}, async ({ direction, amount }) => {
|
|
83
|
+
const cdp = getCDP();
|
|
84
|
+
await cdp.scroll(direction, amount);
|
|
85
|
+
return { content: [{ type: 'text', text: `Scrolled ${direction} ${amount}px` }] };
|
|
86
|
+
});
|
|
87
|
+
// ── npc_find ────────────────────────────────────────────────────────────────
|
|
88
|
+
server.tool('npc_find', 'Find an element on the page by CSS selector or text content. Returns center coordinates for clicking. Searches text content, aria-label, and placeholder attributes.', {
|
|
89
|
+
selector: z.string().describe('CSS selector (e.g. "button.submit") or text to find (e.g. "Send message")')
|
|
90
|
+
}, async ({ selector }) => {
|
|
91
|
+
const cdp = getCDP();
|
|
92
|
+
const result = await cdp.findElement(selector);
|
|
93
|
+
if (!result.found) {
|
|
94
|
+
return { content: [{ type: 'text', text: `Element not found: "${selector}"` }] };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
content: [{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: `Found at (${result.x}, ${result.y}) - text: "${result.text}"`
|
|
100
|
+
}]
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
// ── npc_batch ───────────────────────────────────────────────────────────────
|
|
104
|
+
server.tool('npc_batch', `Execute multiple browser actions in a single call. Cuts MCP round-trip overhead.
|
|
105
|
+
Each action is an object with an "action" field and relevant params.
|
|
106
|
+
Actions: click(x,y), type(text), key(key), scroll(direction,amount), navigate(url), screenshot(), wait(ms), evaluate(expression), find(selector).
|
|
107
|
+
Stops on first error. Example: [{"action":"find","selector":"Message Tim"},{"action":"click","x":100,"y":200},{"action":"type","text":"hello"},{"action":"key","key":"Enter"}]`, {
|
|
108
|
+
actions: z.array(z.object({
|
|
109
|
+
action: z.enum(['click', 'type', 'key', 'scroll', 'navigate', 'screenshot', 'wait', 'evaluate', 'find']),
|
|
110
|
+
x: z.number().optional(),
|
|
111
|
+
y: z.number().optional(),
|
|
112
|
+
text: z.string().optional(),
|
|
113
|
+
key: z.string().optional(),
|
|
114
|
+
direction: z.enum(['up', 'down']).optional(),
|
|
115
|
+
amount: z.number().optional(),
|
|
116
|
+
url: z.string().optional(),
|
|
117
|
+
expression: z.string().optional(),
|
|
118
|
+
selector: z.string().optional(),
|
|
119
|
+
ms: z.number().optional()
|
|
120
|
+
})).describe('Array of actions to execute sequentially')
|
|
121
|
+
}, async ({ actions }) => {
|
|
122
|
+
const cdp = getCDP();
|
|
123
|
+
const results = await cdp.executeBatch(actions);
|
|
124
|
+
// If any action was a screenshot, include it as image content
|
|
125
|
+
const content = [];
|
|
126
|
+
const summary = [];
|
|
127
|
+
for (const r of results) {
|
|
128
|
+
if (r.ok && r.action === 'screenshot' && r.result?.data) {
|
|
129
|
+
content.push({ type: 'image', data: r.result.data, mimeType: 'image/png' });
|
|
130
|
+
summary.push(`screenshot (dpr: ${r.result.dpr})`);
|
|
131
|
+
}
|
|
132
|
+
else if (r.ok) {
|
|
133
|
+
summary.push(`${r.action}: ${typeof r.result === 'object' ? JSON.stringify(r.result) : r.result}`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
summary.push(`${r.action}: FAILED - ${r.error}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
content.push({ type: 'text', text: `Batch: ${results.length}/${actions.length} actions\n${summary.join('\n')}` });
|
|
140
|
+
return { content };
|
|
141
|
+
});
|
|
142
|
+
// ── npc_extract_text ────────────────────────────────────────────────────────
|
|
143
|
+
server.tool('npc_extract_text', 'Get full text content from the real Chrome browser page', {}, async () => {
|
|
144
|
+
const cdp = getCDP();
|
|
145
|
+
const text = await cdp.extractText();
|
|
146
|
+
return { content: [{ type: 'text', text }] };
|
|
147
|
+
});
|
|
148
|
+
// ── npc_extract_html ────────────────────────────────────────────────────────
|
|
149
|
+
server.tool('npc_extract_html', 'Get full HTML from the real Chrome browser page', {}, async () => {
|
|
150
|
+
const cdp = getCDP();
|
|
151
|
+
const html = await cdp.extractHTML();
|
|
152
|
+
return { content: [{ type: 'text', text: html }] };
|
|
153
|
+
});
|
|
154
|
+
// ── npc_evaluate ────────────────────────────────────────────────────────────
|
|
155
|
+
server.tool('npc_evaluate', 'Run JavaScript in the real Chrome browser page context and return the result', { expression: z.string().describe('JavaScript expression to evaluate') }, async ({ expression }) => {
|
|
156
|
+
const cdp = getCDP();
|
|
157
|
+
const result = await cdp.evaluate(expression);
|
|
158
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
159
|
+
});
|
|
160
|
+
// ── npc_current_url ─────────────────────────────────────────────────────────
|
|
161
|
+
server.tool('npc_current_url', 'Get the URL of the current tab in the real Chrome browser', {}, async () => {
|
|
162
|
+
const cdp = getCDP();
|
|
163
|
+
const url = await cdp.currentUrl();
|
|
164
|
+
return { content: [{ type: 'text', text: url }] };
|
|
165
|
+
});
|
|
166
|
+
// ── npc_page_title ──────────────────────────────────────────────────────────
|
|
167
|
+
server.tool('npc_page_title', 'Get the title of the current tab in the real Chrome browser', {}, async () => {
|
|
168
|
+
const cdp = getCDP();
|
|
169
|
+
const title = await cdp.pageTitle();
|
|
170
|
+
return { content: [{ type: 'text', text: title }] };
|
|
171
|
+
});
|
|
172
|
+
return server;
|
|
173
|
+
}
|