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 +21 -0
- package/README.md +116 -0
- package/package.json +44 -0
- package/src/daemon-client.js +281 -0
- package/src/daemon.js +312 -0
- package/src/import-bundle-schema.js +221 -0
- package/src/index.js +113 -0
- package/src/mcp-server.js +674 -0
- package/src/process-manager.js +36 -0
- package/src/protocol.js +151 -0
- package/src/websocket-bridge.js +228 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
function createDefaultLogger() {
|
|
5
|
+
return {
|
|
6
|
+
info: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
7
|
+
warn: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
8
|
+
error: message => console.error(`[interceptpilot-mcp] ${message}`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getDefaultDaemonEntryPath() {
|
|
13
|
+
return fileURLToPath(new URL('./index.js', import.meta.url))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function startDaemonProcess(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
port,
|
|
19
|
+
key = '',
|
|
20
|
+
nodeExecPath = process.execPath,
|
|
21
|
+
entryPath = getDefaultDaemonEntryPath(),
|
|
22
|
+
spawnFn = spawn,
|
|
23
|
+
logger = createDefaultLogger()
|
|
24
|
+
} = options
|
|
25
|
+
|
|
26
|
+
const args = [entryPath, 'daemon', '--port', String(port), '--key', String(key || '')]
|
|
27
|
+
const child = spawnFn(nodeExecPath, args, {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: 'ignore',
|
|
30
|
+
windowsHide: true
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (typeof child?.unref === 'function') child.unref()
|
|
34
|
+
logger.info('Started InterceptPilot daemon process.')
|
|
35
|
+
return child
|
|
36
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export const BRIDGE_HOST = '127.0.0.1'
|
|
2
|
+
export const BRIDGE_PORT = 37177
|
|
3
|
+
export const BRIDGE_PROTOCOL_VERSION = 1
|
|
4
|
+
export const BRIDGE_SOURCE = 'interceptpilot-extension'
|
|
5
|
+
export const MCP_CLIENT_SOURCE = 'interceptpilot-mcp-client'
|
|
6
|
+
|
|
7
|
+
export const MESSAGE_TYPES = {
|
|
8
|
+
hello: 'hello',
|
|
9
|
+
helloAck: 'hello:ack',
|
|
10
|
+
command: 'command',
|
|
11
|
+
commandResult: 'command:result',
|
|
12
|
+
commandError: 'command:error',
|
|
13
|
+
clientHello: 'client:hello',
|
|
14
|
+
clientHelloAck: 'client:hello:ack',
|
|
15
|
+
clientCommand: 'client:command',
|
|
16
|
+
clientResult: 'client:result',
|
|
17
|
+
clientError: 'client:error',
|
|
18
|
+
ping: 'ping',
|
|
19
|
+
pong: 'pong'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ALLOWED_COMMANDS = Object.freeze([
|
|
23
|
+
'list_captured_requests',
|
|
24
|
+
'search_captured_requests',
|
|
25
|
+
'list_collections',
|
|
26
|
+
'list_rules',
|
|
27
|
+
'search_rules',
|
|
28
|
+
'list_sanitized_logs',
|
|
29
|
+
'search_sanitized_logs',
|
|
30
|
+
'list_recent_rule_results',
|
|
31
|
+
'search_rule_results',
|
|
32
|
+
'get_active_collection',
|
|
33
|
+
'get_current_test_context',
|
|
34
|
+
'get_import_bundle_schema',
|
|
35
|
+
'get_import_bundle_examples',
|
|
36
|
+
'explain_rule_match',
|
|
37
|
+
'import_bundle',
|
|
38
|
+
'request_enable_rule',
|
|
39
|
+
'request_disable_rule',
|
|
40
|
+
'request_set_only_active_rule',
|
|
41
|
+
'request_set_active_collection',
|
|
42
|
+
'request_update_rule',
|
|
43
|
+
'request_update_collection',
|
|
44
|
+
'request_delete_rule',
|
|
45
|
+
'request_delete_collection',
|
|
46
|
+
'request_reload_captured_tab',
|
|
47
|
+
'request_start_capture',
|
|
48
|
+
'request_restart_capture',
|
|
49
|
+
'request_stop_capture',
|
|
50
|
+
'request_capture_current_tab',
|
|
51
|
+
'request_set_capture_mode'
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const ALLOWED_COMMAND_SET = new Set(ALLOWED_COMMANDS)
|
|
55
|
+
|
|
56
|
+
export function isAllowedCommand(command) {
|
|
57
|
+
return ALLOWED_COMMAND_SET.has(String(command || ''))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createHelloAckMessage() {
|
|
61
|
+
return {
|
|
62
|
+
type: MESSAGE_TYPES.helloAck,
|
|
63
|
+
ok: true,
|
|
64
|
+
version: BRIDGE_PROTOCOL_VERSION
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createClientHelloAckMessage() {
|
|
69
|
+
return {
|
|
70
|
+
type: MESSAGE_TYPES.clientHelloAck,
|
|
71
|
+
ok: true,
|
|
72
|
+
version: BRIDGE_PROTOCOL_VERSION
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createCommandMessage(id, command, payload = {}) {
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
type: MESSAGE_TYPES.command,
|
|
80
|
+
command,
|
|
81
|
+
payload
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createPingMessage() {
|
|
86
|
+
return { type: MESSAGE_TYPES.ping }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseBridgeMessage(raw) {
|
|
90
|
+
try {
|
|
91
|
+
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? '')
|
|
92
|
+
const message = JSON.parse(text)
|
|
93
|
+
return message && typeof message === 'object' ? message : null
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function validateHelloMessage(message = {}, options = {}) {
|
|
100
|
+
const key = String(message.key || '')
|
|
101
|
+
const expectedKey = String(options.expectedKey || '')
|
|
102
|
+
const ok = message.type === MESSAGE_TYPES.hello
|
|
103
|
+
&& message.source === BRIDGE_SOURCE
|
|
104
|
+
&& Number(message.version) === BRIDGE_PROTOCOL_VERSION
|
|
105
|
+
&& Boolean(key)
|
|
106
|
+
&& (!expectedKey || key === expectedKey)
|
|
107
|
+
|
|
108
|
+
if (!ok) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: 'Invalid InterceptPilot hello message.'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: true }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function validateClientHelloMessage(message = {}, options = {}) {
|
|
119
|
+
const key = String(message.key || '')
|
|
120
|
+
const expectedKey = String(options.expectedKey || '')
|
|
121
|
+
const ok = message.type === MESSAGE_TYPES.clientHello
|
|
122
|
+
&& message.source === MCP_CLIENT_SOURCE
|
|
123
|
+
&& Number(message.version) === BRIDGE_PROTOCOL_VERSION
|
|
124
|
+
&& Boolean(key)
|
|
125
|
+
&& (!expectedKey || key === expectedKey)
|
|
126
|
+
|
|
127
|
+
if (!ok) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: 'Invalid InterceptPilot client hello message.'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { ok: true }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isCommandResponse(message = {}) {
|
|
138
|
+
return Boolean(
|
|
139
|
+
message
|
|
140
|
+
&& typeof message.id === 'string'
|
|
141
|
+
&& [MESSAGE_TYPES.commandResult, MESSAGE_TYPES.commandError].includes(message.type)
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isClientResponse(message = {}) {
|
|
146
|
+
return Boolean(
|
|
147
|
+
message
|
|
148
|
+
&& typeof message.id === 'string'
|
|
149
|
+
&& [MESSAGE_TYPES.clientResult, MESSAGE_TYPES.clientError].includes(message.type)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_HOST,
|
|
5
|
+
BRIDGE_PORT,
|
|
6
|
+
MESSAGE_TYPES,
|
|
7
|
+
createCommandMessage,
|
|
8
|
+
createHelloAckMessage,
|
|
9
|
+
createPingMessage,
|
|
10
|
+
isAllowedCommand,
|
|
11
|
+
isCommandResponse,
|
|
12
|
+
parseBridgeMessage,
|
|
13
|
+
validateHelloMessage
|
|
14
|
+
} from './protocol.js'
|
|
15
|
+
|
|
16
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
|
|
17
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 20000
|
|
18
|
+
const DEFAULT_MAX_PENDING_REQUESTS = 25
|
|
19
|
+
const NOT_CONNECTED_MESSAGE = 'InterceptPilot extension is not connected. Open InterceptPilot settings and start an AI Bridge session.'
|
|
20
|
+
const TIMEOUT_MESSAGE = 'InterceptPilot did not respond in time.'
|
|
21
|
+
const DISCONNECTED_MESSAGE = 'InterceptPilot extension disconnected.'
|
|
22
|
+
const COMMAND_NOT_ALLOWED_MESSAGE = 'Command is not allowed.'
|
|
23
|
+
const TOO_MANY_PENDING_MESSAGE = 'Too many pending InterceptPilot requests.'
|
|
24
|
+
|
|
25
|
+
function createDefaultLogger() {
|
|
26
|
+
return {
|
|
27
|
+
info: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
28
|
+
warn: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
29
|
+
error: message => console.error(`[interceptpilot-mcp] ${message}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addSocketListener(socket, event, handler) {
|
|
34
|
+
if (typeof socket.on === 'function') {
|
|
35
|
+
socket.on(event, handler)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
socket[`on${event}`] = handler
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sendJson(socket, message) {
|
|
42
|
+
socket.send(JSON.stringify(message))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createSafeError(message) {
|
|
46
|
+
return new Error(message)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createExtensionBridge(options = {}) {
|
|
50
|
+
const {
|
|
51
|
+
WebSocketServerCtor = WebSocketServer,
|
|
52
|
+
port = BRIDGE_PORT,
|
|
53
|
+
expectedKey = '',
|
|
54
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
55
|
+
maxPendingRequests = DEFAULT_MAX_PENDING_REQUESTS,
|
|
56
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
57
|
+
idGenerator,
|
|
58
|
+
logger = createDefaultLogger(),
|
|
59
|
+
setTimeoutFn = setTimeout,
|
|
60
|
+
clearTimeoutFn = clearTimeout,
|
|
61
|
+
setIntervalFn = setInterval,
|
|
62
|
+
clearIntervalFn = clearInterval
|
|
63
|
+
} = options
|
|
64
|
+
|
|
65
|
+
const expectedConnectionKey = String(expectedKey || '')
|
|
66
|
+
|
|
67
|
+
let server = null
|
|
68
|
+
let activeSocket = null
|
|
69
|
+
let connected = false
|
|
70
|
+
let requestSeq = 0
|
|
71
|
+
let heartbeatTimer = null
|
|
72
|
+
const pending = new Map()
|
|
73
|
+
const host = BRIDGE_HOST
|
|
74
|
+
|
|
75
|
+
function nextRequestId() {
|
|
76
|
+
if (idGenerator) return idGenerator()
|
|
77
|
+
requestSeq += 1
|
|
78
|
+
return `req_${requestSeq}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isConnected() {
|
|
82
|
+
return Boolean(activeSocket && connected)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rejectPending(message) {
|
|
86
|
+
for (const [, request] of pending) {
|
|
87
|
+
clearTimeoutFn(request.timer)
|
|
88
|
+
request.reject(createSafeError(message))
|
|
89
|
+
}
|
|
90
|
+
pending.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startHeartbeat() {
|
|
94
|
+
if (heartbeatTimer) return
|
|
95
|
+
heartbeatTimer = setIntervalFn(() => {
|
|
96
|
+
if (activeSocket && connected) sendJson(activeSocket, createPingMessage())
|
|
97
|
+
}, heartbeatIntervalMs)
|
|
98
|
+
heartbeatTimer?.unref?.()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopHeartbeat() {
|
|
102
|
+
if (!heartbeatTimer) return
|
|
103
|
+
clearIntervalFn(heartbeatTimer)
|
|
104
|
+
heartbeatTimer = null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function detachSocket(socket) {
|
|
108
|
+
if (socket !== activeSocket) return
|
|
109
|
+
activeSocket = null
|
|
110
|
+
connected = false
|
|
111
|
+
stopHeartbeat()
|
|
112
|
+
rejectPending(DISCONNECTED_MESSAGE)
|
|
113
|
+
logger.info('Extension disconnected.')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleResponse(message) {
|
|
117
|
+
if (!isCommandResponse(message)) return
|
|
118
|
+
const request = pending.get(message.id)
|
|
119
|
+
if (!request) return
|
|
120
|
+
pending.delete(message.id)
|
|
121
|
+
clearTimeoutFn(request.timer)
|
|
122
|
+
|
|
123
|
+
if (message.type === 'command:error' || message.ok === false) {
|
|
124
|
+
request.reject(createSafeError(String(message.error?.message || 'InterceptPilot command failed.')))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
request.resolve(message.result)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function acceptHello(socket, message) {
|
|
132
|
+
const validation = validateHelloMessage(message, { expectedKey: expectedConnectionKey })
|
|
133
|
+
if (!validation.ok) {
|
|
134
|
+
logger.warn(validation.error)
|
|
135
|
+
socket.close()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (activeSocket && activeSocket !== socket) {
|
|
140
|
+
const previousSocket = activeSocket
|
|
141
|
+
rejectPending(DISCONNECTED_MESSAGE)
|
|
142
|
+
previousSocket.close()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
activeSocket = socket
|
|
146
|
+
connected = true
|
|
147
|
+
sendJson(socket, createHelloAckMessage())
|
|
148
|
+
startHeartbeat()
|
|
149
|
+
logger.info('Extension connected.')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleSocketMessage(socket, raw) {
|
|
153
|
+
const message = parseBridgeMessage(raw)
|
|
154
|
+
if (!message) {
|
|
155
|
+
logger.warn('Ignoring invalid message from extension.')
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!connected || socket !== activeSocket) {
|
|
160
|
+
if (message.type === 'hello') acceptHello(socket, message)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (message.type === MESSAGE_TYPES.pong) return
|
|
165
|
+
|
|
166
|
+
handleResponse(message)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleSocket(socket) {
|
|
170
|
+
addSocketListener(socket, 'message', raw => handleSocketMessage(socket, raw))
|
|
171
|
+
addSocketListener(socket, 'close', () => detachSocket(socket))
|
|
172
|
+
addSocketListener(socket, 'error', () => detachSocket(socket))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function start() {
|
|
176
|
+
if (server) return server
|
|
177
|
+
server = new WebSocketServerCtor({ host, port })
|
|
178
|
+
server.on('connection', handleSocket)
|
|
179
|
+
server.on('listening', () => logger.info(`WebSocket bridge listening on ${host}:${port}.`))
|
|
180
|
+
server.on('error', () => logger.error('WebSocket bridge error.'))
|
|
181
|
+
return server
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function close() {
|
|
185
|
+
stopHeartbeat()
|
|
186
|
+
rejectPending(DISCONNECTED_MESSAGE)
|
|
187
|
+
if (activeSocket) activeSocket.close()
|
|
188
|
+
activeSocket = null
|
|
189
|
+
connected = false
|
|
190
|
+
if (server && typeof server.close === 'function') {
|
|
191
|
+
await new Promise(resolve => server.close(resolve))
|
|
192
|
+
}
|
|
193
|
+
server = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function callCommand(command, payload = {}) {
|
|
197
|
+
if (!isAllowedCommand(command)) {
|
|
198
|
+
return Promise.reject(createSafeError(COMMAND_NOT_ALLOWED_MESSAGE))
|
|
199
|
+
}
|
|
200
|
+
if (!isConnected()) {
|
|
201
|
+
return Promise.reject(createSafeError(NOT_CONNECTED_MESSAGE))
|
|
202
|
+
}
|
|
203
|
+
if (pending.size >= maxPendingRequests) {
|
|
204
|
+
return Promise.reject(createSafeError(TOO_MANY_PENDING_MESSAGE))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const id = nextRequestId()
|
|
208
|
+
const message = createCommandMessage(id, command, payload && typeof payload === 'object' ? payload : {})
|
|
209
|
+
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
const timer = setTimeoutFn(() => {
|
|
212
|
+
pending.delete(id)
|
|
213
|
+
reject(createSafeError(TIMEOUT_MESSAGE))
|
|
214
|
+
}, requestTimeoutMs)
|
|
215
|
+
pending.set(id, { resolve, reject, timer })
|
|
216
|
+
sendJson(activeSocket, message)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
start,
|
|
222
|
+
close,
|
|
223
|
+
handleSocket,
|
|
224
|
+
callCommand,
|
|
225
|
+
isConnected,
|
|
226
|
+
getPendingCount: () => pending.size
|
|
227
|
+
}
|
|
228
|
+
}
|