mcp-scraper 0.5.0 → 0.7.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.
Files changed (52) hide show
  1. package/dist/bin/api-server.cjs +675 -214
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +2 -2
  4. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  5. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  6. package/dist/bin/mcp-scraper-cli.js +1 -1
  7. package/dist/bin/mcp-scraper-install.cjs +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-install.js +1 -1
  10. package/dist/bin/mcp-stdio-server.cjs +322 -11
  11. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-stdio-server.js +3 -3
  13. package/dist/bin/paa-harvest.cjs +166 -74
  14. package/dist/bin/paa-harvest.cjs.map +1 -1
  15. package/dist/bin/paa-harvest.js +1 -1
  16. package/dist/chunk-EGWJ74EX.js +7 -0
  17. package/dist/chunk-EGWJ74EX.js.map +1 -0
  18. package/dist/{chunk-APJO2XV5.js → chunk-HL33CGJF.js} +3 -3
  19. package/dist/{chunk-APJO2XV5.js.map → chunk-HL33CGJF.js.map} +1 -1
  20. package/dist/{chunk-AUCXKRRH.js → chunk-RMPPYKUV.js} +167 -75
  21. package/dist/chunk-RMPPYKUV.js.map +1 -0
  22. package/dist/{chunk-BL7BBSYF.js → chunk-RUGJE5EB.js} +2 -2
  23. package/dist/{chunk-J4T5OSCF.js → chunk-YGRZU7IR.js} +322 -11
  24. package/dist/chunk-YGRZU7IR.js.map +1 -0
  25. package/dist/index.cjs +166 -74
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/{server-Z4MEISH5.js → server-JNY4XPZE.js} +138 -81
  29. package/dist/server-JNY4XPZE.js.map +1 -0
  30. package/dist/{site-extract-repository-U476J44K.js → site-extract-repository-NVSZH35Y.js} +3 -3
  31. package/dist/{worker-KJZ3ZN2N.js → worker-JQTS437L.js} +4 -4
  32. package/dist/worker-JQTS437L.js.map +1 -0
  33. package/package.json +2 -2
  34. package/dist/chunk-3RTPEQJC.js +0 -7
  35. package/dist/chunk-3RTPEQJC.js.map +0 -1
  36. package/dist/chunk-AUCXKRRH.js.map +0 -1
  37. package/dist/chunk-J4T5OSCF.js.map +0 -1
  38. package/dist/server-Z4MEISH5.js.map +0 -1
  39. package/dist/worker-KJZ3ZN2N.js.map +0 -1
  40. package/docs/spec-browser-routing.md +0 -108
  41. package/docs/spec-kernel-computer-controls.md +0 -166
  42. package/docs/spec-mcp-tools.md +0 -146
  43. package/docs/spec-server-instructions.md +0 -94
  44. package/docs/specs/agent-ready-seo-packet-spec.md +0 -237
  45. package/docs/specs/audit-visual-demo.md +0 -309
  46. package/docs/specs/cli-agent-wiring-spec.md +0 -206
  47. package/docs/specs/local-competitive-audit-spec.md +0 -311
  48. package/docs/specs/scheduled-workflows-api-spec.md +0 -304
  49. package/docs/specs/seo-cli-growth-roadmap-spec.md +0 -181
  50. package/docs/specs/seo-workflow-runner-and-reports-spec.md +0 -241
  51. /package/dist/{chunk-BL7BBSYF.js.map → chunk-RUGJE5EB.js.map} +0 -0
  52. /package/dist/{site-extract-repository-U476J44K.js.map → site-extract-repository-NVSZH35Y.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/api/webhook.ts","../src/api/worker.ts"],"sourcesContent":["export async function deliverWebhook(url: string, payload: object, retries = 3): Promise<void> {\n for (let attempt = 1; attempt <= retries; attempt++) {\n try {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(10_000),\n })\n if (res.ok) return\n console.warn(`[webhook] attempt ${attempt} → ${res.status} from ${url}`)\n } catch (err) {\n console.warn(`[webhook] attempt ${attempt} failed:`, err instanceof Error ? err.message : err)\n }\n if (attempt < retries) await new Promise((r) => setTimeout(r, 1000 * attempt * 2))\n }\n console.error(`[webhook] gave up after ${retries} attempts for ${url}`)\n}\n","import { claimPendingJob, completeJob, failJob, creditMc, debitMc, listHarvestAttempts } from './db.js'\nimport { browserServiceApiKey } from '../lib/browser-service-env.js'\nimport { harvest } from '../harvest.js'\nimport { deliverWebhook } from './webhook.js'\nimport type { HarvestOptions } from '../types.js'\nimport { MC_COSTS } from './rates.js'\nimport { classifyHarvestProblem, harvestProblemResponse, serializeHarvestProblem } from './harvest-problems.js'\nimport { createHarvestAttemptRecorder } from './harvest-attempt-events.js'\nimport { runWithCostContext } from './cost-context.js'\n\nexport type TickResult = {\n claimed: boolean\n jobId?: string\n completed?: boolean\n durationMs?: number\n}\nexport type DrainBudget = {\n maxJobs: number\n deadlineMs: number\n}\n\nconst MAX_CONCURRENT = 2\nlet running = 0\n\nfunction countPaaQuestions(result: unknown): number {\n if (!result || typeof result !== 'object') return 0\n const value = result as { totalQuestions?: unknown; flat?: unknown }\n if (typeof value.totalQuestions === 'number') return value.totalQuestions\n return Array.isArray(value.flat) ? value.flat.length : 0\n}\n\nfunction paaCostForQuestionCount(questionCount: number): number {\n return Math.max(1, questionCount) * MC_COSTS.paa\n}\n\nasync function processJob(job: Awaited<ReturnType<typeof claimPendingJob>> & object) {\n running++\n try {\n const opts = typeof job.options === 'string' ? JSON.parse(job.options) as Partial<HarvestOptions> & { billingHoldMc?: number } : job.options as Partial<HarvestOptions> & { billingHoldMc?: number }\n const result = await runWithCostContext(\n { op: opts.serpOnly ? 'serp' : 'paa', userId: Number(job.user_id) },\n () => harvest({\n ...opts,\n kernelApiKey: browserServiceApiKey(),\n headless: true,\n format: 'json',\n outputDir: '/tmp/paa-output-api',\n onAttemptEvent: createHarvestAttemptRecorder(job.id, job.user_id),\n }),\n )\n await completeJob(job.id, result)\n const attempts = await listHarvestAttempts(job.id, job.user_id)\n if (!opts.serpOnly && typeof opts.billingHoldMc === 'number') {\n const actualCost = paaCostForQuestionCount(countPaaQuestions(result))\n const diff = opts.billingHoldMc - actualCost\n if (diff > 0) await creditMc(job.user_id, diff, 'paa_refund', 'overestimate refund')\n else if (diff < 0) await debitMc(job.user_id, -diff, 'paa', opts.query ?? job.query)\n }\n if (job.callback_url) {\n await deliverWebhook(job.callback_url, { job_id: job.id, status: 'done', result, attempts })\n }\n } catch (err) {\n const problem = classifyHarvestProblem(err)\n await failJob(job.id, serializeHarvestProblem(problem))\n const attempts = await listHarvestAttempts(job.id, job.user_id)\n try {\n const opts = typeof job.options === 'string' ? JSON.parse(job.options) as { billingHoldMc?: number } : job.options as { billingHoldMc?: number }\n if (typeof opts.billingHoldMc === 'number' && opts.billingHoldMc > 0) {\n await creditMc(job.user_id, opts.billingHoldMc, 'refund', 'failed call')\n }\n } catch {}\n if (job.callback_url) {\n await deliverWebhook(job.callback_url, { job_id: job.id, status: 'failed', ...harvestProblemResponse(problem), attempts })\n }\n } finally {\n running--\n }\n}\n\nexport async function tickOnce(): Promise<TickResult> {\n const job = await claimPendingJob()\n if (!job) return { claimed: false }\n const startedAt = Date.now()\n await processJob(job as NonNullable<typeof job>)\n return { claimed: true, jobId: (job as { id: string }).id, completed: true, durationMs: Date.now() - startedAt }\n}\n\nexport async function drainQueue(budget: DrainBudget): Promise<TickResult[]> {\n const results: TickResult[] = []\n for (let i = 0; i < budget.maxJobs; i++) {\n if (Date.now() >= budget.deadlineMs) break\n const r = await tickOnce()\n results.push(r)\n if (!r.claimed) break\n }\n return results\n}\n\nexport function startWorker(): void {\n setInterval(async () => {\n if (running >= MAX_CONCURRENT) return\n const job = await claimPendingJob()\n if (job) void processJob(job as NonNullable<typeof job>)\n }, 2000)\n console.log(`[worker] started — polling every 2s, max ${MAX_CONCURRENT} concurrent`)\n}\n"],"mappings":"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","names":[]}
@@ -1,108 +0,0 @@
1
- # Spec: Headless-first browser routing + smart proxy (scrape / SERP / Maps)
2
-
3
- ## Context & goal
4
- We confirmed (Kernel docs + live probes) that: Kernel defaults to **headful (8 GB, $0.0001333/s)**; we never sent `headless`, so we've been paying **8×** on every browser op. **Headless (1 GB, $0.0000166667/s)** works for most scraping; **proxies of any type are FREE**; **idle is free**. Goal: route each operation to the cheapest mode that still works, escalate only when needed, and always get full (local-pack) SERP/Maps results.
5
-
6
- ## Test evidence (decisions are grounded in these)
7
- - SERP headless + location proxy → 9 organic + 3 local-pack, 1 attempt, 11s. ✅
8
- - SERP headless **no proxy** → 9 organic, **0 local-pack**, 4s. ✅ organic only (local pack needs a regional proxy IP).
9
- - SERP headless + proxy occasionally CAPTCHAs on attempt 1, recovers on a fresh-IP retry. ✅
10
- - Website plain HTTP (no Kernel) scraped dallas.gov, 6s. ✅ (don't need Kernel for normal sites.)
11
- - Website Kernel headless + rotating proxy → 2 pages. ✅
12
- - **maps_search headless** (proxy and no-proxy) → 10 results, fast. ✅
13
- - **maps_place headless → "Target crashed" (×2)**; headful → OK (5 reviews, 23s). ❌ headless for place.
14
-
15
- ## Routing policy (the decision table to implement)
16
- | Op (cost-context `op`) | Primary mode | Proxy | Escalation / fallback | Notes |
17
- |---|---|---|---|---|
18
- | `page_scrape` (extract_url) | plain HTTP (no Kernel) | none | **headless** Kernel on HTTP fail | concurrency 1 |
19
- | `extract_site` (bulk) | plain HTTP, concurrency = plan limit (3 on Starter) | none | **headless** Kernel rotating on block | already HTTP-first |
20
- | `harvest` (serp/paa) | **headless** | **rotating location** | fresh-IP retry ×N, then **headful** Kernel | retry already = new session+IP |
21
- | `maps_search` | **headless** | **rotating location** | fresh-IP retry ×N, then **headful** | |
22
- | `maps_place` | **HEADFUL (required)** | rotating location | n/a (already headful) | **headless crashes** |
23
- | `yt_channel`, `fb_search` | **headless** | existing | — | proven headless |
24
- | `fb_ad`, `instagram_*` | headful (unchanged) | existing | — | **untested headless — keep headful until probed** |
25
- | `browser_session` (interactive) | headful (required) | existing | — | Live View/replays need headful |
26
-
27
- Proxy rule for SERP + Maps: **always rotating location proxy**; if the user passes a `location`, resolve a region proxy but **still rotate to a new proxy per attempt**.
28
-
29
- ## Implementation (exact changes)
30
-
31
- ### 1. Shared headless-op config — `src/api/cost-rates.ts`
32
- Add:
33
- ```ts
34
- export const HEADLESS_OPS = new Set<string>(['harvest', 'maps_search', 'yt_channel', 'fb_search'])
35
- ```
36
- (Do NOT include `maps_place`, `browser_session`, `fb_ad`, `instagram_*`.) This is the single source of truth; expand only after a tool is probed headless-safe.
37
-
38
- ### 2. BrowserDriver headless decision + per-attempt override — `src/driver/BrowserDriver.ts`
39
- - Import `HEADLESS_OPS` from `../api/cost-rates.js` (already imports `currentCostContext`).
40
- - Add to `DriverConfig` (`src/types.ts`): `headlessMode?: 'headless' | 'headful'`.
41
- - Replace the current inline block (the `HEADLESS_SAFE_OPS` set + `useHeadless`) with:
42
- ```ts
43
- const ctx = currentCostContext()
44
- let useHeadless: boolean
45
- if (config.headlessMode === 'headful') useHeadless = false
46
- else if (config.headlessMode === 'headless') useHeadless = true
47
- else useHeadless = ctx?.forceHeadless === true
48
- || (process.env.KERNEL_HEADLESS_DISABLED !== 'true' && !!ctx?.op && HEADLESS_OPS.has(ctx.op))
49
- this.kernelHeadlessSent = useHeadless ? true : null
50
- // create payload: ...(useHeadless ? { headless: true } : {})
51
- ```
52
- `config.headlessMode` (set by the retry loop) wins over the op default, enabling headful escalation.
53
-
54
- ### 3. Headful escalation for SERP/PAA — `src/harvest.ts`
55
- - Add a constant: `const HEADLESS_ATTEMPTS = MAX_ATTEMPTS` (i.e. all normal attempts headless), and extend the loop with ONE extra headful attempt.
56
- - In the attempt loop (`for (let i = 0; i < maxAttempts; i++)`, ~line 252), when building the per-attempt `DriverConfig` that is passed to `driver.launch(config)` (the config assembled around line 166/271), set:
57
- ```ts
58
- const escalateHeadful = i >= maxAttempts // last, extra attempt
59
- config.headlessMode = escalateHeadful ? 'headful' : 'headless'
60
- ```
61
- - Bump the loop bound so there is a final headful attempt: iterate `maxAttempts + 1`, where the last iteration forces `headful`. Only run the headful attempt if prior attempts failed with CAPTCHA (`outcome === 'captcha'`); if a headless attempt already succeeded, break as today.
62
-
63
- ### 4. Headful escalation for maps_search — `src/maps-search-rotation.ts`
64
- Mirror §3 in `runMapsSearchWithRotation` (~line 129): attempts 1..N headless (`headlessMode:'headless'`), final attempt `headlessMode:'headful'` if previous attempts failed. `maps_place` (`src/api/maps-routes.ts` `/place` → `MapsExtractor`) needs **no change** — its op is `maps_place`, absent from `HEADLESS_OPS`, so it stays headful automatically.
65
-
66
- ### 5. Website Kernel fallback → headless — `src/api/kernel-fetch.ts`
67
- Pin the website-fallback browser to headless (proven safe, 8× cheaper). Change the create to default headless unless an explicit headful override is in context:
68
- ```ts
69
- const ctx = currentCostContext()
70
- const useHeadless = ctx?.forceHeadless !== false // default headless for page fetches
71
- const kb = await client.browsers.create({ stealth: true, ...(useHeadless ? { headless: true } : {}), timeout_seconds: 60 })
72
- ```
73
- `rotating-proxy-crawl.ts` already passes `headless` explicitly (keep its `DEFAULT_HEADLESS = true`).
74
-
75
- ### 6. extract_url auto-fallback stays on — `src/api/server.ts` (`/extract-url`)
76
- Confirm the handler always passes `browserServiceApiKey()` into `extractKpo` so `tryPlainFetch → fetchWithKernel` fallback runs without requiring `browserFallback:true` (the probe showed it already falls back). If it is gated, ungate it so concurrency-1 normal scrapes get the HTTP→headless-Kernel backup by default.
77
-
78
- ### 7. Proxy defaults → rotating location for SERP — `src/schemas.ts`
79
- - Change `DEFAULT_PROXY_MODE` to `'location'` (maps is already `'location'`).
80
- - In the proxy resolver (`resolveKernelProxyId` / per-attempt proxy selection used by harvest + maps rotation): ensure each attempt requests a **new** proxy id even when a `location` is fixed (rotate within the region). If no `location` is supplied, `'location'` mode should fall back to rotating generic proxies rather than erroring — verify and patch the resolver.
81
-
82
- ### 8. Telemetry — already in place
83
- `kernel_session_log.headless_sent` + `cost_probe_runs.mode` already record the tier. After this change, `costs`/`probe-report` will show the real (now mostly-headless) spend. No change needed beyond re-running probes.
84
-
85
- ## Files changed (summary)
86
- - `src/api/cost-rates.ts` — add `HEADLESS_OPS`.
87
- - `src/types.ts` — add `headlessMode?` to `DriverConfig`.
88
- - `src/driver/BrowserDriver.ts` — headless decision honoring `config.headlessMode` + `HEADLESS_OPS`.
89
- - `src/harvest.ts` — headless attempts + final headful escalation.
90
- - `src/maps-search-rotation.ts` — same escalation for maps_search.
91
- - `src/api/kernel-fetch.ts` — default website fallback to headless.
92
- - `src/api/server.ts` — ensure extract_url passes kernel key for auto-fallback.
93
- - `src/schemas.ts` — `DEFAULT_PROXY_MODE = 'location'`; resolver rotates proxy per attempt.
94
-
95
- ## Kill-switch / rollback
96
- `KERNEL_HEADLESS_DISABLED=true` (env) reverts ALL ops to headful instantly. Per-op revert = remove the op from `HEADLESS_OPS`.
97
-
98
- ## Verification (run after implementing, before/after compare)
99
- 1. `npm run build` green.
100
- 2. Deploy to prod.
101
- 3. `npx tsx scripts/cost-probe.ts --api-key sk_live_…` (default) and `--headless` — expect: serp/paa/maps_search/yt_channel/fb_search now headless by default; maps_place still headful; website ops HTTP/headless.
102
- 4. `python3 .claude/skills/mcp-metrics/scripts/mcp_metrics.py probe-report` — confirm `mgn_hl` (now real) is positive/healthy and `try` counts show escalation only on CAPTCHA.
103
- 5. `… costs 1` — confirm live spend drops toward the headless column.
104
- 6. Owner pulls the Kernel invoice to confirm the per-second tier flipped to headless for the affected ops.
105
-
106
- ## Open items to test BEFORE widening
107
- - Probe `fb_ad`, `instagram_profile`, `instagram_media`, `fb_video_transcribe` in headless before adding to `HEADLESS_OPS` (maps_place proved untested headless can crash).
108
- - Decide SERP pricing: 0.1 cr barely covers a slow proxy SERP even headless; fast no-proxy profits.
@@ -1,166 +0,0 @@
1
- # Spec — Tighten Kernel Computer Controls usage (human-like input on the hosted browser)
2
-
3
- ## Correction to the original premise
4
- An earlier assessment claimed "we drive the Kernel browser over CDP with raw Playwright — we never use the Computer Controls API." **That was wrong** (a bad grep — searched raw HTTP paths + `page.mouse`, missed the SDK method names). Ground truth in `src/services/browser-agent/browser-agent-service.ts`:
5
-
6
- | Action | Implementation today | API used |
7
- |---|---|---|
8
- | screenshot | `k.browsers.computer.captureScreenshot` | ✅ Computer Controls |
9
- | click | `k.browsers.computer.clickMouse` | ✅ Computer Controls |
10
- | type | `k.browsers.computer.typeText` | ✅ Computer Controls |
11
- | scroll | `k.browsers.computer.scroll` | ✅ Computer Controls |
12
- | press | `k.browsers.computer.pressKey` | ✅ Computer Controls |
13
- | moveMouse | `k.browsers.computer.moveMouse({smooth:true})` | ✅ defined… but never called |
14
- | goto / read / locate / captureFanout | `playwrightChromium.connectOverCDP` | Playwright/CDP (DOM access — required, kept) |
15
-
16
- So the interactive agent's **input + screenshot already follow Kernel's API**. The DOM-read path uses Playwright-over-CDP because Computer Controls cannot read the DOM / element coordinates — that path stays (and is the same free Playwright code used off-Kernel).
17
-
18
- ## Dual-path principle (the rule, stated)
19
- - **On Kernel (hosted browser):** drive *input* (move/click/type/scroll/press/screenshot) through **Computer Controls** (`k.browsers.computer.*`) — native OS input, no CDP input fingerprint, bezier curves, inter-keystroke delays. Read the DOM via Playwright-over-CDP (no Computer Controls equivalent).
20
- - **Off Kernel (free local/HTTP path, `BrowserDriver` non-hosted, future local mode):** keep Playwright. It's free and there's no Computer Controls layer to use.
21
-
22
- This spec changes only the Kernel input path. It does not touch Playwright scraping (`BrowserDriver`, reddit/serp/maps/fb deterministic tools) — their bottleneck is IP/challenge, not input cadence, and they barely click.
23
-
24
- ## The two real gaps (verified)
25
- 1. **Clicks teleport.** `click()` calls `clickMouse(x,y)` with no preceding move. `moveMouse({smooth:true})` exists (bezier) but is **called from nowhere** (`grep` confirms one definition, zero call sites). So every click is an instant cursor jump to the target — a behavioral bot signal Kernel's docs flag ("identical cursor paths, rapid mouse movements"). The bezier capability is built and unused.
26
- 2. **Typing has no human cadence by default.** `typeText(id, text, delayMs?)` only passes `delay` if the *caller* supplies `body.delay` (`browser-agent-routes.ts:486`). The model never sends it → `delay` is `undefined` → instant uniform typing ("uniform typing speeds" — another flagged signal).
27
-
28
- ## Changes
29
-
30
- ### 1. `src/services/browser-agent/browser-agent-service.ts`
31
-
32
- **Add a constant + jitter helper near the top of the action section (after `client()`):**
33
- ```ts
34
- const HUMAN_TYPE_DELAY_MS = 55
35
- function jitter(base: number, spread: number): number {
36
- return Math.max(1, Math.round(base + (Math.random() * 2 - 1) * spread))
37
- }
38
- ```
39
-
40
- **`click()` — move (bezier) to the target before clicking.** Replace the body:
41
- ```ts
42
- export async function click(
43
- runtimeSessionId: string,
44
- x: number,
45
- y: number,
46
- opts: { button?: 'left' | 'right' | 'middle'; numClicks?: number } = {},
47
- ): Promise<void> {
48
- const k = client()
49
- await k.browsers.computer.moveMouse(runtimeSessionId, { x, y, smooth: true })
50
- await k.browsers.computer.clickMouse(runtimeSessionId, {
51
- x,
52
- y,
53
- button: opts.button ?? 'left',
54
- ...(opts.numClicks ? { num_clicks: opts.numClicks } : {}),
55
- })
56
- }
57
- ```
58
- (Optional optimization — collapse the two round-trips into one with `computer.batch` if the installed SDK exposes it: `await k.browsers.computer.batch(runtimeSessionId, { actions: [{ type: 'move_mouse', x, y, smooth: true }, { type: 'click_mouse', x, y, button: opts.button ?? 'left', num_clicks: opts.numClicks ?? 1 }] })`. Verify the SDK method/shape before using; if unavailable, keep the two sequential calls above.)
59
-
60
- **`typeText()` — default a jittered human delay when the caller gives none:**
61
- ```ts
62
- export async function typeText(runtimeSessionId: string, text: string, delayMs?: number): Promise<void> {
63
- const k = client()
64
- const delay = typeof delayMs === 'number' ? delayMs : jitter(HUMAN_TYPE_DELAY_MS, 30)
65
- await k.browsers.computer.typeText(runtimeSessionId, { text, delay })
66
- }
67
- ```
68
-
69
- No route changes required — `browser-agent-routes.ts` already forwards `x/y`, `button`, `num_clicks`, and an optional `delay`; the new behavior is purely in the service defaults. The MCP tool schemas/descriptions need no change (the human-like behavior is automatic; callers still just pass x/y/text).
70
-
71
- ### 2. No change to
72
- - `goto` / `readPage` / `locatePageTargets` / `captureFanout` — DOM access over CDP is required and free-path-shared. Leave as-is.
73
- - `BrowserDriver` and all deterministic scrapers (reddit, serp, maps, fb, instagram, youtube) — Playwright stays.
74
- - The free/local browser path — Playwright stays.
75
-
76
- ## Verification
77
- 1. `grep -n "moveMouse" src/services/browser-agent/browser-agent-service.ts` → now **2** hits (definition + the new call in `click`).
78
- 2. `npm run build` clean.
79
- 3. Deploy prod; open a session and drive it:
80
- - `POST /agent/sessions {url:"https://example.com"}` → `POST /agent/sessions/:id/screenshot` (note an element's x/y) → `POST /agent/sessions/:id/click {x,y}` → screenshot again: the click lands (move+click path works).
81
- - `POST /agent/sessions/:id/type {text:"hello world"}` (no `delay`) succeeds — typing now carries the default jittered cadence server-side.
82
- - `DELETE /agent/sessions/:id`.
83
- 4. Behavioral check on a detection-sensitive target (manual): drive a known bot-walled login form; clicks should approach via curve and typing should be paced (observable in the live-view / replay).
84
-
85
- ## Release
86
- - One file changed (`browser-agent-service.ts`). The behavior ships server-side → live on Vercel deploy; **no npm republish required** (the stdio package is a thin client; tool schemas/descriptions are unchanged).
87
- - Commit on `main`: `feat(browser): human-like input on Kernel — bezier move-before-click + default typing cadence`.
88
-
89
- ---
90
-
91
- # Phase 2 — Move the DOM-read path to the Playwright Execution API (in-VM + Patchright)
92
-
93
- ## Why (revised from "defer")
94
- The read path (`goto`, `readPage`, `locatePageTargets`, `captureFanout`) uses `playwrightChromium.connectOverCDP(cdpWsUrl)` from our serverless function — **outside** the Kernel VM. Kernel's recommended-practices table explicitly says *"Prefer our Playwright Execution API or Computer Controls API over self-hosted Playwright/Puppeteer,"* and the bot-detection guide states the Execution API *"runs in the same VM as the browser, ensuring headers, user-agent strings, and environment match. Kernel automatically applies Patchright to remove automation fingerprints, including headless indicators."* So external CDP is the residual detection signal, and this is the documented fix — not optional polish.
95
-
96
- ## The API
97
- `POST /browsers/{id}/playwright/execute` — body `{ code: string, timeout_sec?: 1..300 }`. The `code` runs **in-VM** inside a function with `page`, `context`, `browser` in scope; `return` a value. Response `{ success, result, error, stdout, stderr }`. SDK call (verify exact name against installed `@onkernel/sdk`): `k.browsers.playwright.execute(runtimeSessionId, { code, timeout_sec })`.
98
-
99
- ## Mechanic
100
- Our read functions already do their work via `page.evaluate(FN)` over a `connectOverCDP` page. The migration is **mechanical**: take the existing in-page extraction JS (the function bodies passed to `page.evaluate`) and ship them as the `code` string to `execute`, returning the same shape — but now running in-VM with Patchright instead of over external CDP.
101
-
102
- ### `src/services/browser-agent/browser-agent-service.ts`
103
- Add a helper:
104
- ```ts
105
- async function runInVm<T>(runtimeSessionId: string, code: string, timeoutSec = 30): Promise<T> {
106
- const k = client()
107
- const res = await k.browsers.playwright.execute(runtimeSessionId, { code, timeout_sec: timeoutSec })
108
- if (!res.success) throw new Error(res.error || 'playwright/execute failed')
109
- return res.result as T
110
- }
111
- ```
112
- Then rewrite each read function to call `runInVm` with its existing extraction logic as a code string instead of `connectOverCDP`:
113
- - **`goto(runtimeSessionId, url)`** → `code`: `` await page.goto(${JSON.stringify(url)}, { waitUntil: 'domcontentloaded', timeout: 45000 }); return { url: page.url(), title: await page.title() }; `` (signature changes from `cdpWsUrl` to `runtimeSessionId`).
114
- - **`readPage`** → move the current element/text-collection `page.evaluate` body into the `code` string; `return` the same `{ url, title, text, elements }` object.
115
- - **`locatePageTargets`** → same: the selector/text-bounds logic becomes the `code` body, `return` the bounds array.
116
- - **`captureFanout`** → port its capture logic; if it relies on response listeners over CDP, evaluate whether it can run in-VM or must stay on CDP (the fan-out hook listens to network events — this one may legitimately stay on CDP; verify before migrating).
117
-
118
- ### Routes (`src/api/browser-agent-routes.ts`)
119
- The read/locate/goto routes currently pass `row.cdp_ws_url` to the service. Change those call sites to pass `row.runtime_session_id`. No other route logic changes.
120
-
121
- ### Keep CDP only where required
122
- If `captureFanout` needs live network-event listeners (CDP-only), leave it on `connectOverCDP`. Everything else moves in-VM.
123
-
124
- ## Dual-path still holds
125
- - **On Kernel:** input → Computer Controls (Phase 1); DOM read → Playwright Execution API (Phase 2). Zero external CDP except any network-listener edge case.
126
- - **Off Kernel (free/local):** unchanged local Playwright.
127
-
128
- ## Verification (Phase 2)
129
- 1. `grep -c connectOverCDP src/services/browser-agent/browser-agent-service.ts` → drops to 0 (or 1 if captureFanout stays).
130
- 2. Build clean.
131
- 3. Drive a session: `goto` → `read` → `locate` return the same shapes as before (regression check against current output).
132
- 4. On a detection-sensitive page, confirm reads no longer attach an external CDP client (the session shows only in-VM execution + Computer Controls input).
133
-
134
- ## Release (Phase 2)
135
- - Same file (+ route call-site edits). Server-side → ships on deploy; no npm republish.
136
- - Separate commit from Phase 1 so each is independently revertible: `feat(browser): read DOM via Kernel Playwright Execution API (in-VM, Patchright) instead of external CDP`.
137
-
138
- ## Sequencing
139
- Ship **Phase 1 first** (tiny, pure win), validate, then **Phase 2** (mechanical but touches 3–4 functions + their routes; needs a regression pass on read/locate output shape). Don't bundle — Phase 2 has real surface area.
140
-
141
- ## Still out of scope
142
- - Migrating the **deterministic scrapers** (`BrowserDriver` reddit/serp/maps/fb) from `connectOverCDP` to Playwright Execution API. Same benefit would apply, but their bottleneck was IP/challenge (solved) and they're a larger blast radius. Flag as Phase 3 if detection on those tools resurfaces.
143
- - **Batch beyond move+click** — only if a concrete need appears.
144
-
145
- ---
146
-
147
- # Other Kernel APIs — assessment + findings (2026-06-30)
148
-
149
- Reviewed the full Kernel API reference (browsers, playwright, computer-controls, filesystem, processes). Findings:
150
-
151
- ## TESTED & REJECTED — browser curl for Reddit
152
- **Hypothesis:** `client.browsers.curl(id, {url})` (`POST /browsers/{id}/curl`) sends an HTTP request through Chrome's stack (TLS fingerprint + cookies + proxy), so curling `old.reddit/...json` through a residential-proxied browser would return the comment tree cheaply, no render.
153
- **Result: FAILED.** Both residential-proxied and no-proxy curl returned **HTTP 403 / Reddit block page** (no JSON). Reason: curl does NOT execute JavaScript, and Reddit's wall is a **JS challenge** that only a *rendered* browser solves. `reddit_thread` therefore MUST stay headful-render (the render is what beats the challenge; the ~$0.0009 cost is justified). **Do not re-attempt curl for Reddit.**
154
- **Curl is still valid** for genuine JSON/REST APIs with no JS challenge (inherits the browser's authenticated session + residential IP). Keep as a back-pocket tier; not a current build item.
155
-
156
- ## REJECTED — do NOT run ffmpeg/media in the Kernel VM (cost-wrong)
157
- Initially flagged process-exec/filesystem for in-VM ffmpeg muxing. **Retracted — it's the wrong place cost-wise.** A Kernel browser VM bills $0.0001333/s (headful); muxing is pure CPU work unrelated to browsing, so running it there is the most expensive option. And we don't need it for product value:
158
- - **Transcription already runs outside Kernel** via fal.ai wizper, which takes an `audio_url` and fetches it itself (`media-transcription.ts`) — no download, no ffmpeg, no Kernel. Cheap and correct.
159
- - **Muxing** (`VideoMixer.ts`) is an optional convenience that gracefully degrades to returning separate track URLs when ffmpeg is absent.
160
- - The `instagram_media` ENOENT was the **Vercel serverless read-only filesystem** (`mkdir ~/Downloads/...`), NOT a Kernel gap. Fix = write to `/tmp` or return track URLs; if muxing must be reliable, run ffmpeg on a cheap non-serverless worker — never in Kernel.
161
- **Conclusion: keep all media transcode/transcription OUTSIDE Kernel.** Process-exec/filesystem APIs have no compelling MCP Scraper use today.
162
-
163
- ## LOW / skip unless needed
164
- - computer-controls extras: `drag` (slider captchas), `clipboard` read/write, `set_cursor`, `get_cursor_position` — situational agent polish.
165
- - browsers: create/get/list/delete/update (already used), ad-hoc extension upload (niche), load-extensions.
166
- - filesystem watch/stream, process async/PTY/signals — only for long-running in-VM jobs.
@@ -1,146 +0,0 @@
1
- # MCP Tool Spec — Current vs Proposed (every tool)
2
-
3
- Implementation blueprint. Each tool lists **current values** (verbatim from source) and **proposed values**, with exact `file:line` targets. Inventory: 46 registered tools — 25 core (`src/mcp/paa-mcp-server.ts`), 2 SERP-snapshot (`src/mcp/mcp-routes.ts`), 19 browser (`src/mcp/browser-agent-mcp-server.ts`).
4
-
5
- Source of truth for costs: `src/api/rates.ts` → `MC_COSTS` (MC_PER_CREDIT = 1000). Site prices already derive from this via `scripts/gen-rates.ts` (the rate catalog). Credit $-value: Starter $0.0015, Growth $0.00125, Scale $0.00104.
6
-
7
- ---
8
-
9
- ## PART 0 — Cross-cutting changes (do these once; they touch many tools)
10
-
11
- ### 0.1 Delete dead code: `MCP_TOOL_MANIFEST`
12
- - **File:** `src/mcp/mcp-tool-manifest.ts` (~39 KB). **Current:** exported, imported by **nothing** (never in any commit). **Proposed:** delete the file, OR (ambitious) wire its `tokenBudgetClass`/`producesHandlesFor` as the progressive-disclosure layer (see 0.3). Default recommendation: **delete**.
13
-
14
- ### 0.2 Remove stale proxy boilerplate (server now self-heals proxy選択)
15
- The agent no longer needs to reason about proxy at all: `DEFAULT_PROXY_MODE='none'`, the dead `configured` proxy is neutralized, and harvest does a 2-strike location→none fallback. So the proxy prose is dead weight + factually wrong ("default configured = highest success").
16
-
17
- - **`src/mcp/mcp-tool-schemas.ts`** — the `proxyMode` / `proxyZip` / `debug` `.describe()` strings (lines 11–13, 134–136, 151–153, 861–863, 874–877; ~15 fields, ~580 tokens). **Current:** each says *"Default configured uses the service proxy … highest general success rate … none only for direct-network debugging."* **Proposed:** collapse each `proxyMode` describe to one line: `'Leave unset. The service auto-selects the most reliable routing (clean egress by default; city/ZIP residential only when a location is given, with automatic fallback). Set "location" only to force local-pack targeting.'` Drop the `proxyZip`/`debug` describe to a single short sentence each.
18
- - **Tool descriptions carrying the same prose** (rewrite, see PART 4): `harvest_paa`, `search_serp`, `capture_serp_snapshot`, `maps_search`, `directory_workflow`.
19
-
20
- ### 0.3 Progressive disclosure (optional, high-leverage)
21
- - **Current:** all 46 tool descriptions load into agent context every session.
22
- - **Proposed:** advertise a **core** set; defer the rest behind a `tools/list` tier or a `workflow_suggest`-style index. Core = `harvest_paa, search_serp, extract_url, extract_site, map_site_urls, maps_search, maps_place_intel, youtube_harvest, youtube_transcribe, facebook_page_intel, instagram_profile_content, credits_info`. Deferred = all `workflow_*`, `rank_tracker_workflow`, `directory_workflow`, `capture_serp_*`, `audit_site`, `facebook_ad_*`, `browser_replay_*`, `browser_list_*`. Reuse the deleted manifest's `tokenBudgetClass` field as the tier key.
23
-
24
- ### 0.4 Cost catalog — DONE this session
25
- `MC_COSTS` is the single source; `gen-rates.ts` regenerates `public/rates.generated.js` on build; all site prices read `window.MCP_RATES`. No further action except the cost changes in PART 1.
26
-
27
- ---
28
-
29
- ## PART 1 — Cost table (all metered tools)
30
-
31
- `cr` = credits. "Margin" = at Starter $0.0015/cr vs measured marginal cost. Measured costs from the 2026-06-29 clean sweep.
32
-
33
- | Tool | MC_COSTS key | Current cr | Measured cost | Margin (Starter) | **Proposed cr** | Why |
34
- |---|---|---|---|---|---|---|
35
- | harvest_paa | `paa` | 0.3 / question | $0.00018/q | ~2.5× @10q, ↑ at scale | **0.3 / q** (keep) | set this session |
36
- | search_serp | `serp` | 0.1 / search | $0.00004 | 3.8× | **0.1** (keep) | cheap, fine |
37
- | extract_url | `page_scrape` | 0.1 / page | ~$0 (HTTP) | huge | **0.1** (keep) | |
38
- | extract_site | `page_scrape` | 0.1 / page | ~$0 (HTTP) | huge | **0.1** (keep) | |
39
- | map_site_urls | `url_map` | 2 / op | ~$0 (HTTP) | huge | **0.5** | overpriced; lower for goodwill |
40
- | audit_site | `page_scrape`×pages | 0.1 / page | ~$0 + LLM | high | **0.1/page** (keep) | LLM cost is small |
41
- | maps_search | `maps_search` | 2 / search | $0.00013 | 23× | **0.5** | 23× is needless; DataForSEO is $0.0006 |
42
- | maps_place_intel | `maps_place` + `maps_review` | 2 + 0.1/review | $0.0015 (headful) | **2.0× — under 4×** | **3 base + 0.2/review** | headful, can't cheapen; raise to clear 4× |
43
- | youtube_harvest | `yt_channel` | 0.2 / call | $0.00011 | ~6× | **0.2** (keep) + consider 0.01/video | flat ok at small N |
44
- | youtube_transcribe | `yt_transcription` | 0.2 / min | ~$0 (free captions) | huge | **0.2/min** (keep) | |
45
- | facebook_page_intel | `fb_ad` | 0.2 / call | $0.00010 | ~5× | **0.2** (keep) | set this session |
46
- | facebook_ad_search | `fb_search` | 0.1 / search | soft-blocks (refunds) | n/a | **0.1** (keep) | only failing tool; see PART 2 |
47
- | facebook_ad_transcribe | `fb_transcribe` | 0.2 / min | ~$0 | high | **0.2/min** (keep) | |
48
- | facebook_video_transcribe | `fb_transcribe` | 0.2 / min | browser + fal | medium | **0.2/min** (keep) | |
49
- | instagram_profile_content | `instagram_profile` | 0.2 / scan | $0.00015 | ~5× | **0.2** (keep) | set this session |
50
- | instagram_media_download | `instagram_media` | 0.2 / post | browser | ~5× | **0.2** (keep) | |
51
- | instagram (transcribe) | `instagram_transcribe` | 0.05 | fal | high | **0.05** (keep) | |
52
- | capture_serp_snapshot | (serp path) | ~0.1 | browser | ok | **0.1** (align to serp) | |
53
- | directory_workflow | maps_search × cities | 2 × cities | maps×N | inherits maps | **0.5 × cities** | follows maps_search drop |
54
- | browser_* (19 tools) | `browser_minute` | 12 / min | ~$0.008/min headful (idle free) | ~2.25× active | **12/min** (keep) | set this session |
55
- | workflow_* / rank_tracker | per underlying call | varies | varies | — | **bill underlying** (keep) | planning tools = free |
56
-
57
- **Net cost changes to make:** `maps_search` 2 → **0.5**, `url_map` 2 → **0.5**, `maps_place` base 2 → **3**, `maps_review` 0.1 → **0.2**. All in `src/api/rates.ts` `MC_COSTS`. Everything else stays as set this session.
58
-
59
- ---
60
-
61
- ## PART 2 — Per-tool spec (current → proposed)
62
-
63
- For each: cost (PART 1), key params (current default/cap → proposed), behavior, description action. `file:line` = `src/mcp/paa-mcp-server.ts` unless noted.
64
-
65
- ### Research / SERP
66
- **harvest_paa** (174) — params: `maxQuestions` default 30 / max 200 (schemas.ts:7); `proxyMode` default none. Behavior: headful PAA, `none` egress, 2-strike fallback, **partial-return on timeout** (softDeadlineMs). **Proposed:** keep params; **rewrite description** (PART 4) to drop proxy prose + note "returns partial results if it hits the time budget." Cap guidance already good.
67
-
68
- **search_serp** (182) — `pages` default 1 / max 2. **Proposed:** rewrite description (drop proxy prose). Keep params.
69
-
70
- **capture_serp_snapshot / capture_serp_page_snapshots** (`mcp-routes.ts` 73, 81) — **Proposed:** rewrite `capture_serp_snapshot` description (drop proxy prose). These are Phoenix-product paths → candidates for **deferred** tier (0.3).
71
-
72
- ### Web extraction
73
- **extract_url** (190) — params: `screenshot`, `extractBranding`, `downloadMedia`, `depositToVault` (all default false). **Proposed:** no change; description is good (selection-oriented).
74
-
75
- **map_site_urls** (198) — `maxUrls` max 10000, >500 → CSV file. **Proposed:** cost 2→0.5; keep caps.
76
-
77
- **extract_site** (206) — `maxPages` max 10000, >25 → folder mode. `rotateProxies` optional (schemas.ts:39 — this proxy hint is **legitimate**, keep it). **Proposed:** no cost change (0.1/page); keep description.
78
-
79
- **audit_site** (214) — `maxPages` cap. **Proposed:** no change; **deferred** tier candidate.
80
-
81
- ### YouTube
82
- **youtube_harvest** (222) — `maxVideos` default 50 / max 500 (schemas.ts:57). **Proposed:** keep 0.2 flat; **note** in cost model that 500-video harvests are the context/cost outlier — consider per-video 0.01 later. Keep description.
83
-
84
- **youtube_transcribe** (230) — **Proposed:** no change.
85
-
86
- ### Facebook
87
- **facebook_page_intel** (238) — `maxAds` default 50 / max 200 (schemas.ts:71). **Proposed:** keep 0.2.
88
- **facebook_ad_search** (246) — `maxResults` default 10 / max 20. **THE ONE BROKEN TOOL:** soft-blocks (FB rejects egress IP for ad-library search; refunds correctly). **Proposed:** (a) try a clean residential/BYO proxy specifically for this path; (b) if unreliable, mark description "best-effort" and keep auto-refund. Needs its own investigation task.
89
- **facebook_ad_transcribe** (254) / **facebook_video_transcribe** (262) — **Proposed:** no change.
90
-
91
- ### Instagram
92
- **instagram_profile_content** (270) — `maxItems` default 50 / max **2000**, `maxScrolls` 10/250. **Proposed:** keep cost 0.2; **keep maxItems cap (no evidence it needs lowering)**.
93
- **instagram_media_download** (278) — `includeTranscript` default false (adds cost). **Proposed:** no change.
94
-
95
- ### Maps
96
- **maps_place_intel** (286) — `includeReviews` false, `maxReviews` default 50 / max **500**. Behavior: **headful** (headless crashes). **Proposed:** cost **3 base + 0.2/review**; **keep maxReviews cap (no evidence it needs lowering)** — customer pays per review. Keep description.
97
- **maps_search** (294) — `maxResults` default 10 / max 50; `proxyMode` default **location** (DEFAULT_MAPS_PROXY_MODE). **Proposed:** cost 2→**0.5**; **rewrite description** to drop the "omit proxyMode for residential" prose (server handles it); keep location default but note auto-fallback. ⚠️ Maps still uses `location` proxy → watch for the same flagged-pool CAPTCHA; if it appears, flip to `none` like SERP.
98
-
99
- ### Workflows / planning (no web, no/low credits)
100
- **directory_workflow** (302) — `state` TN, `minPopulation` 100000, `maxCities` 25/100, `maxResultsPerCity` 50, `concurrency` 5/5. **Proposed:** cost follows maps_search (0.5×cities); rewrite description to drop proxy prose; **deferred** tier.
101
- **workflow_list / workflow_suggest / workflow_run / workflow_step / workflow_status / workflow_artifact_read** (310–356) — planning/orchestration, no credits. **Proposed:** no change; **deferred** tier (except keep `workflow_suggest` discoverable as the router).
102
- **rank_tracker_workflow** (358) — local planning, no credits. **Proposed:** no change; **deferred** tier.
103
- **credits_info** (366) — read-only, server-driven prices. **Proposed:** no change; **core** tier.
104
-
105
- ### Browser (19 tools, `browser-agent-mcp-server.ts`)
106
- `browser_profile_connect, browser_profile_list, browser_open, browser_screenshot, browser_read, browser_locate, browser_goto, browser_click, browser_type, browser_scroll, browser_press, browser_replay_start, browser_replay_stop, browser_list_replays, browser_replay_download, browser_replay_mark, browser_replay_annotate, browser_close, browser_list_sessions`.
107
- - **Cost:** all session-metered via `browser_minute` = **12 cr/min** (idle free — Standby). Accurate post-change.
108
- - **Params:** `timeout_seconds` default 600 (browser-agent-tool-schemas.ts:24) — describe already correct ("idles into zero-cost standby … longer timeout is cheap").
109
- - **Proposed:** **core** = `browser_open, browser_screenshot, browser_read, browser_goto, browser_click, browser_type, browser_scroll, browser_press, browser_close`. **deferred** = all `browser_replay_*`, `browser_list_*`, `browser_profile_*`. No description/cost changes needed.
110
-
111
- ---
112
-
113
- ## PART 3 — Param caps: NO CHANGES
114
-
115
- Earlier drafts proposed lowering `maps_place_intel maxReviews` (500) and `instagram_profile_content maxItems` (2000). **Retracted — there is no evidence either cap causes a problem.** No measured failure, timeout, or cost issue at the high end. The customer pays per unit, and long jobs are handled by partial-return/timeout budgets (already done for PAA), not by capping. **All caps stay as-is.** Only lower a cap if we *observe* a specific failure mode at the high end.
116
-
117
- ---
118
-
119
- ## PART 4 — Description rewrites (exact old → new)
120
-
121
- Remove proxy prose; keep selection guidance. Targets in `src/mcp/paa-mcp-server.ts` (+ mcp-routes.ts).
122
-
123
- **harvest_paa (176)** — strike: *"Omit proxyMode for normal use; the service defaults to the configured browser-service proxy without city/ZIP targeting for the highest general success rate. Use proxyMode location only when the user explicitly needs city/ZIP-targeted residential proxy evidence."* → replace with: *"Routing is automatic — leave proxyMode unset. If a deep harvest exceeds the time budget it returns the questions gathered so far (partial), billed per returned question."*
124
-
125
- **search_serp (184)** — strike: *"Omit proxyMode for normal use; the service defaults to the configured browser-service proxy without city/ZIP targeting. Use proxyMode location and proxyZip only when the user explicitly needs city/ZIP-targeted residential proxy evidence."* → *"Routing is automatic — leave proxyMode unset; set location only to force local-pack targeting."*
126
-
127
- **maps_search (296)** — strike: *"Maps is the default location-targeted surface: omit proxyMode for normal Maps use so the service creates residential proxy evidence for the requested market and rotates on retryable failures. Pass proxyZip only when a specific ZIP or city-center ZIP is known. Use proxyMode configured only when you explicitly do not want city/ZIP proxy targeting."* → *"Routing is automatic and location-aware — leave proxyMode unset; pass proxyZip only to force a specific ZIP."*
128
-
129
- **directory_workflow (304)** — strike the sentence *"Maps workflows default to location-targeted proxying … use proxyMode configured only when you explicitly do not want city/ZIP proxy targeting."* → *"Routing is automatic per city."*
130
-
131
- **capture_serp_snapshot (`mcp-routes.ts:73`)** — strike: *"Omit proxyMode for normal use; the service defaults to the configured browser-service proxy without city/ZIP targeting for the highest general success rate. Use proxyMode location only when the user explicitly needs city/ZIP-targeted residential proxy evidence. Use none only for direct-network debugging."* → *"Routing is automatic — leave proxyMode unset."*
132
-
133
- ---
134
-
135
- ## PART 5 — Execution order, files, release
136
-
137
- 1. **Costs** — `src/api/rates.ts` `MC_COSTS`: `maps_search` 2000→**500**, `url_map` 2000→**500**, `maps_place` 2000→**3000**, `maps_review` 100→**200**.
138
- 2. **Proxy describe trim** — `mcp-tool-schemas.ts` proxyMode/proxyZip/debug fields (0.2).
139
- 3. **Description rewrites** — PART 4 (`paa-mcp-server.ts`, `mcp-routes.ts`).
140
- 4. **Delete** `src/mcp/mcp-tool-manifest.ts` (0.1).
141
- 5. **(Optional, separate PR)** progressive-disclosure tier (0.3).
142
- 6. **Build** (`npm run build` — regenerates rate catalog + bundles), **deploy Vercel prod** (covers REST billing + remote-MCP descriptions + site prices).
143
- 7. **`npm publish`** — REQUIRED this time: tool descriptions are baked into the stdio package, so npm-installed clients need the new version to see trimmed/accurate descriptions. Bump version in the 3 release files first (per release workflow), vercel prod before publish. (No param caps changed.)
144
- 9. **Open task:** `facebook_ad_search` soft-block (clean/BYO proxy or best-effort label).
145
-
146
- **Why npm matters here (unlike the rate change):** rates/logic run server-side and reach thin clients automatically, but **tool descriptions and input-schema caps are advertised by the MCP package itself** → republish required for stdio installs; remote-MCP users get it on deploy.
@@ -1,94 +0,0 @@
1
- # Spec — server-instructions.ts routing-map rewrite
2
-
3
- Implements the approved improvements from the MCP-design audit + reviewer feedback. **One file changes:** `src/mcp/server-instructions.ts` (the `SERVER_INSTRUCTIONS` string — the only content loaded into the model's context before it fetches any tool schema). No other source files. Requires a build + Vercel deploy (remote-MCP gets it immediately) + npm republish (the string ships in the stdio package).
4
-
5
- ## Approved improvements (the full list)
6
- 1. **Add `reddit_thread` to the map; stop routing Reddit to the browser.** Current line 15 says "Anything without a dedicated tool (e.g. Reddit…) -> browser_* agent" — **stale**, we shipped `reddit_thread` in 0.3.25. A model reading the current map will drive a browser for Reddit instead of calling the dedicated tool.
7
- 2. **Make discovery→action ID seams explicit and schema-accurate** (reviewer #1). Encode, per chain, what the discovery tool *returns* and what the action tool *takes* — using the real params, not inferred ones.
8
- 3. **Workflow triggers + boundary** (reviewer #2): plain-language "when to reach for a workflow vs hand-chaining," and state `query_fanout_workflow`'s browser prerequisite.
9
- 4. **Reorder the no-dedicated-tool branch to check login FIRST** (reviewer #3), so a logged-in site isn't short-circuited into `browser_open` without `browser_profile_connect`.
10
- 5. **Keep** the `rotateProxies` NOTE (reviewer #4 — defensible cross-cutting hint).
11
-
12
- Not in this spec (explicitly out): annotations (audited → correct as-is, not a bug), server namespace rename (breaking, deferred decision), tool-count tiering (soft, not needed). `deep_research_workflow` removal already shipped.
13
-
14
- ## Verified schema facts backing each seam (source of truth)
15
- | Discovery tool | Returns (output schema) | Action tool | Takes (input schema) | Seam |
16
- |---|---|---|---|---|
17
- | `map_site_urls` / `search_serp` | `urls` / `organicResults[].url` | `extract_url` | `url` | url → url |
18
- | `search_serp` | `organicResults[].url` (incl. reddit.com) | `reddit_thread` | `url` | reddit url → reddit_thread |
19
- | `maps_search` | `results[].{name, placeUrl, cid}` | `maps_place_intel` | **`businessName` + `location`** (NOT an id) | name → name; **independent, callable directly** |
20
- | `youtube_harvest` | `videos[].videoId` | `youtube_transcribe` | `videoId` OR `url` | videoId → videoId |
21
- | `facebook_ad_search` | `advertisers[].{pageId, libraryId}` | `facebook_page_intel` | `pageId` / `libraryId` / `query` | id → id |
22
- | `facebook_page_intel` | `ads[].videoUrl` | `facebook_ad_transcribe` | `videoUrl` | videoUrl → videoUrl |
23
- | (organic FB url) | — | `facebook_video_transcribe` | `url` (FB reel/video/post) | url direct |
24
- | `instagram_profile_content` | `post`/`reel`/`tv` url arrays | `instagram_media_download` | `url` | url → url |
25
- | (must precede) `browser_open` on chatgpt.com/claude.ai | — | `query_fanout_workflow` | runs against the open AI session | browser-session prerequisite |
26
-
27
- `facebook_ad_transcribe` does NOT consume anything from `facebook_ad_search` — the videoUrl comes from `facebook_page_intel`. `maps_place_intel` does NOT consume a `place_id` — it takes a business name + location.
28
-
29
- ## Exact change
30
- Replace the entire `SERVER_INSTRUCTIONS` template literal in `src/mcp/server-instructions.ts` (lines 1–29) with:
31
-
32
- ```ts
33
- export const SERVER_INSTRUCTIONS = `
34
- This server scrapes and analyzes web, social, search, maps, and site data. Pick a tool by NAME using
35
- this routing map, then load its schema before calling. Where a tool needs a value another tool
36
- produces, the seam is noted so you can chain them.
37
-
38
- ROUTING
39
- - One web page -> extract_url (takes a url). Whole site (crawl + SEO report) -> extract_site (takes a
40
- url). Just the URL list -> map_site_urls (takes a url). map_site_urls and search_serp return urls you
41
- can feed straight into extract_url.
42
- - Web search (organic SERP) -> search_serp. Full SERP + People-Also-Ask / AI-Overview detail ->
43
- harvest_paa. search_serp returns organicResults[].url (often including reddit.com threads) to feed
44
- extract_url or reddit_thread.
45
- - Google Maps: find places -> maps_search (returns results[] with name, placeUrl, cid). One place
46
- deep-dive + reviews -> maps_place_intel (takes businessName + location, NOT an id — call it directly
47
- with a name from a maps_search result or from the user).
48
- - YouTube: find or list videos -> youtube_harvest (returns videos[].videoId). Transcribe one ->
49
- youtube_transcribe (takes a videoId from youtube_harvest, or a url).
50
- - Facebook: find advertisers -> facebook_ad_search (returns advertisers[] with pageId, libraryId). Get
51
- one advertiser's ads -> facebook_page_intel (takes pageId, libraryId, or query; returns ads[].videoUrl).
52
- Transcribe an ad video -> facebook_ad_transcribe (takes a videoUrl from facebook_page_intel).
53
- Transcribe an organic Facebook reel/video/post -> facebook_video_transcribe (takes the Facebook url
54
- directly).
55
- - Instagram: profile inventory -> instagram_profile_content (returns post/reel/tv urls). One post or
56
- reel -> instagram_media_download (takes a url from instagram_profile_content, or a url the user gives).
57
- - Reddit: a reddit.com thread/post URL -> reddit_thread. It returns the post plus its comment tree and
58
- handles Reddit's bot wall itself (no login needed). Find threads first with search_serp.
59
- - No dedicated tool (an arbitrary site, or a logged-in dashboard) -> the browser_* agent. FIRST decide
60
- whether the site needs a login:
61
- - Needs a login (ChatGPT, Claude, any account) -> save it first: browser_profile_connect returns an
62
- mcpscraper.dev sign-in link for the user; one profile holds MANY logins (call it again with the same
63
- profile + a new domain to add accounts). Poll browser_profile_list until AUTHENTICATED, then
64
- browser_open with that profile. browser_profile_list also shows what a profile is connected to.
65
- - No login -> browser_open, then navigate/read.
66
-
67
- WORKFLOWS (multi-step orchestrations — prefer these over hand-chaining primitives when the whole job is one of these)
68
- - Build a local business directory for a niche across a state/region -> directory_workflow.
69
- - Stand up a rank-tracking blueprint (database + cron + ingestion plan) -> rank_tracker_workflow.
70
- - Find which sources an AI answer cites for a query (AEO) -> query_fanout_workflow (open a browser
71
- session on chatgpt.com or claude.ai FIRST, then run it against that session).
72
-
73
- NOTES
74
- - Bulk / full-site crawls: call extract_site with rotateProxies:true for blocked or rate-limited sites.
75
- It discovers URLs and returns a saved folder/artifact plus a summary, not the full content inline.
76
- - Tools that open a browser session or transcribe media cost credits and take longer. Prefer the
77
- cheapest tool that answers the question (plain search/extract before browser agents).
78
- - Large results are saved to disk or an artifact and returned as a summary plus a path; read the path
79
- for full detail rather than expecting the whole payload inline.
80
- \`.trim()
81
- ```
82
-
83
- ## Build / deploy / release
84
- 1. Edit the one file above.
85
- 2. `npm run build` (typecheck + bundle; regenerates rate catalog — no functional change here).
86
- 3. `vercel deploy --prod --yes` → remote-MCP clients get the new routing map immediately.
87
- 4. **Bump 0.3.25 → 0.3.26** in the 3 version files (`package.json`, `package-lock.json`, `src/version.ts`) and `npm publish` — `SERVER_INSTRUCTIONS` is compiled into the stdio package, so npm-installed clients need the new version to see the corrected map.
88
- 5. Commit on `main` (now the source of truth): `fix(mcp): routing map — add reddit_thread, explicit chaining seams, workflow triggers, login-first fallback`.
89
-
90
- ## Verification
91
- - `grep -c reddit_thread src/mcp/server-instructions.ts` → ≥1 (was 0).
92
- - `grep -c "without a dedicated tool" src/mcp/server-instructions.ts` → 0 (old Reddit-to-browser line gone).
93
- - After deploy, the live `/mcp` server-instructions block reflects the new map (inspect via an MCP client's server info, or the SERVER_INSTRUCTIONS in the published tarball).
94
- - Behavioral smoke (manual, in an MCP client): "get the comments on <reddit thread url>" routes to `reddit_thread`, not `browser_open`.