interceptpilot-mcp 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -41,6 +41,8 @@ AI client
41
41
 
42
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
43
 
44
+ The daemon exits on its own after 10 minutes with no adapter and no extension connected. When you update the package, the newer adapter asks an older daemon to stop and starts a fresh one, so both always run the same version.
45
+
44
46
  ## Options
45
47
 
46
48
  | Option | Meaning |
@@ -49,6 +51,7 @@ The MCP client starts the adapter. The adapter starts the local daemon when none
49
51
  | `--port <PORT>` | Local daemon port. Default `37177`. |
50
52
  | `--no-daemon-autostart` | Do not start the daemon automatically (debugging). |
51
53
  | `daemon` | Run only the daemon: `npx -y interceptpilot-mcp daemon --port <PORT> --key <KEY>`. |
54
+ | `--version`, `--help` | Print the version or this usage. |
52
55
 
53
56
  ## Tools
54
57
 
@@ -100,6 +103,8 @@ What each tool exposes depends on the permissions you set in the extension. By d
100
103
 
101
104
  **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
105
 
106
+ **"An older interceptpilot-mcp daemon is running on port … and could not be replaced automatically."** A daemon from version 0.1.0 is still running; that version cannot be stopped remotely. End the `node` process that runs `interceptpilot-mcp daemon` (or restart the machine) and start the MCP client again. Later versions replace themselves without this step.
107
+
103
108
  **Invalid or regenerated key.** Copy the configuration again from the AI Bridge page.
104
109
 
105
110
  **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interceptpilot-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local MCP server for InterceptPilot",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,9 +10,11 @@ import {
10
10
  isClientResponse,
11
11
  parseBridgeMessage
12
12
  } from './protocol.js'
13
+ import { PACKAGE_VERSION, compareVersions } from './package-version.js'
13
14
  import { startDaemonProcess as defaultStartDaemonProcess } from './process-manager.js'
14
15
 
15
16
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000
17
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1500
16
18
  const DEFAULT_RETRY_DELAY_MS = 100
17
19
  const DEFAULT_MAX_PENDING_REQUESTS = 25
18
20
  const DEFAULT_MAX_CONNECT_ATTEMPTS = 30
@@ -22,6 +24,7 @@ const COMMAND_NOT_ALLOWED_MESSAGE = 'Command is not allowed.'
22
24
  const TOO_MANY_PENDING_MESSAGE = 'Too many pending InterceptPilot requests.'
23
25
  const DAEMON_NOT_RUNNING_MESSAGE = 'InterceptPilot daemon is not running. Start AI Bridge or run without --no-daemon-autostart.'
24
26
  const DAEMON_START_FAILED_MESSAGE = 'Unable to start InterceptPilot daemon.'
27
+ const OUTDATED_DAEMON_MESSAGE = port => `An older interceptpilot-mcp daemon is running on port ${port} and could not be replaced automatically. End that node process (or restart the machine) and start the MCP client again.`
25
28
 
26
29
  function createDefaultLogger() {
27
30
  return {
@@ -63,6 +66,8 @@ export function createDaemonClientBridge(options = {}) {
63
66
  maxPendingRequests = DEFAULT_MAX_PENDING_REQUESTS,
64
67
  maxConnectAttempts = DEFAULT_MAX_CONNECT_ATTEMPTS,
65
68
  retryDelayMs = DEFAULT_RETRY_DELAY_MS,
69
+ shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
70
+ packageVersion = PACKAGE_VERSION,
66
71
  idGenerator,
67
72
  clientId = 'interceptpilot-mcp',
68
73
  logger = createDefaultLogger(),
@@ -128,8 +133,9 @@ export function createDaemonClientBridge(options = {}) {
128
133
  timer?.unref?.()
129
134
  }
130
135
 
131
- function connectAttempt(resolve, reject, attempt = 1, autostartTried = false) {
136
+ function connectAttempt(resolve, reject, attempt = 1, autostartTried = false, replaceTried = false) {
132
137
  let settled = false
138
+ let replacing = false
133
139
  const activeSocket = new WebSocketCtor(`ws://${host}:${port}`)
134
140
  socket = activeSocket
135
141
 
@@ -142,7 +148,25 @@ export function createDaemonClientBridge(options = {}) {
142
148
  }
143
149
 
144
150
  function retryAfterAutostart() {
145
- schedule(() => connectAttempt(resolve, reject, attempt + 1, true))
151
+ schedule(() => connectAttempt(resolve, reject, attempt + 1, true, replaceTried))
152
+ }
153
+
154
+ // A daemon older than this adapter may not know newer commands. Ask it to
155
+ // stop and start a fresh one; a daemon too old to understand the request is
156
+ // kept, with a warning, so the user is not blocked.
157
+ function replaceOutdatedDaemon(daemonVersion) {
158
+ replacing = true
159
+ logger.info(`Replacing daemon ${daemonVersion || '(unknown version)'} with ${packageVersion}.`)
160
+ sendJson(activeSocket, { type: MESSAGE_TYPES.clientShutdown })
161
+ const timer = setTimeoutFn(() => {
162
+ if (settled) return
163
+ settled = true
164
+ replacing = false
165
+ connected = true
166
+ logger.warn(OUTDATED_DAEMON_MESSAGE(port))
167
+ resolve()
168
+ }, shutdownTimeoutMs)
169
+ timer?.unref?.()
146
170
  }
147
171
 
148
172
  addSocketListener(activeSocket, 'open', () => {
@@ -161,12 +185,20 @@ export function createDaemonClientBridge(options = {}) {
161
185
  if (!message) return
162
186
 
163
187
  if (message.type === MESSAGE_TYPES.clientHelloAck && message.ok === true && Number(message.version) === BRIDGE_PROTOCOL_VERSION) {
188
+ if (settled || replacing) return
189
+ const daemonVersion = String(message.packageVersion || '')
190
+ if (daemonAutostart && !replaceTried && compareVersions(daemonVersion, packageVersion) < 0) {
191
+ replaceOutdatedDaemon(daemonVersion)
192
+ return
193
+ }
164
194
  settled = true
165
195
  connected = true
166
196
  resolve()
167
197
  return
168
198
  }
169
199
 
200
+ if (message.type === MESSAGE_TYPES.clientShutdownAck) return
201
+
170
202
  handleResponse(message)
171
203
  })
172
204
 
@@ -200,6 +232,18 @@ export function createDaemonClientBridge(options = {}) {
200
232
  })
201
233
 
202
234
  addSocketListener(activeSocket, 'close', () => {
235
+ if (replacing && !settled) {
236
+ settled = true
237
+ socket = null
238
+ try {
239
+ startDaemonProcess({ port, key: connectionKey, logger })
240
+ } catch {
241
+ failStart(DAEMON_START_FAILED_MESSAGE)
242
+ return
243
+ }
244
+ schedule(() => connectAttempt(resolve, reject, attempt + 1, true, true))
245
+ return
246
+ }
203
247
  if (!connected && !settled) {
204
248
  settled = true
205
249
  socket = null
package/src/daemon.js CHANGED
@@ -14,9 +14,11 @@ import {
14
14
  validateClientHelloMessage,
15
15
  validateHelloMessage
16
16
  } from './protocol.js'
17
+ import { PACKAGE_VERSION } from './package-version.js'
17
18
 
18
19
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000
19
20
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 20000
21
+ const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000
20
22
  const DEFAULT_MAX_PENDING_REQUESTS = 25
21
23
  const NOT_CONNECTED_MESSAGE = 'InterceptPilot extension is not connected. Open InterceptPilot settings and start an AI Bridge session.'
22
24
  const TIMEOUT_MESSAGE = 'InterceptPilot did not respond in time.'
@@ -62,6 +64,12 @@ export function createInterceptPilotDaemon(options = {}) {
62
64
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
63
65
  heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
64
66
  maxPendingRequests = DEFAULT_MAX_PENDING_REQUESTS,
67
+ // Exit when no MCP client has been connected for this long and the
68
+ // extension is not connected either. 0 disables it.
69
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
70
+ packageVersion = PACKAGE_VERSION,
71
+ onIdle = () => {},
72
+ onShutdown = () => {},
65
73
  idGenerator,
66
74
  logger = createDefaultLogger(),
67
75
  setTimeoutFn = setTimeout,
@@ -79,6 +87,7 @@ export function createInterceptPilotDaemon(options = {}) {
79
87
  let extensionConnected = false
80
88
  let requestSeq = 0
81
89
  let heartbeatTimer = null
90
+ let idleTimer = null
82
91
 
83
92
  function nextRequestId() {
84
93
  if (idGenerator) return idGenerator()
@@ -104,6 +113,25 @@ export function createInterceptPilotDaemon(options = {}) {
104
113
  heartbeatTimer = null
105
114
  }
106
115
 
116
+ function clearIdleTimer() {
117
+ if (!idleTimer) return
118
+ clearTimeoutFn(idleTimer)
119
+ idleTimer = null
120
+ }
121
+
122
+ function scheduleIdleCheck() {
123
+ clearIdleTimer()
124
+ if (!idleTimeoutMs || clients.size > 0 || isExtensionConnected()) return
125
+ idleTimer = setTimeoutFn(async () => {
126
+ idleTimer = null
127
+ if (clients.size > 0 || isExtensionConnected()) return
128
+ logger.info('No MCP client or extension connected; shutting down idle daemon.')
129
+ await close()
130
+ onIdle()
131
+ }, idleTimeoutMs)
132
+ idleTimer?.unref?.()
133
+ }
134
+
107
135
  function rejectPendingForClient(clientSocket) {
108
136
  for (const [extensionRequestId, request] of pending) {
109
137
  if (request.clientSocket !== clientSocket) continue
@@ -131,6 +159,7 @@ export function createInterceptPilotDaemon(options = {}) {
131
159
  stopHeartbeat()
132
160
  rejectAllPending(DISCONNECTED_MESSAGE)
133
161
  logger.info('Extension disconnected.')
162
+ scheduleIdleCheck()
134
163
  }
135
164
 
136
165
  function detachClient(socket) {
@@ -138,6 +167,7 @@ export function createInterceptPilotDaemon(options = {}) {
138
167
  clients.delete(socket)
139
168
  rejectPendingForClient(socket)
140
169
  logger.info('MCP client disconnected.')
170
+ scheduleIdleCheck()
141
171
  }
142
172
 
143
173
  function acceptExtension(socket, message) {
@@ -156,6 +186,7 @@ export function createInterceptPilotDaemon(options = {}) {
156
186
 
157
187
  extensionSocket = socket
158
188
  extensionConnected = true
189
+ clearIdleTimer()
159
190
  sendJson(socket, createHelloAckMessage())
160
191
  startHeartbeat()
161
192
  logger.info('Extension connected.')
@@ -170,10 +201,20 @@ export function createInterceptPilotDaemon(options = {}) {
170
201
  }
171
202
 
172
203
  clients.add(socket)
173
- sendJson(socket, createClientHelloAckMessage())
204
+ clearIdleTimer()
205
+ sendJson(socket, createClientHelloAckMessage(packageVersion))
174
206
  logger.info('MCP client connected.')
175
207
  }
176
208
 
209
+ // Only a client that passed the key check at hello can ask for this; a newer
210
+ // adapter uses it to replace an outdated daemon on the same port.
211
+ async function handleClientShutdown(socket) {
212
+ logger.info('Shutdown requested by an MCP client; stopping daemon.')
213
+ sendJson(socket, { type: MESSAGE_TYPES.clientShutdownAck, ok: true })
214
+ await close()
215
+ onShutdown()
216
+ }
217
+
177
218
  function sendClientError(socket, id, code, message) {
178
219
  sendJson(socket, createClientError(id, code, message))
179
220
  }
@@ -262,6 +303,7 @@ export function createInterceptPilotDaemon(options = {}) {
262
303
 
263
304
  if (clients.has(socket)) {
264
305
  if (message.type === MESSAGE_TYPES.clientCommand) handleClientCommand(socket, message)
306
+ else if (message.type === MESSAGE_TYPES.clientShutdown) handleClientShutdown(socket)
265
307
  return
266
308
  }
267
309
  }
@@ -284,10 +326,12 @@ export function createInterceptPilotDaemon(options = {}) {
284
326
  server.on('connection', handleSocket)
285
327
  server.on('listening', () => logger.info(`Shared daemon listening on ${host}:${port}.`))
286
328
  server.on('error', () => logger.error('Shared daemon WebSocket error.'))
329
+ scheduleIdleCheck()
287
330
  return server
288
331
  }
289
332
 
290
333
  async function close() {
334
+ clearIdleTimer()
291
335
  stopHeartbeat()
292
336
  rejectAllPending(DISCONNECTED_MESSAGE)
293
337
  if (extensionSocket) extensionSocket.close()
package/src/index.js CHANGED
@@ -7,6 +7,22 @@ import { BRIDGE_PORT } from './protocol.js'
7
7
  import { createDaemonClientBridge } from './daemon-client.js'
8
8
  import { createInterceptPilotDaemon } from './daemon.js'
9
9
  import { createMcpServer } from './mcp-server.js'
10
+ import { PACKAGE_VERSION } from './package-version.js'
11
+
12
+ export const USAGE = `interceptpilot-mcp ${PACKAGE_VERSION}
13
+
14
+ Usage:
15
+ interceptpilot-mcp --key <KEY> [--port <PORT>] [--no-daemon-autostart]
16
+ interceptpilot-mcp daemon --key <KEY> [--port <PORT>]
17
+
18
+ Options:
19
+ --key <KEY> Session key shown by the InterceptPilot AI Bridge page (required)
20
+ --port <PORT> Local daemon port (default ${BRIDGE_PORT})
21
+ --no-daemon-autostart Do not start the shared daemon automatically
22
+ --version Print the package version
23
+ --help Print this help
24
+
25
+ Copy the ready-made configuration from InterceptPilot > AI Bridge and paste it into your MCP client.`
10
26
 
11
27
  export function parseCliArgs(argv = []) {
12
28
  const options = {
@@ -14,7 +30,9 @@ export function parseCliArgs(argv = []) {
14
30
  port: BRIDGE_PORT,
15
31
  key: '',
16
32
  daemonHost: '127.0.0.1',
17
- daemonAutostart: true
33
+ daemonAutostart: true,
34
+ help: false,
35
+ version: false
18
36
  }
19
37
 
20
38
  const args = [...argv]
@@ -33,6 +51,10 @@ export function parseCliArgs(argv = []) {
33
51
  i += 1
34
52
  } else if (arg === '--no-daemon-autostart') {
35
53
  options.daemonAutostart = false
54
+ } else if (arg === '--help' || arg === '-h') {
55
+ options.help = true
56
+ } else if (arg === '--version' || arg === '-v') {
57
+ options.version = true
36
58
  }
37
59
  }
38
60
 
@@ -53,14 +75,17 @@ export async function createRuntime(options, factories = {}) {
53
75
  createInterceptPilotDaemon: createInterceptPilotDaemonFn = createInterceptPilotDaemon,
54
76
  createMcpServer: createMcpServerFn = createMcpServer,
55
77
  StdioServerTransportCtor = StdioServerTransport,
56
- logger = createStderrLogger()
78
+ logger = createStderrLogger(),
79
+ exit = code => process.exit(code)
57
80
  } = factories
58
81
 
59
82
  if (options.mode === 'daemon') {
60
83
  const daemon = createInterceptPilotDaemonFn({
61
84
  port: options.port,
62
85
  expectedKey: options.key,
63
- logger
86
+ logger,
87
+ onIdle: () => exit(0),
88
+ onShutdown: () => exit(0)
64
89
  })
65
90
  daemon.start()
66
91
  return {
@@ -94,7 +119,21 @@ export async function createRuntime(options, factories = {}) {
94
119
  }
95
120
 
96
121
  export async function main(argv = process.argv.slice(2), factories = {}) {
97
- const runtime = await createRuntime(parseCliArgs(argv), factories)
122
+ const options = parseCliArgs(argv)
123
+ if (options.help) {
124
+ console.log(USAGE)
125
+ return null
126
+ }
127
+ if (options.version) {
128
+ console.log(PACKAGE_VERSION)
129
+ return null
130
+ }
131
+ if (!options.key) {
132
+ throw new Error(`--key is required.
133
+
134
+ ${USAGE}`)
135
+ }
136
+ const runtime = await createRuntime(options, factories)
98
137
 
99
138
  process.on('SIGINT', async () => {
100
139
  await runtime.close()
@@ -106,7 +145,9 @@ export async function main(argv = process.argv.slice(2), factories = {}) {
106
145
 
107
146
  const currentFile = fileURLToPath(import.meta.url)
108
147
  if (process.argv[1] === currentFile) {
109
- main().catch(error => {
148
+ main().then(runtime => {
149
+ if (!runtime) process.exit(0)
150
+ }).catch(error => {
110
151
  console.error(`[interceptpilot-mcp] ${String(error?.message || 'Failed to start InterceptPilot MCP server.')}`)
111
152
  process.exit(1)
112
153
  })
package/src/mcp-server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { PACKAGE_VERSION } from './package-version.js'
2
3
  import { z } from 'zod'
3
4
 
4
5
  import { getImportBundleExamples, getImportBundleSchema } from './import-bundle-schema.js'
@@ -658,7 +659,7 @@ export function createToolCallHandler({ bridge }) {
658
659
  export function createMcpServer({ bridge }) {
659
660
  const server = new McpServer({
660
661
  name: 'interceptpilot-mcp',
661
- version: '0.1.0'
662
+ version: PACKAGE_VERSION
662
663
  })
663
664
  const handleToolCall = createToolCallHandler({ bridge })
664
665
 
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ const require = createRequire(import.meta.url)
4
+
5
+ export const PACKAGE_VERSION = String(require('../package.json').version || '0.0.0')
6
+
7
+ // Numeric semver compare on major.minor.patch; anything unparsable counts as 0.
8
+ export function compareVersions(a, b) {
9
+ const parse = value => String(value || '').split('.').slice(0, 3).map(part => Number.parseInt(part, 10) || 0)
10
+ const [left, right] = [parse(a), parse(b)]
11
+ for (let i = 0; i < 3; i += 1) {
12
+ const diff = (left[i] || 0) - (right[i] || 0)
13
+ if (diff !== 0) return diff < 0 ? -1 : 1
14
+ }
15
+ return 0
16
+ }
package/src/protocol.js CHANGED
@@ -15,6 +15,8 @@ export const MESSAGE_TYPES = {
15
15
  clientCommand: 'client:command',
16
16
  clientResult: 'client:result',
17
17
  clientError: 'client:error',
18
+ clientShutdown: 'client:shutdown',
19
+ clientShutdownAck: 'client:shutdown:ack',
18
20
  ping: 'ping',
19
21
  pong: 'pong'
20
22
  }
@@ -65,11 +67,12 @@ export function createHelloAckMessage() {
65
67
  }
66
68
  }
67
69
 
68
- export function createClientHelloAckMessage() {
70
+ export function createClientHelloAckMessage(packageVersion = '') {
69
71
  return {
70
72
  type: MESSAGE_TYPES.clientHelloAck,
71
73
  ok: true,
72
- version: BRIDGE_PROTOCOL_VERSION
74
+ version: BRIDGE_PROTOCOL_VERSION,
75
+ ...(packageVersion ? { packageVersion: String(packageVersion) } : {})
73
76
  }
74
77
  }
75
78