interceptpilot-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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anderson Alpin
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,116 @@
1
+ # interceptpilot-mcp
2
+
3
+ Local [MCP](https://modelcontextprotocol.io) server for the InterceptPilot Chrome extension. It lets AI agents (Claude Code, Codex, Cursor and any other MCP client) read the extension's sanitized test context, inspect captured requests, rules and logs, and propose or run actions such as enabling a mock, importing a collection or capturing a tab.
4
+
5
+ Everything stays on your machine: the server talks to the extension over a local WebSocket on `127.0.0.1`, protected by a session key that the extension generates.
6
+
7
+ ## Setup
8
+
9
+ 1. Open the InterceptPilot Full App, go to **AI Bridge** and click **Start**.
10
+ 2. Click **Copy configuration**. You get something like:
11
+
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "interceptpilot": {
16
+ "command": "npx",
17
+ "args": ["-y", "interceptpilot-mcp", "--port", "37177", "--key", "<SESSION_KEY>"]
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ 3. Add it to your MCP client and restart the client:
24
+ - **Claude Code**: `claude mcp add-json interceptpilot '<the "interceptpilot" object>'`, or paste it into `~/.claude.json`.
25
+ - **Cursor**: `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global).
26
+ - **Claude Desktop**: `claude_desktop_config.json`.
27
+ - **Codex**: `~/.codex/config.toml`, as `[mcp_servers.interceptpilot]` with the same `command` and `args`.
28
+ 4. Back in the extension, the AI Bridge status changes to **Connected**.
29
+
30
+ The key is per session. Regenerating it in the extension invalidates the configuration you copied before. Never paste a real key into issues, logs or documentation.
31
+
32
+ ## How it works
33
+
34
+ ```txt
35
+ AI client
36
+ -> MCP adapter (this package, stdio)
37
+ -> shared local daemon (one per port)
38
+ -> WebSocket connection from the extension
39
+ -> InterceptPilot
40
+ ```
41
+
42
+ The MCP client starts the adapter. The adapter starts the local daemon when none is listening on the configured port; several adapters (several chats or clients) share one daemon and one key. If the extension disconnects, the daemon stays up and the tools return a safe error until the extension reconnects.
43
+
44
+ ## Options
45
+
46
+ | Option | Meaning |
47
+ | --- | --- |
48
+ | `--key <KEY>` | Session key shown by the AI Bridge page. Required. |
49
+ | `--port <PORT>` | Local daemon port. Default `37177`. |
50
+ | `--no-daemon-autostart` | Do not start the daemon automatically (debugging). |
51
+ | `daemon` | Run only the daemon: `npx -y interceptpilot-mcp daemon --port <PORT> --key <KEY>`. |
52
+
53
+ ## Tools
54
+
55
+ What each tool exposes depends on the permissions you set in the extension. By default query-string values, headers, bodies, cookies, `Authorization` values and tab titles are never sent, and every action that changes state opens a proposal you confirm in the Full App. Session permissions let you skip the confirmation for specific groups of actions.
56
+
57
+ ### Reading
58
+
59
+ | Tool | Use it to |
60
+ | --- | --- |
61
+ | `get_current_test_context` | See capture status and mode, the active tab origin and path, the active collection, rule counts and the AI Bridge status. |
62
+ | `list_captured_requests` | List the requests captured in the active context. |
63
+ | `search_captured_requests` | Search captured requests with safe filters and pagination. |
64
+ | `list_collections` | List collections with rule counts. |
65
+ | `get_active_collection` | Get the active collection. |
66
+ | `list_rules` | List rules with sanitized match and action summaries. |
67
+ | `search_rules` | Search rules by metadata, match, action and diagnostics. |
68
+ | `list_sanitized_logs` | Read recent logs from the active context. |
69
+ | `search_sanitized_logs` | Search logs with safe filters and pagination. |
70
+ | `list_recent_rule_results` | See which rules matched recent requests and what was applied. |
71
+ | `search_rule_results` | Search rule results with safe filters and pagination. |
72
+ | `explain_rule_match` | Compare a rule with a captured request and learn why it matched or not. |
73
+ | `get_import_bundle_schema` | Get the import bundle schema. Works without the extension connected. |
74
+ | `get_import_bundle_examples` | Get safe example bundles. Works without the extension connected. |
75
+
76
+ ### Actions
77
+
78
+ | Tool | Use it to | Skips confirmation when |
79
+ | --- | --- | --- |
80
+ | `import_bundle` | Propose a rule or collection bundle. | "Apply imports without confirmation" is on. |
81
+ | `request_enable_rule`, `request_disable_rule`, `request_set_only_active_rule`, `request_set_active_collection` | Toggle rules or switch the active collection. | "Rule and collection actions without confirmation" is on. |
82
+ | `request_update_rule`, `request_update_collection` | Edit safe fields of a rule or collection, with a before/after diff. `responseBody` and `responseHeaders` cannot be edited. | "Rule and collection actions without confirmation" is on. |
83
+ | `request_delete_rule`, `request_delete_collection` | Delete a rule or collection. | "Delete rules and collections without confirmation" is on. |
84
+ | `request_reload_captured_tab` | Reload only the captured tab to validate a mock. Never reloads other or internal pages. | "Reload captured tab without confirmation" is on. |
85
+ | `request_start_capture`, `request_restart_capture`, `request_stop_capture`, `request_capture_current_tab`, `request_set_capture_mode` | Control capture on the eligible web tab; modes are `auto`, `full` and `light`. | "Capture control without confirmation" is on. |
86
+
87
+ ## Security
88
+
89
+ - Local only: the daemon listens on `127.0.0.1`.
90
+ - The session key is required from both the extension and every adapter, and is never echoed in logs, errors or tool results.
91
+ - Sanitized by default; sensitive data is only sent when the matching permission is on.
92
+ - State changes are proposals confirmed by you unless you enable the matching session permission.
93
+ - The daemon accepts an allowlist of commands. There are no tools to clear logs, drive the debugger or reload arbitrary pages.
94
+
95
+ ## Troubleshooting
96
+
97
+ **Extension not connected.** Open the Full App, go to AI Bridge and click Start. Tools return a safe error until the extension connects.
98
+
99
+ **Daemon did not start.** Run it by hand to see the error: `npx -y interceptpilot-mcp daemon --port <PORT> --key <KEY>`.
100
+
101
+ **Port in use.** A healthy daemon on the port is reused by other adapters. If an old process is stuck, stop it or change the port in both the AI Bridge page and the client configuration.
102
+
103
+ **Invalid or regenerated key.** Copy the configuration again from the AI Bridge page.
104
+
105
+ **Client using an old configuration.** Update the client configuration and restart the client. Permission changes happen in the extension and do not require a new key.
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ npm install
111
+ npm test
112
+ ```
113
+
114
+ ## License
115
+
116
+ [MIT](./LICENSE)
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "interceptpilot-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP server for InterceptPilot",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "interceptpilot-mcp": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "model-context-protocol",
21
+ "interceptpilot",
22
+ "chrome-extension",
23
+ "api-mock",
24
+ "network-interception"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/AndersonAlpin/interceptpilot-mcp.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/AndersonAlpin/interceptpilot-mcp/issues"
32
+ },
33
+ "homepage": "https://github.com/AndersonAlpin/interceptpilot-mcp#readme",
34
+ "scripts": {
35
+ "start": "node src/index.js",
36
+ "check": "node --check src/index.js src/import-bundle-schema.js src/mcp-server.js src/protocol.js src/websocket-bridge.js src/daemon.js src/daemon-client.js src/process-manager.js",
37
+ "test": "node --test tests/*.test.js"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "1.30.0",
41
+ "ws": "8.21.0",
42
+ "zod": "4.4.3"
43
+ }
44
+ }
@@ -0,0 +1,281 @@
1
+ import WebSocket from 'ws'
2
+
3
+ import {
4
+ BRIDGE_HOST,
5
+ BRIDGE_PORT,
6
+ BRIDGE_PROTOCOL_VERSION,
7
+ MCP_CLIENT_SOURCE,
8
+ MESSAGE_TYPES,
9
+ isAllowedCommand,
10
+ isClientResponse,
11
+ parseBridgeMessage
12
+ } from './protocol.js'
13
+ import { startDaemonProcess as defaultStartDaemonProcess } from './process-manager.js'
14
+
15
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000
16
+ const DEFAULT_RETRY_DELAY_MS = 100
17
+ const DEFAULT_MAX_PENDING_REQUESTS = 25
18
+ const DEFAULT_MAX_CONNECT_ATTEMPTS = 30
19
+ const TIMEOUT_MESSAGE = 'InterceptPilot did not respond in time.'
20
+ const DISCONNECTED_MESSAGE = 'InterceptPilot daemon disconnected.'
21
+ const COMMAND_NOT_ALLOWED_MESSAGE = 'Command is not allowed.'
22
+ const TOO_MANY_PENDING_MESSAGE = 'Too many pending InterceptPilot requests.'
23
+ const DAEMON_NOT_RUNNING_MESSAGE = 'InterceptPilot daemon is not running. Start AI Bridge or run without --no-daemon-autostart.'
24
+ const DAEMON_START_FAILED_MESSAGE = 'Unable to start InterceptPilot daemon.'
25
+
26
+ function createDefaultLogger() {
27
+ return {
28
+ info: message => console.error(`[interceptpilot-mcp] ${message}`),
29
+ warn: message => console.error(`[interceptpilot-mcp] ${message}`),
30
+ error: message => console.error(`[interceptpilot-mcp] ${message}`)
31
+ }
32
+ }
33
+
34
+ function addSocketListener(socket, event, handler) {
35
+ if (typeof socket.on === 'function') {
36
+ socket.on(event, handler)
37
+ return
38
+ }
39
+ socket[`on${event}`] = handler
40
+ }
41
+
42
+ function sendJson(socket, message) {
43
+ socket.send(JSON.stringify(message))
44
+ }
45
+
46
+ function createSafeError(message) {
47
+ return new Error(message)
48
+ }
49
+
50
+ function getMessageData(raw) {
51
+ if (raw && typeof raw === 'object' && 'data' in raw) return raw.data
52
+ return raw
53
+ }
54
+
55
+ export function createDaemonClientBridge(options = {}) {
56
+ const {
57
+ WebSocketCtor = WebSocket,
58
+ host = BRIDGE_HOST,
59
+ port = BRIDGE_PORT,
60
+ expectedKey = '',
61
+ daemonAutostart = true,
62
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
63
+ maxPendingRequests = DEFAULT_MAX_PENDING_REQUESTS,
64
+ maxConnectAttempts = DEFAULT_MAX_CONNECT_ATTEMPTS,
65
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
66
+ idGenerator,
67
+ clientId = 'interceptpilot-mcp',
68
+ logger = createDefaultLogger(),
69
+ startDaemonProcess = defaultStartDaemonProcess,
70
+ setTimeoutFn = setTimeout,
71
+ clearTimeoutFn = clearTimeout
72
+ } = options
73
+
74
+ const connectionKey = String(expectedKey || '')
75
+ const pending = new Map()
76
+ let socket = null
77
+ let connected = false
78
+ let requestSeq = 0
79
+ let startPromise = null
80
+
81
+ function nextRequestId() {
82
+ if (idGenerator) return idGenerator()
83
+ requestSeq += 1
84
+ return `client_req_${requestSeq}`
85
+ }
86
+
87
+ function isConnected() {
88
+ return Boolean(socket && connected)
89
+ }
90
+
91
+ function rejectPending(message) {
92
+ for (const [, request] of pending) {
93
+ clearTimeoutFn(request.timer)
94
+ request.reject(createSafeError(message))
95
+ }
96
+ pending.clear()
97
+ }
98
+
99
+ function handleResponse(message) {
100
+ if (!isClientResponse(message)) return
101
+ const request = pending.get(message.id)
102
+ if (!request) return
103
+ pending.delete(message.id)
104
+ clearTimeoutFn(request.timer)
105
+
106
+ if (message.type === MESSAGE_TYPES.clientError || message.ok === false) {
107
+ request.reject(createSafeError(String(message.error?.message || 'InterceptPilot command failed.')))
108
+ return
109
+ }
110
+
111
+ request.resolve(message.result)
112
+ }
113
+
114
+ function handleSocketClose(activeSocket) {
115
+ if (socket !== activeSocket) return
116
+ connected = false
117
+ socket = null
118
+ startPromise = null
119
+ rejectPending(DISCONNECTED_MESSAGE)
120
+ }
121
+
122
+ function schedule(fn) {
123
+ if (retryDelayMs <= 0) {
124
+ fn()
125
+ return
126
+ }
127
+ const timer = setTimeoutFn(fn, retryDelayMs)
128
+ timer?.unref?.()
129
+ }
130
+
131
+ function connectAttempt(resolve, reject, attempt = 1, autostartTried = false) {
132
+ let settled = false
133
+ const activeSocket = new WebSocketCtor(`ws://${host}:${port}`)
134
+ socket = activeSocket
135
+
136
+ function failStart(message) {
137
+ settled = true
138
+ socket = null
139
+ connected = false
140
+ startPromise = null
141
+ reject(createSafeError(message))
142
+ }
143
+
144
+ function retryAfterAutostart() {
145
+ schedule(() => connectAttempt(resolve, reject, attempt + 1, true))
146
+ }
147
+
148
+ addSocketListener(activeSocket, 'open', () => {
149
+ if (settled) return
150
+ sendJson(activeSocket, {
151
+ type: MESSAGE_TYPES.clientHello,
152
+ source: MCP_CLIENT_SOURCE,
153
+ key: connectionKey,
154
+ version: BRIDGE_PROTOCOL_VERSION,
155
+ clientId
156
+ })
157
+ })
158
+
159
+ addSocketListener(activeSocket, 'message', raw => {
160
+ const message = parseBridgeMessage(getMessageData(raw))
161
+ if (!message) return
162
+
163
+ if (message.type === MESSAGE_TYPES.clientHelloAck && message.ok === true && Number(message.version) === BRIDGE_PROTOCOL_VERSION) {
164
+ settled = true
165
+ connected = true
166
+ resolve()
167
+ return
168
+ }
169
+
170
+ handleResponse(message)
171
+ })
172
+
173
+ addSocketListener(activeSocket, 'error', () => {
174
+ if (connected || settled) return
175
+ settled = true
176
+ socket = null
177
+
178
+ if (!daemonAutostart) {
179
+ failStart(DAEMON_NOT_RUNNING_MESSAGE)
180
+ return
181
+ }
182
+
183
+ if (!autostartTried) {
184
+ try {
185
+ startDaemonProcess({ port, key: connectionKey, logger })
186
+ } catch {
187
+ failStart(DAEMON_START_FAILED_MESSAGE)
188
+ return
189
+ }
190
+ retryAfterAutostart()
191
+ return
192
+ }
193
+
194
+ if (attempt < maxConnectAttempts) {
195
+ retryAfterAutostart()
196
+ return
197
+ }
198
+
199
+ failStart(DAEMON_START_FAILED_MESSAGE)
200
+ })
201
+
202
+ addSocketListener(activeSocket, 'close', () => {
203
+ if (!connected && !settled) {
204
+ settled = true
205
+ socket = null
206
+ if (!daemonAutostart) {
207
+ failStart(DAEMON_NOT_RUNNING_MESSAGE)
208
+ return
209
+ }
210
+ if (!autostartTried) {
211
+ try {
212
+ startDaemonProcess({ port, key: connectionKey, logger })
213
+ } catch {
214
+ failStart(DAEMON_START_FAILED_MESSAGE)
215
+ return
216
+ }
217
+ retryAfterAutostart()
218
+ return
219
+ }
220
+ failStart(DAEMON_START_FAILED_MESSAGE)
221
+ return
222
+ }
223
+ handleSocketClose(activeSocket)
224
+ })
225
+ }
226
+
227
+ function start() {
228
+ if (isConnected()) return Promise.resolve()
229
+ if (startPromise) return startPromise
230
+ startPromise = new Promise((resolve, reject) => {
231
+ connectAttempt(resolve, reject)
232
+ })
233
+ return startPromise
234
+ }
235
+
236
+ async function close() {
237
+ rejectPending(DISCONNECTED_MESSAGE)
238
+ connected = false
239
+ startPromise = null
240
+ if (socket && typeof socket.close === 'function') socket.close()
241
+ socket = null
242
+ }
243
+
244
+ function callCommand(command, payload = {}) {
245
+ if (!isAllowedCommand(command)) {
246
+ return Promise.reject(createSafeError(COMMAND_NOT_ALLOWED_MESSAGE))
247
+ }
248
+ if (!isConnected()) {
249
+ return Promise.reject(createSafeError(DAEMON_NOT_RUNNING_MESSAGE))
250
+ }
251
+ if (pending.size >= maxPendingRequests) {
252
+ return Promise.reject(createSafeError(TOO_MANY_PENDING_MESSAGE))
253
+ }
254
+
255
+ const id = nextRequestId()
256
+ const message = {
257
+ id,
258
+ type: MESSAGE_TYPES.clientCommand,
259
+ command,
260
+ payload: payload && typeof payload === 'object' ? payload : {}
261
+ }
262
+
263
+ return new Promise((resolve, reject) => {
264
+ const timer = setTimeoutFn(() => {
265
+ pending.delete(id)
266
+ reject(createSafeError(TIMEOUT_MESSAGE))
267
+ }, requestTimeoutMs)
268
+ timer?.unref?.()
269
+ pending.set(id, { resolve, reject, timer })
270
+ sendJson(socket, message)
271
+ })
272
+ }
273
+
274
+ return {
275
+ start,
276
+ close,
277
+ callCommand,
278
+ isConnected,
279
+ getPendingCount: () => pending.size
280
+ }
281
+ }