@houndmcp/hound-mcp-pi 10.3.0 → 10.3.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
@@ -15,12 +15,30 @@ Hound web research for the Pi agent. Six native tools, all routed through a sing
15
15
  # 1. Install Hound (the MCP server / engine)
16
16
  pip install hound-mcp[all]
17
17
 
18
- # 2. Install this Pi extension
18
+ # 2. Install this Pi extension (npm, auto-updates)
19
+ pi install npm:@houndmcp/hound-mcp-pi
20
+ ```
21
+
22
+ Alternatively, install from git (pins to a specific tag):
23
+
24
+ ```bash
19
25
  pi install git:github.com/dondai1234/master-fetch@v10.3.0
20
26
  ```
21
27
 
22
28
  That's it. The extension auto-discovers `hound` on your PATH, spawns it as a singleton subprocess, and prewarms it at session start. No API key, no account, no config file.
23
29
 
30
+ ## Updating
31
+
32
+ ```bash
33
+ # Update Hound (the MCP server)
34
+ hound -u
35
+
36
+ # Update the Pi extension (if installed via npm, unpinned)
37
+ pi update npm:@houndmcp/hound-mcp-pi
38
+ ```
39
+
40
+ The extension checks at session start whether the installed Hound version matches the extension version. If they diverge by a major version, it warns you to update both.
41
+
24
42
  ## How it works
25
43
 
26
44
  The extension spawns `hound` (the MCP server) as a stdio subprocess and speaks MCP JSON-RPC to it. The subprocess is a singleton — it stays alive for the whole Pi session, so Hound's startup prewarm (stealthy browser, search engines, neural reranker) runs once and persists. Zero re-launch cost per call.
@@ -15,7 +15,7 @@
15
15
  * resolved from PATH so it tracks the installed Hound version automatically.
16
16
  *
17
17
  * Install: pip install hound-mcp[all]
18
- * Then: pi install git:github.com/dondai1234/master-fetch@v10.3.0
18
+ * Then: pi install npm:@houndmcp/hound-mcp-pi
19
19
  */
20
20
 
21
21
  import { Type } from "typebox";
@@ -26,9 +26,24 @@ import * as path from "node:path";
26
26
  import * as os from "node:os";
27
27
  import * as fs from "node:fs";
28
28
 
29
- // -- Hound executable resolution --
29
+ // -- Version (read from package.json at load time) --
30
+
31
+ function readPkgVersion(): string {
32
+ try {
33
+ const pkgPath = path.join(__dirname, "..", "package.json");
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
+ return pkg.version || "unknown";
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
41
+ const EXTENSION_VERSION = readPkgVersion();
42
+
43
+ // -- Hound executable resolution (lazy, re-tried on each ensureReady if null) --
30
44
 
31
45
  function resolveHoundExe(): string | null {
46
+ // 1. PATH lookup (covers 99% of installs)
32
47
  try {
33
48
  const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
34
49
  const out = execSync(cmd, {
@@ -39,14 +54,17 @@ function resolveHoundExe(): string | null {
39
54
  const first = out.split(/\r?\n/)[0];
40
55
  if (first && fs.existsSync(first)) return first;
41
56
  } catch {}
42
- const fallback = path.join(
43
- os.homedir(),
44
- "AppData", "Local", "Programs", "Python", "Python311", "Scripts", "hound.exe",
45
- );
46
- return fs.existsSync(fallback) ? fallback : null;
57
+ // 2. Fallback: common Windows install paths (multiple Python versions)
58
+ if (process.platform === "win32") {
59
+ const home = os.homedir();
60
+ for (const v of ["Python311", "Python312", "Python313", "Python310"]) {
61
+ const p = path.join(home, "AppData", "Local", "Programs", "Python", v, "Scripts", "hound.exe");
62
+ if (fs.existsSync(p)) return p;
63
+ }
64
+ }
65
+ return null;
47
66
  }
48
67
 
49
- const HOUND_EXE = resolveHoundExe();
50
68
  const INIT_TIMEOUT_MS = 15_000;
51
69
  const CALL_TIMEOUT_MS = 90_000;
52
70
  const CRAWL_TIMEOUT_MS = 150_000;
@@ -54,20 +72,6 @@ const INIT_ATTEMPTS = 3;
54
72
  const INIT_BACKOFF_MS = 400;
55
73
 
56
74
  // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
57
- //
58
- // Hound is spawned once per Pi session and kept alive. Its startup prewarm
59
- // (stealthy browser + search-engine sessions + neural reranker ONNX load)
60
- // runs in the background during session_start, so by the time the agent
61
- // makes its first web_fetch/search call, Hound is already warm. A dead
62
- // subprocess (crash, OS kill) triggers a transparent re-init on the next
63
- // call - callers never see a raw spawn error, just a slightly slower call.
64
- //
65
- // The MCP initialize handshake has a hard timeout. Hound occasionally spawns
66
- // dead: its synchronous import playwright (via _get_scrapling) runs on the
67
- // single-threaded asyncio loop and freezes it so server.run can never write
68
- // the reply. The process is alive but mute - no stderr, no exit - so a longer
69
- // timeout cannot help. A fresh re-spawn reliably responds in ~2-3s, so we
70
- // kill the dead one and retry up to INIT_ATTEMPTS times.
71
75
 
72
76
  interface Pending {
73
77
  resolve: (v: any) => void;
@@ -77,6 +81,8 @@ interface Pending {
77
81
 
78
82
  class HoundClient {
79
83
  private proc: ChildProcess | null = null;
84
+ private houndExe: string | null = null;
85
+ private houndVersion: string | null = null;
80
86
  private ready: Promise<boolean> | null = null;
81
87
  private initInFlight: Promise<boolean> | null = null;
82
88
  private nextId = 0;
@@ -84,9 +90,17 @@ class HoundClient {
84
90
  private stdoutBuf = "";
85
91
  private lastStderr = "";
86
92
 
93
+ getExe(): string | null { return this.houndExe; }
94
+
95
+ hasExe(): boolean { return this.houndExe !== null; }
96
+
87
97
  async ensureReady(): Promise<boolean> {
88
98
  if (this.ready) return this.ready;
89
- if (!HOUND_EXE) return false;
99
+ // Re-resolve hound exe if not found yet (handles install-after-load)
100
+ if (!this.houndExe) {
101
+ this.houndExe = resolveHoundExe();
102
+ }
103
+ if (!this.houndExe) return false;
90
104
  if (this.initInFlight) return this.initInFlight;
91
105
  const p = this._initWithRetry();
92
106
  this.initInFlight = p;
@@ -107,6 +121,13 @@ class HoundClient {
107
121
  }
108
122
 
109
123
  private _initOnce(): Promise<boolean> {
124
+ // Kill any existing process before spawning a new one (prevents orphans)
125
+ if (this.proc && !this.proc.killed) {
126
+ try { this.proc.stdin?.end(); } catch {}
127
+ try { this.proc.kill(); } catch {}
128
+ }
129
+ this.proc = null;
130
+
110
131
  return new Promise<boolean>((resolve) => {
111
132
  let settled = false;
112
133
  let initTimer: NodeJS.Timeout | undefined;
@@ -115,11 +136,11 @@ class HoundClient {
115
136
  if (settled) return;
116
137
  settled = true;
117
138
  if (initTimer) clearTimeout(initTimer);
118
- if (!ok) this.kill();
139
+ if (!ok) this._killProcess();
119
140
  resolve(ok);
120
141
  };
121
142
  try {
122
- this.proc = spawn(HOUND_EXE!, [], {
143
+ this.proc = spawn(this.houndExe!, [], {
123
144
  stdio: ["pipe", "pipe", "pipe"],
124
145
  windowsHide: true,
125
146
  env: { ...process.env },
@@ -176,7 +197,7 @@ class HoundClient {
176
197
  params: {
177
198
  protocolVersion: "2025-03-26",
178
199
  capabilities: {},
179
- clientInfo: { name: "pi-hound", version: "10.3.0" },
200
+ clientInfo: { name: "pi-hound", version: EXTENSION_VERSION },
180
201
  },
181
202
  id,
182
203
  }) + "\n",
@@ -215,10 +236,15 @@ class HoundClient {
215
236
  } catch {}
216
237
  }
217
238
 
218
- async call(name: string, args: Record<string, any>, timeoutMs = CALL_TIMEOUT_MS): Promise<any> {
239
+ async call(
240
+ name: string,
241
+ args: Record<string, any>,
242
+ timeoutMs = CALL_TIMEOUT_MS,
243
+ signal?: AbortSignal,
244
+ ): Promise<any> {
219
245
  const ready = await this.ensureReady();
220
246
  if (!ready) {
221
- const hint = HOUND_EXE
247
+ const hint = this.houndExe
222
248
  ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
223
249
  : "Hound not found. Install: pip install hound-mcp[all]";
224
250
  throw new Error(hint);
@@ -228,25 +254,44 @@ class HoundClient {
228
254
  reject(new Error("Hound not running"));
229
255
  return;
230
256
  }
257
+ if (signal?.aborted) {
258
+ reject(new Error(`Hound cancelled: ${name}`));
259
+ return;
260
+ }
231
261
  const id = ++this.nextId;
232
- const timer = setTimeout(() => {
262
+ let timer: NodeJS.Timeout;
263
+ let onAbort: () => void;
264
+ const cleanup = () => {
265
+ clearTimeout(timer);
233
266
  this.pending.delete(id);
267
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
268
+ };
269
+ timer = setTimeout(() => {
270
+ cleanup();
234
271
  reject(new Error(`Hound timeout: ${name}`));
235
272
  }, timeoutMs);
236
- this.pending.set(id, { resolve, reject, timer });
273
+ onAbort = () => {
274
+ cleanup();
275
+ reject(new Error(`Hound cancelled: ${name}`));
276
+ };
277
+ signal?.addEventListener("abort", onAbort, { once: true });
278
+ this.pending.set(id, {
279
+ resolve: (v: any) => { cleanup(); resolve(v); },
280
+ reject: (e: Error) => { cleanup(); reject(e); },
281
+ timer,
282
+ });
237
283
  try {
238
284
  this.proc.stdin!.write(
239
285
  JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
240
286
  );
241
287
  } catch (e) {
242
- clearTimeout(timer);
243
- this.pending.delete(id);
288
+ cleanup();
244
289
  reject(e as Error);
245
290
  }
246
291
  });
247
292
  }
248
293
 
249
- kill() {
294
+ private _killProcess() {
250
295
  if (this.proc && !this.proc.killed) {
251
296
  try { this.proc.stdin?.end(); } catch {}
252
297
  try { this.proc.kill(); } catch {}
@@ -257,6 +302,8 @@ class HoundClient {
257
302
  for (const [, { timer }] of this.pending) clearTimeout(timer);
258
303
  this.pending.clear();
259
304
  }
305
+
306
+ kill() { this._killProcess(); }
260
307
  }
261
308
 
262
309
  const hound = new HoundClient();
@@ -288,7 +335,38 @@ function pick(params: Record<string, any>, keys: string[]): Record<string, any>
288
335
  // -- Extension --
289
336
 
290
337
  export default function (pi: ExtensionAPI) {
291
- pi.on("session_start", () => { hound.ensureReady().catch(() => {}); });
338
+ pi.on("session_start", async (_event, ctx) => {
339
+ const ok = await hound.ensureReady().catch(() => false);
340
+ if (!ok) {
341
+ if (ctx.hasUI) {
342
+ ctx.ui.notify(
343
+ hound.hasExe()
344
+ ? "Hound found but failed to start. Run: hound --doctor"
345
+ : "Hound not found. Install: pip install hound-mcp[all]",
346
+ "warn",
347
+ );
348
+ }
349
+ return;
350
+ }
351
+ // Best-effort version sync check (non-blocking)
352
+ if (ctx.hasUI && EXTENSION_VERSION !== "unknown") {
353
+ hound.call("version", {}, 5_000).then((result) => {
354
+ const parsed = tryJson(getText(result));
355
+ if (parsed.version) {
356
+ hound["houndVersion"] = parsed.version;
357
+ const extMajor = EXTENSION_VERSION.split(".")[0];
358
+ const hMajor = String(parsed.version).split(".")[0];
359
+ if (extMajor !== hMajor) {
360
+ ctx.ui.notify(
361
+ `Hound extension v${EXTENSION_VERSION} vs hound v${parsed.version} (major mismatch). Update: hound -u && pi update npm:@houndmcp/hound-mcp-pi`,
362
+ "warn",
363
+ );
364
+ }
365
+ }
366
+ }).catch(() => {});
367
+ }
368
+ });
369
+
292
370
  pi.on("session_shutdown", () => { hound.kill(); });
293
371
 
294
372
  // -- web_fetch --
@@ -313,10 +391,10 @@ export default function (pi: ExtensionAPI) {
313
391
  actions: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }), { description: "Page interactions on stealthy browser AFTER load, BEFORE extraction. Forces stealthy + bypasses cache. Each item: {click:'css'}, {fill:{selector:'css',text:'x'}}, {press:'Enter'}, {wait:500}, {scroll:3}, {wait_selector:'css'}." })),
314
392
  options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "include_links (bool,false: response.links=citations/navigation/external+primary_source), include_media (bool,false: up to 20 page image URLs), archive_fallback (bool,true: recover from Internet Archive on hard-block; false=raw failure), proxy, cookies, extra_headers, useragent, wait, network_idle, headless, respect_robots, real_chrome/solve_cloudflare/block_webrtc/hide_canvas/main_content_only/use_trafilatura (anti-detect tuning, good defaults, rarely needed)." })),
315
393
  }),
316
- async execute(_id, params, _signal, _onUpdate, _ctx) {
394
+ async execute(_id, params, signal, _onUpdate, _ctx) {
317
395
  try {
318
396
  const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
319
- const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS);
397
+ const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS, signal);
320
398
  const text = getText(result);
321
399
  const parsed = tryJson(text);
322
400
  if (Array.isArray(parsed.results)) {
@@ -368,11 +446,11 @@ export default function (pi: ExtensionAPI) {
368
446
  query: Type.String({ description: "Search query" }),
369
447
  options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "max_results (1-50,6), cache_ttl (300), mode (auto|neural|find_similar), engines (list), site, exclude_sites, location, language, region, page, freshness, url (for find_similar)" })),
370
448
  }),
371
- async execute(_id, params, _signal, _onUpdate, _ctx) {
449
+ async execute(_id, params, signal, _onUpdate, _ctx) {
372
450
  try {
373
451
  const args: Record<string, any> = { query: params.query };
374
452
  if (params.options && Object.keys(params.options).length) args.options = params.options;
375
- const result = await hound.call("mcp_smart_search", args, 60_000);
453
+ const result = await hound.call("mcp_smart_search", args, 60_000, signal);
376
454
  const text = getText(result);
377
455
  const parsed = tryJson(text);
378
456
  const results: any[] = parsed.results ?? [];
@@ -425,10 +503,10 @@ export default function (pi: ExtensionAPI) {
425
503
  crawl_urls: Type.Optional(Type.Array(Type.String(), { description: "Chosen subset of URLs to fetch (second-phase selective crawl, no re-discovery). Use after sitemap=true or discover_only=true." })),
426
504
  options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "sitemap (true|'auto'|false,false: true=map from sitemap.xml in one fetch; 'auto'=use if present else BFS), max_pages (1-100,10), max_depth (0-5,2), path_include (list of path prefixes), path_exclude (list to skip), max_content_chars_per (8000), max_total_chars (token budget), concurrency (1-5,3), cache_ttl (3600;0=fresh), respect_robots (false), force_fetcher ('http'|'stealthy'), timeout (ms,30000), deadline_ms (120000)." })),
427
505
  }),
428
- async execute(_id, params, _signal, _onUpdate, _ctx) {
506
+ async execute(_id, params, signal, _onUpdate, _ctx) {
429
507
  try {
430
508
  const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
431
- const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS);
509
+ const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS, signal);
432
510
  const text = getText(result);
433
511
  const parsed = tryJson(text);
434
512
  const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
@@ -474,16 +552,16 @@ export default function (pi: ExtensionAPI) {
474
552
  name: "web_screenshot",
475
553
  label: "Web Screenshot",
476
554
  description: "Screenshot a URL as an image. Multimodal agents only (content as images/canvas/visual layout). Text agents: use web_fetch. Stealthy browser auto-managed.",
477
- promptSnippet: "web_screenshot(url, full_page, image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
555
+ promptSnippet: "web_screenshot(url, options.full_page, options.image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
478
556
  parameters: Type.Object({
479
557
  url: Type.String({ description: "URL to screenshot" }),
480
558
  options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "full_page (bool,false), image_type (png|jpeg,png), quality (0-100,jpeg), wait (ms), wait_selector (css), network_idle (bool), timeout (ms,30000)." })),
481
559
  }),
482
- async execute(_id, params, _signal, _onUpdate, _ctx) {
560
+ async execute(_id, params, signal, _onUpdate, _ctx) {
483
561
  try {
484
562
  const args: Record<string, any> = { url: params.url };
485
563
  if (params.options && Object.keys(params.options).length) args.options = params.options;
486
- const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS);
564
+ const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS, signal);
487
565
  const images = getImages(result);
488
566
  if (images.length) {
489
567
  const out: any[] = [];
@@ -523,10 +601,10 @@ export default function (pi: ExtensionAPI) {
523
601
  parameters: Type.Object({
524
602
  all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
525
603
  }),
526
- async execute(_id, params, _signal, _onUpdate, _ctx) {
604
+ async execute(_id, params, signal, _onUpdate, _ctx) {
527
605
  try {
528
606
  const args = { all: params.all ?? false };
529
- const result = await hound.call("cache_clear", args, 10_000);
607
+ const result = await hound.call("cache_clear", args, 10_000, signal);
530
608
  return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
531
609
  } catch (e: any) {
532
610
  return { content: [{ type: "text", text: `cache_clear error: ${e.message}` }], details: { error: e.message } };
@@ -549,12 +627,28 @@ export default function (pi: ExtensionAPI) {
549
627
  description: "Hound version + update status.",
550
628
  promptSnippet: "hound_version() - hound version + update status.",
551
629
  parameters: Type.Object({}),
552
- async execute(_id, _params, _signal, _onUpdate, _ctx) {
630
+ async execute(_id, _params, signal, _onUpdate, _ctx) {
553
631
  try {
554
- const result = await hound.call("version", {}, 10_000);
555
- return { content: [{ type: "text", text: getText(result) }], details: {} };
632
+ const result = await hound.call("version", {}, 10_000, signal);
633
+ const parsed = tryJson(getText(result));
634
+ const v = parsed.version || "unknown";
635
+ const latest = parsed.latest || "";
636
+ const upToDate = parsed.up_to_date;
637
+ const extInfo = EXTENSION_VERSION !== "unknown" ? ` | extension v${EXTENSION_VERSION}` : "";
638
+ const text = upToDate
639
+ ? `Hound ${v} (up to date)${extInfo}`
640
+ : `Hound ${v} (update available: ${latest})${extInfo}. Run: hound -u`;
641
+ return { content: [{ type: "text", text }], details: { version: v, latest, up_to_date: upToDate, extension: EXTENSION_VERSION } };
556
642
  } catch (e: any) {
557
- return { content: [{ type: "text", text: `hound_version error: ${e.message}` }], details: { error: e.message } };
643
+ // Fallback: try hound --version via execSync (works even when MCP server is down)
644
+ try {
645
+ const exe = hound.getExe();
646
+ if (exe) {
647
+ const out = execSync(`"${exe}" --version`, { timeout: 5000, windowsHide: true }).toString().trim();
648
+ return { content: [{ type: "text", text: out }], details: { fallback: true, extension: EXTENSION_VERSION } };
649
+ }
650
+ } catch {}
651
+ return { content: [{ type: "text", text: `hound_version error: ${e.message}` }], details: { error: e.message, extension: EXTENSION_VERSION } };
558
652
  }
559
653
  },
560
654
  renderCall(_args, theme) {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@houndmcp/hound-mcp-pi",
3
- "version": "10.3.0",
3
+ "version": "10.3.1",
4
4
  "description": "Hound web research for Pi agent - keyless search, anti-bot fetch, deep crawl, screenshot, Internet Archive dead-link recovery. Free, MIT, no API key.",
5
5
  "publishConfig": {"access": "public"},
6
+ "files": ["extensions/", "README.md"],
6
7
  "keywords": ["pi-package", "mcp", "web", "search", "fetch", "crawl", "ai-agent", "scraping"],
7
8
  "license": "MIT",
8
9
  "author": "dondai1234",