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
package/src/daemon.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_HOST,
|
|
5
|
+
BRIDGE_PORT,
|
|
6
|
+
MESSAGE_TYPES,
|
|
7
|
+
createClientHelloAckMessage,
|
|
8
|
+
createCommandMessage,
|
|
9
|
+
createHelloAckMessage,
|
|
10
|
+
createPingMessage,
|
|
11
|
+
isAllowedCommand,
|
|
12
|
+
isCommandResponse,
|
|
13
|
+
parseBridgeMessage,
|
|
14
|
+
validateClientHelloMessage,
|
|
15
|
+
validateHelloMessage
|
|
16
|
+
} from './protocol.js'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
|
|
19
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 20000
|
|
20
|
+
const DEFAULT_MAX_PENDING_REQUESTS = 25
|
|
21
|
+
const NOT_CONNECTED_MESSAGE = 'InterceptPilot extension is not connected. Open InterceptPilot settings and start an AI Bridge session.'
|
|
22
|
+
const TIMEOUT_MESSAGE = 'InterceptPilot did not respond in time.'
|
|
23
|
+
const DISCONNECTED_MESSAGE = 'InterceptPilot extension disconnected.'
|
|
24
|
+
const COMMAND_NOT_ALLOWED_MESSAGE = 'Command is not allowed.'
|
|
25
|
+
const TOO_MANY_PENDING_MESSAGE = 'Too many pending InterceptPilot requests.'
|
|
26
|
+
|
|
27
|
+
function createDefaultLogger() {
|
|
28
|
+
return {
|
|
29
|
+
info: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
30
|
+
warn: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
31
|
+
error: message => console.error(`[interceptpilot-mcp] ${message}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function addSocketListener(socket, event, handler) {
|
|
36
|
+
if (typeof socket.on === 'function') {
|
|
37
|
+
socket.on(event, handler)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
socket[`on${event}`] = handler
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendJson(socket, message) {
|
|
44
|
+
if (!socket || socket.closed) return
|
|
45
|
+
socket.send(JSON.stringify(message))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createClientError(id, code, message) {
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
type: MESSAGE_TYPES.clientError,
|
|
52
|
+
ok: false,
|
|
53
|
+
error: { code, message }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createInterceptPilotDaemon(options = {}) {
|
|
58
|
+
const {
|
|
59
|
+
WebSocketServerCtor = WebSocketServer,
|
|
60
|
+
port = BRIDGE_PORT,
|
|
61
|
+
expectedKey = '',
|
|
62
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
63
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
64
|
+
maxPendingRequests = DEFAULT_MAX_PENDING_REQUESTS,
|
|
65
|
+
idGenerator,
|
|
66
|
+
logger = createDefaultLogger(),
|
|
67
|
+
setTimeoutFn = setTimeout,
|
|
68
|
+
clearTimeoutFn = clearTimeout,
|
|
69
|
+
setIntervalFn = setInterval,
|
|
70
|
+
clearIntervalFn = clearInterval
|
|
71
|
+
} = options
|
|
72
|
+
|
|
73
|
+
const expectedConnectionKey = String(expectedKey || '')
|
|
74
|
+
const clients = new Set()
|
|
75
|
+
const pending = new Map()
|
|
76
|
+
const host = BRIDGE_HOST
|
|
77
|
+
let server = null
|
|
78
|
+
let extensionSocket = null
|
|
79
|
+
let extensionConnected = false
|
|
80
|
+
let requestSeq = 0
|
|
81
|
+
let heartbeatTimer = null
|
|
82
|
+
|
|
83
|
+
function nextRequestId() {
|
|
84
|
+
if (idGenerator) return idGenerator()
|
|
85
|
+
requestSeq += 1
|
|
86
|
+
return `daemon_req_${requestSeq}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isExtensionConnected() {
|
|
90
|
+
return Boolean(extensionSocket && extensionConnected)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startHeartbeat() {
|
|
94
|
+
if (heartbeatTimer) return
|
|
95
|
+
heartbeatTimer = setIntervalFn(() => {
|
|
96
|
+
if (isExtensionConnected()) sendJson(extensionSocket, 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 rejectPendingForClient(clientSocket) {
|
|
108
|
+
for (const [extensionRequestId, request] of pending) {
|
|
109
|
+
if (request.clientSocket !== clientSocket) continue
|
|
110
|
+
clearTimeoutFn(request.timer)
|
|
111
|
+
pending.delete(extensionRequestId)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rejectAllPending(message) {
|
|
116
|
+
for (const [extensionRequestId, request] of pending) {
|
|
117
|
+
clearTimeoutFn(request.timer)
|
|
118
|
+
pending.delete(extensionRequestId)
|
|
119
|
+
sendJson(request.clientSocket, createClientError(
|
|
120
|
+
request.clientRequestId,
|
|
121
|
+
'EXTENSION_DISCONNECTED',
|
|
122
|
+
message
|
|
123
|
+
))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function detachExtension(socket) {
|
|
128
|
+
if (socket !== extensionSocket) return
|
|
129
|
+
extensionSocket = null
|
|
130
|
+
extensionConnected = false
|
|
131
|
+
stopHeartbeat()
|
|
132
|
+
rejectAllPending(DISCONNECTED_MESSAGE)
|
|
133
|
+
logger.info('Extension disconnected.')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function detachClient(socket) {
|
|
137
|
+
if (!clients.has(socket)) return
|
|
138
|
+
clients.delete(socket)
|
|
139
|
+
rejectPendingForClient(socket)
|
|
140
|
+
logger.info('MCP client disconnected.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function acceptExtension(socket, message) {
|
|
144
|
+
const validation = validateHelloMessage(message, { expectedKey: expectedConnectionKey })
|
|
145
|
+
if (!validation.ok) {
|
|
146
|
+
logger.warn(validation.error)
|
|
147
|
+
socket.close()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (extensionSocket && extensionSocket !== socket) {
|
|
152
|
+
const previousSocket = extensionSocket
|
|
153
|
+
detachExtension(previousSocket)
|
|
154
|
+
previousSocket.close()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
extensionSocket = socket
|
|
158
|
+
extensionConnected = true
|
|
159
|
+
sendJson(socket, createHelloAckMessage())
|
|
160
|
+
startHeartbeat()
|
|
161
|
+
logger.info('Extension connected.')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function acceptClient(socket, message) {
|
|
165
|
+
const validation = validateClientHelloMessage(message, { expectedKey: expectedConnectionKey })
|
|
166
|
+
if (!validation.ok) {
|
|
167
|
+
logger.warn(validation.error)
|
|
168
|
+
socket.close()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
clients.add(socket)
|
|
173
|
+
sendJson(socket, createClientHelloAckMessage())
|
|
174
|
+
logger.info('MCP client connected.')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sendClientError(socket, id, code, message) {
|
|
178
|
+
sendJson(socket, createClientError(id, code, message))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function handleExtensionResponse(message) {
|
|
182
|
+
if (!isCommandResponse(message)) return
|
|
183
|
+
const request = pending.get(message.id)
|
|
184
|
+
if (!request) return
|
|
185
|
+
pending.delete(message.id)
|
|
186
|
+
clearTimeoutFn(request.timer)
|
|
187
|
+
|
|
188
|
+
if (message.type === MESSAGE_TYPES.commandError || message.ok === false) {
|
|
189
|
+
sendClientError(
|
|
190
|
+
request.clientSocket,
|
|
191
|
+
request.clientRequestId,
|
|
192
|
+
String(message.error?.code || 'COMMAND_FAILED'),
|
|
193
|
+
String(message.error?.message || 'InterceptPilot command failed.')
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
sendJson(request.clientSocket, {
|
|
199
|
+
id: request.clientRequestId,
|
|
200
|
+
type: MESSAGE_TYPES.clientResult,
|
|
201
|
+
ok: true,
|
|
202
|
+
result: message.result
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleClientCommand(socket, message) {
|
|
207
|
+
const clientRequestId = String(message?.id || '')
|
|
208
|
+
const command = String(message?.command || '')
|
|
209
|
+
if (!clientRequestId) return
|
|
210
|
+
|
|
211
|
+
if (!isAllowedCommand(command)) {
|
|
212
|
+
sendClientError(socket, clientRequestId, 'COMMAND_NOT_ALLOWED', COMMAND_NOT_ALLOWED_MESSAGE)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!isExtensionConnected()) {
|
|
217
|
+
sendClientError(socket, clientRequestId, 'EXTENSION_NOT_CONNECTED', NOT_CONNECTED_MESSAGE)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (pending.size >= maxPendingRequests) {
|
|
222
|
+
sendClientError(socket, clientRequestId, 'TOO_MANY_PENDING_REQUESTS', TOO_MANY_PENDING_MESSAGE)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const extensionRequestId = nextRequestId()
|
|
227
|
+
const timer = setTimeoutFn(() => {
|
|
228
|
+
pending.delete(extensionRequestId)
|
|
229
|
+
sendClientError(socket, clientRequestId, 'COMMAND_TIMEOUT', TIMEOUT_MESSAGE)
|
|
230
|
+
}, requestTimeoutMs)
|
|
231
|
+
timer?.unref?.()
|
|
232
|
+
pending.set(extensionRequestId, { clientSocket: socket, clientRequestId, timer, command })
|
|
233
|
+
sendJson(extensionSocket, createCommandMessage(
|
|
234
|
+
extensionRequestId,
|
|
235
|
+
command,
|
|
236
|
+
message.payload && typeof message.payload === 'object' ? message.payload : {}
|
|
237
|
+
))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function handleSocketMessage(socket, raw) {
|
|
241
|
+
const message = parseBridgeMessage(raw)
|
|
242
|
+
if (!message) {
|
|
243
|
+
logger.warn('Ignoring invalid bridge message.')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (message.type === MESSAGE_TYPES.hello) {
|
|
248
|
+
acceptExtension(socket, message)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (message.type === MESSAGE_TYPES.clientHello) {
|
|
253
|
+
acceptClient(socket, message)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (socket === extensionSocket && extensionConnected) {
|
|
258
|
+
if (message.type === MESSAGE_TYPES.pong) return
|
|
259
|
+
handleExtensionResponse(message)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (clients.has(socket)) {
|
|
264
|
+
if (message.type === MESSAGE_TYPES.clientCommand) handleClientCommand(socket, message)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function handleSocket(socket) {
|
|
270
|
+
addSocketListener(socket, 'message', raw => handleSocketMessage(socket, raw))
|
|
271
|
+
addSocketListener(socket, 'close', () => {
|
|
272
|
+
detachExtension(socket)
|
|
273
|
+
detachClient(socket)
|
|
274
|
+
})
|
|
275
|
+
addSocketListener(socket, 'error', () => {
|
|
276
|
+
detachExtension(socket)
|
|
277
|
+
detachClient(socket)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function start() {
|
|
282
|
+
if (server) return server
|
|
283
|
+
server = new WebSocketServerCtor({ host, port })
|
|
284
|
+
server.on('connection', handleSocket)
|
|
285
|
+
server.on('listening', () => logger.info(`Shared daemon listening on ${host}:${port}.`))
|
|
286
|
+
server.on('error', () => logger.error('Shared daemon WebSocket error.'))
|
|
287
|
+
return server
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function close() {
|
|
291
|
+
stopHeartbeat()
|
|
292
|
+
rejectAllPending(DISCONNECTED_MESSAGE)
|
|
293
|
+
if (extensionSocket) extensionSocket.close()
|
|
294
|
+
for (const client of Array.from(clients)) client.close()
|
|
295
|
+
extensionSocket = null
|
|
296
|
+
extensionConnected = false
|
|
297
|
+
clients.clear()
|
|
298
|
+
if (server && typeof server.close === 'function') {
|
|
299
|
+
await new Promise(resolve => server.close(resolve))
|
|
300
|
+
}
|
|
301
|
+
server = null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
start,
|
|
306
|
+
close,
|
|
307
|
+
handleSocket,
|
|
308
|
+
isExtensionConnected,
|
|
309
|
+
getClientCount: () => clients.size,
|
|
310
|
+
getPendingCount: () => pending.size
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
export const IMPORT_BUNDLE_SCHEMA_VERSION = 1
|
|
2
|
+
export const IMPORT_BUNDLE_TYPES = ['interceptpilot.rule', 'interceptpilot.collection']
|
|
3
|
+
|
|
4
|
+
export const IMPORT_BUNDLE_ALLOWED_VALUES = {
|
|
5
|
+
matchType: ['contains', 'equals', 'regex', 'glob'],
|
|
6
|
+
method: ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
7
|
+
resourceType: ['ANY', 'XHR', 'Fetch', 'Document', 'Script', 'Image', 'Stylesheet', 'Other'],
|
|
8
|
+
actionType: ['mockResponse', 'failRequest', 'delayOnly', 'passThrough'],
|
|
9
|
+
failReason: [
|
|
10
|
+
'Failed',
|
|
11
|
+
'Aborted',
|
|
12
|
+
'TimedOut',
|
|
13
|
+
'AccessDenied',
|
|
14
|
+
'ConnectionClosed',
|
|
15
|
+
'ConnectionRefused',
|
|
16
|
+
'ConnectionReset',
|
|
17
|
+
'InternetDisconnected',
|
|
18
|
+
'NameNotResolved'
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const baseMatchShape = {
|
|
23
|
+
urlPattern: 'string',
|
|
24
|
+
matchType: 'contains | equals | regex | glob',
|
|
25
|
+
method: 'ANY | GET | POST | PUT | PATCH | DELETE | OPTIONS',
|
|
26
|
+
resourceType: 'ANY | XHR | Fetch | Document | Script | Image | Stylesheet | Other'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const baseActionShape = {
|
|
30
|
+
type: 'mockResponse | failRequest | delayOnly | passThrough',
|
|
31
|
+
statusCode: 'number optional, 100-599 for mockResponse',
|
|
32
|
+
statusText: 'string optional',
|
|
33
|
+
contentType: 'string optional',
|
|
34
|
+
responseBody: 'string optional; JSON responses must be stringified JSON',
|
|
35
|
+
responseHeaders: 'string optional; for redirects include a Location header such as "Location: /new-path"',
|
|
36
|
+
delayMs: 'number optional, milliseconds',
|
|
37
|
+
failReason: 'Failed | Aborted | TimedOut | AccessDenied | ConnectionClosed | ConnectionRefused | ConnectionReset | InternetDisconnected | NameNotResolved'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getImportBundleSchema() {
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: IMPORT_BUNDLE_SCHEMA_VERSION,
|
|
43
|
+
supportedTypes: [...IMPORT_BUNDLE_TYPES],
|
|
44
|
+
ruleBundleShape: {
|
|
45
|
+
schemaVersion: IMPORT_BUNDLE_SCHEMA_VERSION,
|
|
46
|
+
type: 'interceptpilot.rule',
|
|
47
|
+
exportedAt: 'ISO date string',
|
|
48
|
+
rule: {
|
|
49
|
+
id: 'string optional',
|
|
50
|
+
collectionId: 'string optional',
|
|
51
|
+
name: 'string',
|
|
52
|
+
enabled: 'boolean',
|
|
53
|
+
priority: 'number',
|
|
54
|
+
match: { ...baseMatchShape },
|
|
55
|
+
action: { ...baseActionShape }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
collectionBundleShape: {
|
|
59
|
+
schemaVersion: IMPORT_BUNDLE_SCHEMA_VERSION,
|
|
60
|
+
type: 'interceptpilot.collection',
|
|
61
|
+
exportedAt: 'ISO date string',
|
|
62
|
+
collection: {
|
|
63
|
+
id: 'string optional',
|
|
64
|
+
name: 'string',
|
|
65
|
+
description: 'string optional',
|
|
66
|
+
enabled: 'boolean',
|
|
67
|
+
order: 'number optional'
|
|
68
|
+
},
|
|
69
|
+
rules: ['array of rule objects using the same rule shape as ruleBundleShape.rule']
|
|
70
|
+
},
|
|
71
|
+
allowedValues: {
|
|
72
|
+
matchType: [...IMPORT_BUNDLE_ALLOWED_VALUES.matchType],
|
|
73
|
+
method: [...IMPORT_BUNDLE_ALLOWED_VALUES.method],
|
|
74
|
+
resourceType: [...IMPORT_BUNDLE_ALLOWED_VALUES.resourceType],
|
|
75
|
+
actionType: [...IMPORT_BUNDLE_ALLOWED_VALUES.actionType],
|
|
76
|
+
failReason: [...IMPORT_BUNDLE_ALLOWED_VALUES.failReason]
|
|
77
|
+
},
|
|
78
|
+
guidance: [
|
|
79
|
+
'Prefer resourceType "ANY" unless the user explicitly asks for a specific type.',
|
|
80
|
+
'Use matchType "contains" for most endpoint path matching unless the user needs exact, regex or glob matching.',
|
|
81
|
+
'Use mockResponse for HTTP status codes from 100 to 599, including 4xx and 5xx.',
|
|
82
|
+
'Use failRequest only for network-level failures where there is no HTTP response.',
|
|
83
|
+
'Use delayOnly only to add latency without changing the response.',
|
|
84
|
+
'Use passThrough to allow the request to continue unchanged.',
|
|
85
|
+
'responseBody must be a string. If it contains JSON, stringify and escape it correctly.',
|
|
86
|
+
'For redirects such as 301 or 302, use mockResponse and include a Location header when appropriate.',
|
|
87
|
+
'Importing a bundle only creates a pending proposal; the user must confirm it in the Full App before anything is applied.'
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createRuleBundle({ name, urlPattern, action, method = 'GET', priority = 100 }) {
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: IMPORT_BUNDLE_SCHEMA_VERSION,
|
|
95
|
+
type: 'interceptpilot.rule',
|
|
96
|
+
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
97
|
+
rule: {
|
|
98
|
+
name,
|
|
99
|
+
enabled: true,
|
|
100
|
+
priority,
|
|
101
|
+
match: {
|
|
102
|
+
urlPattern,
|
|
103
|
+
matchType: 'contains',
|
|
104
|
+
method,
|
|
105
|
+
resourceType: 'ANY'
|
|
106
|
+
},
|
|
107
|
+
action
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getImportBundleExamples() {
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
name: 'Single rule mock 200',
|
|
116
|
+
description: 'Example of a single rule bundle that mocks a JSON success response.',
|
|
117
|
+
bundle: createRuleBundle({
|
|
118
|
+
name: 'Mock example success',
|
|
119
|
+
urlPattern: '/api/example',
|
|
120
|
+
action: {
|
|
121
|
+
type: 'mockResponse',
|
|
122
|
+
statusCode: 200,
|
|
123
|
+
statusText: 'OK',
|
|
124
|
+
contentType: 'application/json',
|
|
125
|
+
responseBody: '{"ok":true,"source":"interceptpilot"}'
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'Single rule HTTP 500',
|
|
131
|
+
description: 'Example of an HTTP error response. Use mockResponse for HTTP 4xx and 5xx statuses.',
|
|
132
|
+
bundle: createRuleBundle({
|
|
133
|
+
name: 'Mock checkout server error',
|
|
134
|
+
urlPattern: '/api/checkout',
|
|
135
|
+
action: {
|
|
136
|
+
type: 'mockResponse',
|
|
137
|
+
statusCode: 500,
|
|
138
|
+
statusText: 'Internal Server Error',
|
|
139
|
+
contentType: 'application/json',
|
|
140
|
+
responseBody: '{"error":"temporary_failure"}'
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'Single rule network timeout',
|
|
146
|
+
description: 'Example of a network-level failure where no HTTP response should exist.',
|
|
147
|
+
bundle: createRuleBundle({
|
|
148
|
+
name: 'Simulate checkout timeout',
|
|
149
|
+
urlPattern: '/api/checkout',
|
|
150
|
+
action: {
|
|
151
|
+
type: 'failRequest',
|
|
152
|
+
failReason: 'TimedOut'
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'Single rule delay only',
|
|
158
|
+
description: 'Example of adding latency without changing the response.',
|
|
159
|
+
bundle: createRuleBundle({
|
|
160
|
+
name: 'Delay profile response',
|
|
161
|
+
urlPattern: '/api/user/profile',
|
|
162
|
+
action: {
|
|
163
|
+
type: 'delayOnly',
|
|
164
|
+
delayMs: 1200
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'Collection with multiple rules',
|
|
170
|
+
description: 'Example of a collection bundle grouping related checkout scenarios.',
|
|
171
|
+
bundle: {
|
|
172
|
+
schemaVersion: IMPORT_BUNDLE_SCHEMA_VERSION,
|
|
173
|
+
type: 'interceptpilot.collection',
|
|
174
|
+
exportedAt: '2026-01-01T00:00:00.000Z',
|
|
175
|
+
collection: {
|
|
176
|
+
name: 'Checkout examples',
|
|
177
|
+
description: 'Safe generic checkout scenarios',
|
|
178
|
+
enabled: true
|
|
179
|
+
},
|
|
180
|
+
rules: [
|
|
181
|
+
{
|
|
182
|
+
name: 'Checkout success',
|
|
183
|
+
enabled: true,
|
|
184
|
+
priority: 100,
|
|
185
|
+
match: {
|
|
186
|
+
urlPattern: '/api/checkout',
|
|
187
|
+
matchType: 'contains',
|
|
188
|
+
method: 'POST',
|
|
189
|
+
resourceType: 'ANY'
|
|
190
|
+
},
|
|
191
|
+
action: {
|
|
192
|
+
type: 'mockResponse',
|
|
193
|
+
statusCode: 200,
|
|
194
|
+
statusText: 'OK',
|
|
195
|
+
contentType: 'application/json',
|
|
196
|
+
responseBody: '{"ok":true,"orderId":"example-order"}'
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'Checkout unavailable',
|
|
201
|
+
enabled: true,
|
|
202
|
+
priority: 110,
|
|
203
|
+
match: {
|
|
204
|
+
urlPattern: '/api/checkout',
|
|
205
|
+
matchType: 'contains',
|
|
206
|
+
method: 'POST',
|
|
207
|
+
resourceType: 'ANY'
|
|
208
|
+
},
|
|
209
|
+
action: {
|
|
210
|
+
type: 'mockResponse',
|
|
211
|
+
statusCode: 503,
|
|
212
|
+
statusText: 'Service Unavailable',
|
|
213
|
+
contentType: 'application/json',
|
|
214
|
+
responseBody: '{"ok":false,"error":"service_unavailable"}'
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
]
|
|
221
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
|
+
|
|
6
|
+
import { BRIDGE_PORT } from './protocol.js'
|
|
7
|
+
import { createDaemonClientBridge } from './daemon-client.js'
|
|
8
|
+
import { createInterceptPilotDaemon } from './daemon.js'
|
|
9
|
+
import { createMcpServer } from './mcp-server.js'
|
|
10
|
+
|
|
11
|
+
export function parseCliArgs(argv = []) {
|
|
12
|
+
const options = {
|
|
13
|
+
mode: 'adapter',
|
|
14
|
+
port: BRIDGE_PORT,
|
|
15
|
+
key: '',
|
|
16
|
+
daemonHost: '127.0.0.1',
|
|
17
|
+
daemonAutostart: true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const args = [...argv]
|
|
21
|
+
if (args[0] === 'daemon') {
|
|
22
|
+
options.mode = 'daemon'
|
|
23
|
+
args.shift()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
27
|
+
const arg = args[i]
|
|
28
|
+
if (arg === '--port' && args[i + 1]) {
|
|
29
|
+
options.port = Number(args[i + 1]) || BRIDGE_PORT
|
|
30
|
+
i += 1
|
|
31
|
+
} else if (arg === '--key' && args[i + 1]) {
|
|
32
|
+
options.key = String(args[i + 1] || '')
|
|
33
|
+
i += 1
|
|
34
|
+
} else if (arg === '--no-daemon-autostart') {
|
|
35
|
+
options.daemonAutostart = false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return options
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createStderrLogger() {
|
|
43
|
+
return {
|
|
44
|
+
info: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
45
|
+
warn: message => console.error(`[interceptpilot-mcp] ${message}`),
|
|
46
|
+
error: message => console.error(`[interceptpilot-mcp] ${message}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function createRuntime(options, factories = {}) {
|
|
51
|
+
const {
|
|
52
|
+
createDaemonClientBridge: createDaemonClientBridgeFn = createDaemonClientBridge,
|
|
53
|
+
createInterceptPilotDaemon: createInterceptPilotDaemonFn = createInterceptPilotDaemon,
|
|
54
|
+
createMcpServer: createMcpServerFn = createMcpServer,
|
|
55
|
+
StdioServerTransportCtor = StdioServerTransport,
|
|
56
|
+
logger = createStderrLogger()
|
|
57
|
+
} = factories
|
|
58
|
+
|
|
59
|
+
if (options.mode === 'daemon') {
|
|
60
|
+
const daemon = createInterceptPilotDaemonFn({
|
|
61
|
+
port: options.port,
|
|
62
|
+
expectedKey: options.key,
|
|
63
|
+
logger
|
|
64
|
+
})
|
|
65
|
+
daemon.start()
|
|
66
|
+
return {
|
|
67
|
+
mode: 'daemon',
|
|
68
|
+
daemon,
|
|
69
|
+
close: () => daemon.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bridge = createDaemonClientBridgeFn({
|
|
74
|
+
port: options.port,
|
|
75
|
+
expectedKey: options.key,
|
|
76
|
+
daemonAutostart: options.daemonAutostart,
|
|
77
|
+
logger
|
|
78
|
+
})
|
|
79
|
+
await bridge.start()
|
|
80
|
+
|
|
81
|
+
const server = createMcpServerFn({ bridge })
|
|
82
|
+
const transport = new StdioServerTransportCtor()
|
|
83
|
+
await server.connect(transport)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
mode: 'adapter',
|
|
87
|
+
bridge,
|
|
88
|
+
server,
|
|
89
|
+
close: async () => {
|
|
90
|
+
await bridge.close?.()
|
|
91
|
+
await server.close?.()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function main(argv = process.argv.slice(2), factories = {}) {
|
|
97
|
+
const runtime = await createRuntime(parseCliArgs(argv), factories)
|
|
98
|
+
|
|
99
|
+
process.on('SIGINT', async () => {
|
|
100
|
+
await runtime.close()
|
|
101
|
+
process.exit(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
return runtime
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const currentFile = fileURLToPath(import.meta.url)
|
|
108
|
+
if (process.argv[1] === currentFile) {
|
|
109
|
+
main().catch(error => {
|
|
110
|
+
console.error(`[interceptpilot-mcp] ${String(error?.message || 'Failed to start InterceptPilot MCP server.')}`)
|
|
111
|
+
process.exit(1)
|
|
112
|
+
})
|
|
113
|
+
}
|