@opute/host-agent 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.
Files changed (3) hide show
  1. package/README.md +67 -0
  2. package/index.js +447 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @opute/host-agent
2
+
3
+ Downloads and launches the checksum-verified Opute Go host agent as a local
4
+ **Streamable HTTP** MCP server for VS Code, Claude Desktop, Cursor, and other
5
+ MCP clients.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # Start in the foreground (logs on stderr)
11
+ npx -y @opute/host-agent start
12
+
13
+ # Or run as a background daemon and print the MCP URL
14
+ npx -y @opute/host-agent start --background
15
+ npx -y @opute/host-agent status
16
+ npx -y @opute/host-agent stop
17
+ ```
18
+
19
+ Point your MCP client at the printed URL (default port **3014**):
20
+
21
+ ```json
22
+ {
23
+ "servers": {
24
+ "oputeLocal": {
25
+ "type": "http",
26
+ "url": "http://127.0.0.1:3014/mcp"
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ stdio MCP transport is not supported.
33
+
34
+ ## Commands
35
+
36
+ | Command | Description |
37
+ |---------|-------------|
38
+ | `start` | Start standalone Streamable HTTP (foreground by default) |
39
+ | `start --background` / `-d` | Daemonize, write pid state, print MCP URL |
40
+ | `stop` | Stop a background daemon started by this launcher |
41
+ | `status` | JSON status (`running`, `healthy`, `url`) |
42
+ | `url` | Print the MCP URL |
43
+
44
+ ## Environment
45
+
46
+ | Variable | Purpose |
47
+ |----------|---------|
48
+ | `HOST_MCP_PORT` | Listen port (default `3014`) |
49
+ | `HOST_MCP_BIND_HOST` | Bind host (default `127.0.0.1`) |
50
+ | `OPUTE_HOST_AGENT_BINARY` | Use a local binary instead of downloading a release |
51
+ | `OPUTE_STANDALONE_ALLOW_MUTATIONS=true` | Enable mutating infrastructure tools |
52
+ | `OPUTE_STANDALONE_STATE_DIR` | Local SQLite / operation journal directory |
53
+
54
+ For development, set `OPUTE_HOST_AGENT_BINARY` to a locally built binary.
55
+ Released packages download the matching versioned artifact and verify it
56
+ against the release `SHA256SUMS` manifest. `OPUTE_HOST_AGENT_SHA256` may be
57
+ supplied to pin a development or mirror artifact explicitly.
58
+
59
+ The stable MVP claim covers local Incus inspection and VM lifecycle. K3s,
60
+ PostgreSQL, SQL execution, and Cloudflare Tunnel tools are exposed as
61
+ experimental capabilities and require the same explicit mutation opt-in.
62
+
63
+ The launcher supports Linux x64 and arm64. Native Windows and macOS are not
64
+ supported by the Incus provider; Windows users should run the Linux binary
65
+ inside WSL and connect from the Windows MCP client over HTTP to
66
+ `http://127.0.0.1:3014/mcp` (with WSL port forwarding as needed). The launcher
67
+ does not install dependencies or run a postinstall hook.
package/index.js ADDED
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs')
4
+ const os = require('node:os')
5
+ const path = require('node:path')
6
+ const crypto = require('node:crypto')
7
+ const http = require('node:http')
8
+ const https = require('node:https')
9
+ const zlib = require('node:zlib')
10
+ const { spawn } = require('node:child_process')
11
+
12
+ const packageVersion = require('./package.json').version
13
+ const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
14
+ const MAX_BINARY_BYTES = 256 * 1024 * 1024
15
+ const REQUEST_TIMEOUT_MS = 15_000
16
+ const LOCK_TIMEOUT_MS = 30_000
17
+ const HEALTH_WAIT_MS = 15_000
18
+
19
+ function targetName() {
20
+ const platform = process.platform
21
+ const arch = process.arch
22
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64'
23
+ if (platform === 'linux' && arch === 'arm64') return 'linux-arm64'
24
+ if (platform === 'win32') {
25
+ throw new Error('native Windows is not supported by the Incus host agent; run the Linux binary inside WSL and point your MCP client at http://127.0.0.1:3014/mcp')
26
+ }
27
+ throw new Error(`unsupported local host-agent target: ${platform}/${arch}; supported targets are Linux x64 and arm64 (Windows via WSL)`)
28
+ }
29
+
30
+ function request(url, redirects = 0) {
31
+ if (redirects > 5) return Promise.reject(new Error('too many artifact redirects'))
32
+ return new Promise((resolve, reject) => {
33
+ let parsed
34
+ try { parsed = new URL(url) } catch { reject(new Error(`invalid artifact URL: ${url}`)); return }
35
+ const transport = parsed.protocol === 'http:' ? http : parsed.protocol === 'https:' ? https : null
36
+ if (!transport) { reject(new Error(`unsupported artifact URL protocol: ${parsed.protocol}`)); return }
37
+ const req = transport.get(parsed, { headers: { 'User-Agent': '@opute/host-agent' } }, response => {
38
+ if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
39
+ response.resume()
40
+ resolve(request(new URL(response.headers.location, parsed).toString(), redirects + 1))
41
+ return
42
+ }
43
+ if (response.statusCode !== 200) {
44
+ response.resume()
45
+ reject(new Error(`host-agent download failed: HTTP ${response.statusCode}`))
46
+ return
47
+ }
48
+ const declared = Number(response.headers['content-length'] || 0)
49
+ if (declared > MAX_ARCHIVE_BYTES) {
50
+ response.resume()
51
+ reject(new Error('host-agent download exceeds the maximum artifact size'))
52
+ return
53
+ }
54
+ const chunks = []
55
+ let size = 0
56
+ response.on('data', chunk => {
57
+ size += chunk.length
58
+ if (size > MAX_ARCHIVE_BYTES) {
59
+ req.destroy(new Error('host-agent download exceeds the maximum artifact size'))
60
+ return
61
+ }
62
+ chunks.push(chunk)
63
+ })
64
+ response.on('end', () => resolve(Buffer.concat(chunks)))
65
+ response.on('error', reject)
66
+ })
67
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('host-agent download timed out')))
68
+ req.on('error', reject)
69
+ })
70
+ }
71
+
72
+ function sha256(data) {
73
+ return crypto.createHash('sha256').update(data).digest('hex')
74
+ }
75
+
76
+ async function withCacheLock(lockPath, fn) {
77
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
78
+ while (true) {
79
+ try {
80
+ fs.mkdirSync(lockPath, { recursive: false, mode: 0o700 })
81
+ try { return await fn() } finally { fs.rmSync(lockPath, { recursive: true, force: true }) }
82
+ } catch (error) {
83
+ if (error && error.code !== 'EEXIST') throw error
84
+ if (Date.now() >= deadline) throw new Error('timed out waiting for another host-agent download')
85
+ await new Promise(resolve => setTimeout(resolve, 100))
86
+ }
87
+ }
88
+ }
89
+
90
+ function readCacheMarker(markerPath, binaryPath, expected) {
91
+ try {
92
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'))
93
+ if (marker.archiveSha256 !== expected) return false
94
+ const binary = fs.readFileSync(binaryPath)
95
+ if (binary.length === 0 || binary.length > MAX_BINARY_BYTES) return false
96
+ return marker.binarySha256 === sha256(binary)
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ async function resolveBinary() {
103
+ if (process.env.OPUTE_HOST_AGENT_BINARY) return process.env.OPUTE_HOST_AGENT_BINARY
104
+ const target = targetName()
105
+ const cacheDir = process.env.OPUTE_HOST_AGENT_CACHE_DIR || path.join(os.homedir(), '.cache', 'opute', 'host-agent')
106
+ const binaryName = `host-agent-${target}`
107
+ const binaryPath = path.join(cacheDir, packageVersion, binaryName)
108
+ const markerPath = `${binaryPath}.verified.json`
109
+ const lockPath = path.join(cacheDir, `${packageVersion}.lock`)
110
+ const base = (process.env.OPUTE_HOST_AGENT_RELEASE_BASE_URL || 'https://github.com/wunderous/host-agents/releases/download').replace(/\/$/, '')
111
+ const artifactName = `host-agent-${target}.gz`
112
+ const expected = process.env.OPUTE_HOST_AGENT_SHA256
113
+ ? process.env.OPUTE_HOST_AGENT_SHA256.toLowerCase().trim()
114
+ : await resolveChecksum(base, artifactName)
115
+ if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error('OPUTE_HOST_AGENT_SHA256 must be a 64-character hexadecimal SHA-256')
116
+
117
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
118
+
119
+ return withCacheLock(lockPath, async () => {
120
+ if (readCacheMarker(markerPath, binaryPath, expected)) return binaryPath
121
+ const archive = await request(`${base}/v${packageVersion}/${artifactName}`)
122
+ const actualArchive = sha256(archive)
123
+ if (actualArchive !== expected) throw new Error('host-agent artifact checksum mismatch')
124
+ let binary
125
+ try { binary = zlib.gunzipSync(archive) } catch { throw new Error('host-agent artifact is not valid gzip') }
126
+ if (binary.length === 0 || binary.length > MAX_BINARY_BYTES) throw new Error('host-agent binary exceeds the maximum size')
127
+ fs.mkdirSync(path.dirname(binaryPath), { recursive: true, mode: 0o700 })
128
+ const temporary = `${binaryPath}.download-${process.pid}-${Date.now()}`
129
+ const temporaryMarker = `${markerPath}.download-${process.pid}-${Date.now()}`
130
+ try {
131
+ fs.writeFileSync(temporary, binary, { mode: 0o700 })
132
+ fs.writeFileSync(temporaryMarker, JSON.stringify({ archiveSha256: expected, binarySha256: sha256(binary) }) + '\n', { mode: 0o600 })
133
+ fs.renameSync(temporary, binaryPath)
134
+ fs.renameSync(temporaryMarker, markerPath)
135
+ } catch (error) {
136
+ fs.rmSync(temporary, { force: true })
137
+ fs.rmSync(temporaryMarker, { force: true })
138
+ throw error
139
+ }
140
+ return binaryPath
141
+ })
142
+ }
143
+
144
+ async function resolveChecksum(base, artifactName) {
145
+ const manifestUrl = process.env.OPUTE_HOST_AGENT_CHECKSUM_URL || `${base}/v${packageVersion}/SHA256SUMS`
146
+ const manifest = (await request(manifestUrl)).toString('utf8')
147
+ for (const line of manifest.split(/\r?\n/)) {
148
+ const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i)
149
+ if (match && path.basename(match[2]) === artifactName) return match[1].toLowerCase()
150
+ }
151
+ throw new Error(`release checksum manifest does not contain ${artifactName}`)
152
+ }
153
+
154
+ function runtimeDir() {
155
+ return process.env.OPUTE_LOCAL_HOST_AGENT_RUNTIME_DIR
156
+ || path.join(os.homedir(), '.cache', 'opute', 'host-agent', 'standalone')
157
+ }
158
+
159
+ function daemonStatePath() {
160
+ return path.join(runtimeDir(), 'daemon.json')
161
+ }
162
+
163
+ function bindHost() {
164
+ return (process.env.HOST_MCP_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'
165
+ }
166
+
167
+ function mcpPort() {
168
+ const raw = (process.env.HOST_MCP_PORT || '3014').trim()
169
+ const port = Number(raw)
170
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
171
+ throw new Error(`invalid HOST_MCP_PORT ${JSON.stringify(raw)}`)
172
+ }
173
+ return port
174
+ }
175
+
176
+ function mcpUrl(host = bindHost(), port = mcpPort()) {
177
+ return `http://${host}:${port}/mcp`
178
+ }
179
+
180
+ function healthUrl(host = bindHost(), port = mcpPort()) {
181
+ return `http://${host}:${port}/health`
182
+ }
183
+
184
+ function readDaemonState() {
185
+ try {
186
+ const raw = fs.readFileSync(daemonStatePath(), 'utf8')
187
+ const state = JSON.parse(raw)
188
+ if (!state || typeof state !== 'object' || !Number.isInteger(state.pid)) {
189
+ return null
190
+ }
191
+ return state
192
+ } catch (error) {
193
+ if (error && (error.code === 'ENOENT' || error instanceof SyntaxError)) {
194
+ return null
195
+ }
196
+ throw error
197
+ }
198
+ }
199
+
200
+ function writeDaemonState(state) {
201
+ fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 })
202
+ fs.writeFileSync(daemonStatePath(), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
203
+ }
204
+
205
+ function clearDaemonState() {
206
+ fs.rmSync(daemonStatePath(), { force: true })
207
+ }
208
+
209
+ function isPidRunning(pid) {
210
+ if (!Number.isInteger(pid) || pid <= 0) return false
211
+ try {
212
+ process.kill(pid, 0)
213
+ return true
214
+ } catch {
215
+ return false
216
+ }
217
+ }
218
+
219
+ function probeHealth(url, timeoutMs = 2000) {
220
+ return new Promise(resolve => {
221
+ const req = http.get(url, { timeout: timeoutMs }, response => {
222
+ const chunks = []
223
+ response.on('data', chunk => { chunks.push(chunk) })
224
+ response.on('end', () => {
225
+ if (response.statusCode !== 200) {
226
+ resolve(false)
227
+ return
228
+ }
229
+ try {
230
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
231
+ resolve(payload && payload.ok === true)
232
+ } catch {
233
+ resolve(false)
234
+ }
235
+ })
236
+ })
237
+ req.on('timeout', () => { req.destroy(); resolve(false) })
238
+ req.on('error', () => resolve(false))
239
+ })
240
+ }
241
+
242
+ async function waitForHealth(url, timeoutMs = HEALTH_WAIT_MS, failure = null) {
243
+ const deadline = Date.now() + timeoutMs
244
+ while (Date.now() < deadline) {
245
+ const earlyFailure = typeof failure === 'function' ? failure() : null
246
+ if (earlyFailure) throw new Error(earlyFailure)
247
+ if (await probeHealth(url)) return
248
+ await new Promise(resolve => setTimeout(resolve, 100))
249
+ }
250
+ throw new Error(`timed out waiting for health at ${url}`)
251
+ }
252
+
253
+ function buildAgentEnv() {
254
+ const env = { ...process.env }
255
+ for (const key of Object.keys(env)) {
256
+ if (key.startsWith('OPUTE_') || key === 'MCP_AUTH_TOKEN' || key === 'BRIDGE_TOKEN') {
257
+ // Keep launcher-only and standalone-safe vars.
258
+ if (
259
+ key === 'OPUTE_HOST_AGENT_BINARY'
260
+ || key === 'OPUTE_HOST_AGENT_CACHE_DIR'
261
+ || key === 'OPUTE_HOST_AGENT_RELEASE_BASE_URL'
262
+ || key === 'OPUTE_HOST_AGENT_CHECKSUM_URL'
263
+ || key === 'OPUTE_HOST_AGENT_SHA256'
264
+ || key === 'OPUTE_LOCAL_HOST_AGENT_RUNTIME_DIR'
265
+ || key === 'OPUTE_STANDALONE_STATE_DIR'
266
+ || key === 'OPUTE_STANDALONE_ALLOW_MUTATIONS'
267
+ || key === 'OPUTE_STANDALONE_ALLOW_INSECURE_DOWNLOADS'
268
+ || key === 'OPUTE_INFRA_PROVIDER_ID'
269
+ ) {
270
+ continue
271
+ }
272
+ delete env[key]
273
+ }
274
+ }
275
+ env.OPUTE_AGENT_MODE = 'standalone'
276
+ env.OPUTE_TRANSPORT = 'http'
277
+ env.HOST_MCP_BIND_HOST = bindHost()
278
+ env.HOST_MCP_PORT = String(mcpPort())
279
+ if (!env.OPUTE_INFRA_PROVIDER_ID) env.OPUTE_INFRA_PROVIDER_ID = 'incus'
280
+ return env
281
+ }
282
+
283
+ function printUsage() {
284
+ console.log(`Usage: opute-host-agent <command>
285
+
286
+ Commands:
287
+ start [--background|-d] Start standalone Streamable HTTP MCP (default: foreground)
288
+ stop Stop a background daemon started by this launcher
289
+ status Show daemon/listener status and MCP URL
290
+ url Print the MCP URL (http://127.0.0.1:3014/mcp by default)
291
+
292
+ Environment:
293
+ HOST_MCP_PORT Listen port (default 3014)
294
+ HOST_MCP_BIND_HOST Bind host (default 127.0.0.1)
295
+ OPUTE_HOST_AGENT_BINARY Use a local binary instead of downloading a release
296
+ OPUTE_STANDALONE_ALLOW_MUTATIONS=true Enable mutating tools
297
+
298
+ MCP client config:
299
+ { "type": "http", "url": "http://127.0.0.1:3014/mcp" }
300
+ `)
301
+ }
302
+
303
+ async function startForeground(binary, passthroughArgs) {
304
+ const host = bindHost()
305
+ const port = mcpPort()
306
+ const url = mcpUrl(host, port)
307
+ console.error(`starting standalone Streamable HTTP MCP at ${url}`)
308
+ const child = spawn(binary, ['--mode=standalone', '--transport=http', ...passthroughArgs], {
309
+ stdio: 'inherit',
310
+ env: buildAgentEnv(),
311
+ })
312
+ let exiting = false
313
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
314
+ process.on(signal, () => { if (!exiting) child.kill(signal) })
315
+ }
316
+ return await new Promise((resolve, reject) => {
317
+ child.on('error', reject)
318
+ child.on('exit', (code, signal) => {
319
+ exiting = true
320
+ resolve(code ?? (signal ? 1 : 0))
321
+ })
322
+ })
323
+ }
324
+
325
+ async function startBackground(binary, passthroughArgs) {
326
+ const existing = readDaemonState()
327
+ if (existing && isPidRunning(existing.pid)) {
328
+ const existingUrl = mcpUrl(existing.host || bindHost(), existing.port || mcpPort())
329
+ throw new Error(`standalone agent already running (pid ${existing.pid}) at ${existingUrl}`)
330
+ }
331
+ const host = bindHost()
332
+ const port = mcpPort()
333
+ const url = mcpUrl(host, port)
334
+ const health = healthUrl(host, port)
335
+ fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 })
336
+ const logPath = path.join(runtimeDir(), 'daemon.log')
337
+ const logFd = fs.openSync(logPath, 'a')
338
+ const child = spawn(binary, ['--mode=standalone', '--transport=http', ...passthroughArgs], {
339
+ detached: true,
340
+ stdio: ['ignore', logFd, logFd],
341
+ env: buildAgentEnv(),
342
+ })
343
+ let spawnError = null
344
+ let childExit = null
345
+ child.once('error', error => { spawnError = error })
346
+ child.once('exit', (code, signal) => { childExit = { code, signal } })
347
+ child.unref()
348
+ fs.closeSync(logFd)
349
+ writeDaemonState({
350
+ pid: child.pid,
351
+ host,
352
+ port,
353
+ startedAt: new Date().toISOString(),
354
+ logPath,
355
+ })
356
+ try {
357
+ await waitForHealth(health, HEALTH_WAIT_MS, () => {
358
+ if (spawnError) return `child process failed: ${spawnError.message}`
359
+ if (childExit) return `child process exited before health (code=${childExit.code}, signal=${childExit.signal || 'none'})`
360
+ return null
361
+ })
362
+ } catch (error) {
363
+ try { process.kill(child.pid, 'SIGTERM') } catch { /* ignore */ }
364
+ clearDaemonState()
365
+ throw new Error(`failed to start standalone agent: ${error.message}`)
366
+ }
367
+ console.log(url)
368
+ return 0
369
+ }
370
+
371
+ async function cmdStop() {
372
+ const state = readDaemonState()
373
+ if (!state || !isPidRunning(state.pid)) {
374
+ clearDaemonState()
375
+ console.error('standalone agent is not running')
376
+ return 0
377
+ }
378
+ process.kill(state.pid, 'SIGTERM')
379
+ const deadline = Date.now() + 10_000
380
+ while (Date.now() < deadline && isPidRunning(state.pid)) {
381
+ await new Promise(resolve => setTimeout(resolve, 100))
382
+ }
383
+ if (isPidRunning(state.pid)) {
384
+ process.kill(state.pid, 'SIGKILL')
385
+ }
386
+ clearDaemonState()
387
+ console.error(`stopped standalone agent (pid ${state.pid})`)
388
+ return 0
389
+ }
390
+
391
+ async function cmdStatus() {
392
+ const state = readDaemonState()
393
+ const host = state?.host || bindHost()
394
+ const port = state?.port || mcpPort()
395
+ const url = mcpUrl(host, port)
396
+ const health = healthUrl(host, port)
397
+ const running = state ? isPidRunning(state.pid) : false
398
+ const healthy = await probeHealth(health)
399
+ console.log(JSON.stringify({
400
+ running,
401
+ healthy,
402
+ pid: running ? state.pid : null,
403
+ url,
404
+ health,
405
+ }))
406
+ return running && healthy ? 0 : 1
407
+ }
408
+
409
+ async function cmdUrl() {
410
+ const state = readDaemonState()
411
+ const host = state?.host || bindHost()
412
+ const port = state?.port || mcpPort()
413
+ console.log(mcpUrl(host, port))
414
+ return 0
415
+ }
416
+
417
+ async function main() {
418
+ const argv = process.argv.slice(2)
419
+ // Backward-compatible: bare invocation with no args starts foreground (tests + local use).
420
+ const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'start'
421
+ const rest = command === argv[0] ? argv.slice(1) : argv
422
+
423
+ if (command === 'help' || command === '--help' || command === '-h') {
424
+ printUsage()
425
+ return 0
426
+ }
427
+ if (command === 'stop') return cmdStop()
428
+ if (command === 'status') return cmdStatus()
429
+ if (command === 'url') return cmdUrl()
430
+ if (command !== 'start') {
431
+ printUsage()
432
+ throw new Error(`unknown command: ${command}`)
433
+ }
434
+
435
+ const background = rest.includes('--background') || rest.includes('-d')
436
+ const passthroughArgs = rest.filter(arg => arg !== '--background' && arg !== '-d')
437
+ const binary = await resolveBinary()
438
+ if (background) return startBackground(binary, passthroughArgs)
439
+ return startForeground(binary, passthroughArgs)
440
+ }
441
+
442
+ main().then(code => {
443
+ if (typeof code === 'number') process.exitCode = code
444
+ }).catch(error => {
445
+ console.error(error.message)
446
+ process.exitCode = 1
447
+ })
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@opute/host-agent",
3
+ "version": "0.1.1",
4
+ "description": "Local Streamable HTTP launcher for the Opute Go host agent",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/wunderous/host-agents.git",
9
+ "directory": "npm/local-host-agent"
10
+ },
11
+ "homepage": "https://github.com/wunderous/host-agents#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/wunderous/host-agents/issues"
14
+ },
15
+ "keywords": ["mcp", "model-context-protocol", "incus", "infrastructure", "opute"],
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
19
+ "bin": {
20
+ "opute-host-agent": "index.js"
21
+ },
22
+ "files": ["index.js", "package.json", "README.md"],
23
+ "engines": {
24
+ "node": ">=18"
25
+ }
26
+ }