From 03751f3e7fadc8ca4fd24213e12df8edb26d60c8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:13:14 +0000 Subject: [PATCH] test: improve test coverage for fetch, gemini, and github connectors Added comprehensive unit tests covering: 1. Fetch: client HTML-to-text extraction, URL safety (SSRF/private IP blocks), manual redirects, and web_fetch tool. 2. Gemini: clients, fallback cascading on 429/503 errors, network timeouts, and Redis-backed model cooldowns. 3. GitHub: list/create branches, list/get commits, list/get PRs, create/update PRs, and PR merges/reviews. All 241 tests pass with no ESLint warnings. Codebase overall statement coverage increased significantly. Co-authored-by: allocsys <225476909+allocsys@users.noreply.github.com> --- coverage/app/config.js.html | 960 ++++ .../app/connectors/cloudflare/client.js.html | 294 ++ coverage/app/connectors/cloudflare/d1.js.html | 285 ++ .../connectors/cloudflare/hyperdrive.js.html | 333 ++ coverage/app/connectors/cloudflare/index.html | 235 + coverage/app/connectors/cloudflare/kv.js.html | 267 ++ .../cloudflare/observability.js.html | 528 +++ .../cloudflare/observability_compare.js.html | 657 +++ coverage/app/connectors/cloudflare/r2.js.html | 243 + .../app/connectors/cloudflare/tools.js.html | 150 + .../app/connectors/cloudflare/workers.js.html | 186 + .../app/connectors/context7/client.js.html | 177 + coverage/app/connectors/context7/index.html | 130 + .../app/connectors/context7/tools.js.html | 246 + coverage/app/connectors/exa/client.js.html | 510 +++ coverage/app/connectors/exa/cooldown.js.html | 405 ++ coverage/app/connectors/exa/index.html | 160 + .../connectors/exa/research_delegate.js.html | 297 ++ .../app/connectors/exa/research_tools.js.html | 654 +++ coverage/app/connectors/fetch/client.js.html | 609 +++ coverage/app/connectors/fetch/index.html | 130 + coverage/app/connectors/fetch/tools.js.html | 261 ++ .../frontend/designer_checkpoint.js.html | 285 ++ .../frontend/designer_delegate.js.html | 1608 +++++++ .../frontend/designer_tool_functions.js.html | 603 +++ .../frontend/designer_tools.js.html | 402 ++ coverage/app/connectors/frontend/index.html | 175 + .../app/connectors/frontend/validate.js.html | 612 +++ .../gemini/agent_checkpoint.js.html | 441 ++ .../connectors/gemini/agent_delegate.js.html | 4029 +++++++++++++++++ .../app/connectors/gemini/agent_tools.js.html | 408 ++ coverage/app/connectors/gemini/client.js.html | 621 +++ .../app/connectors/gemini/cooldown.js.html | 462 ++ coverage/app/connectors/gemini/index.html | 175 + .../app/connectors/github/actions.js.html | 495 ++ .../app/connectors/github/app_auth.js.html | 669 +++ .../app/connectors/github/branches.js.html | 360 ++ .../app/connectors/github/ci_control.js.html | 465 ++ coverage/app/connectors/github/client.js.html | 795 ++++ .../app/connectors/github/clone_token.js.html | 204 + coverage/app/connectors/github/diff.js.html | 486 ++ coverage/app/connectors/github/files.js.html | 1212 +++++ .../app/connectors/github/helpers.js.html | 174 + coverage/app/connectors/github/index.html | 370 ++ coverage/app/connectors/github/issues.js.html | 546 +++ coverage/app/connectors/github/prs.js.html | 852 ++++ .../app/connectors/github/releases.js.html | 282 ++ coverage/app/connectors/github/repo.js.html | 390 ++ .../app/connectors/github/repo_mgmt.js.html | 522 +++ .../app/connectors/github/resource.js.html | 267 ++ .../connectors/github/review_control.js.html | 735 +++ coverage/app/connectors/github/search.js.html | 1050 +++++ coverage/app/connectors/github/tools.js.html | 189 + coverage/app/connectors/index.html | 115 + coverage/app/connectors/mem/client.js.html | 261 ++ coverage/app/connectors/mem/index.html | 130 + coverage/app/connectors/mem/tools.js.html | 3255 +++++++++++++ coverage/app/connectors/notion/client.js.html | 1446 ++++++ coverage/app/connectors/notion/index.html | 145 + .../app/connectors/notion/linking.js.html | 957 ++++ coverage/app/connectors/notion/tools.js.html | 2817 ++++++++++++ coverage/app/connectors/security.js.html | 252 ++ coverage/app/connectors/shared/index.html | 115 + .../app/connectors/shared/rate-limit.js.html | 264 ++ coverage/app/connectors/sync/index.html | 115 + .../app/connectors/sync/mem0_notion.js.html | 750 +++ coverage/app/index.html | 130 + coverage/app/server.js.html | 624 +++ coverage/base.css | 224 + coverage/block-navigation.js | 87 + coverage/clover.xml | 3342 ++++++++++++++ coverage/coverage-final.json | 56 + coverage/favicon.png | Bin 0 -> 445 bytes coverage/index.html | 295 ++ coverage/prettify.css | 1 + coverage/prettify.js | 2 + coverage/sort-arrow-sprite.png | Bin 0 -> 138 bytes coverage/sorter.js | 210 + package-lock.json | 242 + package.json | 3 +- test/fetch-client.test.js | 336 ++ test/gemini-client.test.js | 335 ++ test/github-branches-prs.test.js | 494 ++ 83 files changed, 44603 insertions(+), 1 deletion(-) create mode 100644 coverage/app/config.js.html create mode 100644 coverage/app/connectors/cloudflare/client.js.html create mode 100644 coverage/app/connectors/cloudflare/d1.js.html create mode 100644 coverage/app/connectors/cloudflare/hyperdrive.js.html create mode 100644 coverage/app/connectors/cloudflare/index.html create mode 100644 coverage/app/connectors/cloudflare/kv.js.html create mode 100644 coverage/app/connectors/cloudflare/observability.js.html create mode 100644 coverage/app/connectors/cloudflare/observability_compare.js.html create mode 100644 coverage/app/connectors/cloudflare/r2.js.html create mode 100644 coverage/app/connectors/cloudflare/tools.js.html create mode 100644 coverage/app/connectors/cloudflare/workers.js.html create mode 100644 coverage/app/connectors/context7/client.js.html create mode 100644 coverage/app/connectors/context7/index.html create mode 100644 coverage/app/connectors/context7/tools.js.html create mode 100644 coverage/app/connectors/exa/client.js.html create mode 100644 coverage/app/connectors/exa/cooldown.js.html create mode 100644 coverage/app/connectors/exa/index.html create mode 100644 coverage/app/connectors/exa/research_delegate.js.html create mode 100644 coverage/app/connectors/exa/research_tools.js.html create mode 100644 coverage/app/connectors/fetch/client.js.html create mode 100644 coverage/app/connectors/fetch/index.html create mode 100644 coverage/app/connectors/fetch/tools.js.html create mode 100644 coverage/app/connectors/frontend/designer_checkpoint.js.html create mode 100644 coverage/app/connectors/frontend/designer_delegate.js.html create mode 100644 coverage/app/connectors/frontend/designer_tool_functions.js.html create mode 100644 coverage/app/connectors/frontend/designer_tools.js.html create mode 100644 coverage/app/connectors/frontend/index.html create mode 100644 coverage/app/connectors/frontend/validate.js.html create mode 100644 coverage/app/connectors/gemini/agent_checkpoint.js.html create mode 100644 coverage/app/connectors/gemini/agent_delegate.js.html create mode 100644 coverage/app/connectors/gemini/agent_tools.js.html create mode 100644 coverage/app/connectors/gemini/client.js.html create mode 100644 coverage/app/connectors/gemini/cooldown.js.html create mode 100644 coverage/app/connectors/gemini/index.html create mode 100644 coverage/app/connectors/github/actions.js.html create mode 100644 coverage/app/connectors/github/app_auth.js.html create mode 100644 coverage/app/connectors/github/branches.js.html create mode 100644 coverage/app/connectors/github/ci_control.js.html create mode 100644 coverage/app/connectors/github/client.js.html create mode 100644 coverage/app/connectors/github/clone_token.js.html create mode 100644 coverage/app/connectors/github/diff.js.html create mode 100644 coverage/app/connectors/github/files.js.html create mode 100644 coverage/app/connectors/github/helpers.js.html create mode 100644 coverage/app/connectors/github/index.html create mode 100644 coverage/app/connectors/github/issues.js.html create mode 100644 coverage/app/connectors/github/prs.js.html create mode 100644 coverage/app/connectors/github/releases.js.html create mode 100644 coverage/app/connectors/github/repo.js.html create mode 100644 coverage/app/connectors/github/repo_mgmt.js.html create mode 100644 coverage/app/connectors/github/resource.js.html create mode 100644 coverage/app/connectors/github/review_control.js.html create mode 100644 coverage/app/connectors/github/search.js.html create mode 100644 coverage/app/connectors/github/tools.js.html create mode 100644 coverage/app/connectors/index.html create mode 100644 coverage/app/connectors/mem/client.js.html create mode 100644 coverage/app/connectors/mem/index.html create mode 100644 coverage/app/connectors/mem/tools.js.html create mode 100644 coverage/app/connectors/notion/client.js.html create mode 100644 coverage/app/connectors/notion/index.html create mode 100644 coverage/app/connectors/notion/linking.js.html create mode 100644 coverage/app/connectors/notion/tools.js.html create mode 100644 coverage/app/connectors/security.js.html create mode 100644 coverage/app/connectors/shared/index.html create mode 100644 coverage/app/connectors/shared/rate-limit.js.html create mode 100644 coverage/app/connectors/sync/index.html create mode 100644 coverage/app/connectors/sync/mem0_notion.js.html create mode 100644 coverage/app/index.html create mode 100644 coverage/app/server.js.html create mode 100644 coverage/base.css create mode 100644 coverage/block-navigation.js create mode 100644 coverage/clover.xml create mode 100644 coverage/coverage-final.json create mode 100644 coverage/favicon.png create mode 100644 coverage/index.html create mode 100644 coverage/prettify.css create mode 100644 coverage/prettify.js create mode 100644 coverage/sort-arrow-sprite.png create mode 100644 coverage/sorter.js create mode 100644 test/fetch-client.test.js create mode 100644 test/gemini-client.test.js create mode 100644 test/github-branches-prs.test.js diff --git a/coverage/app/config.js.html b/coverage/app/config.js.html new file mode 100644 index 0000000..7248afa --- /dev/null +++ b/coverage/app/config.js.html @@ -0,0 +1,960 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 | + + + + + + +23x +23x +23x + + + + + + + + +23x + + + + + +23x + + +23x + +23x +23x +23x + + + + + + + +23x +23x +23x + + + + + + + + + + + + + + + + + + + + + + + + + + + +23x + + + + + + + +23x + +23x +23x +23x + + + + + +23x +23x +23x + +23x +23x +23x + + + +23x +23x + + + + + + +23x +23x + + + +23x + + + + + + + + + + + + + +23x + +46x + + + + + + + + +23x + + + + + + + + + + + +23x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +23x + +23x + +23x + + +23x + + + + + + + + + + + + + +23x + +138x + + + + + + + + + + + + + +23x +23x + + + + + + + + +23x + + + + + + + + + + + + + + + +23x +23x +23x + + + + + + + + + + + +23x + +23x + + + + + + + + +23x + + + +23x + +44x + + + + + + + + + + +23x + + + | // ---------------------------------------------------------------------------
+// config.js
+// Central place for all environment variables and shared constants.
+// ---------------------------------------------------------------------------
+// (test commit: verifying the Vercel deploy check after removing the
+// legacy "Manufact"-named deployment -- no functional change)
+
+export const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
+export const GITHUB_API = "https://api.github.com";
+export const DEFAULT_OWNER = process.env.DEFAULT_OWNER || "allocsys";
+
+// Minimum spacing (ms) enforced between outgoing GitHub REST requests, to
+// avoid tripping GitHub's *secondary* rate limit, which fires on request
+// burstiness/concurrency rather than raw hourly quota (see
+// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
+// A shared in-process queue in client.js enforces this even across
+// concurrent tool calls. Override via env var if this proves too
+// conservative or not conservative enough in practice.
+export const GITHUB_MIN_REQUEST_INTERVAL_MS = Number(process.env.GITHUB_MIN_REQUEST_INTERVAL_MS) || 300;
+
+// Retry behavior specifically for secondary-rate-limit (403) and primary
+// rate-limit-exhausted (403 with x-ratelimit-remaining: 0) responses, plus
+// 429s. Does NOT retry other 4xx/5xx errors -- those are real failures, not
+// pacing issues, and should surface immediately.
+export const GITHUB_MAX_RETRIES = Number(process.env.GITHUB_MAX_RETRIES) || 3;
+// Fallback backoff (ms) when GitHub doesn't send a Retry-After header.
+// Doubles each retry (300 -> ~1.6s -> ~3.2s with jitter) if Retry-After is absent.
+export const GITHUB_RETRY_BASE_MS = Number(process.env.GITHUB_RETRY_BASE_MS) || 1500;
+
+export const NOTION_TOKEN = process.env.NOTION_TOKEN;
+export const NOTION_API = "https://api.notion.com/v1";
+export const NOTION_VERSION = "2022-06-28";
+
+// Throttle + retry for the Notion API (fix #3 -- rate-limit asymmetry,
+// 2026-07-27). Notion's documented average rate limit is ~3 requests/second
+// per integration; this spacing keeps a single madmcp instance comfortably
+// under that even when several Notion calls land in the same parallelized
+// delegate_agent step. Mirrors GITHUB_MIN_REQUEST_INTERVAL_MS/
+// GITHUB_MAX_RETRIES/GITHUB_RETRY_BASE_MS above -- same override pattern.
+export const NOTION_MIN_REQUEST_INTERVAL_MS = Number(process.env.NOTION_MIN_REQUEST_INTERVAL_MS) || 350;
+export const NOTION_MAX_RETRIES = Number(process.env.NOTION_MAX_RETRIES) || 3;
+export const NOTION_RETRY_BASE_MS = Number(process.env.NOTION_RETRY_BASE_MS) || 1000;
+
+// Dedicated index DATABASE used for entity_id -> page_id dedup lookups.
+// SUPERSEDES the original page-based index (2026-07-17 fix for gap #1, see
+// mem0 entity_id: madmcp-notion-connector-gaps-roadmap): that fix solved the
+// notion_search indexing-lag problem by reading a page's own blocks directly
+// (uncached, no lag) instead of searching -- but inherited a NEW gap it
+// documented at the time: page block reads are capped at 100 blocks per
+// page (Notion's /blocks/{id}/children pagination), so an index page with
+// more than ~100 tracked entities would silently stop finding older entries.
+// REAL FIX (2026-07-24): a Notion database queried via /databases/{id}/query
+// with a filter on EntityId is just as immediately-consistent as the direct
+// block read (no search-index lag either way, since it's not going through
+// notion_search) but is NOT subject to the 100-block-page limit -- database
+// queries paginate independently of any single page's block count.
+// UPDATE (2026-07-24, later same day): the old page-based index, its
+// migration tool, and a since-discovered duplicate database were all
+// archived/removed once every remaining reader (linking.js's
+// findTagOverlapCandidates, sync/mem0_notion.js's readSyncedIndexEntries)
+// was moved onto queryAllIndexEntries (client.js), which reads this
+// database directly. NOTION_INDEX_PAGE_ID no longer exists as a config
+// value -- nothing in the codebase reads it anymore. This database was
+// also recreated fresh (new ID below) as part of that same cleanup, with
+// zero rows -- no old entries were migrated in.
+// Entity Index database properties: Name (title, holds the entity_id for
+// readability in the Notion UI), EntityId (rich_text, the actual filter
+// target), PageId (rich_text), Url (url), Tags (rich_text, comma-separated).
+// Override via env var if this database is ever moved/recreated.
+export const NOTION_INDEX_DATABASE_ID = process.env.NOTION_INDEX_DATABASE_ID || "3a745572-b580-8160-856b-cf6544c8ffa8";
+
+// Parent page for new pages created by sync_mem0_to_notion (connectors/sync/
+// mem0_notion.js). Was the "Memory Index" page, but that page went 404
+// (deleted/unshared) during a manual Notion reorg on 2026-08-01. Now
+// defaults to the "Claude" page (id below), adopted as the new root --
+// override via env var if that page is ever moved/recreated, same pattern
+// as NOTION_INDEX_PAGE_ID above.
+export const NOTION_SYNC_PARENT_PAGE_ID = process.env.NOTION_SYNC_PARENT_PAGE_ID || "3a045572-b580-8007-b622-c120958557bf";
+
+export const MEM0_API_KEY = process.env.MEM0_API_KEY;
+export const MEM0_API = "https://api.mem0.ai";
+export const MEM0_USER_ID = process.env.MEM0_USER_ID || "default";
+
+// Throttle + retry for the Mem0 API (fix #3 -- rate-limit asymmetry,
+// 2026-07-27). Mem0 doesn't publish a hard per-second limit the way GitHub
+// and Notion do, so this is a conservative default rather than a figure
+// tied to a documented threshold -- same override pattern as the others.
+export const MEM0_MIN_REQUEST_INTERVAL_MS = Number(process.env.MEM0_MIN_REQUEST_INTERVAL_MS) || 300;
+export const MEM0_MAX_RETRIES = Number(process.env.MEM0_MAX_RETRIES) || 3;
+export const MEM0_RETRY_BASE_MS = Number(process.env.MEM0_RETRY_BASE_MS) || 1000;
+
+export const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN;
+export const CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
+export const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
+
+// Context7 works without a key at low rate limits, so this is optional
+// (unlike the other connectors' tokens) — only warn, never hard-fail on it.
+export const CONTEXT7_API_KEY = process.env.CONTEXT7_API_KEY;
+export const CONTEXT7_API = "https://context7.com/api/v2";
+
+// Shared-secret auth for the /mcp endpoint. If set, every request to /mcp
+// must include a matching `x-manufact-key` header, or it is rejected before
+// any connector tools (GitHub, Notion, Mem0, Fetch) are reachable.
+// If unset, the endpoint remains open (legacy behavior) — set this in
+// production so your tokens/connectors aren't usable by anyone with the URL.
+export const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
+export const GEMINI_API = "https://generativelanguage.googleapis.com/v1beta";
+// Default model -- override via env var if this drifts out of date; Google
+// renames/retires Gemini model IDs periodically, so don't assume this stays
+// current without checking https://ai.google.dev/gemini-api/docs/models.
+export const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-flash-latest";
+
+// Fallback model cascade for rate-limit (429) errors. Free-tier Gemini quotas
+// are tracked PER MODEL, so a different model has its own separate RPM
+// bucket -- on a 429 from GEMINI_MODEL, client.js retries the same request
+// against the next model here instead of failing the whole call/investigation
+// outright. This multiplies effective free-tier throughput without enabling
+// billing. Order matters: put higher-RPM/lower-capability models later, since
+// they're only used once the primary model's quota is exhausted for the
+// current window. Override via env var as a comma-separated list of model
+// IDs; GEMINI_MODEL is always tried first regardless of whether it's
+// repeated in this list. See https://ai.google.dev/gemini-api/docs/models for
+// current model IDs/limits -- these drift as Google ships new Flash/Flash-Lite
+// generations.
+export const GEMINI_FALLBACK_MODELS = (process.env.GEMINI_FALLBACK_MODELS || "gemini-3.5-flash-lite,gemini-3.1-flash-lite")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+// Defensive ceiling on a single generateContent call -- no official guidance
+// from Google on max latency, but without SOME timeout a hung/dropped
+// connection leaves agent_delegate.js's per-step checkpointing unable to kick in
+// at all (the call just never returns). Override via env var if this proves
+// too tight for slower multi-tool-call turns, or too loose relative to the
+// hosting platform's own request-duration limit.
+export const GEMINI_REQUEST_TIMEOUT_MS = Number(process.env.GEMINI_REQUEST_TIMEOUT_MS) || 55000;
+
+// Read/write isolation for the Gemini connector's Notion access (2026-07-25
+// plan): Gemini tools may READ any page/database reachable via the existing
+// Notion connector (Memory Index, Entity Index, Job Leads, etc.), but may
+// only WRITE under this one page -- deliberately NOT a caller-supplied
+// parameter anywhere in connectors/gemini/, so there is no code path that
+// lets a Gemini tool call target a write anywhere else. A bad or
+// hallucinated Gemini write can only ever land inside this subtree, never
+// inside the Claude-side Memory Index / Entity Index / Job Leads structures
+// that other tools' dedup and sync logic depend on.
+// "Gemini" page, created as a sibling of the "Claude" root page.
+export const GEMINI_NOTION_ROOT_PAGE_ID = process.env.GEMINI_NOTION_ROOT_PAGE_ID || "3a845572-b580-81d0-8653-f64596e45e58";
+
+// Redis-backed per-model rate-limit cooldown for the Gemini connector (see
+// connectors/gemini/cooldown.js), provisioned via the Vercel Marketplace
+// Upstash integration ("vercel install upstash", or the Vercel dashboard).
+// Not read as named exports here -- connectors/gemini/cooldown.js reads the
+// env vars directly, and accepts EITHER naming convention Vercel might hand
+// you depending on how the integration was provisioned:
+// UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (raw Upstash Marketplace integration)
+// KV_REST_API_URL / KV_REST_API_TOKEN (Vercel's own "KV" product, Upstash-backed)
+// Discovered 2026-07-26: a deployment with the latter names set had Redis
+// fully provisioned and reachable, but every cooldown/checkpoint call
+// reported "not configured" anyway, because the code only checked for the
+// UPSTASH_* names at the time. Listed here only so both are discoverable
+// alongside every other service's env vars, not because config.js exports
+// them. If neither pair is set, cooldown.js fails open (no cross-call
+// rate-limit memory, but never breaks a real Gemini call) -- safe to leave
+// both unset until an integration is activated.
+
+// Exa /answer API (docs.exa.ai/reference/answer) -- backs delegate_research's
+// wide mode as the sole implementation (a single search+synthesis call),
+// not a fallback for anything; see connectors/exa/client.js's file header
+// for the 2026-07-27 history of what this replaced. FORMERLY OPENAI
+// (2026-07-27): this section replaced the OPENAI_* config that used to
+// serve the same role via OpenAI's Responses API web_search tool -- see git
+// history if that needs to be resurrected.
+//
+// EXA_API_KEYS is a comma-separated list -- deliberately supporting
+// MULTIPLE keys/accounts, same reasoning as the OpenAI config it replaces.
+// There is no free tier for this endpoint (billed per call, on top of
+// content-retrieval costs baked into the same call), so this cascade is
+// about rate-limit headroom (Exa's documented default is 10 QPS per
+// account, shared across ALL endpoints) and cost/account isolation, not
+// accessing a free quota. Unlike the OpenAI config this replaces, there is
+// no per-key model tier to cascade through first -- Exa's /answer endpoint
+// has no selectable model for this call shape -- so connectors/exa/client.js
+// simply rotates through EXA_API_KEYS in order on a 429/503/network error.
+export const EXA_API_KEYS = (process.env.EXA_API_KEYS || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+export const EXA_API = "https://api.exa.ai/answer";
+
+// Same defensive-ceiling reasoning as GEMINI_REQUEST_TIMEOUT_MS above.
+export const EXA_REQUEST_TIMEOUT_MS = Number(process.env.EXA_REQUEST_TIMEOUT_MS) || 55000;
+
+// ---------------------------------------------------------------------------
+// Frontend/design delegate (connectors/frontend/) -- delegate_designer's
+// write-capable agent loop (agent.js), backed by the existing Gemini
+// connector (geminiChat/GEMINI_API_KEY/GEMINI_MODEL above) -- no separate
+// provider config needed here.
+
+// Extensions delegate_designer is allowed to read as context OR write
+// as output. Fences BOTH the read side (so a manipulated task can't feed a
+// secrets-adjacent file like config.js to a third-party LLM API as prompt
+// text) and the write side (so a generation can't overwrite server.js/
+// package.json/workflow files/etc) to the frontend surface this tool exists
+// for. Comma-separated, override via env var if the frontend stack changes.
+export const FRONTEND_ALLOWED_EXTENSIONS = (process.env.FRONTEND_ALLOWED_EXTENSIONS || ".html,.css,.scss,.jsx,.tsx,.vue")
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+
+// ---------------------------------------------------------------------------
+// delegate_designer (issue #61 agent redesign) -- bounds
+// connectors/frontend/designer_delegate.js's runDesignAgent loop, the read_file/
+// write_file/validate-based replacement for the generate->validate->fix
+// loop above. Deliberately TIGHTER than delegate_agent's 20 default / 30
+// hard cap (see the GEMINI connector's HARD_MAX_STEPS in connectors/gemini/
+// agent_delegate.js): this agent's tool set (read/write/validate on frontend
+// files within one branch) is far narrower than delegate_agent's open-ended
+// cross-system investigation surface, so it doesn't need investigation-scale
+// step counts to do useful work. Resolved 2026-08-01 per the Notion design
+// doc's open question -- see issue #61.
+export const FRONTEND_DEFAULT_STEPS = Number(process.env.FRONTEND_DEFAULT_STEPS) || 12;
+export const FRONTEND_HARD_MAX_STEPS = Number(process.env.FRONTEND_HARD_MAX_STEPS) || 20;
+
+// validate() calls do NOT count against the step budget above (a validate
+// call is cheap -- local syntax checking, no LLM/network round trip beyond
+// the agent's own turn -- so charging a full step for it would waste budget
+// that's better spent on read_file/write_file turns). Capped independently,
+// PER FILE PATH, so a model stuck in a validate/tweak/validate loop on one
+// file can't thrash indefinitely without ever burning a step -- resolved
+// alongside FRONTEND_DEFAULT_STEPS above, same source.
+export const FRONTEND_MAX_VALIDATE_CALLS = Number(process.env.FRONTEND_MAX_VALIDATE_CALLS) || 5;
+
+// ---------------------------------------------------------------------------
+// GitHub App -- scoped, short-lived clone tokens for PRIVATE repos (2026-07-28
+// plan, see Notion entity_id madmcp-github-app-scoped-clone-token-plan).
+// Deliberately a SEPARATE credential from GITHUB_TOKEN above: GITHUB_TOKEN is
+// a broad, long-lived token used by every other GitHub tool in this
+// connector, while this App is scoped ONLY to contents:read and installed
+// only on repos that need sandbox-clone access. connectors/github/app_auth.js
+// mints per-repo installation tokens from these credentials on demand
+// (~1hr TTL, GitHub's max), returned to the calling model so it can `git
+// clone` a private repo into its own sandbox -- see that file's header for
+// why the token has to pass through the calling model at all (the sandbox
+// can't reach this server directly to fetch it itself).
+// GITHUB_APP_PRIVATE_KEY: paste the PEM as-is; if your env var tooling can't
+// store literal newlines, escape them as \n and app_auth.js unescapes them.
+export const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
+export const GITHUB_APP_INSTALLATION_ID = process.env.GITHUB_APP_INSTALLATION_ID;
+export const GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY;
+
+// Grace period (connectors/github/app_auth.js): how long a freshly minted
+// clone token is allowed to live before this server auto-revokes it via
+// GitHub's revoke endpoint, making it effectively single-use rather than
+// relying on GitHub's own ~1hr installation-token TTL. Enforced via
+// @vercel/functions' waitUntil() (requires Fluid Compute), not a bare
+// setTimeout -- Vercel can freeze/tear down a serverless invocation right
+// after its response is sent, so a plain unref()'d timer isn't reliable
+// there. 30s by default -- short, to minimize compute kept alive per call;
+// raise it (env var) if clones of a particularly large private repo start
+// getting cut off mid-transfer.
+export const GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS = Number(process.env.GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS) || 30;
+
+export const MCP_SHARED_KEY = process.env.MCP_SHARED_KEY;
+
+// IP allowlist for /mcp, /mcp/:key, and /. Restricts inbound requests to
+// known client CIDR ranges regardless of whether the shared key is valid,
+// so a leaked key alone isn't enough to reach the server.
+// Defaults ON, and defaults to Anthropic's published outbound range for
+// Claude connector traffic (https://claude.com/docs/connectors/building/authentication).
+// Add more ranges (e.g. for OpenAI/GPT actions) as a comma-separated list.
+// Set IP_ALLOWLIST_ENABLED=false to disable entirely (e.g. for local dev).
+export const IP_ALLOWLIST_ENABLED = process.env.IP_ALLOWLIST_ENABLED !== "false";
+// 208.77.244.90/32 is manufact's own deploy-time health-check IP (it POSTs an
+// MCP `initialize` request to /mcp as part of deploy verification) — without
+// it, every deploy fails its own health check against this allowlist.
+export const ALLOWED_IP_RANGES = (process.env.ALLOWED_IP_RANGES || "160.79.104.0/21,208.77.244.90/32")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+// Number of reverse-proxy hops in front of this server whose X-Forwarded-For
+// entries should be trusted when determining the real client IP (used for
+// both Express's own trust-proxy setting and the IP allowlist check).
+// Default of 1 matches Render and most single-CDN-hop platforms. Deploying
+// behind a different proxy chain (e.g. a platform that adds more hops before
+// reaching this app) may need a different value — if legitimate requests
+// start getting 403'd, or IP allowlisting seems to trust the wrong address,
+// check this first rather than assuming the allowlist itself is wrong.
+export const TRUST_PROXY_HOPS = Number.isInteger(Number(process.env.TRUST_PROXY_HOPS))
+ ? Number(process.env.TRUST_PROXY_HOPS)
+ : 1;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/client.js
+// Thin wrapper around the Cloudflare REST API (api.cloudflare.com/client/v4),
+// scoped to a single account. Mirrors the auth/error pattern used by the
+// GitHub connector's client.js.
+// ---------------------------------------------------------------------------
+
+import { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API } from "../../config.js";
+
+function assertConfigured() {
+ if (!CLOUDFLARE_API_TOKEN) {
+ throw new Error(
+ "CLOUDFLARE_API_TOKEN is not set. Add it as an environment variable on the madmcp server."
+ );
+ }
+ if (!CLOUDFLARE_ACCOUNT_ID) {
+ throw new Error(
+ "CLOUDFLARE_ACCOUNT_ID is not set. Add it as an environment variable on the madmcp server."
+ );
+ }
+}
+
+// Generic request against any Cloudflare API path (not account-scoped).
+export async function cfRequest(path, { method = "GET", body, accept } = {}) {
+ assertConfigured();
+ const res = await fetch(`${CLOUDFLARE_API}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
+ Accept: accept || "application/json",
+ "Content-Type": "application/json",
+ "User-Agent": "madmcp-server",
+ },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+
+ const contentType = res.headers.get("content-type") || "";
+ const text = await res.text();
+
+ // Non-JSON responses (e.g. raw worker script source) are returned as-is.
+ if (!contentType.includes("application/json")) {
+ if (!res.ok) {
+ throw new Error(`Cloudflare API error (${res.status}): ${text || res.statusText}`);
+ }
+ return text;
+ }
+
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+
+ if (!res.ok || (data && data.success === false)) {
+ const errorList = data && Array.isArray(data.errors) ? data.errors : [];
+ const errors = errorList.length
+ ? errorList.map(e => `${e.message}${e.code ? ` (code ${e.code})` : ""}${e.error_chain ? ` [chain: ${JSON.stringify(e.error_chain)}]` : ""}`).join("; ")
+ : null;
+ // When Cloudflare returns an empty errors array (common on 400s from the
+ // observability query/values endpoints), fall back to the raw response
+ // body so the actual validation failure isn't swallowed.
+ const fallback = errors || (data ? JSON.stringify(data) : null) || text || res.statusText;
+ throw new Error(`Cloudflare API error (${res.status}): ${fallback}`);
+ }
+
+ // Cloudflare wraps successful payloads as { success, result, result_info }.
+ return data && Object.prototype.hasOwnProperty.call(data, "result") ? data.result : data;
+}
+
+// Convenience helper for the common case: paths under /accounts/{account_id}/...
+export function cfAccountRequest(subpath, opts) {
+ return cfRequest(`/accounts/${CLOUDFLARE_ACCOUNT_ID}${subpath}`, opts);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + +3x + + + + + + + +3x + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/d1.js — D1 database tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_d1_database",
+ "DOES: Get a single D1 database (pass database_id), OR list all D1 databases in your Cloudflare account (omit database_id).\n" +
+ "RULE: database_id set -> name/page/per_page ignored.",
+ {
+ database_id: z.string().optional().describe("If provided, fetch this single database instead of listing."),
+ name: z.string().optional().describe("Filter by database name when listing. Ignored if database_id is given."),
+ page: z.number().optional().describe("Page number when listing. Ignored if database_id is given."),
+ per_page: z.number().optional().describe("Results per page when listing. Ignored if database_id is given."),
+ },
+ async ({ database_id, name, page, per_page }) => {
+ if (database_id) {
+ return textResult(await cfAccountRequest(`/d1/database/${database_id}`));
+ }
+ const params = new URLSearchParams();
+ if (name) params.set("name", name);
+ if (page) params.set("page", String(page));
+ if (per_page) params.set("per_page", String(per_page));
+ const qs = params.toString() ? `?${params.toString()}` : "";
+ const result = await cfAccountRequest(`/d1/database${qs}`);
+ return textResult(result);
+ }
+ );
+
+ server.tool(
+ "cf_d1_database_create",
+ "Create a new D1 database in your Cloudflare account",
+ {
+ name: z.string(),
+ primary_location_hint: z.enum(["wnam", "enam", "weur", "eeur", "apac", "oc"]).optional(),
+ },
+ async ({ name, primary_location_hint }) =>
+ textResult(await cfAccountRequest("/d1/database", { method: "POST", body: { name, primary_location_hint } }))
+ );
+
+ server.tool(
+ "cf_d1_database_delete",
+ "Delete a D1 database in your Cloudflare account",
+ { database_id: z.string() },
+ async ({ database_id }) =>
+ textResult(await cfAccountRequest(`/d1/database/${database_id}`, { method: "DELETE" }))
+ );
+
+ server.tool(
+ "cf_d1_database_query",
+ "Query a D1 database in your Cloudflare account",
+ {
+ database_id: z.string(),
+ sql: z.string(),
+ params: z.array(z.string()).optional(),
+ },
+ async ({ database_id, sql, params }) =>
+ textResult(await cfAccountRequest(`/d1/database/${database_id}/query`, { method: "POST", body: { sql, params } }))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/hyperdrive.js — Hyperdrive config tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_hyperdrive_config",
+ "DOES: Get a single Hyperdrive configuration (pass hyperdrive_id), OR list all Hyperdrive configurations in your Cloudflare account (omit hyperdrive_id).\n" +
+ "RULE: hyperdrive_id set -> page/per_page/order/direction ignored.",
+ {
+ hyperdrive_id: z.string().optional().describe("If provided, fetch this single configuration instead of listing."),
+ page: z.number().optional().describe("Page number when listing. Ignored if hyperdrive_id is given."),
+ per_page: z.number().optional().describe("Results per page when listing. Ignored if hyperdrive_id is given."),
+ order: z.enum(["id", "name"]).optional().describe("Sort field when listing. Ignored if hyperdrive_id is given."),
+ direction: z.enum(["asc", "desc"]).optional().describe("Sort direction when listing. Ignored if hyperdrive_id is given."),
+ },
+ async ({ hyperdrive_id, page, per_page, order, direction }) => {
+ if (hyperdrive_id) {
+ return textResult(await cfAccountRequest(`/hyperdrive/configs/${hyperdrive_id}`));
+ }
+ const params = new URLSearchParams();
+ if (page) params.set("page", String(page));
+ if (per_page) params.set("per_page", String(per_page));
+ if (order) params.set("order", order);
+ if (direction) params.set("direction", direction);
+ const qs = params.toString() ? `?${params.toString()}` : "";
+ return textResult(await cfAccountRequest(`/hyperdrive/configs${qs}`));
+ }
+ );
+
+ server.tool(
+ "cf_hyperdrive_config_update",
+ "Update (patch) a Hyperdrive configuration in your Cloudflare account",
+ {
+ hyperdrive_id: z.string(),
+ name: z.string().optional(),
+ database: z.string().optional(),
+ host: z.string().optional(),
+ port: z.number().optional(),
+ scheme: z.enum(["postgresql"]).optional(),
+ user: z.string().optional(),
+ caching_disabled: z.boolean().optional(),
+ caching_max_age: z.number().optional(),
+ caching_stale_while_revalidate: z.number().optional(),
+ },
+ async ({ hyperdrive_id, ...patch }) => {
+ const body = {};
+ if (patch.name !== undefined) body.name = patch.name;
+ if (patch.database || patch.host || patch.port || patch.scheme || patch.user) {
+ body.origin = {
+ ...(patch.database ? { database: patch.database } : {}),
+ ...(patch.host ? { host: patch.host } : {}),
+ ...(patch.port ? { port: patch.port } : {}),
+ ...(patch.scheme ? { scheme: patch.scheme } : {}),
+ ...(patch.user ? { user: patch.user } : {}),
+ };
+ }
+ if (patch.caching_disabled !== undefined || patch.caching_max_age !== undefined || patch.caching_stale_while_revalidate !== undefined) {
+ body.caching = {
+ ...(patch.caching_disabled !== undefined ? { disabled: patch.caching_disabled } : {}),
+ ...(patch.caching_max_age !== undefined ? { max_age: patch.caching_max_age } : {}),
+ ...(patch.caching_stale_while_revalidate !== undefined ? { stale_while_revalidate: patch.caching_stale_while_revalidate } : {}),
+ };
+ }
+ return textResult(await cfAccountRequest(`/hyperdrive/configs/${hyperdrive_id}`, { method: "PATCH", body }));
+ }
+ );
+
+ server.tool(
+ "cf_hyperdrive_config_delete",
+ "Delete a Hyperdrive configuration in your Cloudflare account",
+ { hyperdrive_id: z.string() },
+ async ({ hyperdrive_id }) =>
+ textResult(await cfAccountRequest(`/hyperdrive/configs/${hyperdrive_id}`, { method: "DELETE" }))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| client.js | +
+
+ |
+ 0% | +0/23 | +0% | +0/43 | +0% | +0/4 | +0% | +0/21 | +
| d1.js | +
+
+ |
+ 20% | +4/20 | +0% | +0/10 | +16.66% | +1/6 | +23.52% | +4/17 | +
| hyperdrive.js | +
+
+ |
+ 11.53% | +3/26 | +0% | +0/42 | +20% | +1/5 | +14.28% | +3/21 | +
| kv.js | +
+
+ |
+ 19.04% | +4/21 | +0% | +0/12 | +16.66% | +1/6 | +23.52% | +4/17 | +
| observability.js | +
+
+ |
+ 14.28% | +4/28 | +0% | +0/26 | +11.11% | +1/9 | +17.39% | +4/23 | +
| observability_compare.js | +
+
+ |
+ 14.28% | +9/63 | +0% | +0/61 | +9.09% | +1/11 | +15.51% | +9/58 | +
| r2.js | +
+
+ |
+ 14.28% | +3/21 | +0% | +0/14 | +20% | +1/5 | +18.75% | +3/16 | +
| tools.js | +
+
+ |
+ 100% | +7/7 | +100% | +0/0 | +100% | +1/1 | +100% | +7/7 | +
| workers.js | +
+
+ |
+ 37.5% | +3/8 | +0% | +0/2 | +20% | +1/5 | +37.5% | +3/8 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + +3x + + + + + + + +3x + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/kv.js — Workers KV namespace tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_kv_namespace",
+ "DOES: Get a single KV namespace (pass namespace_id), OR list all KV namespaces in your Cloudflare account (omit namespace_id).\n" +
+ "RULE: namespace_id set -> page/per_page/order/direction ignored.",
+ {
+ namespace_id: z.string().optional().describe("If provided, fetch this single namespace instead of listing."),
+ page: z.number().optional().describe("Page number when listing. Ignored if namespace_id is given."),
+ per_page: z.number().optional().describe("Results per page when listing. Ignored if namespace_id is given."),
+ order: z.enum(["id", "title"]).optional().describe("Sort field when listing. Ignored if namespace_id is given."),
+ direction: z.enum(["asc", "desc"]).optional().describe("Sort direction when listing. Ignored if namespace_id is given."),
+ },
+ async ({ namespace_id, page, per_page, order, direction }) => {
+ if (namespace_id) {
+ return textResult(await cfAccountRequest(`/storage/kv/namespaces/${namespace_id}`));
+ }
+ const params = new URLSearchParams();
+ if (page) params.set("page", String(page));
+ if (per_page) params.set("per_page", String(per_page));
+ if (order) params.set("order", order);
+ if (direction) params.set("direction", direction);
+ const qs = params.toString() ? `?${params.toString()}` : "";
+ return textResult(await cfAccountRequest(`/storage/kv/namespaces${qs}`));
+ }
+ );
+
+ server.tool(
+ "cf_kv_namespace_create",
+ "Create a new kv namespace in your Cloudflare account",
+ { title: z.string() },
+ async ({ title }) =>
+ textResult(await cfAccountRequest("/storage/kv/namespaces", { method: "POST", body: { title } }))
+ );
+
+ server.tool(
+ "cf_kv_namespace_update",
+ "Update the title of a kv namespace in your Cloudflare account",
+ { namespace_id: z.string(), title: z.string() },
+ async ({ namespace_id, title }) =>
+ textResult(await cfAccountRequest(`/storage/kv/namespaces/${namespace_id}`, { method: "PUT", body: { title } }))
+ );
+
+ server.tool(
+ "cf_kv_namespace_delete",
+ "Delete a kv namespace in your Cloudflare account",
+ { namespace_id: z.string() },
+ async ({ namespace_id }) =>
+ textResult(await cfAccountRequest(`/storage/kv/namespaces/${namespace_id}`, { method: "DELETE" }))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/observability.js — Workers Logs / Traces / Events
+// Wraps the Workers Observability "telemetry" API. This single dataset holds
+// invocation logs, custom logs, traces, and the raw event stream — the same
+// data backing the Observability dashboard's Overview/Invocations/Events tabs
+// and the Query Builder.
+//
+// Docs: https://developers.cloudflare.com/workers/observability/query-builder/
+// API: POST /accounts/{account_id}/workers/observability/telemetry/{query,keys,values}
+//
+// NOT included: real-time `wrangler tail` streaming — that's a websocket
+// session, not a request/response REST call, so it doesn't fit this tool
+// model. Logpush (export to R2/S3/etc.) is also out of scope here since it's
+// a push-configuration resource rather than a query.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+// Cloudflare's telemetry query/values endpoints require timeframe bounds as
+// epoch millis (numbers), not ISO strings — accept either from callers and
+// normalize here.
+export function toEpochMillis(ts) {
+ if (typeof ts === "number") return ts;
+ if (/^\d+$/.test(ts)) return Number(ts);
+ const parsed = Date.parse(ts);
+ if (Number.isNaN(parsed)) throw new Error(`Invalid timeframe value: ${ts}`);
+ return parsed;
+}
+
+// The telemetry query API's `parameters.filters` entries are a discriminated
+// union of either a "group" node ({kind:"group", filterCombination, filters})
+// or a leaf filter node. A leaf node requires `operation` (not `operator`)
+// and a `type` describing the value's type — both were previously missing,
+// which caused every query with any filter (including the script_name
+// convenience filter) to fail Cloudflare's schema validation with a 400.
+function inferValueType(value) {
+ if (typeof value === "boolean") return "boolean";
+ if (typeof value === "number") return "number";
+ return "string";
+}
+
+function normalizeFilter(f) {
+ // Accept both the tool's public `operator` param name and, defensively,
+ // an already-correct `operation` field if a caller supplies one directly.
+ const operation = f.operation || f.operator;
+ const type = f.type || inferValueType(f.value);
+ return { key: f.key, operation, type, value: f.value };
+}
+
+const filterSchema = z.object({
+ key: z.string().describe("Field to filter on, e.g. '$workers.event.response.status' or '$metadata.service'. Use cf_workers_observability_keys to discover valid keys."),
+ operator: z.string().describe("Comparison operator, e.g. 'eq', 'neq', 'gt', 'lt', 'includes'"),
+ value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to compare against"),
+}).passthrough();
+
+// Shared query function — used directly by cf_workers_observability_query
+// and reused by cf_workers_observability_compare so both tools stay in sync
+// on filter-normalization and timeframe handling.
+export async function queryTelemetry({
+ timeframe_from,
+ timeframe_to,
+ script_name,
+ view = "events",
+ dataset = "cloudflare-workers",
+ filters = [],
+ limit,
+ query_id,
+}) {
+ const rawFilters = script_name
+ ? [{ key: "$metadata.service", operator: "eq", value: script_name }, ...filters]
+ : filters;
+
+ const allFilters = rawFilters.map(normalizeFilter);
+
+ const body = {
+ queryId: query_id || `madmcp-${Date.now()}`,
+ view,
+ datasets: [dataset],
+ timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) },
+ parameters: { filters: allFilters },
+ ...(limit ? { limit } : {}),
+ };
+
+ return cfAccountRequest("/workers/observability/telemetry/query", { method: "POST", body });
+}
+
+export function register(server) {
+ server.tool(
+ "cf_workers_observability_keys",
+ "DOES: List all keys available in Workers Observability telemetry (logs/traces/events) -- what fields you can filter/group by.\n" +
+ "RULE: call this before cf_workers_observability_query if you don't already know the field names to filter on.",
+ {
+ dataset: z.string().optional().describe("Telemetry dataset (default: 'cloudflare-workers')"),
+ timeframe_from: z.string().describe("Start of time range, ISO 8601 (e.g. '2026-07-01T00:00:00Z') or epoch millis"),
+ timeframe_to: z.string().describe("End of time range, ISO 8601 or epoch millis"),
+ },
+ async ({ dataset = "cloudflare-workers", timeframe_from, timeframe_to }) =>
+ textResult(await cfAccountRequest("/workers/observability/telemetry/keys", {
+ method: "POST",
+ body: { dataset, timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) } },
+ }))
+ );
+
+ server.tool(
+ "cf_workers_observability_values",
+ "DOES: List unique values seen for a given telemetry key in range (e.g. all distinct $workers.event.response.status values) -- for building filters.",
+ {
+ key: z.string().describe("The telemetry key to list values for, e.g. '$workers.event.response.status'"),
+ dataset: z.string().optional().describe("Telemetry dataset (default: 'cloudflare-workers')"),
+ timeframe_from: z.string().describe("Start of time range, ISO 8601 or epoch millis"),
+ timeframe_to: z.string().describe("End of time range, ISO 8601 or epoch millis"),
+ type: z.enum(["string", "boolean", "number"]).optional().describe("The value type of the key being listed (required by the Cloudflare API). Default: 'string'."),
+ },
+ async ({ key, dataset = "cloudflare-workers", timeframe_from, timeframe_to, type = "string" }) =>
+ textResult(await cfAccountRequest("/workers/observability/telemetry/values", {
+ method: "POST",
+ body: {
+ datasets: [dataset],
+ key,
+ type,
+ timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) },
+ },
+ }))
+ );
+
+ server.tool(
+ "cf_workers_observability_query",
+ "DOES: Query Workers logs/traces/events (invocation logs, console.log output, exceptions, request/response metadata, trace spans) -- same data as the Observability dashboard's Overview/Invocations/Events tabs.\n" +
+ "RULE: comparing two scripts over the same timeframe -> use cf_workers_observability_compare instead (normalizes rates, not raw counts).\n" +
+ "RULE: don't know filterable field names -> cf_workers_observability_keys first.",
+ {
+ timeframe_from: z.string().describe("Start of time range, ISO 8601 (e.g. '2026-07-01T00:00:00Z') or epoch millis"),
+ timeframe_to: z.string().describe("End of time range, ISO 8601 or epoch millis"),
+ script_name: z.string().optional().describe("Convenience filter: scope results to one Worker script. Adds a filter on '$metadata.service' — if that key doesn't match your account's schema, use the 'filters' param directly instead (check cf_workers_observability_keys)."),
+ view: z.string().optional().describe("Result grouping mode, e.g. 'events' (raw event stream) or 'invocations' (grouped by invocation). Default: 'events'."),
+ dataset: z.string().optional().describe("Telemetry dataset (default: 'cloudflare-workers')"),
+ filters: z.array(filterSchema).optional().describe("Additional structured filters, e.g. [{key: '$workers.event.response.status', operator: 'gt', value: 500}]"),
+ limit: z.number().optional().describe("Max number of results (default: server default, typically 100)"),
+ query_id: z.string().optional().describe("Optional query identifier for the request (any string); Cloudflare uses this to tag/save the query"),
+ },
+ async (args) => textResult(await queryTelemetry(args))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x +3x +3x +3x + + + + +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/observability_compare.js
+//
+// cf_workers_observability_compare — fetches Workers Observability telemetry
+// for TWO scripts over the SAME timeframe and returns a normalized,
+// side-by-side diff, instead of two raw event dumps that have to be eyeballed
+// separately.
+//
+// Why this exists: raw event counts between two workers aren't comparable
+// unless normalized, because the two queries can span very different amounts
+// of actual wall-clock time even with the same `limit` (e.g. a busier worker
+// fills its event quota over a much shorter window). Every ad-hoc comparison
+// done manually against cf_workers_observability_query had to redo this
+// normalization by hand and was easy to get wrong (see: a same-day comparison
+// that used differing sample windows and produced an apparently-contradictory
+// result versus an earlier, larger-sample comparison).
+//
+// What this tool normalizes:
+// - event rate (events/sec, computed off actual min/max timestamp span of
+// the returned sample — NOT off the requested timeframe window, since a
+// `limit` cutoff usually means the sample covers less time than requested)
+// - loadShed / error / exception rate (per second, same basis)
+// - a "stuck socket" heuristic flag: events where wall-clock duration is
+// wildly larger than CPU time (the workerd#2060 stuck-TCP-connect
+// signature: connection holds a slot ~21s after close() even though the
+// Worker's own code barely ran) — surfaced as a count + example events
+// rather than requiring a human to spot it in a wall of JSON.
+//
+// What this tool deliberately does NOT try to normalize (confounds that need
+// a human, per DumbCodesOnly's own past findings): different test-server
+// geography (e.g. SG vs DE testmy.net endpoints), client-side network
+// conditions, and time-of-day traffic differences. The output includes a
+// caveat noting these aren't a controlled A/B.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { queryTelemetry, toEpochMillis } from "./observability.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+function getPath(obj, path) {
+ return path.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
+}
+
+function firstDefined(obj, paths) {
+ for (const p of paths) {
+ const v = getPath(obj, p);
+ if (v !== undefined && v !== null) return v;
+ }
+ return undefined;
+}
+
+// Different dataset views (cloudflare-workers "fetch" events vs "otel" spans)
+// place outcome/timing fields at different paths. Try the known locations
+// rather than assuming one schema.
+const OUTCOME_PATHS = ["$workers.event.outcome", "source.cloudflare.outcome"];
+const DURATION_PATHS = ["$metadata.traceDuration", "source.durationMS", "$workers.event.durationMS"];
+const CPU_MS_PATHS = ["source.cpu_time_ms", "$workers.event.cpu_time_ms"];
+const LEVEL_PATHS = ["$metadata.level"];
+const MESSAGE_PATHS = ["$metadata.message", "source.message"];
+const TIMESTAMP_PATHS = ["timestamp", "$metadata.startTime"];
+
+// Heuristic thresholds for flagging the workerd#2060 stuck-connect signature:
+// wall time much larger than CPU time, and large enough in absolute terms to
+// matter (avoids flagging trivially small durations).
+const STUCK_RATIO_THRESHOLD = 15;
+const STUCK_MIN_DURATION_MS = 1000;
+
+function analyzeEvents(events) {
+ const timestamps = [];
+ const levelCounts = {};
+ const outcomeCounts = {};
+ const messageCounts = {};
+ const stuckSocketEvents = [];
+ let errorLikeCount = 0;
+
+ for (const e of events) {
+ const ts = firstDefined(e, TIMESTAMP_PATHS);
+ if (typeof ts === "number") timestamps.push(ts);
+
+ const level = firstDefined(e, LEVEL_PATHS) || "none";
+ levelCounts[level] = (levelCounts[level] || 0) + 1;
+
+ const outcome = firstDefined(e, OUTCOME_PATHS);
+ if (outcome) outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
+
+ const message = firstDefined(e, MESSAGE_PATHS);
+ if (level === "error" || (typeof message === "string" && /exception|error/i.test(message))) {
+ errorLikeCount += 1;
+ if (typeof message === "string") {
+ // Collapse dynamic reference IDs so repeated error types group together.
+ const normalized = message.replace(/reference = [a-z0-9]+/gi, "reference = <id>");
+ messageCounts[normalized] = (messageCounts[normalized] || 0) + 1;
+ }
+ }
+
+ const durationMS = firstDefined(e, DURATION_PATHS);
+ const cpuMs = firstDefined(e, CPU_MS_PATHS);
+ if (typeof durationMS === "number" && typeof cpuMs === "number" && cpuMs >= 0) {
+ const safeCpu = Math.max(cpuMs, 1);
+ const ratio = durationMS / safeCpu;
+ if (durationMS >= STUCK_MIN_DURATION_MS && ratio >= STUCK_RATIO_THRESHOLD) {
+ stuckSocketEvents.push({
+ timestamp: ts,
+ durationMS,
+ cpuMs,
+ ratio: Math.round(ratio * 10) / 10,
+ message: typeof message === "string" ? message : undefined,
+ outcome,
+ });
+ }
+ }
+ }
+
+ const minTs = timestamps.length ? Math.min(...timestamps) : null;
+ const maxTs = timestamps.length ? Math.max(...timestamps) : null;
+ const spanSeconds = minTs !== null && maxTs !== null ? Math.max((maxTs - minTs) / 1000, 0.001) : null;
+
+ const rate = (count) => (spanSeconds ? Math.round((count / spanSeconds) * 1000) / 1000 : null);
+
+ const loadShedCount = outcomeCounts.loadShed || 0;
+
+ return {
+ sampleSize: events.length,
+ sampleSpanSeconds: spanSeconds,
+ sampleSpanCaveat: spanSeconds && spanSeconds < 60
+ ? "Sample covers under a minute of wall-clock time — rates from this small a window are noisy; prefer a larger limit or narrower script-specific timeframe for a firmer read."
+ : undefined,
+ levelCounts,
+ outcomeCounts,
+ ratesPerSecond: {
+ events: rate(events.length),
+ loadShed: rate(loadShedCount),
+ errorLike: rate(errorLikeCount),
+ },
+ topErrorMessages: Object.entries(messageCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([message, count]) => ({ message, count })),
+ stuckSocketSuspects: {
+ count: stuckSocketEvents.length,
+ rate: rate(stuckSocketEvents.length),
+ examples: stuckSocketEvents
+ .sort((a, b) => b.ratio - a.ratio)
+ .slice(0, 5),
+ },
+ };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_workers_observability_compare",
+ "DOES: Compare Workers Observability telemetry between TWO scripts over the SAME timeframe -- normalized rates (events/sec, loadShed/sec, error/sec), not raw counts, plus a 'stuck socket' heuristic (high wall-time vs low CPU-time) with example events per side.\n" +
+ "RULE: comparing a deploy against a baseline -> this, not two separate cf_workers_observability_query calls -- raw counts aren't comparable across differing sample time-spans (see file header for why).\n" +
+ "NOT a controlled A/B: traffic mix, client geography, time-of-day aren't normalized -- output includes that caveat.",
+ {
+ script_a: z.string().describe("First Worker script name, e.g. the post-deploy / current version"),
+ script_b: z.string().describe("Second Worker script name, e.g. the pre-deploy / baseline version"),
+ timeframe_from: z.string().describe("Start of time range, ISO 8601 (e.g. '2026-07-01T00:00:00Z') or epoch millis — applied identically to both scripts"),
+ timeframe_to: z.string().describe("End of time range, ISO 8601 or epoch millis — applied identically to both scripts"),
+ dataset: z.string().optional().describe("Telemetry dataset (default: 'cloudflare-workers'). Pass 'otel' to compare span/exception data instead."),
+ view: z.string().optional().describe("Result grouping mode, e.g. 'events' or 'invocations'. Default: 'events'."),
+ limit: z.number().optional().describe("Max events fetched per script (default: 1000). Same limit applied to both sides for a fair comparison."),
+ },
+ async ({ script_a, script_b, timeframe_from, timeframe_to, dataset = "cloudflare-workers", view = "events", limit = 1000 }) => {
+ const from = toEpochMillis(timeframe_from);
+ const to = toEpochMillis(timeframe_to);
+
+ const [resultA, resultB] = await Promise.all([
+ queryTelemetry({ timeframe_from: from, timeframe_to: to, script_name: script_a, dataset, view, limit }),
+ queryTelemetry({ timeframe_from: from, timeframe_to: to, script_name: script_b, dataset, view, limit }),
+ ]);
+
+ const eventsA = resultA?.events?.events || [];
+ const eventsB = resultB?.events?.events || [];
+
+ const analysisA = analyzeEvents(eventsA);
+ const analysisB = analyzeEvents(eventsB);
+
+ return textResult({
+ timeframe: { from, to },
+ scripts: { a: script_a, b: script_b },
+ a: analysisA,
+ b: analysisB,
+ note: "Rates are normalized per-second off each sample's own observed timestamp span, not off the requested timeframe — a `limit` cutoff usually means the returned sample covers less wall-clock time than requested, especially for a busier script. This is NOT a controlled A/B: differing traffic mix, client geography, and time-of-day are not accounted for here and can still explain rate differences on their own.",
+ });
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + +3x + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/r2.js — R2 bucket tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_r2_bucket",
+ "DOES: Get a single R2 bucket (pass name), OR list all R2 buckets in your Cloudflare account (omit name).\n" +
+ "RULE: name set -> cursor/direction/name_contains/per_page/start_after ignored.",
+ {
+ name: z.string().optional().describe("If provided, fetch this single bucket instead of listing."),
+ cursor: z.string().optional().describe("Pagination cursor when listing. Ignored if name is given."),
+ direction: z.enum(["asc", "desc"]).optional().describe("Sort direction when listing. Ignored if name is given."),
+ name_contains: z.string().optional().describe("Filter buckets by name substring when listing. Ignored if name is given."),
+ per_page: z.number().optional().describe("Results per page when listing. Ignored if name is given."),
+ start_after: z.string().optional().describe("Start listing after this bucket name. Ignored if name is given."),
+ },
+ async ({ name, cursor, direction, name_contains, per_page, start_after }) => {
+ if (name) {
+ return textResult(await cfAccountRequest(`/r2/buckets/${name}`));
+ }
+ const params = new URLSearchParams();
+ if (cursor) params.set("cursor", cursor);
+ if (direction) params.set("direction", direction);
+ if (name_contains) params.set("name_contains", name_contains);
+ if (per_page) params.set("per_page", String(per_page));
+ if (start_after) params.set("start_after", start_after);
+ const qs = params.toString() ? `?${params.toString()}` : "";
+ return textResult(await cfAccountRequest(`/r2/buckets${qs}`));
+ }
+ );
+
+ server.tool(
+ "cf_r2_bucket_create",
+ "Create a new r2 bucket in your Cloudflare account",
+ { name: z.string() },
+ async ({ name }) => textResult(await cfAccountRequest("/r2/buckets", { method: "POST", body: { name } }))
+ );
+
+ server.tool(
+ "cf_r2_bucket_delete",
+ "Delete an R2 bucket",
+ { name: z.string() },
+ async ({ name }) => textResult(await cfAccountRequest(`/r2/buckets/${name}`, { method: "DELETE" }))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 | + + + + + + + + + + + + + +3x +3x +3x +3x +3x +3x +3x + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/tools.js — aggregates and registers all Cloudflare
+// sub-tool modules (D1, KV, R2, Workers, Hyperdrive) with the MCP server.
+// ---------------------------------------------------------------------------
+
+import * as d1 from "./d1.js";
+import * as kv from "./kv.js";
+import * as r2 from "./r2.js";
+import * as workers from "./workers.js";
+import * as hyperdrive from "./hyperdrive.js";
+import * as observability from "./observability.js";
+import * as observabilityCompare from "./observability_compare.js";
+
+export function register(server) {
+ d1.register(server);
+ kv.register(server);
+ r2.register(server);
+ workers.register(server);
+ hyperdrive.register(server);
+ observability.register(server);
+ observabilityCompare.register(server);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 | + + + + + + + + + + + + +3x + + + + + + +3x + + + + + + +3x + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/cloudflare/workers.js — Workers script inspection tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { cfAccountRequest } from "./client.js";
+
+function textResult(data) {
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
+ return { content: [{ type: "text", text }] };
+}
+
+export function register(server) {
+ server.tool(
+ "cf_workers_list",
+ "List all Workers in your Cloudflare account",
+ {},
+ async () => textResult(await cfAccountRequest("/workers/scripts"))
+ );
+
+ server.tool(
+ "cf_workers_get_worker",
+ "Get the details of the Cloudflare Worker",
+ { scriptName: z.string() },
+ async ({ scriptName }) => textResult(await cfAccountRequest(`/workers/scripts/${scriptName}/settings`))
+ );
+
+ server.tool(
+ "cf_workers_get_worker_code",
+ "Get the source code of a Cloudflare Worker. Note: This may be a bundled version of the worker.",
+ { scriptName: z.string() },
+ async ({ scriptName }) => textResult(await cfAccountRequest(`/workers/scripts/${scriptName}`))
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/context7/client.js — Context7 REST API (context7.com)
+// Docs: https://context7.com/docs/api-guide
+// Auth header: "Authorization: Bearer <api_key>" (optional — unauthenticated
+// requests work at lower rate limits, unlike every other connector here).
+// ---------------------------------------------------------------------------
+
+import { CONTEXT7_API_KEY, CONTEXT7_API } from "../../config.js";
+
+export async function context7Request(path, params = {}) {
+ const url = new URL(`${CONTEXT7_API}${path}`);
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null && value !== "") {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ const headers = {};
+ if (CONTEXT7_API_KEY) headers.Authorization = `Bearer ${CONTEXT7_API_KEY}`;
+
+ const res = await fetch(url, { headers });
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+
+ if (!res.ok) {
+ const message = (data && (data.message || data.error || JSON.stringify(data))) || res.statusText;
+ throw new Error(`Context7 API error (${res.status}): ${message}`);
+ }
+ return data;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/context7/tools.js — up-to-date library/framework documentation.
+// Two-step flow, same as Context7's own MCP server: search_library to
+// resolve a name to a Context7 library ID, then get_library_docs to fetch
+// version-specific docs and code examples for that ID.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { context7Request } from "./client.js";
+
+export function register(server) {
+
+ server.tool(
+ "search_library",
+ "DOES: Search Context7's index by library/framework name, return matching Context7 library IDs.\n" +
+ "RULE: call before get_library_docs UNLESS you already know the exact ID (format: /org/project, e.g. /vercel/next.js).",
+ {
+ libraryName: z.string().describe("The library or framework name to search for (e.g. \"next.js\", \"react\", \"fastapi\")"),
+ query: z.string().describe("The task or question you're trying to solve — used to rank results by relevance (e.g. \"app router middleware\")"),
+ },
+ async ({ libraryName, query }) => {
+ const data = await context7Request("/libs/search", { libraryName, query });
+ const results = data?.results || [];
+ if (!results.length) {
+ return { content: [{ type: "text", text: `No libraries found matching "${libraryName}".` }] };
+ }
+ const lines = results.slice(0, 10).map((r) =>
+ `${r.id} — ${r.title || r.name || r.id}${r.trustScore !== undefined ? ` [trust ${r.trustScore}]` : ""}${r.versions?.length ? ` (versions: ${r.versions.slice(0, 5).join(", ")})` : ""}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ server.tool(
+ "get_library_docs",
+ "DOES: Fetch up-to-date, version-specific docs + code examples for a library, given its Context7 library ID.\n" +
+ "RULE: need the ID first via search_library, UNLESS already given an exact one like /vercel/next.js or /vercel/next.js/v15.1.0.",
+ {
+ libraryId: z.string().describe("Exact Context7-compatible library ID, e.g. \"/vercel/next.js\" or \"/vercel/next.js/v15.1.0\""),
+ query: z.string().describe("The specific question or task to retrieve relevant docs for (e.g. \"how to set up middleware\") — be specific, vague queries return vague docs"),
+ tokens: z.number().optional().describe("Max tokens of documentation to return (default 5000, minimum 1000)"),
+ },
+ async ({ libraryId, query, tokens }) => {
+ const data = await context7Request("/context", {
+ libraryId,
+ query,
+ type: "txt",
+ tokens,
+ });
+ const text = typeof data === "string" ? data : (data?.context || data?.text || JSON.stringify(data, null, 2));
+ return { content: [{ type: "text", text: text || "(no documentation returned)" }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/exa/client.js — Exa /answer API, backing delegate_research's
+// wide mode. Docs: https://docs.exa.ai/reference/answer
+// Auth header: "x-api-key: <api_key>"
+//
+// NOT A FALLBACK ANYMORE (2026-07-27): this file used to plug a gap in
+// research_delegate.js's Gemini-native-search-grounding loop (for older
+// GEMINI_FALLBACK_MODELS that rejected the search+web_fetch tool
+// combination). That loop is gone -- research_delegate.js (same directory) now
+// calls exaWebSearch() below directly, as the only implementation of wide
+// mode, not as a fallback for anything. See research_delegate.js's header for that
+// history and the full rationale.
+//
+// FORMERLY OPENAI (2026-07-27): this file replaces connectors/openai/
+// client.js, which did the same job via OpenAI's Responses API web_search
+// tool. Swapped to Exa's /answer endpoint -- same "search + synthesize
+// with sources" shape, different provider. See git history for the prior
+// implementation if the OpenAI version needs to be resurrected.
+//
+// NO FREE TIER: Exa's /answer endpoint is billed per call (plus content
+// retrieval costs baked into the same call), regardless of key. The
+// cascade below is about RATE-LIMIT HEADROOM (Exa's documented default is
+// 10 QPS per account, shared across ALL endpoints) and having a second
+// account's quota to fall into if one is exhausted -- not about reaching
+// some free quota. Don't describe this to a caller/user as "free."
+//
+// CASCADE ORDER: unlike the OpenAI client this replaces, there is no
+// model tier to cascade through first -- Exa's /answer endpoint doesn't
+// expose a selectable model for this call shape. The cascade is simply
+// EXA_API_KEYS in order: on a 429/503/network-transient error, rotate to
+// the next key. See connectors/exa/cooldown.js for the per-keyIndex
+// cross-call memory that lets a known-cooling-down key be skipped without
+// spending a request on it.
+// ---------------------------------------------------------------------------
+
+import { EXA_API_KEYS, EXA_API, EXA_REQUEST_TIMEOUT_MS } from "../../config.js";
+import { isKeyCoolingDown, setKeyCooldown, parseRetryDelaySeconds } from "./cooldown.js";
+
+async function callAnswerOnce(query, apiKey) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), EXA_REQUEST_TIMEOUT_MS);
+
+ let res;
+ try {
+ res = await fetch(EXA_API, {
+ method: "POST",
+ headers: {
+ "x-api-key": apiKey,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ query, text: true }),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ // Network-level failure (dropped connection, DNS/TLS error, our own
+ // abort firing) -- none of these carry an HTTP status. `transient: true`
+ // lets the cascade below treat them the same as a 503, same reasoning
+ // as connectors/gemini/client.js's callGenerateContentOnce.
+ const isAbort = err.name === "AbortError";
+ const wrapped = new Error(
+ isAbort
+ ? `Exa request timed out after ${EXA_REQUEST_TIMEOUT_MS}ms`
+ : `Exa request failed (network error): ${err.message}`
+ );
+ wrapped.transient = true;
+ throw wrapped;
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+
+ if (!res.ok) {
+ // Exa's 429 body is a bare { "error": "..." } string, not the nested
+ // { error: { message } } shape OpenAI/Gemini use -- handle both just
+ // in case, but Exa's documented format is the flat one.
+ const message = (data && (data.error?.message || data.error || JSON.stringify(data))) || res.statusText;
+ const err = new Error(`Exa API error (${res.status}): ${message}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data;
+}
+
+// Cascades across EXA_API_KEYS on a 429 (rate limit -- Exa's documented
+// default is 10 QPS shared across all endpoints, per account), 503
+// (overloaded), or network-transient error. Any other status (400, 401,
+// etc.) is a real failure and surfaces immediately -- none of those are
+// problems a different key would fix. Throws if EXA_API_KEYS is empty,
+// since there is nothing to cascade through.
+async function callAnswer(query) {
+ if (EXA_API_KEYS.length === 0) {
+ throw new Error("EXA_API_KEYS is not set. Add at least one key as a comma-separated env var on the madmcp server.");
+ }
+
+ let lastErr;
+ for (let keyIndex = 0; keyIndex < EXA_API_KEYS.length; keyIndex++) {
+ const apiKey = EXA_API_KEYS[keyIndex];
+ const isLastAttempt = keyIndex === EXA_API_KEYS.length - 1;
+
+ // Best-effort cross-call memory (see cooldown.js): if this key was
+ // 429'd recently -- possibly in a prior invocation, since Vercel
+ // doesn't guarantee a warm/reused instance -- skip it without spending
+ // a request.
+ if (await isKeyCoolingDown(keyIndex)) {
+ lastErr = lastErr || new Error(`Exa API error (429): key #${keyIndex} is in a recorded cooldown from a recent rate limit.`);
+ continue;
+ }
+
+ try {
+ const data = await callAnswerOnce(query, apiKey);
+ if (keyIndex > 0) data._fallbackUsed = { keyIndex }; // surfaced for logging/debugging only
+ return data;
+ } catch (err) {
+ lastErr = err;
+ const isRateLimited = err.status === 429;
+ const isOverloaded = err.status === 503;
+ const isNetworkTransient = err.transient === true;
+ if ((!isRateLimited && !isOverloaded && !isNetworkTransient) || isLastAttempt) throw err;
+ if (isRateLimited) {
+ await setKeyCooldown(keyIndex, parseRetryDelaySeconds(err.message));
+ }
+ // Fall through to the next key.
+ }
+ }
+ throw lastErr;
+}
+
+// Runs one web search + synthesis via Exa's /answer endpoint and returns
+// plain text (the model's own synthesis) -- same shape a caller would get
+// from Gemini's native Google Search grounding or the OpenAI web_search
+// tool this replaces. Throws if every key in the cascade fails; the caller
+// (research_delegate.js's function execute() wrapper) turns that into an
+// "Error: ..." string rather than crashing the research loop.
+export async function exaWebSearch(query) {
+ const data = await callAnswer(query);
+ const answer = typeof data?.answer === "string" ? data.answer : (data?.answer ? JSON.stringify(data.answer) : "");
+ if (!answer) throw new Error("Exa /answer returned no answer text.");
+ return answer;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 | + + + + + + + + + + + + + + + + + + + + + +3x + + + + +3x + +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/exa/cooldown.js — per-key rate-limit cooldown for the Exa
+// connector, backed by Upstash Redis (Vercel Marketplace integration).
+// Same Redis instance/credentials as connectors/gemini/cooldown.js -- it
+// just namespaces its keys differently below -- but a SEPARATE client +
+// separate exported functions, not a shared import. See that file's header
+// for why that duplication is deliberate.
+//
+// WHY REDIS, NOT IN-MEMORY / WHY THIS NEVER SLEEPS: identical reasoning to
+// connectors/gemini/cooldown.js -- see that file's header. Not repeated here
+// beyond this pointer, to avoid the comments drifting apart over time.
+//
+// WHY keyIndex ALONE, UNLIKE connectors/gemini/cooldown.js's per-model
+// namespacing: Exa's /answer endpoint has no selectable model for this call
+// shape (see client.js's file header) -- the cascade in client.js is a 1D
+// rotation across EXA_API_KEYS only, so a cooldown only ever needs to be
+// namespaced by keyIndex (position in EXA_API_KEYS, not the key value
+// itself), never a raw API key in a Redis key name.
+// ---------------------------------------------------------------------------
+
+import { Redis } from "@upstash/redis";
+
+const COOLDOWN_KEY_PREFIX = "exa:cooldown:";
+// Used only when a 429's message doesn't contain a parseable retry-delay
+// hint. Exa's documented 429 body is a bare { "error": "..." } string with
+// no structured retry-delay field, so this fallback carries most of the
+// load in practice -- see parseRetryDelaySeconds below.
+const DEFAULT_COOLDOWN_SECONDS = 60;
+
+let redisClient = null;
+let redisInitAttempted = false;
+
+// Same dual-naming-convention handling as connectors/gemini/cooldown.js's
+// getRedis() -- see that file's comment for the 2026-07-26 discovery this
+// works around (Vercel's own "KV" product exposes the same Upstash REST
+// credentials under KV_REST_API_URL/TOKEN instead of the raw Marketplace
+// integration's UPSTASH_REDIS_REST_URL/TOKEN names).
+export function getRedis() {
+ if (redisInitAttempted) return redisClient;
+ redisInitAttempted = true;
+ const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
+ if (!url || !token) {
+ return null; // Neither naming convention is set -- fine, just no cross-call memory.
+ }
+ try {
+ redisClient = new Redis({ url, token });
+ } catch (err) {
+ console.warn("Redis client construction failed (exa connector) -- URL/token were found but rejected; treating Redis as unconfigured:", err?.message ?? err);
+ redisClient = null;
+ }
+ return redisClient;
+}
+
+// Same purpose as connectors/gemini/cooldown.js's isRedisConfigured -- lets
+// a caller know upfront whether cross-call cooldown memory is actually
+// available this run, rather than discovering it only via silent no-ops.
+export function isRedisConfigured() {
+ return getRedis() !== null;
+}
+
+// Extracts a retry delay in whole seconds from an Exa 429 error message, if
+// present. Exa's documented error format ({"error": "You've exceeded your
+// Exa rate limit of 10 requests per second..."}) doesn't carry a structured
+// delay in the observed wording, but this stays tolerant of the same
+// phrasings connectors/openai/cooldown.js parses in case Exa's message
+// wording includes one on a given account/plan -- returns null (falling
+// back to DEFAULT_COOLDOWN_SECONDS) when neither pattern matches.
+export function parseRetryDelaySeconds(message) {
+ const match =
+ /try again in ([\d.]+)\s*s/i.exec(message || "") ||
+ /retry(?:[- ]after)? ([\d.]+)\s*s/i.exec(message || "");
+ return match ? Math.ceil(parseFloat(match[1])) : null;
+}
+
+function cooldownKey(keyIndex) {
+ return `${COOLDOWN_KEY_PREFIX}${keyIndex}`;
+}
+
+// True if the key at `keyIndex` (its position in EXA_API_KEYS, not the key
+// value) is currently recorded as rate-limited. Fails open (returns false)
+// if Redis isn't configured or unreachable -- never throws.
+export async function isKeyCoolingDown(keyIndex) {
+ const client = getRedis();
+ if (!client) return false;
+ try {
+ const value = await client.get(cooldownKey(keyIndex));
+ return value != null;
+ } catch {
+ return false;
+ }
+}
+
+// Records keyIndex as rate-limited for `seconds` (or
+// DEFAULT_COOLDOWN_SECONDS if omitted/invalid), auto-expiring via Redis
+// TTL. Fails open (silent no-op) if Redis isn't configured or unreachable --
+// never throws.
+export async function setKeyCooldown(keyIndex, seconds) {
+ const client = getRedis();
+ if (!client) return;
+ const ttl = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_COOLDOWN_SECONDS;
+ try {
+ await client.set(cooldownKey(keyIndex), "1", { ex: ttl });
+ } catch {
+ // best-effort -- see file header
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| client.js | +
+
+ |
+ 0% | +0/48 | +0% | +0/33 | +0% | +0/4 | +0% | +0/41 | +
| cooldown.js | +
+
+ |
+ 12.12% | +4/33 | +0% | +0/28 | +0% | +0/6 | +13.33% | +4/30 | +
| research_delegate.js | +
+
+ |
+ 0% | +0/8 | +0% | +0/8 | +0% | +0/1 | +0% | +0/8 | +
| research_tools.js | +
+
+ |
+ 77.77% | +35/45 | +74.24% | +49/66 | +100% | +2/2 | +77.27% | +34/44 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/exa/research_delegate.js — backs delegate_research's "wide mode"
+// (a `task`, no `url`/`question`) in research_tools.js -- the precision mode
+// (single url + question, one geminiGenerate call) stays inline in
+// research_tools.js since it's a genuinely different, simpler code path.
+//
+// NO GEMINI "FIRST TRY" (2026-07-27): this used to run a multi-step Gemini
+// loop first (native Google Search grounding + a web_fetch function tool),
+// falling back to OpenAI's web_search only when Gemini failed or its search
+// tool was rejected mid-run. That architecture, and OpenAI along with it,
+// is gone -- this now calls Exa's /answer endpoint directly, which already
+// does search + synthesis with sources in a single call. No multi-step
+// loop, no checkpointing, no tool-combination handling, no Gemini call at
+// all. See client.js (same directory) for the retry/cooldown/key-rotation
+// behavior backing this call, and git history for the prior Gemini-loop
+// implementation (from when this file lived under connectors/gemini/) if
+// it's ever wanted back.
+//
+// resume_run_id: research_tools.js's delegate_research still accepts this param for
+// wide mode (shared validation with delegate_agent's checkpoint/resume
+// story), but there is no longer anything to resume -- a single Exa call
+// either succeeds or fails in one shot, with nothing partial to save. If a
+// caller passes resume_run_id without a task, that's surfaced as a failed
+// result explaining resuming isn't a thing here anymore, rather than
+// silently doing nothing or throwing.
+// ---------------------------------------------------------------------------
+
+import { randomUUID } from "node:crypto";
+import { exaWebSearch } from "./client.js";
+
+// Runs wide-mode research via a single Exa /answer call. Returns the same
+// { answer, steps, transcript, runId, task, failed? } shape the prior
+// Gemini-loop implementation returned, so research_tools.js's delegate_research
+// handler (and its Notion logging / transcript display) needs no changes.
+export async function runResearch({ task, resume_run_id }) {
+ const runId = randomUUID();
+
+ if (!task) {
+ return {
+ answer: resume_run_id
+ ? `(resume_run_id "${resume_run_id}" cannot be resumed -- wide-mode delegate_research is now a single direct call to Exa, with no multi-step loop or checkpoint to resume. Call delegate_research again with a task and no resume_run_id.)`
+ : `(No task provided.)`,
+ steps: 0,
+ transcript: [],
+ runId,
+ task,
+ failed: true,
+ };
+ }
+
+ try {
+ const answer = await exaWebSearch(task);
+ return {
+ answer,
+ steps: 1,
+ transcript: [`[step 1] exa_web_search -> ${answer.length > 300 ? answer.slice(0, 300) + "…" : answer}`],
+ runId,
+ task,
+ };
+ } catch (err) {
+ const errMessage = err?.message ?? String(err);
+ return {
+ answer: `(Exa research call failed: ${errMessage})`,
+ steps: 0,
+ transcript: [],
+ runId,
+ task,
+ failed: true,
+ };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +17x + + + +17x + + + + + + + + + + + + + + + + + + + + + + +14x +14x + +14x +1x + +13x +1x + +12x +2x + +10x + + + + +10x +3x + + +7x + + +3x +3x + +1x + + +2x +3x +3x + + +2x + + + + + +3x +3x + + + + +2x +2x + + + + + + + + + + + + + + + + +2x + + + + +4x +4x + +1x + + + + + +3x + +14x +14x + + + + + + + + + + + + + + +3x + + + +14x + + + + | // ---------------------------------------------------------------------------
+// connectors/exa/research_tools.js
+//
+// Registers delegate_research: one tool, two mutually-exclusive modes,
+// selected by which args are passed --
+// PRECISION MODE (url + question): fetches a URL and hands the page content
+// + question to Gemini in a single call, returning ONLY Gemini's answer --
+// not the raw page. This is the original token-saving mechanism (see plan
+// discussion, 2026-07-25): the existing web_fetch tool returns up to
+// 500,000 raw characters into the calling model's context by default. This
+// mode instead lets Gemini read the firehose server-side and returns a
+// compact answer, at the cost of one extra API call + latency. Stays inline
+// in this file (not research_delegate.js) since it's a genuinely simpler,
+// one-shot code path with no loop.
+// WIDE MODE (task): delegates to runResearch() in research_delegate.js --
+// a single, WEB-ONLY call to Exa's /answer endpoint (search + synthesis in
+// one shot), not a multi-step Gemini loop -- see research_delegate.js's
+// header for why that loop was retired and why this stays WEB-ONLY (kept
+// separate from delegate_agent's GitHub/Notion/Cloudflare/Context7/Mem0
+// access) regardless of provider.
+//
+// MOVED HERE FROM gemini/agent_tools.js (2026-08-01): delegate_research's
+// registration used to live inside gemini/tools.js (now agent_tools.js)
+// alongside delegate_agent, purely because precision mode calls out to
+// Gemini's geminiGenerate(). That co-location was flagged as a structural
+// inconsistency in the delegation-naming-convention Notion plan (Phase 3):
+// every OTHER MCP tool registration lives in its own connector's tools.js,
+// but delegate_research (an Exa-backed tool) didn't have one of its own.
+// This file resolves that as "Option B" from the plan -- delegate_research
+// now has its own registration file here, importing geminiGenerate from
+// ../gemini/client.js for precision mode the same way any other cross-
+// connector helper would be imported. No behavior change from the move --
+// same handler logic, same validation, same Notion logging.
+//
+// NOTE ON WHY PRECISION MODE SAVES TOKENS AND OTHER SIMILAR-LOOKING TOOLS
+// DON'T: any argument a caller passes INTO a tool call (e.g. "summarize this
+// text: ...") already had to be generated by the caller first, so passing
+// raw content as a tool argument never saves anything. The savings here
+// come entirely from fetching server-side -- the caller supplies only a URL
+// and a question, never the page content itself.
+//
+// NOTION WRITE ISOLATION (2026-07-25 plan): this tool's optional Notion
+// logging ALWAYS targets GEMINI_NOTION_ROOT_PAGE_ID -- that constant is not
+// exposed as a tool parameter anywhere in this file, so there is no
+// argument-based path for a Gemini-driven write to land anywhere else in
+// the workspace (Memory Index, Entity Index, Job Leads, etc.), which the
+// Claude-side dedup/sync tools depend on staying uncontaminated. Reads are
+// unrestricted -- future tools that need Notion context can query any
+// page/database via the existing connectors/notion/client.js exports.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { geminiGenerate } from "../gemini/client.js";
+import { runResearch } from "./research_delegate.js";
+import { fetchUrl, htmlToText } from "../fetch/client.js";
+import { doCreatePage } from "../notion/tools.js";
+import { GEMINI_NOTION_ROOT_PAGE_ID } from "../../config.js";
+
+const DEFAULT_MAX_SOURCE_CHARS = 300000;
+
+export function register(server) {
+
+ server.tool(
+ "delegate_research",
+ "DOES: Web research, ONE of two mutually-exclusive modes selected by which args you pass.\n" +
+ "MODE url+question (PRECISION, via Gemini): fetches the URL, hands page + question to Gemini, returns ONLY Gemini's compact answer -- not the raw page. Use for a specific answer from a page (e.g. 'does this doc mention rate limits?').\n" +
+ "NOT: exact wording, code snippets to copy, or content to edit -> use web_fetch instead.\n" +
+ "MODE task (WIDE, via Exa): single-shot Exa /answer call (search+synthesis in one), returns answer+sources. Use for 'current status of X' or comparing multiple sources. WEB-ONLY -- no GitHub/Notion/Cloudflare -> use delegate_agent for internal-systems investigations instead.\n" +
+ "RULE: pass EITHER url+question OR task -- never both, never neither.\n" +
+ "RULE (wide mode): single-shot only -- max_steps/resume_run_id accepted for param compatibility with delegate_agent but have no real effect.",
+ {
+ url: z.string().url().optional().describe("PRECISION MODE: the URL to fetch. Must be paired with `question`; do not combine with `task`."),
+ question: z.string().optional().describe("PRECISION MODE: the specific question to answer using the page's content. Be specific -- vague questions get vague answers. Must be paired with `url`."),
+ max_source_chars: z.number().optional().describe(`PRECISION MODE only: truncate the fetched page to this many characters before sending to Gemini (default: ${DEFAULT_MAX_SOURCE_CHARS}).`),
+ task: z.string().optional().describe("WIDE MODE: the research task/question, described with enough context for a single Exa /answer call to act on without needing to ask you anything back -- it can't. Do not combine with `url`/`question`."),
+ max_steps: z.number().optional().describe("WIDE MODE only: accepted for parameter compatibility with delegate_agent, but has no effect -- wide mode is always a single Exa /answer call regardless of this value."),
+ resume_run_id: z.string().optional().describe("WIDE MODE only: accepted for parameter compatibility with delegate_agent, but wide mode is a single-shot Exa call with no checkpoint -- passing this returns a result explaining there's nothing to resume, rather than continuing a prior run."),
+ show_transcript: z.boolean().optional().describe("WIDE MODE only: include the full step-by-step tool-call transcript in the response, even on a successful run (default: false)."),
+ log_to_notion: z.boolean().optional().describe("Whether to log this call's inputs/outputs as a page under the Gemini section of Notion (default: false). The write always targets the fixed Gemini root page -- this cannot be redirected elsewhere."),
+ },
+ async ({ url, question, max_source_chars = DEFAULT_MAX_SOURCE_CHARS, task, max_steps = 20, resume_run_id, show_transcript = false, log_to_notion = false }) => {
+ // Mode selection is by presence of args, not an explicit "mode" param --
+ // see file header. Validate mutual exclusivity up front so a caller who
+ // passes both (or neither) gets a clear error instead of one set of
+ // args being silently ignored.
+ const hasPrecisionArgs = url !== undefined || question !== undefined;
+ const hasWideArgs = task !== undefined || resume_run_id !== undefined;
+
+ if (hasPrecisionArgs && hasWideArgs) {
+ return { content: [{ type: "text", text: "Invalid arguments: pass EITHER url+question (precision mode) OR task/resume_run_id (wide mode), not both." }], isError: true };
+ }
+ if (!hasPrecisionArgs && !hasWideArgs) {
+ return { content: [{ type: "text", text: "Missing arguments: pass either url+question (precision mode) or task (wide mode)." }], isError: true };
+ }
+ if (hasPrecisionArgs && (url === undefined || question === undefined)) {
+ return { content: [{ type: "text", text: "Precision mode requires BOTH url and question." }], isError: true };
+ }
+ Iif (hasWideArgs && !task && !resume_run_id) {
+ return { content: [{ type: "text", text: "Wide mode requires task, unless resuming a live checkpoint via resume_run_id." }], isError: true };
+ }
+ // Same off-by-invalid-input guard as delegate_agent's max_steps check --
+ // see agent_tools.js's delegate_agent handler comment for the full reasoning.
+ if (hasWideArgs && max_steps !== undefined && (!Number.isInteger(max_steps) || max_steps < 1)) {
+ return { content: [{ type: "text", text: `Invalid max_steps: ${max_steps}. Must be a positive integer (at least 1); the hard cap is 30 regardless of a larger value.` }], isError: true };
+ }
+
+ if (hasPrecisionArgs) {
+ // ---- Precision mode: single fetch + single geminiGenerate call ----
+ let fetched;
+ try {
+ fetched = await fetchUrl(url);
+ } catch (err) {
+ return { content: [{ type: "text", text: `Fetch failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+
+ let sourceText = fetched.contentType.includes("text/html") ? htmlToText(fetched.text) : fetched.text;
+ const truncated = sourceText.length > max_source_chars;
+ if (truncated) sourceText = sourceText.slice(0, max_source_chars);
+
+ const prompt =
+ `Answer the question below using ONLY the page content provided. ` +
+ `Be concise and specific. If the answer isn't in the content, say so plainly rather than guessing.\n\n` +
+ `Question: ${question}\n\n` +
+ `Page content (from ${url}${truncated ? ", truncated" : ""}):\n${sourceText}`;
+
+ let answer;
+ try {
+ answer = await geminiGenerate(prompt);
+ } catch (err) {
+ return { content: [{ type: "text", text: `Gemini call failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+
+ let notionNote = "";
+ Iif (log_to_notion) {
+ try {
+ const logged = await doCreatePage({
+ parent_id: GEMINI_NOTION_ROOT_PAGE_ID,
+ parent_type: "page",
+ title: `delegate_research (precision): ${url}`,
+ content: `URL: ${url}\nQuestion: ${question}\n\nAnswer:\n${answer}`,
+ one_off: true,
+ });
+ notionNote = `\n\n(Logged to Notion: ${logged.url})`;
+ } catch (err) {
+ // Best-effort -- a failed log write shouldn't hide the answer the
+ // caller actually asked for.
+ notionNote = `\n\n(⚠️ Notion logging failed: ${err.message})`;
+ }
+ }
+
+ return { content: [{ type: "text", text: `${answer}${notionNote}` }] };
+ }
+
+ // ---- Wide mode: single-shot, web-only research call (research_delegate.js) ----
+ let result;
+ try {
+ result = await runResearch({ task, max_steps, resume_run_id });
+ } catch (err) {
+ return { content: [{ type: "text", text: `Research failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+
+ // On a resumed run, task may be undefined here -- runResearch returns
+ // the effective task text it actually used, mirroring delegate_agent's
+ // handling in agent_tools.js.
+ const effectiveTask = task || result.task || "(resumed run)";
+
+ let notionNote = "";
+ Iif (log_to_notion) {
+ try {
+ const logged = await doCreatePage({
+ parent_id: GEMINI_NOTION_ROOT_PAGE_ID,
+ parent_type: "page",
+ title: `${result.failed ? "delegate_research (partial): " : "delegate_research: "}${effectiveTask.slice(0, 80)}`,
+ content: `Task: ${effectiveTask}\n\nrunId: ${result.runId}${result.failed ? " (resumable)" : ""}\n\nSteps taken: ${result.steps}\n\nTool calls:\n${result.transcript.join("\n") || "(none)"}\n\nAnswer:\n${result.answer}`,
+ one_off: true,
+ });
+ notionNote = `\n\n(Logged to Notion: ${logged.url})`;
+ } catch (err) {
+ notionNote = `\n\n(⚠️ Notion logging failed: ${err.message})`;
+ }
+ }
+
+ const transcriptBlock = result.transcript?.length && (result.failed || show_transcript)
+ ? `\n\n${result.failed ? "Tool calls completed before the failure" : "Tool call transcript"}:\n${result.transcript.join("\n")}`
+ : "";
+
+ return { content: [{ type: "text", text: `${result.answer}${transcriptBlock}\n\n(${result.steps} step(s) taken)${notionNote}` }], isError: !!result.failed };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 | + + + + + + + + + + + + + + + + + + +5x + + +27x +104x +26x +26x +26x +24x +23x +22x +21x +20x +19x +18x + + + +5x +5x +4x +3x +2x +1x + +1x + + + + + +31x +31x +5x + + + + + +37x +37x + +2x + +35x +1x + +34x +34x +2x + + + +32x +12x + +20x +20x +19x + +1x + + +31x +13x + + + + + + + +18x + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + +22x +22x +3x +1x + +2x + + + + + + + + + +19x + + + +30x +30x +37x +18x + + + + + + + + + + + + + +18x +9x +9x +1x +1x +1x + +8x +8x + + +9x +37x +9x + +1x + + | // ---------------------------------------------------------------------------
+// connectors/fetch/client.js — simple HTTP fetch helper, with an SSRF guard.
+//
+// web_fetch takes an arbitrary caller-supplied URL (and forwards arbitrary
+// caller-supplied headers), so without validation it can be used to reach
+// internal/private network addresses reachable from this server (cloud
+// metadata endpoints like 169.254.169.254, localhost services, RFC1918
+// ranges, etc). isSafeUrl() resolves the hostname and rejects anything
+// that isn't a public address before the request is made, and redirects
+// are followed manually (not via fetch's redirect:"follow") so every hop
+// gets re-validated the same way — an attacker-controlled redirect can't
+// bounce the request to an internal address after an initial public URL
+// passes the check.
+// ---------------------------------------------------------------------------
+
+import dns from "node:dns/promises";
+import net from "node:net";
+import { Agent, fetch as undiciFetch } from "undici";
+
+const MAX_REDIRECTS = 5;
+
+function isPrivateIPv4(ip) {
+ const parts = ip.split(".").map(Number);
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true; // malformed -> treat as unsafe
+ const [a, b] = parts;
+ Iif (a === 0) return true; // 0.0.0.0/8
+ if (a === 10) return true; // 10.0.0.0/8
+ if (a === 127) return true; // 127.0.0.0/8 (loopback)
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (CGNAT)
+ if (a >= 224) return true; // multicast/reserved
+ return false;
+}
+
+function isPrivateIPv6(ip) {
+ const lower = ip.toLowerCase();
+ if (lower === "::1") return true; // loopback
+ if (lower === "::") return true; // unspecified
+ if (lower.startsWith("fe80:")) return true; // link-local
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local (fc00::/7)
+ Eif (lower.startsWith("::ffff:")) {
+ // IPv4-mapped IPv6 — check the embedded IPv4 address too.
+ return isPrivateIPv4(lower.slice(7));
+ }
+ return false;
+}
+
+function isPrivateIP(ip) {
+ const family = net.isIP(ip);
+ if (family === 4) return isPrivateIPv4(ip);
+ Eif (family === 6) return isPrivateIPv6(ip);
+ return true; // not a recognizable IP -> treat as unsafe
+}
+
+async function assertSafeUrl(urlStr) {
+ let parsed;
+ try {
+ parsed = new URL(urlStr);
+ } catch {
+ throw new Error(`Invalid URL: ${urlStr}`);
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error(`Blocked: unsupported protocol "${parsed.protocol}" — only http/https are allowed.`);
+ }
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets if present
+ if (hostname.toLowerCase() === "localhost") {
+ throw new Error("Blocked: requests to localhost are not allowed.");
+ }
+
+ let addresses;
+ if (net.isIP(hostname)) {
+ addresses = [hostname];
+ } else {
+ try {
+ const results = await dns.lookup(hostname, { all: true });
+ addresses = results.map((r) => r.address);
+ } catch (err) {
+ throw new Error(`Could not resolve host "${hostname}": ${err.message}`, { cause: err });
+ }
+ }
+ if (!addresses.length || addresses.some(isPrivateIP)) {
+ throw new Error(`Blocked: "${hostname}" resolves to a private, loopback, or link-local address, which this tool is not allowed to reach.`);
+ }
+ // Return the resolved address alongside the parsed URL so the caller can
+ // pin the actual TCP connection to it (see fetchUrl below). Without this,
+ // fetch() would re-resolve the hostname itself a moment later — if the
+ // attacker controls DNS for the host, they can serve a public IP here and
+ // a private/metadata IP on the real connection (DNS rebinding / TOCTOU),
+ // slipping past this check entirely.
+ return { parsed, address: addresses[0] };
+}
+
+// Strip HTML tags and collapse whitespace into readable plain text. Lives
+// here (not fetch/tools.js) so other connectors that need the same
+// HTML-to-text step server-side -- e.g. exa/research_tools.js's delegate_research
+// (both its precision mode and, via research_delegate.js, its wide mode), which
+// strips a fetched page before handing it to Gemini -- can reuse this
+// instead of duplicating the tag/entity-stripping regexes.
+export function htmlToText(html) {
+ return html
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/[ \t]+/g, " ")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+// A dns.lookup-compatible function that ignores whatever hostname it's asked
+// to resolve and always answers with the single pre-validated `address`.
+// Handles both the plain (err, address, family) callback form and the
+// { all: true } form (an array of {address, family}) that Node's Happy
+// Eyeballs / autoSelectFamily connection logic uses.
+export function pinnedLookup(address) {
+ const family = net.isIP(address);
+ return (_hostname, options, callback) => {
+ if (options && options.all) {
+ callback(null, [{ address, family }]);
+ } else {
+ callback(null, address, family);
+ }
+ };
+}
+
+// Builds a dispatcher that forces the TCP connection to `address` while
+// leaving the request's Host header / TLS SNI on the original hostname
+// (undici derives those from the URL, not from this lookup override) — so
+// the connection goes exactly where assertSafeUrl() validated it would.
+export function pinnedDispatcher(address) {
+ return new Agent({ connect: { lookup: pinnedLookup(address) }, allowH2: false });
+}
+
+export async function fetchUrl(url, { method = "GET", headers = {}, body } = {}) {
+ let currentUrl = url;
+ for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
+ const { parsed, address } = await assertSafeUrl(currentUrl);
+ const res = await undiciFetch(parsed, {
+ method,
+ headers: {
+ "User-Agent": "madmcp-server/2.0",
+ ...headers,
+ },
+ body: body === undefined ? undefined : (typeof body === "string" ? body : JSON.stringify(body)),
+ redirect: "manual",
+ dispatcher: pinnedDispatcher(address),
+ });
+
+ // Manual redirect handling: re-validate the Location header through the
+ // same SSRF check before following it, rather than letting fetch follow
+ // it automatically and unchecked.
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
+ const location = res.headers.get("location");
+ if (!location) {
+ const contentType = res.headers.get("content-type") || "";
+ const text = await res.text();
+ return { status: res.status, ok: res.ok, contentType, text };
+ }
+ currentUrl = new URL(location, parsed).toString();
+ continue;
+ }
+
+ const contentType = res.headers.get("content-type") || "";
+ const text = await res.text();
+ return { status: res.status, ok: res.ok, contentType, text };
+ }
+ throw new Error(`Too many redirects (> ${MAX_REDIRECTS}) while fetching ${url}.`);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 | + + + + + + + + + + + + + + + + + + +9x + + + + + + + + + + + + + +6x +6x + +6x + +6x +1x +5x +2x +2x + + + +6x +6x + +6x + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/fetch/tools.js — web_fetch MCP tool
+// Fetches a URL and returns its content (text, JSON, or HTML).
+// HTML is stripped to readable text to keep responses concise.
+//
+// TOKEN COST NOTE: default max_chars is 500,000 -- this tool returns the raw
+// page content straight into the calling model's context. When the actual
+// need is just an answer to a specific question about a page (not the exact
+// text/code itself), delegate_research's precision mode (url+question) is
+// far cheaper: it fetches server-side and hands only Gemini's compact
+// answer back, never the raw page. See exa/research_tools.js's file header
+// for the full token-cost comparison.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { fetchUrl, htmlToText } from "./client.js";
+
+export function register(server) {
+
+ server.tool(
+ "web_fetch",
+ "DOES: Fetch any public URL, return text/JSON/stripped HTML. Also supports POST/PUT/PATCH/DELETE + JSON body for public write APIs (set method and body).\n" +
+ "RULE: need only an answer to a specific question about the page, not its exact text/code -> use delegate_research (url+question, precision mode) instead -- far fewer tokens, since that fetches server-side and returns only the compact answer.\n" +
+ "USE THIS INSTEAD when you need: exact wording, code snippets to copy, or content to edit in place.",
+ {
+ url: z.string().url().describe("The URL to fetch"),
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional().describe("HTTP method (default: GET)"),
+ body: z.any().optional().describe("JSON body to send (object). Only meaningful for POST/PUT/PATCH. Sent with Content-Type: application/json."),
+ max_chars: z.number().optional().describe("Truncate response to this many characters (default: 500000)"),
+ raw_html: z.boolean().optional().describe("Return raw HTML instead of stripped plain text (default: false)"),
+ headers: z.record(z.string()).optional().describe("Optional extra HTTP request headers (e.g. Authorization)"),
+ },
+ async ({ url, method = "GET", body, max_chars = 500000, raw_html = false, headers = {} }) => {
+ const mergedHeaders = body ? { "Content-Type": "application/json", ...headers } : headers;
+ const { status, ok, contentType, text } = await fetchUrl(url, { method, body, headers: mergedHeaders });
+
+ let output = text;
+
+ if (!raw_html && contentType.includes("text/html")) {
+ output = htmlToText(text);
+ } else if (contentType.includes("application/json")) {
+ try {
+ output = JSON.stringify(JSON.parse(text), null, 2);
+ } catch { /* keep raw */ }
+ }
+
+ const truncated = output.length > max_chars;
+ const result = truncated ? output.slice(0, max_chars) + `\n\n[... truncated at ${max_chars} chars — use max_chars to increase]` : output;
+
+ return {
+ content: [{
+ type: "text",
+ text: `HTTP ${status} — ${url}\nContent-Type: ${contentType}\n${ok ? "" : "⚠️ Non-2xx response\n"}\n${result}`,
+ }],
+ isError: !ok,
+ };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 | + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/frontend/designer_checkpoint.js — Redis-backed checkpointing for
+// delegate_designer's agent loop (connectors/frontend/designer_delegate.js,
+// issue #61 step 2).
+//
+// SIMPLER THAN connectors/gemini/agent_checkpoint.js'S LIST+META SPLIT, ON
+// PURPOSE: that split exists because delegate_agent's open-ended
+// investigation surface (GitHub/Cloudflare/Notion/Context7/Mem0, many of
+// which return multi-KB text blobs per call) can grow a `contents` array
+// large enough that rewriting it whole on every step is real, avoidable
+// cost. This agent's tool set is three functions over frontend source files
+// on one branch, bounded by FRONTEND_HARD_MAX_STEPS (20) -- its `contents`
+// array is small enough that a whole-blob-per-save approach is still the
+// right call here too. Reuse that shape rather than importing gemini/
+// agent_checkpoint.js's list-vs-meta split, which would be unused complexity
+// for this loop's actual size.
+//
+// SAME FAIL-OPEN CONTRACT AS EVERY OTHER CHECKPOINT MODULE IN THIS REPO: if
+// Redis isn't configured or a call fails, every function here no-ops /
+// returns null. A missing Redis must never be the reason this agent can't
+// run -- it only means a slow/interrupted run can't be resumed across
+// calls.
+// ---------------------------------------------------------------------------
+
+import { getRedis } from "../gemini/cooldown.js";
+
+const CHECKPOINT_KEY_PREFIX = "designer:checkpoint:";
+// Same reasoning as every other checkpoint module in this repo -- only
+// needs to survive long enough for the caller to retry with resume_run_id.
+const CHECKPOINT_TTL_SECONDS = 3600;
+
+function key(runId) {
+ return `${CHECKPOINT_KEY_PREFIX}${runId}`;
+}
+
+export async function saveCheckpoint(runId, state) {
+ const client = getRedis();
+ if (!client) return;
+ try {
+ await client.set(key(runId), JSON.stringify(state), { ex: CHECKPOINT_TTL_SECONDS });
+ } catch {
+ // best-effort -- see file header
+ }
+}
+
+export async function loadCheckpoint(runId) {
+ const client = getRedis();
+ if (!client) return null;
+ try {
+ const raw = await client.get(key(runId));
+ if (raw == null) return null;
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
+ } catch (err) {
+ console.warn(`loadCheckpoint(${runId}) failed -- treating as no checkpoint:`, err?.message ?? err);
+ return null;
+ }
+}
+
+export async function deleteCheckpoint(runId) {
+ const client = getRedis();
+ if (!client) return;
+ try {
+ await client.del(key(runId));
+ } catch {
+ // best-effort
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + +9x + + + + + + + + + + + +8x +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +5x +5x +4x +4x + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + +9x +27x + + +9x + + + + + + + + + + + + + + +10x + +10x + + + + + +10x +10x +10x +10x + + + + + + + + + + +10x +10x +10x + +10x + +10x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + +1x +1x +9x + + + + + + + + + + + + + +9x + + + + +9x +9x + + + +9x +1x + + +8x +8x +8x +8x +8x +8x + + +9x + + + +9x + + + + + + + + + + + +19x + + + + + + + + + + + + + +9x + + + + + +25x + + + + + + + + + +25x +25x + + +25x +25x + +1x +1x +1x + + + + +1x + + + + + + + + + + +24x +25x + + + + + + + + + +25x +1x +1x + + +1x + + + + + + + + + + +23x +7x +7x + + + + + + + + +1x +1x + + + + +1x + + + + + + + + + +6x +6x + + +16x + + +16x + + + + + + + + + + + + +16x + +16x +17x +17x +17x +17x +17x + +22x + +17x +17x +4x +4x +13x + + +13x +13x + + + + +17x + + +17x +8x + +17x + + + +16x +17x +4x +4x +4x + + + + +17x +25x + +25x +17x +17x +17x + + + + + + + + + + + + + + +16x +1x + + + + +16x +16x +3x + + +13x + + + + + +16x +16x + + + + + + + + + + +16x + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/frontend/designer_delegate.js — delegate_designer's tool-calling agent
+// loop (issue #61, Notion "madmcp-delegate-designer-v2-plan" design doc,
+// step 2: "Agent loop wiring").
+//
+// Adapts connectors/gemini/agent_delegate.js's runInvestigation loop -- multi-step
+// Gemini function-calling, not a single one-shot completion -- but restricted
+// to exactly three tools (read_file / write_file / validate, all from
+// connectors/frontend/designer_tool_functions.js, built in step 1) instead of
+// delegate_agent's large read-only cross-system surface, and WRITE-capable
+// rather than read-only.
+//
+// SCOPE FENCING, ENFORCED AT THE TOOL LAYER (per issue #61 -- "not just
+// prompt instructions"): owner/repo/branch are fixed for the whole run and
+// are NOT parameters the model can set via a function call -- the FUNCTIONS
+// closures below bind them from runDesignAgent's own arguments, so there is
+// no code path for the model to redirect a read/write at a different
+// repo/branch than the one this run was started against. Extension fencing
+// (FRONTEND_ALLOWED_EXTENSIONS) is enforced one level down, inside
+// designer_tool_functions.js's readFile/writeFile themselves -- not repeated here.
+// Default-branch refusal is checked once up front (connectors/frontend/
+// designer_tools.js).
+//
+// NOT YET WIRED TO AN MCP TOOL: this file exports runDesignAgent as a plain
+// function, unit-testable independently (mirrors step 1's "build the tools
+// layer, unit test each independently of the agent loop" -- this step does
+// the analogous thing one level up: build the loop, unit test it
+// independently of any server.tool(...) registration). MCP registration
+// is step 5 in the design doc, not this step.
+// ---------------------------------------------------------------------------
+
+import { randomUUID } from "node:crypto";
+import { geminiChat } from "../gemini/client.js";
+import { readFile, writeFile, validate as validateFile } from "./designer_tool_functions.js";
+import { saveCheckpoint, loadCheckpoint, deleteCheckpoint } from "./designer_checkpoint.js";
+import { isRedisConfigured } from "../gemini/cooldown.js";
+import { githubRequest } from "../github/client.js";
+import {
+ FRONTEND_ALLOWED_EXTENSIONS,
+ FRONTEND_DEFAULT_STEPS,
+ FRONTEND_HARD_MAX_STEPS,
+ FRONTEND_MAX_VALIDATE_CALLS,
+} from "../../config.js";
+
+// Same reasoning as connectors/gemini/agent_delegate.js's isTransientGeminiError:
+// only 429 (rate limit) and 503 (overloaded) are worth resuming past --
+// everything else (bad request, auth, missing key) will reproduce
+// identically on a resume.
+function isTransientGeminiError(err) {
+ return err?.status === 429 || err?.status === 503 || err?.transient === true;
+}
+
+function buildSystemPreamble({ owner, repo, branch, task }) {
+ return (
+ "You are a frontend/UI design agent working inside ONE fixed repository and branch. You may " +
+ `read and write files with these extensions only: ${FRONTEND_ALLOWED_EXTENSIONS.join(", ")}. ` +
+ `Repository: ${owner}/${repo}. Branch: ${branch} (already confirmed to not be the default branch).\n\n` +
+ "You have three tools:\n" +
+ "- read_file(path): reads a file's current content on this branch, together with its blob sha. " +
+ "Always read a file before patching it -- write_file's patch mode requires the exact sha the " +
+ "content was read from.\n" +
+ "- write_file(path, content OR patch, base_sha, message): writes a file. Give `content` for a full " +
+ "overwrite, or `patch` (a list of {find, replace} operations, each `find` must appear exactly once) " +
+ "to edit part of a file you already read. `base_sha` is required for patch mode, and required for " +
+ "content mode too whenever you are replacing a file you already read (omit it only when creating a " +
+ "brand-new file that doesn't exist yet). If a write is rejected as a conflict, the file changed " +
+ "since you read it -- re-read it and retry, don't assume your version is still current.\n" +
+ "- validate(path, content): syntax-checks content against its file type before you write it. Not " +
+ "free of limits -- capped per file, so don't call it more than genuinely useful; a couple of passes " +
+ "per file is normal, looping it dozens of times is not.\n\n" +
+ "Work iteratively: read what you need, make changes, validate before writing when it's cheap to do " +
+ "so, write, and confirm the result makes sense. When the task is fully done, respond with a final " +
+ "plain-text summary of what you changed and no further function calls.\n\n" +
+ `Task: ${task}`
+ );
+}
+
+// Builds the three function declarations + their execute() closures for one
+// run. owner/repo/branch are captured here, NOT exposed as parameters the
+// model can set -- see file header.
+function buildFunctions({ owner, repo, branch, validateCounts, writtenFiles }) {
+ const FUNCTIONS = [
+ {
+ name: "read_file",
+ description: "Read a file's current content on this run's branch, together with its blob sha (needed for write_file's base_sha).",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: `File path within the repo. Must end in one of: ${FRONTEND_ALLOWED_EXTENSIONS.join(", ")}` },
+ },
+ required: ["path"],
+ },
+ execute: async ({ path }) => {
+ const result = await readFile(owner, repo, path, branch);
+ return `sha: ${result.sha}\n\n${result.content}`;
+ },
+ },
+ {
+ name: "write_file",
+ description: "Write a file on this run's branch. Exactly one of `content` (full overwrite) or `patch` (find/replace operations) is required. `base_sha` is required for `patch`, and required for `content` too unless this is a brand-new file with no prior read_file call.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: `File path within the repo. Must end in one of: ${FRONTEND_ALLOWED_EXTENSIONS.join(", ")}` },
+ content: { type: "string", description: "Full new file content (mutually exclusive with patch)" },
+ patch: {
+ type: "array",
+ description: "List of find/replace operations, applied sequentially (mutually exclusive with content)",
+ items: {
+ type: "object",
+ properties: {
+ find: { type: "string" },
+ replace: { type: "string" },
+ },
+ required: ["find", "replace"],
+ },
+ },
+ base_sha: { type: "string", description: "The sha returned by a prior read_file call on this exact path" },
+ message: { type: "string", description: "Commit message (optional -- a reasonable default is used if omitted)" },
+ },
+ required: ["path"],
+ },
+ execute: async ({ path, content, patch, base_sha, message }) => {
+ try {
+ const result = await writeFile(owner, repo, path, { content, patch, baseSha: base_sha, branch, message });
+ writtenFiles.push(result.path);
+ return `Wrote ${result.path} (commit ${result.commitSha.slice(0, 7)}, new sha ${result.sha}).`;
+ } catch (err) {
+ // Conflict errors (designer_tool_functions.js's `.conflict = true`) are a
+ // normal, expected outcome the model should react to (re-read,
+ // re-diff, retry) -- per the design doc, NOT a hard tool failure.
+ // Returning the message as a regular string result (rather than
+ // throwing) is what lets the loop's existing error-string
+ // convention carry it back to the model as a next-turn input,
+ // same as any other tool result.
+ return `Error: ${err.message}`;
+ }
+ },
+ },
+ {
+ name: "validate",
+ description: "Syntax-check content against its file type (by extension) before writing. Capped per file path -- see the system instructions.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path (used only to determine which validator to run, by extension)" },
+ content: { type: "string", description: "Content to validate" },
+ },
+ required: ["path", "content"],
+ },
+ execute: async ({ path, content }) => {
+ const count = validateCounts.get(path) || 0;
+ if (count >= FRONTEND_MAX_VALIDATE_CALLS) {
+ return `Error: validate() has already been called ${count} time(s) for "${path}", which is this run's per-file cap (${FRONTEND_MAX_VALIDATE_CALLS}). Proceed without further validation of this file, or write it and reconsider your approach if it's still not right.`;
+ }
+ validateCounts.set(path, count + 1);
+ const result = await validateFile(path, content);
+ return result.valid ? "Valid -- no syntax errors found." : `Invalid:\n${result.errors.join("\n")}`;
+ },
+ },
+ ];
+
+ const declarations = [{
+ functionDeclarations: FUNCTIONS.map(({ name, description, parameters }) => ({ name, description, parameters })),
+ }];
+
+ return { FUNCTIONS, declarations };
+}
+
+// Runs the write-capable design agent loop. Returns
+// { answer, steps, transcript, runId, writtenFiles, task, failed? } --
+// same overall shape as connectors/gemini/agent_delegate.js's runInvestigation,
+// so a future MCP-facing tool (step 5) can follow the same
+// resume_run_id/failed-response conventions delegate_agent already uses.
+//
+// On a fresh call, owner/repo/branch/task are required; on a resume
+// (resume_run_id set), they're restored from the checkpoint and any passed
+// values are ignored, matching connectors/gemini/agent_delegate.js's own resume
+// contract (see its comments for why `task` specifically must never be
+// trusted over the checkpoint's own record of it on a live resume).
+export async function runDesignAgent({ owner, repo, branch, task, max_steps = FRONTEND_DEFAULT_STEPS, resume_run_id }) {
+ const cappedSteps = Math.min(max_steps, FRONTEND_HARD_MAX_STEPS);
+
+ let runId = resume_run_id;
+ let contents;
+ let transcript;
+ let startStep;
+ let validateCounts;
+ let writtenFiles;
+ let effectiveOwner = owner;
+ let effectiveRepo = repo;
+ let effectiveBranch = branch;
+ let effectiveTask = task;
+ // Stuck-loop detection (mirrors connectors/gemini/agent_delegate.js's fix #4):
+ // repeatCounts tracks how many times each exact (function name + JSON-
+ // stringified args) signature has been called THIS RUN, persisted across
+ // resumes so a resumed run doesn't forget what it already tried.
+ // resultCache holds the actual result text per signature -- deliberately
+ // NOT persisted in the checkpoint (same reasoning as agent_delegate.js: keeps
+ // checkpoint writes small; a resumed run re-executing one exact-repeat
+ // call and re-caching it is a correctness no-op). consecutiveAllRepeatSteps
+ // counts how many steps IN A ROW consisted ENTIRELY of repeat calls -- a
+ // single repeat mixed with new calls is normal exploration, not stuck.
+ let repeatCounts = new Map();
+ let resultCache = new Map();
+ let consecutiveAllRepeatSteps = 0;
+
+ const checkpoint = resume_run_id ? await loadCheckpoint(resume_run_id) : null;
+
+ if (checkpoint) {
+ contents = checkpoint.contents;
+ transcript = checkpoint.transcript;
+ startStep = checkpoint.stepsDone + 1;
+ validateCounts = new Map(Object.entries(checkpoint.validateCounts || {}));
+ writtenFiles = checkpoint.writtenFiles || [];
+ effectiveOwner = checkpoint.owner;
+ effectiveRepo = checkpoint.repo;
+ effectiveBranch = checkpoint.branch;
+ effectiveTask = checkpoint.task;
+ // Checkpoints saved before this fix existed won't have these fields --
+ // fall back to empty/zero rather than erroring, same defensive pattern
+ // as validateCounts/writtenFiles above.
+ repeatCounts = new Map(Object.entries(checkpoint.repeatCounts || {}));
+ consecutiveAllRepeatSteps = checkpoint.consecutiveAllRepeatSteps || 0;
+ } else Iif (resume_run_id) {
+ // A resume was requested but its checkpoint didn't load (expired past
+ // the 1-hour TTL, Redis unavailable, or an invalid/typo'd runId). Same
+ // "fail loudly and distinctly" reasoning as connectors/gemini/
+ // agent_delegate.js -- silently falling through to a fresh run here would
+ // require owner/repo/branch/task to have been re-supplied anyway (this
+ // loop, unlike agent_delegate.js, has no task-optional fallback path), so
+ // there's no ambiguous case to accommodate -- always an error.
+ throw new Error(
+ isRedisConfigured()
+ ? `resume_run_id "${resume_run_id}" has no live checkpoint -- it may have expired (1 hour TTL) or the id may be wrong. Start a new run with owner/repo/branch/task instead.`
+ : `resume_run_id "${resume_run_id}" has no live checkpoint -- and Redis is NOT configured in this environment, so no checkpoint could ever have been saved. Start a new run with owner/repo/branch/task instead.`
+ );
+ } else {
+ Iif (!owner || !repo || !branch || !task) {
+ throw new Error("owner, repo, branch, and task are all required on a fresh call (not resuming).");
+ }
+
+ let repoInfo;
+ try {
+ repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ } catch (err) {
+ throw new Error(`Failed to look up ${owner}/${repo}: ${err.message}`, { cause: err });
+ }
+ if (branch === repoInfo.default_branch) {
+ throw new Error(`Refusing to run: "${branch}" is ${owner}/${repo}'s default branch. Create/use a feature branch instead -- this agent never writes directly to the default branch.`);
+ }
+
+ runId = randomUUID();
+ contents = [{ role: "user", parts: [{ text: buildSystemPreamble({ owner, repo, branch, task }) }] }];
+ transcript = [];
+ startStep = 1;
+ validateCounts = new Map();
+ writtenFiles = [];
+ }
+
+ const { FUNCTIONS, declarations } = buildFunctions({
+ owner: effectiveOwner, repo: effectiveRepo, branch: effectiveBranch, validateCounts, writtenFiles,
+ });
+
+ Iif (checkpoint && startStep > cappedSteps) {
+ return {
+ answer: `(This run already completed ${startStep - 1} step(s), which meets or exceeds the requested max_steps of ${cappedSteps} -- no new steps were taken this call. The checkpoint has NOT been discarded. Call again with resume_run_id: "${runId}" and a higher max_steps to continue.)`,
+ steps: startStep - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+ }
+
+ const saveState = (stepsDone) => saveCheckpoint(runId, {
+ contents,
+ transcript,
+ stepsDone,
+ task: effectiveTask,
+ owner: effectiveOwner,
+ repo: effectiveRepo,
+ branch: effectiveBranch,
+ validateCounts: Object.fromEntries(validateCounts),
+ writtenFiles,
+ repeatCounts: Object.fromEntries(repeatCounts),
+ consecutiveAllRepeatSteps,
+ });
+
+ for (let step = startStep; step <= cappedSteps; step++) {
+ // Withhold tools on the final step so the model is structurally forced
+ // to answer in plain text instead of attempting one more function call
+ // that never gets to run -- same fix connectors/gemini/agent_delegate.js
+ // applies for the identical reason (a text-only reminder alone wasn't
+ // reliable enough there either).
+ const isFinalStep = step === cappedSteps;
+ // Same withholding, second trigger: 3 consecutive steps that were
+ // ENTIRELY repeat calls means the model is stuck re-trying the same
+ // thing rather than making progress -- force a plain-text answer now
+ // instead of letting it burn the rest of the step budget the same way.
+ // Mirrors connectors/gemini/agent_delegate.js's fix #4; unlike that file, a
+ // stuck loop here can't be quietly served from cache indefinitely
+ // because write_file is never cache-served (see below) -- it would
+ // otherwise keep spending real GitHub API calls, not just wasted model
+ // turns.
+ const stuckLoopForce = consecutiveAllRepeatSteps >= 3;
+ const withholdTools = isFinalStep || stuckLoopForce;
+
+ let candidate;
+ try {
+ candidate = await geminiChat(contents, { tools: withholdTools ? undefined : declarations });
+ } catch (err) {
+ await saveState(step - 1);
+ const redisOk = isRedisConfigured();
+ const resumeHint = isTransientGeminiError(err)
+ ? (redisOk
+ ? ` ${transcript.length} tool call(s) already completed this run are saved. Call again with resume_run_id: "${runId}" to continue instead of starting over. Checkpoint expires in 1 hour.`
+ : ` ${transcript.length} tool call(s) were completed this run, but Redis is NOT configured, so nothing was actually saved -- resume_run_id: "${runId}" will NOT work. Re-run from scratch with the full task text.`)
+ : ` This does not look like a transient error (not a 429/503) -- resuming will likely reproduce the same failure. Check the underlying cause before retrying.`;
+ return {
+ answer: `(Gemini call failed on step ${step}: ${err?.message ?? String(err)} --${resumeHint})`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+ }
+
+ const parts = candidate.content?.parts || [];
+ const functionCalls = parts.filter((p) => p.functionCall);
+
+ // Guard against a step where tools were withheld (final step OR stuck-
+ // loop force): the model can't legitimately act here, but Gemini does
+ // not always reject an attempted function call API-side (the
+ // MALFORMED_FUNCTION_CALL path below covers when it does -- sometimes
+ // it just returns a function call anyway despite no tools being
+ // declared). Discard it unexecuted rather than running it, or the
+ // "no tools means nothing acts" guarantee this withholding exists for
+ // doesn't actually hold.
+ if (withholdTools && functionCalls.length) {
+ await saveState(step - 1);
+ const reason = stuckLoopForce
+ ? `the agent appeared stuck repeating the same call(s) for ${consecutiveAllRepeatSteps} consecutive steps, so tools were withheld to force a plain-text answer instead of continuing to loop`
+ : `the model attempted a function call on the final step, where no tools are available`;
+ return {
+ answer: `(Run stopped after reaching the step cap of ${cappedSteps}: ${reason}, so it was discarded rather than executed -- the task may need to be narrowed, or more steps requested up to the hard cap of ${FRONTEND_HARD_MAX_STEPS}. ${transcript.length} tool call(s) already completed this run are saved. Call again with resume_run_id: "${runId}" to continue instead of starting over. Checkpoint expires in 1 hour.)`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+ }
+
+ if (!functionCalls.length) {
+ const answer = parts.map((p) => p.text || "").join("").trim();
+ if (!answer) {
+ // No text and no function calls -- the model didn't actually
+ // finish. Keep the checkpoint alive (don't delete it) and mark
+ // this failed so the caller gets a real, usable resume_run_id --
+ // matching the resume contract used everywhere else in this loop
+ // (Gemini call errors, mid-step processing errors). Previously
+ // this path deleted the checkpoint and omitted `failed`, which is
+ // why a run stopping here never actually surfaced a usable
+ // resume_run_id despite the message implying resumability.
+ await saveState(step - 1);
+ const starvationNote = withholdTools && candidate.finishReason === "MALFORMED_FUNCTION_CALL"
+ ? (stuckLoopForce
+ ? ` Tools were withheld this step because the agent appeared stuck repeating the same call(s) for ${consecutiveAllRepeatSteps} consecutive steps, but the model attempted a function call anyway, which Gemini rejects as malformed when no tools are available.`
+ : ` This was the final allowed step, which never includes tools -- but the model attempted a function call anyway, which Gemini rejects as malformed when no tools are available. This usually means the task needed more steps than max_steps (${cappedSteps}) allowed. Retry with a higher max_steps.`)
+ : "";
+ return {
+ answer: `(Gemini stopped without a final answer -- finishReason: ${candidate.finishReason || "unknown"})${starvationNote} ${transcript.length} tool call(s) already completed this run are saved. Call again with resume_run_id: "${runId}" to continue instead of starting over. Checkpoint expires in 1 hour.`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+ }
+ await deleteCheckpoint(runId);
+ return { answer, steps: step, transcript, runId, task: effectiveTask, writtenFiles };
+ }
+
+ contents.push({ role: "model", parts });
+
+ let responseParts;
+ try {
+ // Parallelized for the same reason as connectors/gemini/agent_delegate.js:
+ // every call batched into one model turn was decided without seeing
+ // any of the others' results, so awaiting them concurrently changes
+ // only wall-clock time, not what information was available to what
+ // call. Unlike agent_delegate.js's read-only tool set, write_file has a
+ // real side effect (a commit) -- but two write_file calls in the same
+ // batched turn would already be targeting different paths in any
+ // sane model plan (the model has no way to make a second write
+ // depend on the first write's result within the same turn either
+ // way), so this doesn't introduce a new ordering hazard beyond what
+ // agent_delegate.js already accepts for its own batched calls.
+ // Only read_file and validate are safe to serve from cache on an exact repeat -- both are pure reads with no side effect, so serving a cached result changes nothing about what actually happened. write_file is NEVER cache-served: it has a real side effect (a commit), and silently skipping that on a "repeat" call would let the model believe a write happened when it didn't -- exactly the kind of silent-mismatch bug this whole file's other fixes exist to prevent. An identical write_file call is instead just executed again for real (its repeat count still contributes to stuck-loop detection below, so repeatedly retrying the same failing write still gets caught and stopped -- it just isn't executed from cache while that's happening).
+ const CACHEABLE_TOOLS = new Set(["read_file", "validate"]);
+
+ const results = await Promise.all(functionCalls.map(async (part) => {
+ const { name, args, id } = part.functionCall;
+ const signature = `${name}:${JSON.stringify(args || {})}`;
+ const priorCount = repeatCounts.get(signature) || 0;
+ const isRepeat = priorCount > 0;
+ repeatCounts.set(signature, priorCount + 1);
+
+ const fn = FUNCTIONS.find((f) => f.name === name);
+ let resultText;
+ let servedFromCache = false;
+ if (isRepeat && CACHEABLE_TOOLS.has(name) && resultCache.has(signature)) {
+ resultText = resultCache.get(signature);
+ servedFromCache = true;
+ } else Iif (!fn) {
+ resultText = `Error: unknown function "${name}".`;
+ } else {
+ try {
+ resultText = await fn.execute(args || {});
+ } catch (err) {
+ resultText = `Error: ${err?.message ?? String(err)}`;
+ }
+ }
+ Iif (typeof resultText !== "string") {
+ resultText = `Error: ${name} returned a non-string result (${typeof resultText}); this is a bug in its execute().`;
+ }
+ if (!servedFromCache && CACHEABLE_TOOLS.has(name)) {
+ resultCache.set(signature, resultText);
+ }
+ return { name, args, id, resultText, isRepeat, servedFromCache };
+ }));
+
+ // Invalidate any cached read_file result for a path this step just wrote to successfully. Without this, a read_file call AFTER a write_file to the same path -- an entirely reasonable thing for the model to do, e.g. to confirm what actually landed -- would be treated as an "exact repeat" of an earlier read_file call on that path (the cache key is just the path, not the content) and served stale PRE-write content instead of what is actually on the branch now. Also clears repeatCounts for that signature, not just the cache entry: otherwise the next read would still be flagged isRepeat (misclassifying a genuinely-necessary re-read as a stuck-loop repeat) even though it is forced to execute for real. A failed write_file (its result starts with "Error:") changed nothing on the branch, so it does NOT invalidate anything.
+ for (const r of results) {
+ if (r.name === "write_file" && !r.resultText.startsWith("Error:") && r.args?.path) {
+ const readSignature = `read_file:${JSON.stringify({ path: r.args.path })}`;
+ resultCache.delete(readSignature);
+ repeatCounts.delete(readSignature);
+ }
+ }
+
+ // Stuck-loop tracking: this step counts as "all repeat" only if EVERY call in it was already seen before -- a step that mixes a repeat with a genuinely new call is normal exploration (e.g. re-reading one file while reading a second file for the first time), not a stuck loop.
+ const allRepeatsThisStep = results.length > 0 && results.every((r) => r.isRepeat);
+ consecutiveAllRepeatSteps = allRepeatsThisStep ? consecutiveAllRepeatSteps + 1 : 0;
+
+ responseParts = results.map((r) => {
+ const cacheNote = r.servedFromCache ? " [served from cache -- identical call already made this run]" : "";
+ transcript.push(`[step ${step}] ${r.name}(${JSON.stringify(r.args || {})})${cacheNote} -> ${r.resultText.length > 300 ? r.resultText.slice(0, 300) + "…" : r.resultText}`);
+ return { functionResponse: { name: r.name, id: r.id, response: { result: r.resultText } } };
+ });
+ } catch (err) {
+ await saveState(step - 1);
+ return {
+ answer: `(Unexpected error while processing step ${step}'s function calls: ${err?.message ?? String(err)} -- ${transcript.length} tool call(s) already completed this run are saved. Call again with resume_run_id: "${runId}" to continue.)`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+ }
+
+ if (consecutiveAllRepeatSteps === 2) {
+ responseParts.push({
+ text: "[SYSTEM NOTE: the last 2 steps consisted entirely of calls identical to ones already made this run. One more step like that and tools will be withheld to force a plain-text answer instead. If you're re-reading to double-check, that's fine once -- but if you're retrying the same write and getting the same result, stop and explain what's blocking it instead of repeating the call.]",
+ });
+ }
+
+ const remainingAfterThisStep = cappedSteps - step;
+ if (remainingAfterThisStep === 1) {
+ responseParts.push({
+ text: "[SYSTEM NOTE: only 1 step remains after this one, and the step after that has NO tools available. Finish any in-progress write now if the file is ready, or explain what's left undone -- do not leave a task half-written without saying so.]",
+ });
+ } else Iif (remainingAfterThisStep === 0) {
+ responseParts.push({
+ text: "[SYSTEM NOTE: the next turn will NOT include any tools -- you must answer now in plain text summarizing what you changed (or didn't, and why) rather than attempting another function call.]",
+ });
+ }
+
+ contents.push({ role: "user", parts: responseParts });
+ await saveState(step);
+ }
+
+ // Defensive fallback only -- with the isFinalStep guard above, the loop
+ // should always return from inside its final iteration (either a real
+ // answer, a discarded final-step function call, or a starved no-answer
+ // response), so falling out of the for loop here should no longer be
+ // reachable in normal operation. Kept as a safety net in case future
+ // changes reintroduce a path that falls through, using the same
+ // keep-checkpoint-alive-and-mark-failed resume contract as every other
+ // stopped-without-an-answer path in this file.
+ return {
+ answer: `(Run stopped after reaching the step cap of ${cappedSteps} without a final answer -- the task may need to be narrowed, or more steps requested up to the hard cap of ${FRONTEND_HARD_MAX_STEPS}. ${transcript.length} tool call(s) already completed this run are saved. Call again with resume_run_id: "${runId}" to continue instead of starting over. Checkpoint expires in 1 hour.)`,
+ steps: cappedSteps,
+ transcript,
+ runId,
+ task: effectiveTask,
+ writtenFiles,
+ failed: true,
+ };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +13x +13x + + + +13x +13x +2x + + + + + + + + + +2x + + + + + + + +2x + + + + + + + +5x + +5x + + + +4x +1x + +3x +1x + + +2x + + + + + + + + + + + + + +5x +5x +5x +6x +6x +5x +4x + +5x +2x + +3x + + + + + + + + + + + + + + +11x +2x + +9x +1x + +8x + +8x +8x +1x +1x +1x + + +7x +7x + + + + + + + + +3x + +4x +1x + + + +1x +1x + +3x +1x + + + +1x +1x + +2x + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/frontend/designer_tool_functions.js — read/write/validate primitives for
+// delegate_designer v2 (issue #61, Notion "madmcp-delegate-designer-v2-plan"
+// design doc). Step 1 of the implementation sequence: "Tools layer" only --
+// these are plain async functions, NOT yet wired up as the agent's
+// tool-calling loop (that's step 2, adapting connectors/gemini/agent_delegate.js's
+// runInvestigation pattern). Kept separate from tools.js (the current v1
+// generate->validate->fix loop) so v1 keeps working unmodified while v2 is
+// built alongside it; tools.js gets retired in step 5 (rollout).
+//
+// FIXES #59 (stale-context race): v1's write path re-fetched the current
+// blob sha immediately before the PUT, so a write could silently clobber a
+// concurrent change as long as ITS sha matched at PUT time -- the sha used
+// for the write was never tied to the sha the content was actually read
+// from. Here, read_file returns { content, sha } together, and write_file
+// requires that exact sha back as base_sha, sending it as the PUT's `sha`
+// field. If the file changed on the branch in between, GitHub's Contents
+// API rejects the PUT with 409 (sha mismatch) instead of silently
+// overwriting -- surfaced here as a normal Error with `.conflict = true` so
+// the step-2 agent loop can catch it, re-read, and retry rather than the
+// call hard-failing.
+//
+// SCOPE FENCING: same as tools.js -- read and write paths are both
+// restricted to FRONTEND_ALLOWED_EXTENSIONS. Enforced here, at the tool
+// layer, not left to the agent's own judgment (per issue #61: "enforced at
+// the tool layer, not just prompt instructions").
+// ---------------------------------------------------------------------------
+
+import { githubRequest, toBase64, fromBase64 } from "../github/client.js";
+import { FRONTEND_ALLOWED_EXTENSIONS } from "../../config.js";
+import { validateByExtension } from "./validate.js";
+
+function extensionOf(path) {
+ const match = /\.[a-z0-9]+$/i.exec(path);
+ return match ? match[0].toLowerCase() : "";
+}
+
+function assertAllowedExtension(path, label) {
+ const ext = extensionOf(path);
+ if (!FRONTEND_ALLOWED_EXTENSIONS.includes(ext)) {
+ throw new Error(
+ `${label} "${path}" has extension "${ext || "(none)"}", which is not in the allowed frontend extensions ` +
+ `(${FRONTEND_ALLOWED_EXTENSIONS.join(", ")}). This tool is fenced to frontend files only.`
+ );
+ }
+}
+
+// Used only when we DID send a base_sha: any 409 on a sha-bearing PUT means
+// the sha we sent no longer matches what's on the branch.
+function isStaleShaError(err) {
+ return /GitHub API error \(409\)/.test(err.message) || /sha does not match/i.test(err.message);
+}
+
+// Used only when we DIDN'T send a base_sha: GitHub's Contents API refuses to
+// overwrite an existing file without a sha, and says so in the error text.
+// Deliberately narrower than isStaleShaError -- a generic 409 with no
+// base_sha in play should NOT be assumed to be about sha state at all.
+function isMissingShaError(err) {
+ return /sha was not supplied/i.test(err.message);
+}
+
+// -- read_file ---------------------------------------------------------
+// Real on-demand read (unlike v1's single static context dump at task
+// start). Returns the blob sha alongside the content so a later write_file
+// call can tie its write back to exactly what was read here.
+export async function readFile(owner, repo, path, ref) {
+ assertAllowedExtension(path, "path");
+
+ const data = await githubRequest(
+ `/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${ref ? `?ref=${encodeURIComponent(ref)}` : ""}`
+ );
+
+ if (Array.isArray(data)) {
+ throw new Error(`"${path}" is a directory, not a file.`);
+ }
+ if (typeof data.content !== "string") {
+ throw new Error(`"${path}" has no inline content in GitHub's response (file may be over the Contents API's ~1MB inline limit). Not supported by this tool.`);
+ }
+
+ return {
+ path,
+ content: fromBase64(data.content.replace(/\n/g, "")),
+ sha: data.sha,
+ };
+}
+
+// -- patch application ---------------------------------------------------
+// Sequential find/replace operations, same semantics as edit_file's
+// `replacements` mode in connectors/github/files.js (kept consistent
+// deliberately -- no new patch-format dependency introduced for this).
+// Each `find` must appear EXACTLY ONCE in the content being patched, or the
+// whole patch is rejected before anything is written.
+export function applyPatch(content, patch) {
+ let updated = content;
+ const errors = [];
+ for (const { find, replace } of patch) {
+ const count = updated.split(find).length - 1;
+ if (count === 0) { errors.push(`String not found: ${JSON.stringify(find)}`); continue; }
+ if (count > 1) { errors.push(`String found ${count} times (must be unique): ${JSON.stringify(find)}`); continue; }
+ updated = updated.replace(find, replace);
+ }
+ if (errors.length) {
+ throw new Error(`Patch rejected -- fix these issues before retrying:\n${errors.join("\n")}`);
+ }
+ return updated;
+}
+
+// -- write_file ----------------------------------------------------------
+// Unified write tool: exactly one of `content` (full overwrite) or `patch`
+// (find/replace operations, applied against the exact blob identified by
+// base_sha) must be given.
+//
+// base_sha is required whenever `patch` is used (there is no content to
+// patch against without it). For `content` mode, base_sha is optional:
+// omitted means "create a new file" (no sha sent on the PUT); provided
+// means "this is meant to replace the exact version read earlier" and is
+// sent as-is so GitHub 409s on a stale/mismatched sha instead of silently
+// overwriting a concurrent change (fixes #59).
+export async function writeFile(owner, repo, path, { content, patch, baseSha, branch, message } = {}) {
+ if ((content === undefined) === (patch === undefined)) {
+ throw new Error("Provide exactly one of `content` (full overwrite) or `patch` (find/replace operations).");
+ }
+ if (patch && !baseSha) {
+ throw new Error("base_sha is required when using `patch` -- read_file the target path first and pass back the sha it returned.");
+ }
+ assertAllowedExtension(path, "path");
+
+ let finalContent = content;
+ if (patch) {
+ const blob = await githubRequest(`/repos/${owner}/${repo}/git/blobs/${baseSha}`);
+ const baseContent = fromBase64(blob.content.replace(/\n/g, ""));
+ finalContent = applyPatch(baseContent, patch);
+ }
+
+ try {
+ const result = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ body: {
+ message: message || `Update ${path}`,
+ content: toBase64(finalContent),
+ branch,
+ sha: baseSha,
+ },
+ });
+ return { path, content: finalContent, sha: result.content.sha, commitSha: result.commit.sha };
+ } catch (err) {
+ if (baseSha && isStaleShaError(err)) {
+ const conflictErr = new Error(
+ `Write conflict on "${path}": the file changed on "${branch}" since it was read (base_sha ${baseSha} is stale). ` +
+ `Re-read the file and retry instead of overwriting blindly. Original error: ${err.message}`
+ );
+ conflictErr.conflict = true;
+ throw conflictErr;
+ }
+ if (!baseSha && isMissingShaError(err)) {
+ const conflictErr = new Error(
+ `Write conflict on "${path}": this file already exists on "${branch}", so it can't be created blind. ` +
+ `Use read_file to get its current content and sha, then retry write_file with that sha as base_sha. Original error: ${err.message}`
+ );
+ conflictErr.conflict = true;
+ throw conflictErr;
+ }
+ throw err;
+ }
+}
+
+// -- validate --------------------------------------------------------------
+// Re-exported as-is: validateByExtension (connectors/frontend/validate.js)
+// was already a standalone, dependency-free-of-the-agent-loop callable --
+// nothing about it needed to change for v2, it's just exposed here
+// alongside read_file/write_file so the step-2 agent loop has one import
+// surface for all three tools.
+export { validateByExtension as validate };
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/frontend/designer_tools.js — delegate_designer
+//
+// Write-capable tool-calling agent for HTML/CSS/SCSS/JSX/TSX/Vue files
+// (issue #61 redesign). Thin wrapper around runDesignAgent (designer_delegate.js):
+// validation and response-shaping here deliberately mirror connectors/
+// gemini/agent_tools.js's delegate_agent (same resume_run_id / max_steps /
+// show_transcript conventions), since this is architecturally the same
+// kind of step-bounded, checkpointable tool-calling loop -- just
+// write-capable and scoped to frontend files instead of read-only/
+// cross-system.
+//
+// HISTORY: this replaced an older one-shot generate -> validate -> fix loop
+// (bounded 3-attempt blind retry, single static context dump, syntax-only
+// validation, split full-write/patch-write paths) that accumulated five
+// closed issues (#56-#60) which turned out to be tightly entangled --
+// patching them one-by-one kept requiring changes to each other, so issue
+// #61 redesigned from scratch instead: a real multi-step agent loop
+// (read_file/write_file/validate, modeled on delegate_gemini's
+// runInvestigation) replacing the fixed pipeline. That loop ran alongside
+// the old one as "delegate_designer_v2" behind a feature flag during a
+// dark-launch/monitoring period; once it proved stable (four post-launch
+// bugs found and fixed live: a final-step write-execution guard, a
+// resume_run_id gap on step-cap-without-answer stops, stuck-loop detection,
+// and a stale-read-cache bug introduced by that same stuck-loop fix -- see
+// the issue #61 Notion design doc for the full history), the old loop and
+// its feature flag were removed entirely and this tool took over the
+// "delegate_designer" name. connectors/frontend/client.js and checkpoint.js
+// (the old loop's provider-agnostic generator and Redis checkpoint module)
+// were deleted alongside it -- nothing else in the codebase imported them.
+//
+// SCOPE FENCING (deliberate, not incidental), enforced at the TOOL layer
+// inside designer_tool_functions.js's own read_file/write_file (not just prompt
+// instructions), per issue #61:
+// - READ/WRITE: both fenced to FRONTEND_ALLOWED_EXTENSIONS -- a bad or
+// manipulated task can't point this at config.js or other
+// secret-adjacent files, and can't land a write on server.js/
+// package.json/workflows/etc.
+// - BRANCH: refuses to run at all if `branch` resolves to the repo's
+// default branch, checked once up front before any tool call --
+// nothing this agent does can land on main directly.
+// - No delete capability at all (create/overwrite only).
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { runDesignAgent } from "./designer_delegate.js";
+import {
+ DEFAULT_OWNER, FRONTEND_ALLOWED_EXTENSIONS, FRONTEND_DEFAULT_STEPS,
+} from "../../config.js";
+
+export function register(server) {
+ server.tool(
+ "delegate_designer",
+ "TRIGGERS: \"build a page\", \"restyle X\", \"make responsive\", \"clean up this CSS\", \"turn mockup into markup\" -- ANY HTML/CSS/SCSS/JSX/TSX/Vue creation or edit, new file or existing.\n" +
+ "RULE: ALWAYS prefer this over hand-writing/editing HTML/CSS/JSX yourself, even if you could do it directly -- delegates to a model-driven agent that reads files on demand, edits or creates them, validates syntax, and iterates.\n" +
+ "IS: WRITE TOOL, bounded agentic loop (default " + FRONTEND_DEFAULT_STEPS + " steps) with three tools of its own (read_file/write_file/validate) -- writes to repo in the same call. Returns the agent's own final text summary, not the generated code.\n" +
+ "PREREQUISITE: branch != repo's default branch. No branch yet -> call create_branch first.\n" +
+ "SCOPE: reads and writes both fenced to " + FRONTEND_ALLOWED_EXTENSIONS.join(", ") + " only, enforced at the tool layer inside the agent's own read_file/write_file (not just prompt instructions). Refuses default-branch writes. No delete.\n" +
+ "RESUME: failed/partial run -> response includes resume_run_id -> pass back to continue from last completed step instead of restarting.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().optional().describe("Repository name. Not needed when resuming (resume_run_id carries it)."),
+ branch: z.string().optional().describe("Branch to work on. MUST NOT be the repo's default branch. Not needed when resuming."),
+ task: z.string().optional().describe("What to build or change, described with enough detail for the agent to act without asking anything back -- it can't. Ignored when resume_run_id resolves to a live checkpoint (the original task from that run is reused). Optional ONLY when resume_run_id is given and its checkpoint is still live; required otherwise."),
+ max_steps: z.number().optional().describe(`Max agent steps before being forced to answer (default ${FRONTEND_DEFAULT_STEPS}, hard cap 20 regardless of this value). On a resumed run this is the new ceiling, not additional steps on top of what's already done.`),
+ resume_run_id: z.string().optional().describe("A runId returned from a previous failed/partial delegate_designer call. If its checkpoint is still live (1 hour TTL), continues that run's conversation instead of starting fresh."),
+ show_transcript: z.boolean().optional().describe("Include the full step-by-step tool-call transcript in the response, even on a successful run (default: false). On a failed/partial run the transcript is always shown regardless of this flag."),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, branch, task, max_steps, resume_run_id, show_transcript = false }) => {
+ // Same "task is only genuinely optional when resuming a live
+ // checkpoint" reasoning as delegate_agent's handler in
+ // connectors/gemini/agent_tools.js.
+ if (!task && !resume_run_id) {
+ return {
+ content: [{ type: "text", text: "Missing required argument: task must be provided unless resuming a live checkpoint via resume_run_id." }],
+ isError: true,
+ };
+ }
+ if (max_steps !== undefined && (!Number.isInteger(max_steps) || max_steps < 1)) {
+ return {
+ content: [{ type: "text", text: `Invalid max_steps: ${max_steps}. Must be a positive integer (at least 1); the hard cap is 20 regardless of a larger value.` }],
+ isError: true,
+ };
+ }
+
+ let result;
+ try {
+ result = await runDesignAgent({ owner, repo, branch, task, max_steps, resume_run_id });
+ } catch (err) {
+ return { content: [{ type: "text", text: `delegate_designer failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+
+ const writtenNote = result.writtenFiles?.length
+ ? `\n\nFiles written: ${result.writtenFiles.join(", ")}`
+ : "";
+ const transcriptBlock = result.transcript?.length && (result.failed || show_transcript)
+ ? `\n\n${result.failed ? "Tool calls completed before the failure" : "Tool call transcript"}:\n${result.transcript.join("\n")}`
+ : "";
+
+ return {
+ content: [{ type: "text", text: `${result.answer}${writtenNote}${transcriptBlock}\n\n(${result.steps} step(s) taken)` }],
+ isError: !!result.failed,
+ };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| designer_checkpoint.js | +
+
+ |
+ 8.69% | +2/23 | +0% | +0/12 | +0% | +0/4 | +10.52% | +2/19 | +
| designer_delegate.js | +
+
+ |
+ 88.32% | +121/137 | +67.54% | +77/114 | +93.33% | +14/15 | +87.78% | +115/131 | +
| designer_tool_functions.js | +
+
+ |
+ 100% | +50/50 | +94.87% | +37/39 | +100% | +7/7 | +100% | +46/46 | +
| designer_tools.js | +
+
+ |
+ 9.09% | +1/11 | +0% | +0/22 | +50% | +1/2 | +9.09% | +1/11 | +
| validate.js | +
+
+ |
+ 96.2% | +76/79 | +90% | +45/50 | +92.85% | +13/14 | +98.57% | +69/70 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 | + + + + + + + + + + + + + + + + + + + +5x + + + + + +12x +12x + + + + +12x + + + + +12x + +12x +39x +39x +39x +39x +33x +16x +1x +15x +2x + + +2x + +13x + + +17x + + +12x +2x + +12x + + + + + + + +8x + + +8x + + + + +8x +8x +261x +249x +12x +12x +1x +1x + + + +8x +8x + + + + + + + + +9x +9x + + + + + + +9x + + + + + + + +9x + + + + +9x +3x + +6x + + +2x + + + + + +4x +4x +12x +12x +12x +1x + + +4x +4x +3x +3x +3x + +4x +4x +4x +4x + +4x + + +5x +1x +2x +1x +1x +1x +1x + + + + + + + + + +8x +8x +8x +8x +7x + + | // ---------------------------------------------------------------------------
+// connectors/frontend/validate.js — lightweight, per-extension syntax
+// validation for delegate_designer's generate -> validate -> fix loop.
+//
+// SCOPE: syntax only, not visual/rendering correctness (see the Notion plan
+// page "madmcp-delegate-designer-frontend-tool-2026-07-28" for why: real
+// visual checking needs a headless browser or a rendering API, which is a
+// genuine infra addition, not something this stateless serverless tool
+// takes on in v1).
+//
+// Each validator returns { valid: boolean, errors: string[] } -- errors are
+// short, human-readable strings meant to be fed straight back into a
+// follow-up LLM prompt asking it to fix them, not a structured AST diff.
+// ---------------------------------------------------------------------------
+
+// -- HTML: tag-balance check --------------------------------------------
+// Deliberately NOT a full HTML parser (no new dependency needed for this --
+// mismatched/unclosed tags are the dominant real-world failure mode from
+// LLM-generated markup, and a regex-based stack check catches those without
+// the weight of a real parser). Void elements never need a closing tag.
+const VOID_ELEMENTS = new Set([
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
+ "link", "meta", "param", "source", "track", "wbr",
+]);
+
+export function validateHtml(content) {
+ const errors = [];
+ const stack = [];
+ // Strip comments and content inside <script>/<style> first -- tag-like
+ // text inside those (e.g. a JS string containing "<div>") would otherwise
+ // produce false positives; scripts/styles get their own validators when
+ // relevant (e.g. a Vue SFC's <script> block).
+ const stripped = content
+ .replace(/<!--[\s\S]*?-->/g, "")
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "<script></script>")
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "<style></style>");
+
+ const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g;
+ let match;
+ while ((match = tagRe.exec(stripped))) {
+ const [full, tagName, selfClose] = match;
+ const lower = tagName.toLowerCase();
+ const isClosing = full.startsWith("</");
+ if (VOID_ELEMENTS.has(lower) || selfClose === "/") continue;
+ if (isClosing) {
+ if (stack.length === 0) {
+ errors.push(`Unexpected closing tag </${tagName}> with no matching open tag.`);
+ } else if (stack[stack.length - 1] !== lower) {
+ errors.push(`Mismatched tag: expected </${stack[stack.length - 1]}> but found </${tagName}>.`);
+ // Best-effort recovery: pop anyway so one mismatch doesn't cascade
+ // into dozens of downstream false positives.
+ stack.pop();
+ } else {
+ stack.pop();
+ }
+ } else {
+ stack.push(lower);
+ }
+ }
+ if (stack.length) {
+ errors.push(`Unclosed tag(s): <${stack.join(">, <")}> never closed.`);
+ }
+ return { valid: errors.length === 0, errors };
+}
+
+// -- CSS/SCSS: brace-balance + basic structure check ---------------------
+// Also not a full CSS parser -- unbalanced braces are the dominant failure
+// mode for LLM-generated CSS/SCSS (SCSS nesting is fine under a pure
+// brace-count check, it doesn't need to understand selectors).
+export function validateCss(content) {
+ const errors = [];
+ // Strip comments and string literals first, so a brace/quote inside a
+ // comment or a content: "..." value can't desync the counts below.
+ const stripped = content
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''");
+
+ let depth = 0;
+ for (const ch of stripped) {
+ if (ch === "{") depth++;
+ else if (ch === "}") {
+ depth--;
+ if (depth < 0) {
+ errors.push("Unexpected closing brace '}' with no matching '{'.");
+ depth = 0; // recover, same reasoning as the HTML validator above
+ }
+ }
+ }
+ if (depth > 0) errors.push(`${depth} unclosed brace(s) '{' -- missing matching '}'.`);
+ return { valid: errors.length === 0, errors };
+}
+
+// -- JSX/TSX: real parse via @babel/parser --------------------------------
+// Unlike HTML/CSS, JSX genuinely isn't valid plain JS syntax -- a regex
+// approach can't reliably validate it, so this is the one case that
+// warrants an actual parser dependency.
+export async function validateJsx(content, { typescript = false } = {}) {
+ let parse;
+ try {
+ ({ parse } = await import("@babel/parser"));
+ } catch {
+ // Dependency missing for some reason (shouldn't happen once package.json
+ // is updated, but fail open rather than crash the whole tool call --
+ // treat as "unvalidated" rather than "invalid").
+ return { valid: true, errors: [], skipped: "‘@babel/parser’ is not installed -- syntax check skipped." };
+ }
+ try {
+ // errorRecovery: true means @babel/parser does NOT throw for most
+ // syntax errors -- it instead returns an AST with an `errors` array
+ // attached, so it can keep parsing past the first mistake. That's
+ // useful for tools that want a best-effort AST despite bad input, but
+ // it means a bare try/catch here would silently treat recoverable
+ // syntax errors as valid. Check ast.errors explicitly rather than
+ // relying on parse() to throw.
+ const ast = parse(content, {
+ sourceType: "module",
+ plugins: typescript ? ["jsx", "typescript"] : ["jsx"],
+ errorRecovery: true,
+ });
+ if (ast.errors && ast.errors.length) {
+ return { valid: false, errors: ast.errors.map((e) => e.message || String(e)) };
+ }
+ return { valid: true, errors: [] };
+ } catch (err) {
+ // Non-recoverable errors (parser gives up entirely) still throw.
+ return { valid: false, errors: [err.message || String(err)] };
+ }
+}
+
+// -- Vue SFC: block-balance + parse the <script> block if present --------
+export async function validateVue(content) {
+ const errors = [];
+ for (const tag of ["template", "script", "style"]) {
+ const openCount = (content.match(new RegExp(`<${tag}\\b`, "gi")) || []).length;
+ const closeCount = (content.match(new RegExp(`</${tag}>`, "gi")) || []).length;
+ if (openCount !== closeCount) {
+ errors.push(`Mismatched <${tag}> blocks: ${openCount} opening vs ${closeCount} closing.`);
+ }
+ }
+ const scriptMatch = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(content);
+ if (scriptMatch) {
+ const isTs = /lang=["']ts["']/i.test(scriptMatch[0]);
+ const scriptResult = await validateJsx(scriptMatch[1], { typescript: isTs });
+ if (!scriptResult.valid) errors.push(...scriptResult.errors.map((e) => `<script> block: ${e}`));
+ }
+ const templateMatch = /<template[^>]*>([\s\S]*?)<\/template>/i.exec(content);
+ Eif (templateMatch) {
+ const templateResult = validateHtml(templateMatch[1]);
+ Iif (!templateResult.valid) errors.push(...templateResult.errors.map((e) => `<template> block: ${e}`));
+ }
+ return { valid: errors.length === 0, errors };
+}
+
+const VALIDATORS = {
+ ".html": async (c) => validateHtml(c),
+ ".css": async (c) => validateCss(c),
+ ".scss": async (c) => validateCss(c),
+ ".jsx": (c) => validateJsx(c, { typescript: false }),
+ ".tsx": (c) => validateJsx(c, { typescript: true }),
+ ".vue": (c) => validateVue(c),
+};
+
+// Dispatches to the right validator based on file extension. Returns
+// { valid: true, errors: [] } for an extension with no validator registered
+// (fail-open -- an unrecognized-but-allowlisted extension shouldn't block a
+// write, it just doesn't get a syntax check). Async throughout (even the
+// HTML/CSS branches, which don't need to be) so callers have one uniform
+// `await validateByExtension(...)` regardless of which file type they hit.
+export async function validateByExtension(path, content) {
+ const match = /\.[a-z0-9]+$/i.exec(path);
+ const ext = match ? match[0].toLowerCase() : "";
+ const validator = VALIDATORS[ext];
+ if (!validator) return { valid: true, errors: [] };
+ return validator(content);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 | + + + + + + + + + + + + + + + + + + + + + + + + +3x + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/gemini/agent_checkpoint.js — Redis-backed checkpointing for
+// delegate_agent's multi-step loop, so a run that dies partway through
+// (Gemini 503/429, network blip, function timeout) doesn't lose every tool
+// call it already made.
+//
+// STORAGE SHAPE (fix #5, 2026-07-27 -- append-delta instead of overwrite-
+// whole-blob): the conversation `contents` array is the part of loop state
+// that grows every step and can get large (tool outputs up to ~30k chars
+// each) -- it lives in its own Redis LIST, and callers only ever RPUSH the
+// turns added since the last checkpoint (see saveCheckpoint's `newContents`
+// param), not the whole array. Write cost is therefore O(delta per step),
+// not O(total conversation so far). Everything else (transcript, stepsDone,
+// task, and fix #4's repeat-signature tracking state) stays small and cheap
+// regardless of run length, so it's kept as one JSON blob under a separate
+// key -- no benefit to splitting that up further.
+//
+// SAME FAIL-OPEN CONTRACT AS cooldown.js: if Redis isn't configured or a
+// call fails, every function here no-ops / returns null. A missing Redis
+// must never be the reason an investigation can't run -- it only means a
+// failure can't be resumed, same as before this file existed.
+// ---------------------------------------------------------------------------
+
+import { getRedis } from "./cooldown.js";
+
+const CHECKPOINT_KEY_PREFIX = "gemini:checkpoint:";
+// A checkpoint only needs to survive long enough for the caller to retry
+// with resume_run_id -- not to become a permanent store.
+const CHECKPOINT_TTL_SECONDS = 3600;
+
+function contentsKey(runId) {
+ return `${CHECKPOINT_KEY_PREFIX}${runId}:contents`;
+}
+function metaKey(runId) {
+ return `${CHECKPOINT_KEY_PREFIX}${runId}:meta`;
+}
+
+// Persists loop state after a step completes:
+// - newContents: ONLY the turn(s) added to `contents` since the last
+// saveCheckpoint call for this runId (may be an empty array -- e.g. a
+// geminiChat failure that happens before any new turn was pushed --
+// in which case the list simply isn't touched this call, only meta is).
+// The caller (agent_delegate.js) is responsible for tracking which slice of
+// its in-memory `contents` array is new; this function has no way to
+// know that on its own since it never sees the full array.
+// - transcript/stepsDone/task/repeatCounts/consecutiveAllRepeatSteps: the
+// small stuff, always written in full (cheap regardless of run length).
+// Fails open -- never throws.
+export async function saveCheckpoint(runId, { newContents = [], transcript, stepsDone, task, repeatCounts, consecutiveAllRepeatSteps }) {
+ const client = getRedis();
+ if (!client) return;
+ try {
+ const ops = [];
+ if (newContents.length) {
+ ops.push(client.rpush(contentsKey(runId), ...newContents.map((c) => JSON.stringify(c))));
+ // EXPIRE (not a per-SET `ex` option, since RPUSH has no TTL param of
+ // its own) re-armed on every push so the list's TTL tracks the meta
+ // key's, rather than being set once and left to whatever it was at
+ // list-creation time.
+ ops.push(client.expire(contentsKey(runId), CHECKPOINT_TTL_SECONDS));
+ }
+ const meta = JSON.stringify({ transcript, stepsDone, task, repeatCounts, consecutiveAllRepeatSteps });
+ ops.push(client.set(metaKey(runId), meta, { ex: CHECKPOINT_TTL_SECONDS }));
+ await Promise.all(ops);
+ } catch {
+ // best-effort -- see file header
+ }
+}
+
+// Loads a previously saved checkpoint, or null if missing/expired/Redis is
+// unavailable/either stored value doesn't parse. Reconstructs `contents` by
+// concatenating every entry in the list (LRANGE 0 -1) -- this is the one
+// place read cost is still O(total run length), but it only happens once
+// per resume, not once per step (see file header).
+//
+// A genuine exception here (network blip, malformed JSON, etc.) is logged
+// as a warning before returning null -- distinct from the ordinary "key
+// doesn't exist" case (empty list / null meta), which is expected and
+// silent. Both cases still return null to the caller (agent_delegate.js can't do
+// anything different with either -- see its header), so this doesn't
+// change behavior, only observability: without it, a Redis outage and an
+// expired checkpoint look identical in the logs.
+export async function loadCheckpoint(runId) {
+ const client = getRedis();
+ if (!client) return null;
+ try {
+ const [rawList, rawMeta] = await Promise.all([
+ client.lrange(contentsKey(runId), 0, -1),
+ client.get(metaKey(runId)),
+ ]);
+ // A live checkpoint always has both a non-empty contents list AND meta
+ // (they're written together every step) -- either being missing means
+ // there's nothing usable to resume (expired, never existed, or a
+ // partial/corrupted write), same as the old single-key "raw == null"
+ // check.
+ if (!rawList || !rawList.length || rawMeta == null) return null;
+ // Upstash's client auto-parses JSON-looking values in some SDK versions
+ // and returns a raw string in others -- guard both, same as the old
+ // single-key version did.
+ const contents = rawList.map((entry) => (typeof entry === "string" ? JSON.parse(entry) : entry));
+ const meta = typeof rawMeta === "string" ? JSON.parse(rawMeta) : rawMeta;
+ return { contents, ...meta };
+ } catch (err) {
+ console.warn(`loadCheckpoint(${runId}) failed -- treating as no checkpoint:`, err?.message ?? err);
+ return null;
+ }
+}
+
+// Deletes a checkpoint once a run finishes (a final answer, or the model
+// stops issuing function calls) -- nothing left to resume. Clears both keys.
+export async function deleteCheckpoint(runId) {
+ const client = getRedis();
+ if (!client) return;
+ try {
+ await Promise.all([client.del(contentsKey(runId)), client.del(metaKey(runId))]);
+ } catch {
+ // best-effort
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +162x + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/gemini/agent_delegate.js — read-only investigation loop.
+//
+// Lets Gemini run its OWN multi-step tool-use loop server-side (via Gemini
+// function calling) to answer an open-ended question, instead of the
+// calling model doing 5-10 separate manual tool round-trips. One
+// delegate_agent call in, one synthesized answer out.
+//
+// SCOPE: every delegated function below is READ-ONLY. Gemini is never given
+// a write-capable function here -- writes stay confined to the fixed
+// GEMINI_NOTION_ROOT_PAGE_ID path in agent_tools.js, same isolation rule as
+// delegate_research. This file only reaches into GitHub/Cloudflare/Notion's
+// existing client-layer functions (not the MCP tool layer) to avoid
+// round-tripping through the MCP server for its own internal calls.
+//
+// IMPORTANT -- INDEPENDENT FROM THE MCP-FACING TOOL DESCRIPTIONS:
+// The `description` strings on FUNCTIONS below are what GEMINI sees during
+// its own tool-calling loop. They are entirely separate from the
+// server.tool(...) descriptions the CALLING MODEL (e.g. Claude) sees for
+// read_file/get_file_tree/list_directory/etc. in connectors/github/files.js
+// (and equivalents in other connectors/*/tools.js files). Editing one set
+// does NOT affect the other -- they are different objects read by different
+// models for different purposes.
+// Concretely: connectors/github/files.js's read_file/get_file_tree descriptions
+// carry "RULE for the calling model: ... use delegate_agent instead" text
+// aimed at steering Claude away from manual multi-file loops. Do NOT copy
+// that kind of "use delegate_agent instead" language onto github_read_file/
+// github_get_file_tree/etc. below -- Gemini calling one of these FUNCTIONS
+// *is* delegate_agent already running; a self-referential "delegate to
+// delegate_agent" hint here would be nonsensical and could confuse Gemini
+// into stalling instead of just calling the function. Keep these
+// descriptions plain and factual, matching what they actually do.
+//
+// STEP CAP: HARD_MAX_STEPS bounds the loop regardless of the caller's
+// max_steps argument -- both to bound Gemini API cost and because a
+// synchronous madmcp tool call has to fit inside the hosting platform's
+// request duration limit (a real constraint on Vercel -- see the Notion
+// plan page for the "known constraint" note; unresolved as of writing).
+// ---------------------------------------------------------------------------
+
+import { randomUUID } from "node:crypto";
+import { geminiChat } from "./client.js";
+import { saveCheckpoint, loadCheckpoint, deleteCheckpoint } from "./agent_checkpoint.js";
+import { isRedisConfigured } from "./cooldown.js";
+import { githubRequest } from "../github/client.js";
+import { readFileViaBlob } from "../github/helpers.js";
+import { extractRepoQualifier, fallbackCodeSearch } from "../github/search.js";
+import { queryTelemetry, toEpochMillis } from "../cloudflare/observability.js";
+import { cfAccountRequest } from "../cloudflare/client.js";
+import { context7Request } from "../context7/client.js";
+import { mem0Request } from "../mem/client.js";
+import { notionRequest, notionRichTextToString, notionPageTitle, notionDatabaseTitle, notionBlocksToText } from "../notion/client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+const HARD_MAX_STEPS = 30;
+
+// 429 (rate limit) and 503 (overloaded/high demand) are the only cases
+// documented as transient -- see client.js's own model-fallback cascade,
+// which deliberately only retries a different model on a 429 for the same
+// reason. Everything else (400 malformed request, 401/403 auth, 404 unknown
+// model, or no err.status at all -- e.g. "GEMINI_API_KEY is not set" thrown
+// locally in client.js, or "Gemini returned no candidates" from a
+// safety/recitation block) is a config or request problem that will
+// reproduce identically on a resume, not something retrying fixes.
+function isTransientGeminiError(err) {
+ return err?.status === 429 || err?.status === 503 || err?.transient === true;
+}
+
+// Minimal line-based diff (LCS backtrace) -- good enough for investigation
+// summaries, not a full unified-diff implementation. Capped so a huge file
+// pair can't blow up the O(n*m) table.
+function simpleLineDiff(aText, bText) {
+ const a = aText.split("\n");
+ const b = bText.split("\n");
+ if (a.length > 2000 || b.length > 2000) {
+ return a.join("\n") === b.join("\n") ? "(files identical)" : "(files differ -- too large for line diff, showing lengths only: " + a.length + " vs " + b.length + " lines)";
+ }
+ const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
+ for (let i = a.length - 1; i >= 0; i--) {
+ for (let j = b.length - 1; j >= 0; j--) {
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ const lines = [];
+ let i = 0, j = 0;
+ while (i < a.length && j < b.length) {
+ if (a[i] === b[j]) { i++; j++; }
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { lines.push(`-${a[i]}`); i++; }
+ else { lines.push(`+${b[j]}`); j++; }
+ }
+ while (i < a.length) { lines.push(`-${a[i]}`); i++; }
+ while (j < b.length) { lines.push(`+${b[j]}`); j++; }
+ return lines.length ? lines.join("\n") : "(files identical)";
+}
+
+// ---------------------------------------------------------------------------
+// Delegated function declarations -- Gemini's "tools" param (a subset of
+// OpenAPI schema: type/properties/required, no $ref/oneOf/etc support).
+// Each entry pairs the Gemini-facing declaration with a local `execute`
+// that calls the real connector client function.
+// ---------------------------------------------------------------------------
+
+const FUNCTIONS = [
+ {
+ name: "github_read_file",
+ description: "Read a file's full contents from a GitHub repository.",
+ parameters: {
+ type: "object",
+ properties: {
+ owner: { type: "string", description: `Repository owner (default "${DEFAULT_OWNER}" if omitted)` },
+ repo: { type: "string", description: "Repository name" },
+ path: { type: "string", description: "File path within the repo" },
+ ref: { type: "string", description: "Branch, tag, or commit SHA (default: repo default branch)" },
+ },
+ required: ["repo", "path"],
+ },
+ execute: async ({ owner = DEFAULT_OWNER, repo, path, ref }) => {
+ const content = await readFileViaBlob(owner, repo, path, ref);
+ // Keep the loop's own context bounded -- this is server-side content
+ // feeding back into Gemini's next turn, not returned to the caller.
+ return content.length > 30000 ? content.slice(0, 30000) + "\n...[truncated]" : content;
+ },
+ },
+ {
+ name: "github_get_file_tree",
+ description: "Recursively list all files and folders in a GitHub repository.",
+ parameters: {
+ type: "object",
+ properties: {
+ owner: { type: "string", description: "Repository owner" },
+ repo: { type: "string", description: "Repository name" },
+ ref: { type: "string", description: "Branch, tag, or commit SHA (default: repo default branch)" },
+ },
+ required: ["owner", "repo"],
+ },
+ execute: async ({ owner, repo, ref }) => {
+ let treeSha;
+ if (ref) {
+ try {
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(ref)}`);
+ treeSha = refData.object.sha;
+ } catch { treeSha = ref; }
+ } else {
+ const repoData = await githubRequest(`/repos/${owner}/${repo}`);
+ const branchData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${repoData.default_branch}`);
+ treeSha = branchData.object.sha;
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
+ return data.tree.map((item) => `${item.type === "tree" ? "dir " : "file"} ${item.path}`).join("\n");
+ },
+ },
+ {
+ name: "github_list_commits",
+ description: "List recent commits on a branch in a GitHub repository.",
+ parameters: {
+ type: "object",
+ properties: {
+ owner: { type: "string", description: `Repository owner (default "${DEFAULT_OWNER}" if omitted)` },
+ repo: { type: "string", description: "Repository name" },
+ branch: { type: "string", description: "Branch name (default: repo default branch)" },
+ per_page: { type: "number", description: "Number of commits to return (default 20, max 100)" },
+ },
+ required: ["repo"],
+ },
+ execute: async ({ owner = DEFAULT_OWNER, repo, branch, per_page = 20 }) => {
+ const query = new URLSearchParams({ per_page: String(Math.min(per_page, 100)) });
+ if (branch) query.set("sha", branch);
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits?${query}`);
+ return data.map((c) => `${c.sha.slice(0, 7)} — ${c.commit.message.split("\n")[0]} (${c.commit.author?.name}, ${c.commit.author?.date?.slice(0, 10)})`).join("\n");
+ },
+ },
+ {
+ name: "github_search_issues",
+ description: "Search issues and pull requests across GitHub using GitHub's issue-search syntax (label:, is:issue, is:open, stars:>N, org:, repo:, -repo:, -org:, no:assignee, etc., combined with spaces as AND). Useful for cross-repo discovery like good-first-issue scanning -- github_read_file/github_get_file_tree only work within a single already-known repo.",
+ parameters: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "GitHub issue-search query string, e.g. 'label:\"good first issue\" is:open is:issue no:assignee stars:>2000 -org:someorg'" },
+ sort: { type: "string", description: "Sort field: created, updated, or comments (default: best-match relevance)" },
+ order: { type: "string", description: "Sort order: asc or desc (default: desc)" },
+ per_page: { type: "number", description: "Number of results to return, max 100 (default 20)" },
+ },
+ required: ["query"],
+ },
+ execute: async ({ query, sort, order = "desc", per_page = 20 }) => {
+ let path = `/search/issues?q=${encodeURIComponent(query)}&order=${order}&per_page=${Math.min(per_page, 100)}`;
+ if (sort) path += `&sort=${sort}`;
+ const data = await githubRequest(path);
+ if (!data.items?.length) return "No results found.";
+ const lines = data.items.map((item) => {
+ const kind = item.pull_request ? "PR" : "Issue";
+ const labels = item.labels?.length ? ` [${item.labels.map((l) => l.name).join(", ")}]` : "";
+ const assignee = item.assignee ? ` (assigned: ${item.assignee.login})` : " (unassigned)";
+ return `${kind} #${item.number} [${item.state}] ${item.title}${labels}${assignee} -- ${item.repository_url.replace("https://api.github.com/repos/", "")} | created ${item.created_at.slice(0, 10)} | ${item.html_url}`;
+ });
+ const text = `Found ${data.total_count} total result(s), showing ${data.items.length}:\n${lines.join("\n")}`;
+ return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "cf_query_logs",
+ description: "Query Cloudflare Workers Observability logs/traces/events for a time range.",
+ parameters: {
+ type: "object",
+ properties: {
+ timeframe_from: { type: "string", description: "Start of time range, ISO 8601 or epoch millis" },
+ timeframe_to: { type: "string", description: "End of time range, ISO 8601 or epoch millis" },
+ script_name: { type: "string", description: "Optional: scope to one Worker script" },
+ limit: { type: "number", description: "Max results (default ~100)" },
+ },
+ required: ["timeframe_from", "timeframe_to"],
+ },
+ execute: async ({ timeframe_from, timeframe_to, script_name, limit }) => {
+ const data = await queryTelemetry({ timeframe_from, timeframe_to, script_name, limit });
+ return JSON.stringify(data).slice(0, 30000);
+ },
+ },
+ {
+ name: "notion_get_page",
+ description: "Read a Notion page's title and text content by page ID (read-only). Use this after notion_search finds a candidate page, to actually see what's on it -- notion_search only returns titles/ids, not content.",
+ parameters: {
+ type: "object",
+ properties: {
+ page_id: { type: "string", description: "Notion page ID, e.g. from notion_search results" },
+ },
+ required: ["page_id"],
+ },
+ execute: async ({ page_id }) => {
+ const [page, blocksData] = await Promise.all([
+ notionRequest(`/pages/${page_id}`),
+ notionRequest(`/blocks/${page_id}/children?page_size=100`),
+ ]);
+ const title = notionPageTitle(page);
+ const blocks = blocksData.results || [];
+ const content = notionBlocksToText(blocks) || "(no content)";
+ const hasMore = blocksData.has_more ? "\n[note: page has more than 100 blocks, only the first 100 are shown]" : "";
+ const text = `# ${title}\n${content}${hasMore}`;
+ return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "notion_search",
+ description: "Search pages and databases in the Notion workspace (read-only).",
+ parameters: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "Search query string" },
+ filter_type: { type: "string", description: "Restrict to 'page' or 'database' (optional)" },
+ page_size: { type: "number", description: "Number of results (default 10, max 100)" },
+ },
+ required: ["query"],
+ },
+ execute: async ({ query, filter_type, page_size = 10 }) => {
+ const body = { query, page_size };
+ if (filter_type) body.filter = { value: filter_type, property: "object" };
+ const data = await notionRequest("/search", { method: "POST", body });
+ if (!data.results?.length) return "No results found.";
+ return data.results.map((r) => {
+ const title = r.object === "page" ? notionPageTitle(r) : (notionDatabaseTitle(r) || "(untitled)");
+ return `[${r.object}] ${title} — id: ${r.id}`;
+ }).join("\n");
+ },
+ },
+ {
+ name: "notion_query_database",
+ description: "Query rows from a Notion database (read-only), with an optional filter.",
+ parameters: {
+ type: "object",
+ properties: {
+ database_id: { type: "string", description: "Notion database ID" },
+ page_size: { type: "number", description: "Number of rows (default 20, max 100)" },
+ },
+ required: ["database_id"],
+ },
+ execute: async ({ database_id, page_size = 20 }) => {
+ const data = await notionRequest(`/databases/${database_id}/query`, { method: "POST", body: { page_size } });
+ if (!data.results?.length) return "No rows found.";
+ return data.results.map((row) => {
+ const props = Object.entries(row.properties || {}).map(([name, val]) => {
+ if (val.type === "title") return `${name}: ${notionRichTextToString(val.title)}`;
+ if (val.type === "rich_text") return `${name}: ${notionRichTextToString(val.rich_text)}`;
+ return `${name}: ${JSON.stringify(val[val.type] ?? "")}`;
+ }).join(" | ");
+ return `- ${props}`;
+ }).join("\n");
+ },
+ },
+
+ // -- GitHub: issues / PRs --------------------------------------------
+ {
+ name: "github_get_issue",
+ description: "Read a single GitHub issue's full body, labels, assignees, and (optionally) comments.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, issue_number: { type: "number" },
+ include_comments: { type: "boolean" },
+ }, required: ["repo", "issue_number"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, issue_number, include_comments = false }) => {
+ const issue = await githubRequest(`/repos/${owner}/${repo}/issues/${issue_number}`);
+ let text = `#${issue.number} [${issue.state}] ${issue.title}\nLabels: ${(issue.labels || []).map(l => l.name).join(", ") || "none"}\nAssignees: ${(issue.assignees || []).map(a => a.login).join(", ") || "none"}\n\n${issue.body || "(no body)"}`;
+ if (include_comments && issue.comments > 0) {
+ const comments = await githubRequest(`/repos/${owner}/${repo}/issues/${issue_number}/comments?per_page=50`);
+ text += "\n\n--- comments ---\n" + comments.map(c => `${c.user?.login}: ${c.body}`).join("\n---\n");
+ }
+ return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "github_list_pull_requests",
+ description: "List pull requests in a repo, optionally filtered by state.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, state: { type: "string", description: "open, closed, or all (default open)" }, per_page: { type: "number" },
+ }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, state = "open", per_page = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls?state=${state}&per_page=${Math.min(per_page, 100)}`);
+ return data.map(pr => `#${pr.number} [${pr.state}${pr.draft ? " draft" : ""}] ${pr.title} (${pr.head?.ref} -> ${pr.base?.ref}) by ${pr.user?.login}`).join("\n") || "No pull requests found.";
+ },
+ },
+ {
+ name: "github_get_pull_request",
+ description: "Get a single pull request's details, optionally including comments, reviews, and commits.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, pull_number: { type: "number" },
+ include_comments: { type: "boolean" }, include_reviews: { type: "boolean" }, include_commits: { type: "boolean" },
+ }, required: ["repo", "pull_number"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, pull_number, include_comments, include_reviews, include_commits }) => {
+ const pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`);
+ let text = `#${pr.number} [${pr.state}] ${pr.title}\n${pr.head?.ref} -> ${pr.base?.ref} by ${pr.user?.login}\nMergeable: ${pr.mergeable} (${pr.mergeable_state})\n\n${pr.body || "(no body)"}`;
+ if (include_comments) {
+ const c = await githubRequest(`/repos/${owner}/${repo}/issues/${pull_number}/comments?per_page=50`);
+ text += "\n\n--- comments ---\n" + c.map(x => `${x.user?.login}: ${x.body}`).join("\n---\n");
+ }
+ if (include_reviews) {
+ const r = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews?per_page=50`);
+ text += "\n\n--- reviews ---\n" + r.map(x => `${x.user?.login}: ${x.state} -- ${x.body || "(no comment)"}`).join("\n");
+ }
+ if (include_commits) {
+ const cm = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/commits?per_page=50`);
+ text += "\n\n--- commits ---\n" + cm.map(x => `${x.sha.slice(0, 7)} ${x.commit.message.split("\n")[0]}`).join("\n");
+ }
+ return text.length > 25000 ? text.slice(0, 25000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "github_get_pr_comments",
+ description: "Get the conversation comments on a pull request.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, pull_number: { type: "number" }, per_page: { type: "number" } }, required: ["repo", "pull_number"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, pull_number, per_page = 50 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues/${pull_number}/comments?per_page=${Math.min(per_page, 100)}`);
+ return data.map(c => `${c.user?.login} (${c.created_at?.slice(0, 10)}): ${c.body}`).join("\n---\n") || "No comments.";
+ },
+ },
+ {
+ name: "github_get_pr_reviews",
+ description: "Get the formal reviews (approve/request-changes/comment) on a pull request.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, pull_number: { type: "number" }, per_page: { type: "number" } }, required: ["repo", "pull_number"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, pull_number, per_page = 50 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews?per_page=${Math.min(per_page, 100)}`);
+ return data.map(r => `${r.user?.login}: ${r.state} -- ${r.body || "(no comment)"}`).join("\n") || "No reviews.";
+ },
+ },
+ {
+ name: "github_get_pr_mergeability",
+ description: "Check whether a pull request can be merged (mergeable state, conflicts). GitHub computes this async, so this retries briefly if the result isn't ready yet.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, pull_number: { type: "number" } }, required: ["repo", "pull_number"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, pull_number }) => {
+ let pr;
+ for (let i = 0; i < 3; i++) {
+ pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`);
+ if (pr.mergeable !== null) break;
+ await new Promise(r => setTimeout(r, 1000));
+ }
+ return `mergeable: ${pr.mergeable}\nmergeable_state: ${pr.mergeable_state}\nrebaseable: ${pr.rebaseable}`;
+ },
+ },
+
+ // -- GitHub: CI / checks -----------------------------------------------
+ {
+ name: "github_get_check_runs",
+ description: "Get CI check-run results (pass/fail dots) for a commit or ref.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, ref: { type: "string" }, per_page: { type: "number" } }, required: ["repo", "ref"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, ref, per_page = 50 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=${Math.min(per_page, 100)}`);
+ return `${data.total_count} check run(s):\n` + data.check_runs.map(c => `${c.name}: ${c.status}/${c.conclusion}`).join("\n");
+ },
+ },
+ {
+ name: "github_get_combined_status",
+ description: "Get the combined commit status (overall pass/fail/pending rollup) for a ref.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, ref: { type: "string" } }, required: ["repo", "ref"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, ref }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits/${ref}/status`);
+ return `Overall state: ${data.state} (${data.total_count} statuses)\n` + (data.statuses || []).map(s => `${s.context}: ${s.state} -- ${s.description || ""}`).join("\n");
+ },
+ },
+ {
+ name: "github_list_workflow_runs",
+ description: "List recent GitHub Actions workflow runs for a repo, optionally scoped to one workflow.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, workflow_id: { type: "string" }, branch: { type: "string" }, status: { type: "string" }, per_page: { type: "number" },
+ }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, workflow_id, branch, status, per_page = 20 }) => {
+ const qs = new URLSearchParams({ per_page: String(Math.min(per_page, 100)) });
+ if (branch) qs.set("branch", branch);
+ if (status) qs.set("status", status);
+ const path = workflow_id ? `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow_id)}/runs?${qs}` : `/repos/${owner}/${repo}/actions/runs?${qs}`;
+ const data = await githubRequest(path);
+ return data.workflow_runs.map(r => `#${r.run_number} [${r.status}/${r.conclusion}] ${r.name} on ${r.head_branch} (${r.created_at?.slice(0, 10)}) -- run_id ${r.id}`).join("\n") || "No runs found.";
+ },
+ },
+ {
+ name: "github_get_workflow_run_logs",
+ description: "Get a summary of a workflow run's jobs and steps (status/conclusion per step). For raw log text, use github_get_job_logs with a job_id from this result.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, run_id: { type: "number" } }, required: ["repo", "run_id"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, run_id }) => {
+ const [run, jobsData] = await Promise.all([
+ githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}`),
+ githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`),
+ ]);
+ let text = `Run #${run.run_number} [${run.status}/${run.conclusion}] ${run.name} on ${run.head_branch}\n\n`;
+ text += jobsData.jobs.map(j => `Job ${j.id} "${j.name}": ${j.status}/${j.conclusion}\n` + (j.steps || []).map(s => ` - ${s.name}: ${s.status}/${s.conclusion}`).join("\n")).join("\n\n");
+ return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "github_get_job_logs",
+ description: "Get raw log text for a specific workflow job (find the job_id via github_get_workflow_run_logs first, or pass job_name to look it up).",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, run_id: { type: "number" }, job_id: { type: "number" }, job_name: { type: "string" },
+ }, required: ["repo", "run_id"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, run_id, job_id, job_name }) => {
+ let id = job_id;
+ if (!id) {
+ const jobsData = await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
+ const match = job_name ? jobsData.jobs.find(j => j.name === job_name) : jobsData.jobs[0];
+ if (!match) return `No job found${job_name ? ` matching "${job_name}"` : ""}.`;
+ id = match.id;
+ }
+ const logs = await githubRequest(`/repos/${owner}/${repo}/actions/jobs/${id}/logs`, { accept: "application/vnd.github+json" });
+ const text = typeof logs === "string" ? logs : JSON.stringify(logs);
+ return text.length > 25000 ? "...[truncated, showing tail]...\n" + text.slice(-25000) : text;
+ },
+ },
+
+ // -- GitHub: repo metadata / discovery ----------------------------------
+ {
+ name: "github_list_issues",
+ description: "List issues in a repo (excludes pull requests), optionally filtered by state/labels/assignee.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, state: { type: "string" }, labels: { type: "string" }, assignee: { type: "string" }, per_page: { type: "number" },
+ }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, state = "open", labels, assignee, per_page = 20 }) => {
+ const qs = new URLSearchParams({ state, per_page: String(Math.min(per_page, 100)) });
+ if (labels) qs.set("labels", labels);
+ if (assignee) qs.set("assignee", assignee);
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues?${qs}`);
+ const issues = data.filter(i => !i.pull_request);
+ return issues.map(i => `#${i.number} [${i.state}] ${i.title}`).join("\n") || "No issues found.";
+ },
+ },
+ {
+ name: "github_list_releases",
+ description: "List releases in a repo.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, per_page: { type: "number" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, per_page = 10 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/releases?per_page=${Math.min(per_page, 100)}`);
+ return data.map(r => `${r.tag_name}${r.name ? ` (${r.name})` : ""} -- ${r.prerelease ? "prerelease" : r.draft ? "draft" : "release"}, published ${r.published_at?.slice(0, 10) || "n/a"}`).join("\n") || "No releases found.";
+ },
+ },
+ {
+ name: "github_list_tags",
+ description: "List tags in a repo.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, per_page: { type: "number" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, per_page = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/tags?per_page=${Math.min(per_page, 100)}`);
+ return data.map(t => `${t.name} -- ${t.commit?.sha?.slice(0, 7)}`).join("\n") || "No tags found.";
+ },
+ },
+ {
+ name: "github_list_contributors",
+ description: "List contributors to a repo with commit counts.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, per_page: { type: "number" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, per_page = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/contributors?per_page=${Math.min(per_page, 100)}`);
+ return data.map(c => `${c.login}: ${c.contributions} commits`).join("\n") || "No contributors found.";
+ },
+ },
+ {
+ name: "github_get_repo",
+ description: "Get repo metadata: description, default branch, language, stars, topics, etc.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo }) => {
+ const r = await githubRequest(`/repos/${owner}/${repo}`);
+ return `${r.full_name} (${r.visibility})\n${r.description || "(no description)"}\nDefault branch: ${r.default_branch} | Language: ${r.language} | Stars: ${r.stargazers_count} | Forks: ${r.forks_count} | Open issues: ${r.open_issues_count}\nTopics: ${(r.topics || []).join(", ") || "none"}\nURL: ${r.html_url}`;
+ },
+ },
+ {
+ name: "github_get_branch_protection",
+ description: "Get branch protection rules for a branch (required checks, required reviews, etc.). Returns a note if the branch is unprotected or the caller lacks access.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, branch: { type: "string" } }, required: ["repo", "branch"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, branch }) => {
+ try {
+ const data = await githubRequest(`/repos/${owner}/${repo}/branches/${branch}/protection`);
+ return JSON.stringify(data, null, 2).slice(0, 8000);
+ } catch (err) {
+ return `No accessible branch protection for "${branch}": ${err.message}`;
+ }
+ },
+ },
+ {
+ name: "github_list_branches",
+ description: "List branches in a repo.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/branches`);
+ return data.map(b => `${b.name}${b.protected ? " (protected)" : ""}`).join("\n") || "No branches found.";
+ },
+ },
+ {
+ name: "github_get_repo_topics",
+ description: "Get the topics/tags set on a repo.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/topics`, { accept: "application/vnd.github.mercy-preview+json" });
+ return (data.names || []).join(", ") || "No topics set.";
+ },
+ },
+ {
+ name: "github_list_directory",
+ description: "List files and folders at a specific path in a repo (non-recursive; use github_get_file_tree for the full recursive tree).",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, path = "", ref }) => {
+ const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
+ const data = await githubRequest(`/repos/${owner}/${repo}/contents/${path}${qs}`);
+ const entries = Array.isArray(data) ? data : [data];
+ return entries.map(e => `${e.type === "dir" ? "dir " : "file"} ${e.path}`).join("\n") || "(empty)";
+ },
+ },
+
+ // -- GitHub: commits / diffs / code search ------------------------------
+ {
+ name: "github_get_commit",
+ description: "Get a commit's message, author, and changed files.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, sha: { type: "string" } }, required: ["repo", "sha"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, sha }) => {
+ const c = await githubRequest(`/repos/${owner}/${repo}/commits/${sha}`);
+ const files = (c.files || []).map(f => ` ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`).join("\n");
+ return `${c.sha.slice(0, 7)} by ${c.commit.author?.name} on ${c.commit.author?.date?.slice(0, 10)}\n${c.commit.message}\n\nFiles changed:\n${files || "(none)"}`;
+ },
+ },
+ {
+ name: "github_get_file_at_commit",
+ description: "Read a file's contents as it existed at a specific commit SHA.",
+ parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, path: { type: "string" }, commit: { type: "string" } }, required: ["repo", "path", "commit"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, path, commit }) => {
+ const content = await readFileViaBlob(owner, repo, path, commit);
+ return content.length > 30000 ? content.slice(0, 30000) + "\n...[truncated]" : content;
+ },
+ },
+ {
+ name: "github_diff_files",
+ description: "Compare the same file (or two different files) between two refs/branches/commits and return a line-based diff.",
+ parameters: { type: "object", properties: {
+ owner: { type: "string" }, repo: { type: "string" }, path: { type: "string", description: "File path (used for both sides unless base_path/head_path given)" },
+ base_ref: { type: "string" }, head_ref: { type: "string" }, base_path: { type: "string" }, head_path: { type: "string" },
+ }, required: ["repo", "path", "base_ref", "head_ref"] },
+ execute: async ({ owner = DEFAULT_OWNER, repo, path, base_ref, head_ref, base_path, head_path }) => {
+ const [a, b] = await Promise.all([
+ readFileViaBlob(owner, repo, base_path || path, base_ref),
+ readFileViaBlob(owner, repo, head_path || path, head_ref),
+ ]);
+ const diff = simpleLineDiff(a, b);
+ return diff.length > 20000 ? diff.slice(0, 20000) + "\n...[truncated]" : diff;
+ },
+ },
+ {
+ name: "github_search_code",
+ description: "Search code across GitHub using GitHub's code-search syntax (e.g. 'foo repo:owner/name', 'extension:js useState'). If the query is scoped to a single repo via repo:owner/name and GitHub's index returns nothing (a known gap for private repos), or if `ref` is given (GitHub's index only ever covers the default branch), this automatically falls back to fetching that repo as a tarball and grepping it locally instead of just reporting no results.",
+ parameters: { type: "object", properties: {
+ query: { type: "string", description: "Search query, e.g. 'foo repo:owner/name' or 'extension:js useState'" },
+ per_page: { type: "number", description: "Number of results to return, max 100 (default 20)" },
+ ref: { type: "string", description: "Branch, tag, or commit SHA to search instead of the default branch. Requires a repo:owner/name qualifier in the query -- GitHub's search index only covers the default branch, so this always uses the local content-search fallback rather than the real API." },
+ }, required: ["query"] },
+ execute: async ({ query, per_page = 20, ref }) => {
+ const scoped = extractRepoQualifier(query);
+
+ if (ref) {
+ if (!scoped) {
+ return "Error: `ref` requires a repo:owner/name qualifier in the query -- GitHub's search index only covers the default branch, so a specific repo must be named for the branch-aware fallback to know what to fetch.";
+ }
+ let fb;
+ try {
+ fb = await fallbackCodeSearch({ ...scoped, query, per_page, ref });
+ } catch (err) {
+ return `Branch search failed: ${err?.message ?? String(err)}`;
+ }
+ if (fb?.matches.length) {
+ const lines = fb.matches.map((m) => `${scoped.owner}/${scoped.repo}/${m.path}:${m.line} -- ${m.snippet}`);
+ return `Searched ${scoped.owner}/${scoped.repo}@${ref} directly (GitHub's code-search index only covers the default branch) -- scanned ${fb.scanned} file(s)${fb.truncated ? ", capped -- repo has more" : ""}:\n${lines.join("\n")}`;
+ }
+ return `No results found on ${scoped.owner}/${scoped.repo}@${ref} (scanned ${fb?.scanned ?? 0} file(s)${fb?.truncated ? ", capped -- repo has more" : ""}).`;
+ }
+
+ const data = await githubRequest(`/search/code?q=${encodeURIComponent(query)}&per_page=${Math.min(per_page, 100)}`);
+ if (data.items?.length) {
+ const text = `Found ${data.total_count} total, showing ${data.items.length}:\n` + data.items.map(i => `${i.repository.full_name}: ${i.path}`).join("\n");
+ return text.length > 15000 ? text.slice(0, 15000) + "\n...[truncated]" : text;
+ }
+
+ if (scoped) {
+ const fb = await fallbackCodeSearch({ ...scoped, query, per_page }).catch(() => null);
+ if (fb?.matches.length) {
+ const lines = fb.matches.map((m) => `${scoped.owner}/${scoped.repo}/${m.path}:${m.line} -- ${m.snippet}`);
+ return `GitHub's code-search index returned nothing for this repo (a known gap for private repos), so this used a direct content search instead (scanned ${fb.scanned} file(s)${fb.truncated ? ", capped -- repo has more" : ""}):\n${lines.join("\n")}`;
+ }
+ if (fb) {
+ return `No results found. Also tried a direct content search of ${scoped.owner}/${scoped.repo} (GitHub's search index can return empty for private repos regardless of permissions) -- scanned ${fb.scanned} file(s)${fb.truncated ? " (capped, repo has more)" : ""}, no match.`;
+ }
+ }
+
+ return "No results found.";
+ },
+ },
+
+ // -- Cloudflare: Workers / D1 / KV / R2 / Hyperdrive ---------------------
+ {
+ name: "cf_workers_list",
+ description: "List all Cloudflare Workers scripts in the account.",
+ parameters: { type: "object", properties: {} },
+ execute: async () => {
+ const data = await cfAccountRequest("/workers/scripts");
+ return (data || []).map(w => `${w.id} (modified ${w.modified_on?.slice(0, 10)})`).join("\n") || "No workers found.";
+ },
+ },
+ {
+ name: "cf_workers_get_worker",
+ description: "Get settings/metadata for a single Cloudflare Worker.",
+ parameters: { type: "object", properties: { scriptName: { type: "string" } }, required: ["scriptName"] },
+ execute: async ({ scriptName }) => JSON.stringify(await cfAccountRequest(`/workers/scripts/${scriptName}/settings`), null, 2).slice(0, 8000),
+ },
+ {
+ name: "cf_workers_get_worker_code",
+ description: "Get the source code of a Cloudflare Worker.",
+ parameters: { type: "object", properties: { scriptName: { type: "string" } }, required: ["scriptName"] },
+ execute: async ({ scriptName }) => {
+ const data = await cfAccountRequest(`/workers/scripts/${scriptName}`);
+ const text = typeof data === "string" ? data : JSON.stringify(data);
+ return text.length > 30000 ? text.slice(0, 30000) + "\n...[truncated]" : text;
+ },
+ },
+ {
+ name: "cf_d1_databases_list",
+ description: "List D1 databases in the account.",
+ parameters: { type: "object", properties: { name: { type: "string" } } },
+ execute: async ({ name }) => {
+ const qs = name ? `?name=${encodeURIComponent(name)}` : "";
+ const data = await cfAccountRequest(`/d1/database${qs}`);
+ return (data || []).map(d => `${d.name} -- ${d.uuid}`).join("\n") || "No databases found.";
+ },
+ },
+ {
+ name: "cf_d1_database_get",
+ description: "Get details for a single D1 database.",
+ parameters: { type: "object", properties: { database_id: { type: "string" } }, required: ["database_id"] },
+ execute: async ({ database_id }) => JSON.stringify(await cfAccountRequest(`/d1/database/${database_id}`), null, 2).slice(0, 5000),
+ },
+ {
+ name: "cf_kv_namespaces_list",
+ description: "List KV namespaces in the account.",
+ parameters: { type: "object", properties: {} },
+ execute: async () => {
+ const data = await cfAccountRequest("/storage/kv/namespaces");
+ return (data || []).map(n => `${n.title} -- ${n.id}`).join("\n") || "No namespaces found.";
+ },
+ },
+ {
+ name: "cf_kv_namespace_get",
+ description: "Get details for a single KV namespace.",
+ parameters: { type: "object", properties: { namespace_id: { type: "string" } }, required: ["namespace_id"] },
+ execute: async ({ namespace_id }) => JSON.stringify(await cfAccountRequest(`/storage/kv/namespaces/${namespace_id}`), null, 2).slice(0, 5000),
+ },
+ {
+ name: "cf_r2_buckets_list",
+ description: "List R2 buckets in the account.",
+ parameters: { type: "object", properties: {} },
+ execute: async () => {
+ const data = await cfAccountRequest("/r2/buckets");
+ return (data?.buckets || data || []).map(b => `${b.name} (created ${b.creation_date?.slice(0, 10) || "n/a"})`).join("\n") || "No buckets found.";
+ },
+ },
+ {
+ name: "cf_r2_bucket_get",
+ description: "Get details for a single R2 bucket.",
+ parameters: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
+ execute: async ({ name }) => JSON.stringify(await cfAccountRequest(`/r2/buckets/${name}`), null, 2).slice(0, 5000),
+ },
+ {
+ name: "cf_hyperdrive_configs_list",
+ description: "List Hyperdrive configurations in the account.",
+ parameters: { type: "object", properties: {} },
+ execute: async () => {
+ const data = await cfAccountRequest("/hyperdrive/configs");
+ return (data || []).map(h => `${h.name} -- ${h.id}`).join("\n") || "No Hyperdrive configs found.";
+ },
+ },
+ {
+ name: "cf_hyperdrive_config_get",
+ description: "Get details for a single Hyperdrive configuration.",
+ parameters: { type: "object", properties: { hyperdrive_id: { type: "string" } }, required: ["hyperdrive_id"] },
+ execute: async ({ hyperdrive_id }) => JSON.stringify(await cfAccountRequest(`/hyperdrive/configs/${hyperdrive_id}`), null, 2).slice(0, 5000),
+ },
+ {
+ name: "cf_workers_observability_keys",
+ description: "List available telemetry keys (log/trace/event fields) for a time range.",
+ parameters: { type: "object", properties: { timeframe_from: { type: "string" }, timeframe_to: { type: "string" }, dataset: { type: "string" } }, required: ["timeframe_from", "timeframe_to"] },
+ execute: async ({ timeframe_from, timeframe_to, dataset = "cloudflare-workers" }) => {
+ const data = await cfAccountRequest("/workers/observability/telemetry/keys", { method: "POST", body: { dataset, timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) } } });
+ return JSON.stringify(data).slice(0, 8000);
+ },
+ },
+ {
+ name: "cf_workers_observability_values",
+ description: "List the distinct values seen for a given telemetry key over a time range.",
+ parameters: { type: "object", properties: {
+ key: { type: "string" }, timeframe_from: { type: "string" }, timeframe_to: { type: "string" }, dataset: { type: "string" }, valueType: { type: "string", description: "string, boolean, or number (default string)" },
+ }, required: ["key", "timeframe_from", "timeframe_to"] },
+ execute: async ({ key, timeframe_from, timeframe_to, dataset = "cloudflare-workers", valueType = "string" }) => {
+ const data = await cfAccountRequest("/workers/observability/telemetry/values", { method: "POST", body: { datasets: [dataset], key, type: valueType, timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) } } });
+ return JSON.stringify(data).slice(0, 8000);
+ },
+ },
+
+ // -- Context7 -----------------------------------------------------------
+ {
+ name: "context7_search_library",
+ description: "Search Context7's index for a library/framework by name to get its library ID.",
+ parameters: { type: "object", properties: { libraryName: { type: "string" }, query: { type: "string" } }, required: ["libraryName", "query"] },
+ execute: async ({ libraryName, query }) => {
+ const data = await context7Request("/libs/search", { libraryName, query });
+ return (data.results || []).map(r => `${r.id} -- ${r.title} (trust ${r.trustScore})`).join("\n") || "No libraries found.";
+ },
+ },
+ {
+ name: "context7_get_library_docs",
+ description: "Fetch version-specific documentation and code examples for a library by its Context7 library ID (from context7_search_library).",
+ parameters: { type: "object", properties: { libraryId: { type: "string" }, query: { type: "string" }, tokens: { type: "number" } }, required: ["libraryId", "query"] },
+ execute: async ({ libraryId, query, tokens }) => {
+ const data = await context7Request("/context", { libraryId, query, tokens });
+ const text = typeof data === "string" ? data : (data.context || data.text || JSON.stringify(data));
+ return text.length > 25000 ? text.slice(0, 25000) + "\n...[truncated]" : text;
+ },
+ },
+
+ // -- Mem0 -----------------------------------------------------------------
+ {
+ name: "mem0_search",
+ description: "Search memories in the Mem0 workspace using hybrid semantic + keyword retrieval.",
+ parameters: { type: "object", properties: { query: { type: "string" }, limit: { type: "number" } }, required: ["query"] },
+ execute: async ({ query, limit = 10 }) => {
+ const data = await mem0Request("/v3/memories/search/", { method: "POST", body: { query, limit } });
+ const results = data.results || data || [];
+ return results.map(m => `[${m.score?.toFixed?.(2) ?? "?"}] ${m.memory || m.content}`).join("\n---\n") || "No memories found.";
+ },
+ },
+ {
+ name: "mem0_list",
+ description: "List recent memories from the Mem0 workspace.",
+ parameters: { type: "object", properties: { page_size: { type: "number" } } },
+ execute: async ({ page_size = 20 }) => {
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { page_size } });
+ const results = data.results || data.memories || data || [];
+ return results.map(m => `${m.id}: ${m.memory || m.content}`).join("\n") || "No memories found.";
+ },
+ },
+ {
+ name: "mem0_get",
+ description: "Get the full content of a specific Mem0 memory by ID.",
+ parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
+ execute: async ({ id }) => {
+ const m = await mem0Request(`/v1/memories/${id}/`);
+ return `${m.memory}\ncreated: ${m.created_at} | updated: ${m.updated_at}\nmetadata: ${JSON.stringify(m.metadata || {})}`;
+ },
+ },
+ {
+ name: "mem0_get_history",
+ description: "Get the version/audit history of a Mem0 memory by ID.",
+ parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
+ execute: async ({ id }) => {
+ const data = await mem0Request(`/v1/memories/${id}/history/`);
+ return (data || []).map(h => `${h.event} @ ${h.timestamp}: ${h.old_memory || ""} -> ${h.new_memory || ""}`).join("\n") || "No history found.";
+ },
+ },
+
+ // -- Notion ---------------------------------------------------------------
+ {
+ name: "notion_get_database",
+ description: "Get a Notion database's schema (title and property definitions) and basic info. Use this before notion_query_database to see what properties are available and their types.",
+ parameters: { type: "object", properties: { database_id: { type: "string" } }, required: ["database_id"] },
+ execute: async ({ database_id }) => {
+ const data = await notionRequest(`/databases/${database_id}`);
+ const title = notionDatabaseTitle(data);
+ const propLines = Object.entries(data.properties || {}).map(([name, def]) => ` ${name}: ${def.type}`);
+ return `# ${title}\nID: ${data.id}\nURL: ${data.url}\nCreated: ${data.created_time?.slice(0, 10)} | Last edited: ${data.last_edited_time?.slice(0, 10)}\n\nProperties:\n${propLines.join("\n") || "(none)"}`;
+ },
+ },
+ {
+ name: "notion_list",
+ description: "List recent pages and/or databases in the Notion workspace, sorted by most recently edited first -- no search query needed. Use this (not notion_search) when the task is 'find the latest X' or 'what's changed recently in Notion' -- notion_search requires a keyword and doesn't guarantee recency ordering.",
+ parameters: { type: "object", properties: {
+ filter_type: { type: "string", description: "Restrict to 'page' or 'database' (optional, default both)" },
+ page_size: { type: "number", description: "Number of results (default 10, max 100)" },
+ } },
+ execute: async ({ filter_type, page_size = 10 }) => {
+ const body = { query: "", sort: { direction: "descending", timestamp: "last_edited_time" }, page_size };
+ if (filter_type) body.filter = { value: filter_type, property: "object" };
+ const data = await notionRequest("/search", { method: "POST", body });
+ if (!data.results?.length) return "No pages or databases found.";
+ return data.results.map(r => {
+ const title = r.object === "page" ? notionPageTitle(r) : (notionRichTextToString(r.title) || "(untitled)");
+ return `[${r.object}] ${title} — id: ${r.id} — last edited ${r.last_edited_time?.slice(0, 16)}`;
+ }).join("\n");
+ },
+ },
+ {
+ name: "notion_get_page_history",
+ description: "Get the changelog/version history entries recorded on a Notion page (read-only; looks for logged changelog blocks, not Notion's native edit history).",
+ parameters: { type: "object", properties: { page_id: { type: "string" } }, required: ["page_id"] },
+ execute: async ({ page_id }) => {
+ const data = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const text = notionBlocksToText(data.results || []) || "(no content)";
+ return text.length > 10000 ? text.slice(0, 10000) + "\n...[truncated]" : text;
+ },
+ },
+];
+
+const FUNCTION_DECLARATIONS = [{
+ functionDeclarations: FUNCTIONS.map(({ name, description, parameters }) => ({ name, description, parameters })),
+}];
+
+// SCOPE NOTE (2026-07-27): this file deliberately has NO web access (no
+// web_fetch, no Google Search grounding) -- that lives entirely in
+// connectors/exa/research_delegate.js, behind the separate delegate_research
+// tool. Keeping the two apart is a security boundary, not just a UX split:
+// this loop reads private GitHub/Notion/Cloudflare/Context7/Mem0 data, and
+// research_delegate.js's Exa call reads untrusted public web content -- a single loop
+// with both would let a malicious page or search result Gemini encounters
+// mid-investigation try to talk the model into leaking whatever it just
+// read from those private systems (e.g. via a crafted outbound fetch to an
+// attacker-controlled URL). Neither loop can do that, because neither ever
+// has both capabilities available at once. Do NOT re-add web_fetch or a
+// google_search tool here -- add web capability to research_delegate.js instead.
+
+const SYSTEM_PREAMBLE =
+ "You are a read-only investigation agent. Use the available functions to gather whatever " +
+ "information you need to answer the task fully, calling as many as necessary across multiple " +
+ "turns. When you have enough information, respond with a final plain-text answer and no further " +
+ "function calls. Be specific and cite what you found (file paths, commit SHAs, log entries, page " +
+ "titles) rather than speculating.\n\n" +
+ "IMPORTANT -- cross-check, don't just aggregate: when the task touches more than one source " +
+ "(e.g. a GitHub PR's status vs. a Notion tracking page, or a repo file vs. what a database row " +
+ "claims), actively look for contradictions between them rather than reporting each source's claim " +
+ "in isolation. A thing that LOOKS current, open, or resolved in one source can be stale or wrong " +
+ "according to another -- if your task plan touches multiple sources for related claims, check them " +
+ "against each other before answering, and call out any discrepancy explicitly (including which " +
+ "source you consider more authoritative and why) rather than picking one silently.\n\n" +
+ "IMPORTANT -- respect scope, don't let same-named symbols bleed across files: when a question is " +
+ "about whether something is used, referenced, or defined WITHIN A SPECIFIC FILE OR SCOPE (e.g. an " +
+ "unused-import lint warning, which is always per-file), only evidence found in THAT exact file or " +
+ "scope counts. A same-named function/variable being called somewhere else in the repo -- even in a " +
+ "file that imports it from the same source module -- does NOT mean it's used in the file the question " +
+ "is actually about; each file's own import/declaration is independent. Before calling a usage claim a " +
+ "'false positive' or asserting something IS used, quote the exact call site (file + line/snippet) " +
+ "inside the specific scope in question. If you can't produce that quote from within the scope asked " +
+ "about, say plainly that no such usage was found there, rather than pointing to usage elsewhere as if " +
+ "it answered the question.\n\n" +
+ "IMPORTANT -- re-scan your OWN retrieved text before writing a verdict word (consistent, fixed, " +
+ "resolved, stale, up-to-date, matches, etc.): a long tool-use run compresses many turns of raw " +
+ "file/page content into one final summary, and that compression step is itself a separate inference " +
+ "that can pattern-match toward a comfortable verdict even when the contradicting text is sitting " +
+ "unused in your own transcript. If your task asks you to check whether something is stale, " +
+ "inconsistent, or still-accurate, before writing the verdict go back through EVERY piece of raw " +
+ "content you fetched (not just the ones that confirm your leaning) and check it against the specific " +
+ "claim in the question -- do not let a majority of confirming sources outvote a single contradicting " +
+ "one you already retrieved. If you find a contradiction this way, quote it and flag it explicitly " +
+ "even if most of what you found points the other way.";
+
+// Runs the investigation loop. Returns { answer, steps, transcript, runId,
+// failed? } where transcript is a human-readable log of each function call
+// made (for the Notion write in tools.js) and steps is how many model turns
+// it took.
+//
+// CHECKPOINTING: after every step that completes its function calls, the
+// NEW turns added this step are appended to Redis under a per-run UUID
+// (see checkpoint.js's fix #5 -- append-delta, not a full-array overwrite;
+// write cost is O(turns added this step), not O(conversation so far).
+// stepsDone/transcript/task and fix #4's repeat-tracking state are small
+// and get rewritten in full each time, which is cheap regardless of run
+// length). If the NEXT geminiChat() call
+// then fails (429/503/network blip -- exactly what killed a run in testing
+// on 2026-07-25), the already-completed steps are not lost: the caller gets
+// them back plus `runId`, and can pass `resume_run_id` on a follow-up call
+// to continue the same conversation from where it left off instead of
+// re-running (and re-paying for) steps 1..N again. Redis is best-effort
+// (see checkpoint.js) -- if it's unavailable, resumption just isn't
+// possible, same as before this existed; a failure still returns whatever
+// transcript was gathered in-memory this call.
+export async function runInvestigation({ task, max_steps = 20, resume_run_id }) {
+ const cappedSteps = Math.min(max_steps, HARD_MAX_STEPS);
+
+ let runId = resume_run_id;
+ let contents;
+ let transcript;
+ let startStep;
+ // The task text actually in effect for this run -- the caller-supplied
+ // one on a fresh run, or the one restored from a resumed checkpoint.
+ // Tracked (and persisted in every checkpoint below) so callers/tools.js
+ // can log/title a resumed run without needing the caller to re-supply
+ // task text the loop itself ignores on resume.
+ let effectiveTask = task;
+ // Stuck-loop detection (fix #4, 2026-07-27): repeatCounts tracks how many
+ // times each exact (function name + JSON-stringified args) signature has
+ // been called THIS RUN, persisted across resumes (see checkpoint.js) so a
+ // resumed run doesn't forget what it already tried. resultCache holds the
+ // actual result text per signature -- deliberately NOT persisted in the
+ // checkpoint (only counts are, to keep checkpoint writes small per fix
+ // #5): on a resume, an exact-repeat call that was cached in a prior
+ // in-memory run simply re-executes once more and gets re-cached, which is
+ // a correctness no-op (same call, same result), not worth the extra
+ // checkpoint weight of persisting every cached result string.
+ // consecutiveAllRepeatSteps counts how many steps IN A ROW consisted
+ // ENTIRELY of repeat calls -- the real stuck-loop signal (a single repeat
+ // mixed with new calls is normal exploration, not a stuck loop).
+ let repeatCounts = new Map();
+ let resultCache = new Map();
+ let consecutiveAllRepeatSteps = 0;
+ // How many entries of `contents` have already been pushed to the Redis
+ // checkpoint list (fix #5) -- saveCheckpoint only ever needs the SLICE
+ // added since the last checkpoint, not the whole array, so this cursor is
+ // what makes that possible without checkpoint.js needing to diff arrays
+ // itself.
+ let contentsCheckpointedUpTo = 0;
+
+ const checkpoint = resume_run_id ? await loadCheckpoint(resume_run_id) : null;
+ if (checkpoint) {
+ contents = checkpoint.contents;
+ transcript = checkpoint.transcript;
+ startStep = checkpoint.stepsDone + 1;
+ // Every entry loadCheckpoint returned in `contents` was already RPUSHed
+ // to Redis in a prior call -- nothing new to push until this run adds
+ // more turns, so the cursor starts at the end of what was loaded.
+ contentsCheckpointedUpTo = contents.length;
+ // Maps aren't JSON-serializable, so saveCheckpoint stores repeatCounts
+ // as a plain object and this reconstructs the Map on load. Checkpoints
+ // saved before fix #4 existed won't have this field -- fall back to an
+ // empty Map rather than erroring, same defensive pattern as
+ // `checkpoint.task || task` below.
+ repeatCounts = new Map(Object.entries(checkpoint.repeatCounts || {}));
+ consecutiveAllRepeatSteps = checkpoint.consecutiveAllRepeatSteps || 0;
+ // Prefer the checkpoint's own record of the original task -- `task` is
+ // genuinely ignored on a live resume (see file header), so this is the
+ // only reliable source once a run is past step 1. Checkpoints saved
+ // before this field existed won't have it; fall back to whatever the
+ // caller passed (may be undefined) rather than erroring.
+ effectiveTask = checkpoint.task || task;
+ } else if (resume_run_id && !task) {
+ // A resume WAS requested but its checkpoint didn't load -- expired past
+ // the 1-hour TTL, Redis unavailable (checkpoint.js is deliberately
+ // fail-open, see its header), or an invalid/typo'd runId -- AND there is
+ // no task to fall back on either. This must NEVER be silently treated as
+ // "no resume was requested" and fall through to a fresh run: that
+ // previously produced a conversation seeded with `Task: undefined` (task
+ // is genuinely ignored on a live resume, so callers legitimately omit
+ // it), and the model burned several steps hunting blind for context
+ // instead of investigating (found via the 2026-07-26 checkpoint-miss
+ // test). Fail loudly and distinctly instead, so the caller can tell
+ // "your resume target is gone" apart from any other failure.
+ //
+ // If a task WAS provided alongside a resume_run_id that fails to load,
+ // this branch is skipped and the fresh-run branch below runs instead --
+ // a legitimate defensive-caller pattern (passing the task as a fallback
+ // even on a resume call), kept intentionally per the fix plan.
+ throw new Error(
+ isRedisConfigured()
+ ? `resume_run_id "${resume_run_id}" has no live checkpoint -- it may have expired (1 hour TTL) or the id may be wrong. ` +
+ `There is no saved task to resume from. Start a new investigation by calling again with a task and no resume_run_id.`
+ : `resume_run_id "${resume_run_id}" has no live checkpoint -- and Redis is NOT configured in this environment ` +
+ `(UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN unset or unreachable), so no checkpoint could ever have been saved to resume from, ` +
+ `regardless of the runId or how recently the original call failed. Retrying resume_run_id again will not help -- ` +
+ `start a new investigation with a task instead, and expect that a future transient failure won't be resumable either until Redis is configured.`
+ );
+ } else {
+ // Either no resume_run_id was given, or one was given with its checkpoint
+ // missing but a `task` supplied as a fallback (see branch above) --
+ // start a fresh run either way. Requires a real `task` (the caller-facing
+ // tool in tools.js already guards against a missing task on a
+ // non-resumable call, so `task` is trustworthy here).
+ runId = randomUUID();
+ contents = [{ role: "user", parts: [{ text: `${SYSTEM_PREAMBLE}\n\nTask: ${task}` }] }];
+ transcript = [];
+ startStep = 1;
+ }
+
+ // Resuming with a max_steps ceiling that's already been met or exceeded
+ // by the checkpoint's own stepsDone (e.g. a checkpoint has 5 completed
+ // steps and the caller resumes with max_steps: 2) -- there's no budget
+ // left to take even one more step. Don't fall into the loop-and-fall-
+ // through path below: that unconditionally deletes the checkpoint via
+ // deleteCheckpoint(runId) once the loop exits, which would throw away a
+ // still-good, still-resumable checkpoint for no reason (the loop body
+ // simply never executes when startStep > cappedSteps), and the generic
+ // step-cap message doesn't explain that anything was actually completed.
+ // Leave the checkpoint alone -- it's still resumable with a higher
+ // max_steps -- and say so explicitly instead.
+ if (checkpoint && startStep > cappedSteps) {
+ return {
+ answer: `(This run already completed ${startStep - 1} step(s), which meets or exceeds the requested max_steps of ${cappedSteps} -- no new steps were taken this call. The checkpoint has NOT been discarded. Call delegate_agent again with resume_run_id: "${runId}" and a higher max_steps to continue, or treat the ${transcript.length} tool call(s) below as the result so far.)`,
+ steps: startStep - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ failed: true,
+ };
+ }
+
+ for (let step = startStep; step <= cappedSteps; step++) {
+ // On the final allowed step, withhold the function-calling tools
+ // entirely instead of just reminding the model to wrap up: a text-only
+ // reminder wasn't reliable enough on its own (found via the 2026-07-26
+ // test -- the model spent its very last step on another tool call
+ // anyway, and the run hit the cap with zero synthesized answer, not
+ // even an incomplete one). Without `tools` in the request body, Gemini
+ // structurally cannot return a functionCall part here, so this step is
+ // guaranteed to be a real text-answer attempt rather than another read.
+ const isFinalStep = step === cappedSteps;
+ // Stuck-loop forced-answer (fix #4): once 3 consecutive steps have
+ // consisted ENTIRELY of repeat calls (consecutiveAllRepeatSteps, updated
+ // at the end of each step below), withhold tools the same way the final
+ // step already does -- a text-only SYSTEM NOTE alone wasn't trusted to
+ // reliably stop a model that keeps re-issuing the same call (same
+ // lesson as isFinalStep's own history, see its comment above), so this
+ // reuses that structural fix instead of a new mechanism.
+ const stuckLoopForce = consecutiveAllRepeatSteps >= 3;
+ const withholdTools = isFinalStep || stuckLoopForce;
+ let candidate;
+ try {
+ candidate = await geminiChat(contents, { tools: withholdTools ? undefined : FUNCTION_DECLARATIONS });
+ } catch (err) {
+ // The step-1..N-1 work already happened and is real -- don't throw it
+ // away. Persist it (redundant with the save at the end of the prior
+ // iteration, but cheap and safe) and hand the caller everything they
+ // need to resume instead of restarting. newContents is usually empty
+ // here (this failure happens before this step's model turn is ever
+ // pushed to `contents`) -- saveCheckpoint just re-writes the small
+ // meta blob in that case, which is exactly the O(delta) behavior fix
+ // #5 is for.
+ await saveCheckpoint(runId, {
+ newContents: contents.slice(contentsCheckpointedUpTo),
+ transcript,
+ stepsDone: step - 1,
+ task: effectiveTask,
+ repeatCounts: Object.fromEntries(repeatCounts),
+ consecutiveAllRepeatSteps,
+ });
+ const errMessage = err?.message ?? String(err);
+ const redisOk = isRedisConfigured();
+ const resumeHint = isTransientGeminiError(err)
+ ? (redisOk
+ ? ` ${transcript.length} tool call(s) already completed this run are saved. Call delegate_agent again with resume_run_id: "${runId}" to continue from here instead of starting over. Checkpoint expires in 1 hour.`
+ : ` ${transcript.length} tool call(s) were completed this run, but Redis is NOT configured in this environment, so nothing was actually saved -- resume_run_id: "${runId}" will NOT work no matter how soon you retry. ` +
+ `The completed tool calls are listed in this run's transcript/Notion log (if log_to_notion was set) for manual reference, but the only way to continue is a fresh call with the full task text.`)
+ : ` This does not look like a transient error (not a 429/503) -- resuming with resume_run_id: "${runId}" will likely reproduce the same failure, so check the underlying cause (e.g. GEMINI_API_KEY, request format, safety/recitation block) before retrying. The ${transcript.length} tool call(s) already completed are still saved if you want to resume anyway${redisOk ? "" : " (though note: Redis is NOT configured in this environment, so nothing was actually saved regardless)"}.`;
+ return {
+ answer: `(Gemini call failed on step ${step}: ${errMessage} --${resumeHint})`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ failed: true,
+ };
+ }
+
+ const parts = candidate.content?.parts || [];
+ const functionCalls = parts.filter((p) => p.functionCall);
+
+ if (!functionCalls.length) {
+ const answer = parts.map((p) => p.text || "").join("").trim();
+ await deleteCheckpoint(runId);
+ if (!answer) {
+ // MALFORMED_FUNCTION_CALL on the final step specifically means: this
+ // step had NO tools in the request (isFinalStep withholds them
+ // entirely, see above), but the model tried to make a function call
+ // anyway -- Gemini rejects that as malformed rather than falling
+ // back to text. Observed concretely with max_steps: 1 on a task that
+ // genuinely needed a file read: the model had no way to answer
+ // without a tool, no tools were offered, and the result was this
+ // opaque finishReason with zero explanation of why (2026-07-26
+ // stress test). Surface the actual cause instead of just the raw
+ // enum value, since "try a higher max_steps" is the fix and the
+ // caller has no way to infer that from "MALFORMED_FUNCTION_CALL"
+ // alone.
+ const starvationNote = withholdTools && candidate.finishReason === "MALFORMED_FUNCTION_CALL"
+ ? (isFinalStep
+ ? ` This was the final allowed step, which never includes tools (so the model can only answer in plain text here) -- but the model attempted a function call anyway, which Gemini rejects as malformed when no tools are available. This almost always means the task genuinely requires at least one tool call and max_steps (${cappedSteps}) left no tool-enabled steps to make it in. Retry with a higher max_steps (at least 2, ideally the default of 6 for anything non-trivial).`
+ : ` This step had no tools available because ${consecutiveAllRepeatSteps} consecutive steps consisted entirely of repeat calls (same function + arguments already tried this run) -- fix #4's stuck-loop guard forces a text-only answer the same way the final step does, but the model attempted a function call anyway, which Gemini rejects as malformed when no tools are available. The task likely needs to be narrowed or rephrased so it doesn't require repeating the same information-gathering calls.`)
+ : "";
+ return { answer: `(Gemini stopped without a final answer -- finishReason: ${candidate.finishReason || "unknown"})${starvationNote}`, steps: step, transcript, runId, task: effectiveTask };
+ }
+ return { answer, steps: step, transcript, runId, task: effectiveTask };
+ }
+
+ // Record the model's turn (including its functionCall parts) before
+ // executing anything, so the conversation history stays accurate even
+ // if a function call below throws.
+ contents.push({ role: "model", parts });
+
+ const responseParts = [];
+ try {
+ // PARALLELIZED (2026-07-26, confirmed via live show_transcript testing
+ // that Gemini routinely batches several independent calls into one
+ // turn -- e.g. file tree + commit list + issue list all landing in the
+ // same step). These calls were previously await'd one at a time in a
+ // for-loop for no real reason: within a single turn, Gemini already
+ // committed to every one of these calls before seeing ANY of their
+ // results, so none of them can depend on another's output -- executing
+ // them concurrently changes wall-clock time only, not what information
+ // is available to what call. Cross-step sequencing (the real
+ // plan->act->observe->re-plan loop) is untouched: that dependency
+ // chain lives between steps, not within one.
+ //
+ // Results are collected here and then pushed to transcript/
+ // responseParts below in ORIGINAL (input) order, not completion order --
+ // so the transcript and the conversation history sent back to Gemini
+ // are byte-for-byte the same shape they'd be under sequential
+ // execution, just produced faster. functionResponse.id (not array
+ // position) is what actually threads each result back to its call on
+ // Gemini's side, so reordering here would be safe even without this,
+ // but keeping input order makes the transcript's own readability not
+ // regress either.
+ //
+ // NOTE ON "BLIND" BATCHING: calls sharing a step number are, by
+ // definition, decided without seeing each other's results -- that was
+ // true before this change too (sequential execution didn't feed call
+ // N's result to call N+1's args; Gemini had already written both calls
+ // in the same turn). This just makes that pre-existing fact match the
+ // wall-clock reality instead of an execution order that only
+ // coincidentally looked sequential.
+ //
+ // RATE-LIMIT NOTE: connectors/github/client.js has its own burst-safe
+ // throttle queue (scheduleThrottled) specifically built to absorb
+ // concurrent GitHub calls, so parallelizing those is fully safe.
+ // Notion (connectors/notion/client.js) and Mem0 (connectors/mem/
+ // client.js) have no equivalent throttle/retry/backoff -- a step that
+ // batches several Notion or Mem0 calls together is now more likely to
+ // trip those APIs' own rate limits than under sequential execution.
+ // Not a correctness risk (every call below is already individually
+ // try/caught into an error string, same as before), just a new-ish
+ // source of noisier per-call failures under heavier batching that's
+ // worth watching for in practice rather than something this change
+ // guards against.
+ const results = await Promise.all(functionCalls.map(async (part) => {
+ const { name, args, id } = part.functionCall;
+ // Stuck-loop detection (fix #4): a signature identifies an exact
+ // repeat of a call already made this run. `isRepeat` reflects
+ // whether this signature has been SEEN before (checked before the
+ // increment below); repeatCounts itself is incremented regardless
+ // of whether it's a repeat, purely for observability/debugging --
+ // only the boolean matters to the stuck-loop logic further down.
+ const signature = `${name}:${JSON.stringify(args || {})}`;
+ const isRepeat = repeatCounts.has(signature);
+ repeatCounts.set(signature, (repeatCounts.get(signature) || 0) + 1);
+
+ let resultText;
+ let servedFromCache = false;
+ if (isRepeat && resultCache.has(signature)) {
+ // Exact repeat -- don't re-execute at all, just return what this
+ // same call returned last time. This is the free win: no network
+ // call, no wasted budget, regardless of whether the run as a
+ // whole turns out to be stuck (see allRepeatsThisStep below).
+ resultText = resultCache.get(signature);
+ servedFromCache = true;
+ } else {
+ const fn = FUNCTIONS.find((f) => f.name === name);
+ if (!fn) {
+ resultText = `Error: unknown function "${name}".`;
+ } else {
+ try {
+ resultText = await fn.execute(args || {});
+ } catch (err) {
+ resultText = `Error: ${err?.message ?? String(err)}`;
+ }
+ }
+ // Defensive: every FUNCTIONS[].execute() is expected to return a
+ // string. Guard against a future one accidentally returning
+ // something else (object, undefined, etc.) so this can't throw
+ // mid-transcript and take down the whole step -- see the outer
+ // catch below for why that matters.
+ if (typeof resultText !== "string") {
+ resultText = `Error: ${name} returned a non-string result (${typeof resultText}); this is a bug in the function's execute().`;
+ }
+ resultCache.set(signature, resultText);
+ }
+ return { name, args, id, resultText, isRepeat, servedFromCache };
+ }));
+
+ for (const r of results) {
+ const cacheNote = r.servedFromCache ? " [CACHED -- identical call already made this run, not re-executed]" : "";
+ transcript.push(`[step ${step}] ${r.name}(${JSON.stringify(r.args || {})})${cacheNote} -> ${r.resultText.length > 300 ? r.resultText.slice(0, 300) + "…" : r.resultText}`);
+ // Gemini 3 (current generateContent contract, verified 2026-07-25): function-result
+ // turns go back with role "user" (NOT "function" -- that was the older doc convention
+ // and is rejected by Gemini 3 models), and functionResponse.id echoes the model's
+ // original functionCall.id so the API can thread multi-call turns correctly.
+ responseParts.push({ functionResponse: { name: r.name, id: r.id, response: { result: r.resultText } } });
+ }
+
+ // Stuck-loop bookkeeping (fix #4): only counts as a stuck step if
+ // EVERY call this step was an exact repeat -- see isRepeat's comment
+ // above for why a partial repeat doesn't count.
+ const allRepeatsThisStep = results.length > 0 && results.every((r) => r.isRepeat);
+ consecutiveAllRepeatSteps = allRepeatsThisStep ? consecutiveAllRepeatSteps + 1 : 0;
+ if (consecutiveAllRepeatSteps === 2) {
+ // Earlier, softer nudge -- same two-steps-ahead pattern as the
+ // step-budget reminder below, giving the model a chance to steer
+ // away before the hard stop one step down.
+ responseParts.push({
+ text: `[SYSTEM NOTE: you're re-requesting information you already have -- the last 2 steps consisted entirely of repeat calls (same function + arguments as something already tried this run). Either try a different angle (a different file, query, or function) or answer now with what you've got.]`,
+ });
+ } else if (consecutiveAllRepeatSteps >= 3) {
+ // Matches reality: withholdTools (computed at the top of the loop)
+ // will be true next iteration because consecutiveAllRepeatSteps >= 3
+ // here, so the next turn genuinely won't have tools available.
+ responseParts.push({
+ text: `[SYSTEM NOTE: 3 consecutive steps have consisted entirely of repeat calls. The next turn will NOT include any tools -- you must answer now in plain text with whatever you've already found, since repeating the same calls further will not surface new information.]`,
+ });
+ }
+ } catch (err) {
+ // Belt-and-suspenders: nothing inside the loop above should throw past
+ // its own per-call try/catch or the typeof guard anymore, but if
+ // something still does (a bug in a future function, an unexpected
+ // JSON.stringify(args) failure on a circular/exotic args shape, etc.),
+ // don't let it escape runInvestigation and land in tools.js's generic
+ // catch, which has no runId to offer -- that would silently lose this
+ // step's (and any prior steps') completed work. Checkpoint what's
+ // already done (this step's model turn was already pushed to
+ // `contents` above) and return the same resumable-failure shape as a
+ // geminiChat failure.
+ await saveCheckpoint(runId, {
+ newContents: contents.slice(contentsCheckpointedUpTo),
+ transcript,
+ stepsDone: step - 1,
+ task: effectiveTask,
+ repeatCounts: Object.fromEntries(repeatCounts),
+ consecutiveAllRepeatSteps,
+ });
+ const errMessage = err?.message ?? String(err);
+ return {
+ answer: `(Unexpected error while processing step ${step}'s function calls: ${errMessage} -- ${transcript.length} tool call(s) already completed this run are saved. Call delegate_agent again with resume_run_id: "${runId}" to continue from here instead of starting over. Checkpoint expires in 1 hour.)`,
+ steps: step - 1,
+ transcript,
+ runId,
+ task: effectiveTask,
+ failed: true,
+ };
+ }
+ // Step-budget reminder (added after the 2026-07-26 resume-truncation
+ // bug): SYSTEM_PREAMBLE and the task's own formatting instructions only
+ // ever appear once, in turn 1 -- by the last couple of steps before
+ // cappedSteps, those instructions are many turns back in a long tool-use
+ // history, and a model under a tight remaining budget has an incentive
+ // to produce SOME answer rather than none, which can mean quietly
+ // dropping the originally requested format/exhaustiveness. Surfacing the
+ // remaining-step count explicitly turns a silent quality regression into
+ // an honest one: the model is told to say it couldn't finish, rather
+ // than presenting a rushed, incomplete answer as if it were complete.
+ const remainingAfterThisStep = cappedSteps - step;
+ if (remainingAfterThisStep === 2) {
+ // Earlier, softer nudge -- gives the model a chance to steer toward
+ // synthesis before the hard cutoff two notes down, instead of only
+ // finding out at the last possible moment.
+ responseParts.push({
+ text: `[SYSTEM NOTE: only 2 step(s) remain after this one. Start wrapping up -- prioritize synthesizing what you've already found over opening new lines of investigation.]`,
+ });
+ } else if (remainingAfterThisStep <= 1) {
+ // When remainingAfterThisStep is 0, the NEXT turn is the final step,
+ // which is called with no tools at all (see isFinalStep above) -- so
+ // this note can say so as a fact, not just a suggestion to wrap up.
+ const noToolsNote = remainingAfterThisStep === 0
+ ? " The next turn will NOT include any tools -- a function call is not possible; you must answer in plain text now."
+ : "";
+ responseParts.push({
+ text: `[SYSTEM NOTE: only ${remainingAfterThisStep} step(s) remain before this investigation is forced to stop.${noToolsNote} If you cannot fully complete the task -- including any specific format requested (e.g. an exhaustive table, per-item breakdown) -- in the remaining budget, say so explicitly and describe what's missing, rather than presenting a partial or reformatted-for-brevity answer as if it were complete. Before you write your verdict, scroll back through the raw content you already fetched this run (not just your impression of it) and confirm nothing you retrieved contradicts what you're about to claim -- a contradiction sitting unused in your own transcript is a miss, not a non-finding.]`,
+ });
+ }
+
+ contents.push({ role: "user", parts: responseParts });
+
+ // Checkpoint after every fully-completed step, so a failure on the NEXT
+ // Gemini call (or a hosting-platform timeout) doesn't lose this one.
+ // newContents/contentsCheckpointedUpTo implement fix #5 (append-delta
+ // instead of overwrite-whole-blob): only the turns added THIS step (the
+ // model's turn + the function-response turn, normally 2 entries) are
+ // pushed, not the whole conversation so far -- write cost is O(delta),
+ // not O(total run length).
+ await saveCheckpoint(runId, {
+ newContents: contents.slice(contentsCheckpointedUpTo),
+ transcript,
+ stepsDone: step,
+ task: effectiveTask,
+ repeatCounts: Object.fromEntries(repeatCounts),
+ consecutiveAllRepeatSteps,
+ });
+ contentsCheckpointedUpTo = contents.length;
+ }
+
+ await deleteCheckpoint(runId);
+ return { answer: `(Investigation stopped after reaching the step cap of ${cappedSteps} without a final answer -- the task may need to be narrowed, or more steps requested up to the hard cap of ${HARD_MAX_STEPS}.)`, steps: cappedSteps, transcript, runId, task: effectiveTask };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 | + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/gemini/agent_tools.js
+//
+// Registers delegate_agent: open-ended, multi-step READ-ONLY investigation
+// across GitHub/Notion/Cloudflare/Context7/Mem0, backed by agent_delegate.js's
+// server-side Gemini function-calling loop.
+//
+// delegate_research (web research, Exa-backed) used to be co-located in
+// this file alongside delegate_agent -- both were "the Gemini connector's
+// tools" at the time. As of the exa/gemini naming pass (2026-08-01),
+// delegate_research now has its own file, connectors/exa/research_tools.js,
+// so each MCP tool's registration lives next to the connector that actually
+// backs it (agent_tools.js here for Gemini/delegate_agent, research_tools.js
+// for Exa/delegate_research). See the delegation-naming-convention Notion
+// plan for the full history of this split.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { runInvestigation } from "./agent_delegate.js";
+import { doCreatePage } from "../notion/tools.js";
+import { GEMINI_NOTION_ROOT_PAGE_ID } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "delegate_agent",
+ "DOES: Open-ended, multi-step READ-ONLY investigation across GitHub/Notion/Cloudflare -- Gemini runs its own server-side loop (bounded by max_steps) reading files/trees/commits/logs/pages across as many turns as needed, cross-checks claims BETWEEN sources, flags discrepancies, returns one synthesized answer.\n" +
+ "RULE: default choice for multi-file or open-ended investigation -- prefer over manual read_file/get_file_tree/list_directory loops UNLESS you need exactly one named file.\n" +
+ "NOT: web access -> use delegate_research (task param, wide mode) instead. NOT: any write -> read-only by design.\n" +
+ "USE FOR: e.g. 'why is CI failing on PR #42', 'summarize what changed in this repo over the last week' -- cases needing 5-10+ manual cross-system calls otherwise.\n" +
+ "RESUME: failed/partial run -> response includes resume_run_id -> pass back to continue from last completed step instead of restarting.",
+ {
+ task: z.string().optional().describe("The investigation task/question, described with enough context (repo names, time ranges, etc.) for Gemini to act without needing to ask you anything back -- it can't. Ignored when resume_run_id resolves to a live checkpoint (the original task from that run is reused). Optional ONLY when resume_run_id is given and its checkpoint is still live; required otherwise -- omitting it on a fresh run (no resume_run_id, or an expired one) returns an error rather than silently proceeding with no task."),
+ max_steps: z.number().optional().describe("Max tool-use turns Gemini gets before being forced to answer (default 20, hard cap 30 regardless of this value). On a resumed run this is the new ceiling, not additional steps on top of what's already done."),
+ log_to_notion: z.boolean().optional().describe("Whether to log the task, step-by-step tool calls, and final answer as a page under the Gemini section of Notion (default: false). Write always targets the fixed Gemini root page."),
+ resume_run_id: z.string().optional().describe("A runId returned from a previous failed/partial delegate_agent call. If its checkpoint is still live (1 hour TTL), continues that run's conversation instead of starting fresh."),
+ show_transcript: z.boolean().optional().describe("Include the full step-by-step tool-call transcript in the response, even on a successful run (default: false). Useful for debugging what Gemini actually called and in what order/grouping -- e.g. checking whether independent calls were batched into the same step. On a failed/partial run the transcript is always shown regardless of this flag."),
+ },
+ async ({ task, max_steps = 20, log_to_notion = false, resume_run_id, show_transcript = false }) => {
+ // task is only genuinely optional when resuming a live checkpoint --
+ // runInvestigation ignores task entirely in that branch (it rebuilds
+ // `contents` straight from the saved checkpoint). On a fresh run (no
+ // resume_run_id, or one whose checkpoint already expired), there is no
+ // saved task to fall back on, so fail loudly here rather than letting
+ // runInvestigation start a conversation with an undefined task.
+ if (!task && !resume_run_id) {
+ return {
+ content: [{ type: "text", text: "Missing required argument: task must be provided unless resuming a live checkpoint via resume_run_id." }],
+ isError: true,
+ };
+ }
+
+ // max_steps has no floor in its Zod type (z.number().optional() accepts
+ // 0, negatives, and non-integers), but runInvestigation's loop is a
+ // `for (step = startStep; step <= cappedSteps; ...)` that simply never
+ // executes when cappedSteps < startStep -- silently "succeeding" with
+ // zero Gemini calls made and a confusing "reached the step cap of 0"
+ // answer instead of surfacing that the input itself was invalid.
+ if (max_steps !== undefined && (!Number.isInteger(max_steps) || max_steps < 1)) {
+ return {
+ content: [{ type: "text", text: `Invalid max_steps: ${max_steps}. Must be a positive integer (at least 1); the hard cap is 30 regardless of a larger value.` }],
+ isError: true,
+ };
+ }
+
+ let result;
+ try {
+ result = await runInvestigation({ task, max_steps, resume_run_id });
+ } catch (err) {
+ return { content: [{ type: "text", text: `Investigation failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+
+ // On a resumed run, `task` may be undefined here (a fresh run always has
+ // it, per the guard above) -- runInvestigation returns the effective
+ // task text it actually used (the caller-supplied one, or the one
+ // restored from the checkpoint) so logging/titling never has to guess.
+ const effectiveTask = task || result.task || "(resumed run)";
+
+ let notionNote = "";
+ if (log_to_notion) {
+ try {
+ const logged = await doCreatePage({
+ parent_id: GEMINI_NOTION_ROOT_PAGE_ID,
+ parent_type: "page",
+ title: `${result.failed ? "investigate (partial): " : "investigate: "}${effectiveTask.slice(0, 80)}`,
+ content: `Task: ${effectiveTask}\n\nrunId: ${result.runId}${result.failed ? " (resumable)" : ""}\n\nSteps taken: ${result.steps}\n\nTool calls:\n${result.transcript.join("\n") || "(none)"}\n\nAnswer:\n${result.answer}`,
+ one_off: true,
+ });
+ notionNote = `\n\n(Logged to Notion: ${logged.url})`;
+ } catch (err) {
+ notionNote = `\n\n(\u26a0\ufe0f Notion logging failed: ${err.message})`;
+ }
+ }
+
+ // On a failed/partial run, the tool calls already completed are real
+ // work (and already checkpointed to Redis -- see runInvestigation's
+ // comment) that shouldn't be thrown away. Print them here instead of
+ // just a step count, so the caller can see what was actually found
+ // before the failure without needing a resume_run_id round-trip or
+ // log_to_notion just to inspect them.
+ const transcriptBlock = result.transcript?.length && (result.failed || show_transcript)
+ ? `\n\n${result.failed ? "Tool calls completed before the failure" : "Tool call transcript"}:\n${result.transcript.join("\n")}`
+ : "";
+
+ return { content: [{ type: "text", text: `${result.answer}${transcriptBlock}\n\n(${result.steps} step(s) taken)${notionNote}` }], isError: !!result.failed };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 | + + + + + + + + + +14x + +13x +13x + + +13x +13x + + + + + + + + + + + + + + + + +3x +3x +3x +3x + +13x + + +10x + +10x + +10x +4x +4x +4x +4x + +6x + + + + + + + + + + + + + + + + + +10x + +18x + + +10x +16x + + + + +16x +2x +2x + +14x +14x +6x +6x + +8x +8x +8x +8x +8x +8x +6x + + + + + + + + + + + +2x + +6x + + + + + + + + + + + +8x + + +8x + +8x +4x +8x +8x +8x + +8x + + +1x + +3x + + + + + + + + + + + + + + + + + + + + +2x +2x + + + + + + + + +2x +2x + +2x +2x +2x +1x + +1x + + | // ---------------------------------------------------------------------------
+// connectors/gemini/client.js — Gemini API (generativelanguage.googleapis.com)
+// Docs: https://ai.google.dev/gemini-api/docs
+// Auth header: "x-goog-api-key: <api_key>"
+// ---------------------------------------------------------------------------
+
+import { GEMINI_API_KEY, GEMINI_API, GEMINI_MODEL, GEMINI_FALLBACK_MODELS, GEMINI_REQUEST_TIMEOUT_MS } from "../../config.js";
+import { isModelCoolingDown, setModelCooldown, parseRetryDelaySeconds } from "./cooldown.js";
+
+async function callGenerateContentOnce(body, model) {
+ if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set. Add it as an environment variable on the madmcp server.");
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), GEMINI_REQUEST_TIMEOUT_MS);
+
+ let res;
+ try {
+ res = await fetch(`${GEMINI_API}/models/${model}:generateContent`, {
+ method: "POST",
+ headers: {
+ "x-goog-api-key": GEMINI_API_KEY,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ // Network-level failure -- connection dropped, DNS/TLS error, or our own
+ // abort firing. None of these carry an HTTP status (err.status is
+ // undefined), so without this they'd fall through callGenerateContent's
+ // 429/503-only retry check as a hard, non-cascading failure even though
+ // they're exactly as transient as a 503 in practice. `transient: true`
+ // lets the cascade (and agent_delegate.js's isTransientGeminiError) treat them
+ // the same way, without pretending they're a real HTTP status code.
+ const isAbort = err.name === "AbortError";
+ const wrapped = new Error(isAbort ? `Gemini request timed out after ${GEMINI_REQUEST_TIMEOUT_MS}ms (model: ${model})` : `Gemini request failed (network error, model: ${model}): ${err.message}`);
+ wrapped.transient = true;
+ throw wrapped;
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+
+ if (!res.ok) {
+ const message = (data && (data.error?.message || JSON.stringify(data))) || res.statusText;
+ const err = new Error(`Gemini API error (${res.status}): ${message}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data;
+}
+
+// Cascades through GEMINI_MODEL + GEMINI_FALLBACK_MODELS on a 429 (rate
+// limit exceeded) OR a 503 (overloaded/high demand). Free-tier Gemini
+// quotas are tracked per model, so a fresh model has its own separate RPM
+// bucket on a 429, making cascade a legitimate way to keep going rather
+// than a blind retry. A 503 isn't a quota signal, but each model is still a
+// separate backend deployment, so one being overloaded doesn't mean the
+// next is -- worth trying before failing the whole call. Any other status
+// (400, 500, etc.) is a real failure and surfaces immediately without
+// trying other models, since those aren't problems a different model would fix.
+//
+// If the caller passed an explicit `model` that differs from the configured
+// default (GEMINI_MODEL), that choice is honored exactly with no cascade --
+// they asked for that specific model, so silently substituting another one
+// on a 429 would violate that request.
+async function callGenerateContent(body, requestedModel) {
+ const models = requestedModel && requestedModel !== GEMINI_MODEL
+ ? [requestedModel]
+ : [GEMINI_MODEL, ...GEMINI_FALLBACK_MODELS.filter((m) => m !== GEMINI_MODEL)];
+
+ let lastErr;
+ for (let i = 0; i < models.length; i++) {
+ const model = models[i];
+ // Best-effort cross-call memory (see cooldown.js): if this model was 429'd
+ // recently -- possibly in a prior invocation, since Vercel doesn't
+ // guarantee a warm/reused instance between calls -- skip it without
+ // spending a request, same as if it had just failed with a fresh 429.
+ if (await isModelCoolingDown(model)) {
+ lastErr = lastErr || new Error(`Gemini API error (429): model "${model}" is in a recorded cooldown from a recent rate limit.`);
+ continue;
+ }
+ try {
+ const data = await callGenerateContentOnce(body, model);
+ if (i > 0) data._fallbackModelUsed = model; // surfaced for logging/debugging, not required by callers
+ return data;
+ } catch (err) {
+ lastErr = err;
+ const isLast = i === models.length - 1;
+ const isRateLimited = err.status === 429;
+ const isOverloaded = err.status === 503;
+ const isNetworkTransient = err.transient === true; // timeout/dropped connection, see callGenerateContentOnce
+ if (!isRateLimited && !isOverloaded && !isNetworkTransient) throw err;
+ if (isRateLimited) {
+ // Rate-limited on this model -- record a cooldown (best-effort; never
+ // blocks or throws on its own) so future calls -- including a
+ // resumed/retried one -- can skip straight past it. Recorded even
+ // when this is the LAST model in the chain (isLast below): a 429 on
+ // the last model still means it's exhausted for the window, and
+ // skipping the setModelCooldown call in that case (as this used to)
+ // meant a resume would skip the earlier cooling-down models but walk
+ // straight back into this same exhausted one and fail identically.
+ // No equivalent recording for 503: there's no per-model quota hint to
+ // parse, and an overload isn't reliably tied to this model
+ // specifically the way a 429 is.
+ await setModelCooldown(model, parseRetryDelaySeconds(err.message));
+ }
+ if (isLast) throw err;
+ // Fall through to try the next model either way.
+ }
+ }
+ throw lastErr;
+}
+
+// Single-turn text generation. Takes a plain prompt string (build any
+// system/user framing into it before calling) and returns the model's text
+// output. Used by delegate_research's precision mode (url + question) --
+// a genuine one-shot "here's context, answer this" call with no tool use.
+export async function geminiGenerate(prompt, { model = GEMINI_MODEL, maxOutputTokens } = {}) {
+ const body = {
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
+ };
+ Iif (maxOutputTokens) body.generationConfig = { maxOutputTokens };
+
+ const data = await callGenerateContent(body, model);
+ const candidate = data?.candidates?.[0];
+ const finishReason = candidate?.finishReason;
+ const parts = candidate?.content?.parts || [];
+ const output = parts.map((p) => p.text || "").join("");
+
+ if (!output) {
+ // e.g. finishReason "SAFETY" or "RECITATION" with no text part -- surface
+ // the reason rather than silently returning an empty string.
+ throw new Error(`Gemini returned no text output (finishReason: ${finishReason || "unknown"}).`);
+ }
+ return output;
+}
+
+// Multi-turn call WITH function-calling support -- used by
+// connectors/gemini/agent_delegate.js's GitHub/Notion/Cloudflare investigation loop.
+// Unlike geminiGenerate,
+// this takes/returns the raw `contents` conversation array and the raw
+// candidate, since the caller (agent_delegate.js) needs to inspect whether the
+// response is a functionCall (keep looping) or plain text (done), which a
+// single flattened string can't represent.
+//
+// `contents` follows Gemini's REST shape: an array of
+// { role: "user"|"model", parts: [...] } turns. CORRECTED 2026-07-25: an
+// earlier version of this comment said function-call results go back as a
+// distinct "function" role -- that was true of an older multi-turn doc
+// example, but current Gemini 3 models (see the generateContent docs) expect
+// function results back as role: "user" wrapping a functionResponse part,
+// with functionResponse.id echoing the originating functionCall.id. See
+// agent_delegate.js for how a turn is actually built -- don't "fix" it back to
+// role: "function" without re-checking current docs against the model in use.
+export async function geminiChat(contents, { model = GEMINI_MODEL, tools, toolConfig, maxOutputTokens } = {}) {
+ const body = { contents };
+ if (tools) body.tools = tools;
+ // toolConfig is a historical param from when research_delegate.js (then
+ // still under connectors/gemini/) ran a multi-step Gemini loop passing
+ // { includeServerSideToolInvocations: true } to combine the native
+ // googleSearch tool with a custom function declaration in the same call.
+ // That loop was retired 2026-07-27 in favor of a direct Exa /answer call
+ // (see research_delegate.js's header) -- nothing in this codebase passes
+ // toolConfig anymore, but the param is left in place in case a future
+ // caller needs it. agent_delegate.js never passes this: it has no built-in tools.
+ Iif (toolConfig) body.toolConfig = toolConfig;
+ Iif (maxOutputTokens) body.generationConfig = { maxOutputTokens };
+
+ const data = await callGenerateContent(body, model);
+ const candidate = data?.candidates?.[0];
+ if (!candidate) {
+ throw new Error("Gemini returned no candidates.");
+ }
+ return candidate; // { content: { role, parts }, finishReason, ... }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +25x + + + +25x + +25x +25x + + + + + + + + + + + + + + +30x +19x +19x +30x +30x +10x + +9x +9x + + + + + + + + + + + + +9x + + + + + + + + + + + + +3x + + + + + + +8x +8x + + + + + +20x +20x +9x +9x +8x + +1x + + + + + + + +5x +5x +4x +5x +5x + + + + + | // ---------------------------------------------------------------------------
+// connectors/gemini/cooldown.js — per-model rate-limit cooldown, backed by
+// Upstash Redis (Vercel Marketplace integration).
+//
+// WHY REDIS, NOT IN-MEMORY: Vercel serverless functions don't guarantee a
+// warm/reused instance between invocations (especially on the Hobby plan,
+// which lacks Pro's "cold start prevention" / reserved concurrency), so an
+// in-process Map would only help within a single delegate_agent call's
+// own multi-step loop -- it can't prevent separate tool calls from re-hitting
+// a model that was already rate-limited seconds ago in a prior invocation.
+// Redis's native TTL gives "key expires itself" for free, matching the data
+// shape exactly (model -> cooldown-until), which is why this isn't Postgres:
+// no schema, no manual expiry bookkeeping, one round-trip per check.
+//
+// WHY THIS NEVER SLEEPS: Hobby-plan function duration is capped (60s without
+// Fluid Compute; still bounded with it), and HARD_MAX_STEPS's investigation
+// loop can run many turns in one invocation -- blocking on Google's own
+// "retry in Ns" hint would eat directly into that budget. So this only ever
+// SKIPS a model known to be cooling down (saving a wasted quota-consuming
+// request) and lets the existing cascade in client.js fall through to the
+// next model immediately, exactly as it does today for a live 429.
+//
+// BEST-EFFORT BY DESIGN: if UPSTASH_REDIS_REST_URL/TOKEN aren't set (e.g.
+// before the Marketplace integration is activated in the Vercel dashboard)
+// or a Redis call fails for any reason, every function here fails open --
+// checks return "not cooling down" and writes silently no-op. A missing or
+// down Redis must never be the reason a real Gemini call fails; it only
+// means cross-call memory is temporarily unavailable, same as before this
+// file existed.
+// ---------------------------------------------------------------------------
+
+import { Redis } from "@upstash/redis";
+
+const COOLDOWN_KEY_PREFIX = "gemini:cooldown:";
+// Used only when a 429's message doesn't contain a parseable "retry in Ns"
+// hint -- Google's actual responses observed so far always include one, so
+// this is a conservative fallback, not the common case.
+const DEFAULT_COOLDOWN_SECONDS = 60;
+
+let redisClient = null;
+let redisInitAttempted = false;
+
+// NAMING: @upstash/redis's own Redis.fromEnv() only ever reads
+// UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN -- but Vercel's own KV
+// product (Marketplace "KV" / "Upstash for Redis" depending on how it was
+// provisioned) names the exact same underlying Upstash REST credentials
+// KV_REST_API_URL/KV_REST_API_TOKEN instead. Discovered 2026-07-26: Redis
+// was fully provisioned and reachable, but getRedis() reported it as
+// unconfigured on every call because Redis.fromEnv() was silently looking
+// for env vars that were never going to exist under this integration --
+// not a connectivity or credentials problem, a naming mismatch. Built the
+// client manually with an explicit fallback so either naming convention
+// works, rather than requiring the operator to duplicate env vars under
+// both names.
+export function getRedis() {
+ if (redisInitAttempted) return redisClient;
+ redisInitAttempted = true;
+ const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
+ if (!url || !token) {
+ return null; // Neither naming convention is set -- fine, just no cross-call memory.
+ }
+ try {
+ redisClient = new Redis({ url, token });
+ } catch (err) {
+ // Distinct from the "env vars simply unset" branch above, which is
+ // expected and silent -- this means credentials WERE found under one of
+ // the two naming conventions but the Redis client constructor itself
+ // rejected them (malformed URL, wrong format, etc). Previously silent,
+ // which made a genuine misconfiguration indistinguishable from Redis
+ // just not being set up -- isModelCoolingDown/isRedisConfigured/etc all
+ // report the same "not configured" either way, so this warning is the
+ // only place that fact ever surfaces.
+ console.warn("Redis client construction failed -- URL/token were found but rejected; treating Redis as unconfigured:", err?.message ?? err);
+ redisClient = null;
+ }
+ return redisClient;
+}
+
+// Synchronous, side-effect-free (beyond the one-time lazy init above) check
+// for whether cross-call Redis memory is actually available right now --
+// used by agent_delegate.js to tell a caller UPFRONT that checkpointing/resume
+// won't work this run (env var missing, or client construction failed),
+// rather than letting them discover it only when a resume_run_id later
+// comes back with no live checkpoint. Does not distinguish "not configured"
+// from "configured but Redis.fromEnv() itself threw" -- both mean the same
+// thing to a caller (no cross-call memory this run) and getRedis() doesn't
+// preserve which one happened.
+export function isRedisConfigured() {
+ return getRedis() !== null;
+}
+
+// Extracts a retry delay in whole seconds from a Gemini 429 error message,
+// e.g. "...Please retry in 52.395004654s." Returns null if not found, so the
+// caller can fall back to DEFAULT_COOLDOWN_SECONDS.
+export function parseRetryDelaySeconds(message) {
+ const match = /retry in ([\d.]+)\s*s/i.exec(message || "");
+ return match ? Math.ceil(parseFloat(match[1])) : null;
+}
+
+// True if `model` is currently recorded as rate-limited. Fails open (returns
+// false) if Redis isn't configured or unreachable -- never throws.
+export async function isModelCoolingDown(model) {
+ const client = getRedis();
+ if (!client) return false;
+ try {
+ const value = await client.get(COOLDOWN_KEY_PREFIX + model);
+ return value != null;
+ } catch {
+ return false;
+ }
+}
+
+// Records `model` as rate-limited for `seconds` (or DEFAULT_COOLDOWN_SECONDS
+// if omitted/invalid), auto-expiring via Redis TTL. Fails open (silent no-op)
+// if Redis isn't configured or unreachable -- never throws.
+export async function setModelCooldown(model, seconds) {
+ const client = getRedis();
+ if (!client) return;
+ const ttl = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_COOLDOWN_SECONDS;
+ try {
+ await client.set(COOLDOWN_KEY_PREFIX + model, "1", { ex: ttl });
+ } catch {
+ // best-effort -- see file header
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| agent_checkpoint.js | +
+
+ |
+ 5.88% | +2/34 | +0% | +0/20 | +0% | +0/7 | +7.14% | +2/28 | +
| agent_delegate.js | +
+
+ |
+ 1.24% | +5/401 | +0% | +0/437 | +0.9% | +1/110 | +1.53% | +5/325 | +
| agent_tools.js | +
+
+ |
+ 5.88% | +1/17 | +0% | +0/32 | +50% | +1/2 | +5.88% | +1/17 | +
| client.js | +
+
+ |
+ 92.95% | +66/71 | +84.9% | +45/53 | +85.71% | +6/7 | +98.27% | +57/58 | +
| cooldown.js | +
+
+ |
+ 93.75% | +30/32 | +91.66% | +22/24 | +100% | +5/5 | +93.1% | +27/29 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 | + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/actions.js — GitHub Actions / CI tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "list_workflow_runs",
+ "List recent GitHub Actions workflow runs for a repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ workflow_id: z.string().optional().describe("Workflow file name or ID (e.g. 'ci.yml'). Omit for all workflows."),
+ branch: z.string().optional().describe("Filter by branch name"),
+ status: z.enum(["queued", "in_progress", "completed", "waiting", "requested", "pending"]).optional().describe("Filter by run status"),
+ per_page: z.number().optional().describe("Number of runs to return, max 100 (default: 10)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, workflow_id, branch, status, per_page = 10 }) => {
+ const query = new URLSearchParams({ per_page: String(per_page) });
+ if (branch) query.set("branch", branch);
+ if (status) query.set("status", status);
+ const endpoint = workflow_id
+ ? `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow_id)}/runs?${query}`
+ : `/repos/${owner}/${repo}/actions/runs?${query}`;
+ const data = await githubRequest(endpoint);
+ const runs = data.workflow_runs;
+ if (!runs?.length) return { content: [{ type: "text", text: "No workflow runs found." }] };
+ const icon = (s, c) => s === "in_progress" ? "🔄" : s === "queued" || s === "waiting" ? "⏳" : c === "success" ? "✅" : c === "failure" ? "❌" : c === "cancelled" ? "🚫" : "⚪";
+ const lines = runs.map((r) =>
+ `${icon(r.status, r.conclusion)} #${r.run_number} — ${r.name} (${r.head_branch})\n` +
+ ` Status: ${r.status}${r.conclusion ? ` / ${r.conclusion}` : ""} | Triggered: ${r.event} | ${r.created_at.slice(0, 10)}\n` +
+ ` ${r.html_url}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "get_workflow_run_logs",
+ "Get the logs summary for a specific GitHub Actions workflow run.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ run_id: z.number().describe("Workflow run ID (from list_workflow_runs)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, run_id }) => {
+ const run = await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
+ const jobsData = await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
+ const jobs = jobsData.jobs || [];
+ const icon = (s, c) => s === "in_progress" ? "🔄" : c === "success" ? "✅" : c === "failure" ? "❌" : c === "cancelled" ? "🚫" : "⚪";
+ const jobLines = jobs.map((j) => {
+ const steps = j.steps
+ ?.filter((s) => s.conclusion !== "success")
+ .map((s) => ` ${icon(s.status, s.conclusion)} Step ${s.number}: ${s.name} [${s.conclusion || s.status}]`)
+ .join("\n") || "";
+ return ` ${icon(j.status, j.conclusion)} Job: ${j.name} [${j.conclusion || j.status}]\n${steps}`;
+ });
+ const text =
+ `Run #${run.run_number}: ${run.name}\n` +
+ `Status: ${run.status}${run.conclusion ? ` / ${run.conclusion}` : ""}\n` +
+ `Branch: ${run.head_branch} | Commit: ${run.head_sha.slice(0, 7)}\n` +
+ `Triggered by: ${run.event} | Started: ${run.created_at.slice(0, 10)}\n\n` +
+ `Jobs (${jobs.length}):\n${jobLines.join("\n\n")}\n\n` +
+ `Full logs: ${run.html_url}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "get_job_logs",
+ "Get the raw console/log text for a specific GitHub Actions job — actual error messages, stack traces, and stdout/stderr, not just pass/fail step status. Use this after get_workflow_run_logs has identified which job failed, when you need to see *why* it failed (e.g. a syntax error, assertion failure, or stack trace). Provide either job_id directly, or run_id (+ optional job_name to disambiguate; defaults to the first failed job in the run).",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ run_id: z.number().optional().describe("Workflow run ID (from list_workflow_runs). Required if job_id is not provided — used to look up the job."),
+ job_id: z.number().optional().describe("Specific job ID. If provided, run_id/job_name are not needed. Get this from a run's job list if already known."),
+ job_name: z.string().optional().describe("Job name or partial match (e.g. 'Windows, packages-and-tools') to disambiguate which job to fetch when a run has multiple jobs. Only used with run_id. If omitted, the first failed job in the run is used."),
+ grep: z.string().optional().describe("Optional case-insensitive regex to filter log lines (with 2 lines of context around each match). Defaults to common error patterns (##[error], SyntaxError, 'Error:', 'FAIL', assertion failures, etc.) when omitted."),
+ max_matches: z.number().optional().describe("Max number of matched error blocks to return (default: 40)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, run_id, job_id, job_name, grep, max_matches = 40 }) => {
+ if (!job_id) {
+ if (!run_id) throw new Error("Provide either job_id, or run_id (optionally with job_name).");
+ const jobsData = await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
+ const jobs = jobsData.jobs || [];
+ let candidates;
+ if (job_name) {
+ const needle = job_name.toLowerCase();
+ candidates = jobs.filter((j) => j.name.toLowerCase().includes(needle));
+ } else {
+ candidates = jobs.filter((j) => j.conclusion === "failure");
+ }
+ if (!candidates.length) {
+ throw new Error(
+ `No matching job found in run ${run_id}` +
+ (job_name ? ` for job_name "${job_name}"` : " with a failure conclusion") +
+ `. Available jobs: ${jobs.map((j) => j.name).join(", ")}`
+ );
+ }
+ job_id = candidates[0].id;
+ }
+
+ const rawText = await githubRequest(`/repos/${owner}/${repo}/actions/jobs/${job_id}/logs`, {
+ accept: "application/vnd.github+json",
+ });
+ const logText = typeof rawText === "string" ? rawText : JSON.stringify(rawText);
+ const lines = logText.split("\n");
+
+ const pattern = grep
+ ? new RegExp(grep, "i")
+ : /##\[error\]|error TS\d|SyntaxError|ReferenceError|TypeError|Error:|FAIL\b|✗|AssertionError|Unexpected token|Process completed with exit code [1-9]/i;
+
+ const blocks = [];
+ for (let i = 0; i < lines.length && blocks.length < max_matches; i++) {
+ if (pattern.test(lines[i])) {
+ const start = Math.max(0, i - 2);
+ const end = Math.min(lines.length, i + 4);
+ blocks.push(lines.slice(start, end).join("\n"));
+ }
+ }
+
+ const body = blocks.length
+ ? blocks.join("\n---\n")
+ : `No lines matched /${pattern.source}/. Showing last 150 lines instead:\n\n${lines.slice(-150).join("\n")}`;
+
+ const text =
+ `Job ID: ${job_id} | Total log lines: ${lines.length}\n` +
+ `${blocks.length ? `Matched ${blocks.length} error block(s) for /${pattern.source}/` : "No pattern matches"}:\n\n${body}`;
+
+ return { content: [{ type: "text", text }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +19x + + +26x + + + + + + + +13x + + + + + + + + + + + + + +15x +2x + + + + +13x +13x + + +13x +13x + + +13x + + +15x + + + + + + + +16x +1x + + + + +15x +15x + + + + + + + + + + + + +13x +2x +2x + + + + +11x +11x + + + + + + + + + +6x +6x + + + + + + + + + +5x +1x +1x + + +1x + + + + + + + + + + +16x + + + + + +11x + +11x +6x + + + + +11x + + | // ---------------------------------------------------------------------------
+// connectors/github/app_auth.js — GitHub App auth for scoped, short-lived
+// PRIVATE-repo clone tokens (2026-07-28 plan, see Notion entity_id
+// madmcp-github-app-scoped-clone-token-plan).
+//
+// WHY THIS EXISTS: the old download_repo tool returned full file contents
+// as a JSON payload straight into the calling model's context — expensive,
+// and overkill for a repo the model just needs to run/test/lint (not read).
+// It has since been removed (2026-07-28) now that this module covers its
+// run/test/lint use case via a real `git clone` into the model's own
+// sandbox. Public repos already support that today (github.com/
+// codeload.github.com/raw.githubusercontent.com are on the sandbox's own
+// network allowlist, no token needed). Private repos can't, since a clone
+// needs credentials and the sandbox has none. This module supplies those
+// credentials in the narrowest, shortest-lived form practical:
+// - a GitHub App (NOT the broad, long-lived GITHUB_TOKEN used everywhere
+// else in this connector), scoped to contents:read only
+// - installed only on the specific repo(s) that need this
+// - minted as a per-repo installation token, ~1hr TTL (GitHub's own max)
+// - revoked server-side a short grace period after minting (see below) --
+// the actual single-use guarantee, independent of GitHub's own TTL
+//
+// WHY THE TOKEN PASSES THROUGH THE CALLING MODEL AT ALL (2026-07-28 decision):
+// the original plan assumed the calling model's bash sandbox could reach a
+// mint endpoint on THIS server directly, the same way it already reaches
+// github.com. That's false — the sandbox's network allowlist is a fixed,
+// separate Anthropic-side environment setting that does not include this
+// server's domain, and nothing in this codebase can add to it. So "mint
+// server-side, clone server-side, token never touches the calling model" —
+// the original goal — isn't achievable without either (a) an allowlist
+// change outside this repo, or (b) doing the clone/run entirely on THIS
+// server instead of the model's sandbox (considered, rejected for this pass
+// since the actual want was local sandbox file access, not command-output-
+// only). So: the token is minted here and returned to the calling model,
+// which runs `git clone` with it in its own sandbox.
+//
+// ONE-TIME-USE (2026-07-28 update): the token used to be cached server-side
+// (Redis) and reused across repeat calls for the same repo within its ~1hr
+// GitHub-issued TTL. That's cheap on mint calls but means a token that
+// leaked/lingered anywhere (logs, shell history, a second unintended clone)
+// stayed valid for up to an hour. Caching has been REMOVED: every call now
+// mints a genuinely fresh token, and getCloneToken() schedules a server-side
+// revoke via GitHub's `DELETE /installation/token` endpoint
+// GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS after minting -- comfortably long
+// enough for a `git clone` to finish, short enough that the token is dead
+// well before GitHub's own ~1hr TTL would otherwise expire it. This is
+// enforced server-side (GitHub kills the token outright), not merely
+// "please don't reuse this" guidance to the calling model. The tradeoff:
+// repeat clones of the same repo now always cost a fresh mint call (cheap;
+// contents:read, single repo) instead of reusing a cached one.
+// ---------------------------------------------------------------------------
+
+import crypto from "node:crypto";
+import { waitUntil } from "@vercel/functions";
+import { GITHUB_API } from "../../config.js";
+import {
+ GITHUB_APP_ID,
+ GITHUB_APP_INSTALLATION_ID,
+ GITHUB_APP_PRIVATE_KEY,
+ GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS,
+} from "../../config.js";
+
+// GitHub rejects App JWTs older than 10 minutes; keep comfortably under that
+// to absorb clock skew between this server and GitHub's.
+const JWT_TTL_SECONDS = 540;
+
+function base64url(input) {
+ return Buffer.from(input)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+function signRs256(signingInput, privateKeyPem) {
+ return crypto
+ .createSign("RSA-SHA256")
+ .update(signingInput)
+ .sign(privateKeyPem, "base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+// Builds a GitHub App JWT (iss = App ID), used only to authenticate the
+// single "mint an installation token" call below -- never returned to a
+// caller, never cached (cheap to build fresh each time; only the resulting
+// installation token is minted/revoked).
+function buildAppJwt() {
+ if (!GITHUB_APP_ID || !GITHUB_APP_PRIVATE_KEY) {
+ throw new Error(
+ "GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY not configured -- private-repo clone tokens are unavailable. " +
+ "See connectors/github/app_auth.js and config.js for setup."
+ );
+ }
+ const now = Math.floor(Date.now() / 1000);
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
+ // iat backdated 60s -- standard GitHub App auth guidance, absorbs clock
+ // skew where GitHub's clock is slightly ahead of this server's.
+ const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + JWT_TTL_SECONDS, iss: GITHUB_APP_ID }));
+ const signingInput = `${header}.${payload}`;
+ // Support both a literal PEM (real newlines) and an escaped one (\n) --
+ // some env var tooling can't store literal newlines, so accept either.
+ const privateKey = GITHUB_APP_PRIVATE_KEY.includes("\\n")
+ ? GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, "\n")
+ : GITHUB_APP_PRIVATE_KEY;
+ return `${signingInput}.${signRs256(signingInput, privateKey)}`;
+}
+
+// Mints a FRESH installation token scoped to exactly one repo, contents:read
+// only. Always hits GitHub's API -- there is no cache to check anymore (see
+// ONE-TIME-USE note above), so every call to getCloneToken() below results
+// in exactly one of these.
+async function mintInstallationToken(owner, repo) {
+ if (!GITHUB_APP_INSTALLATION_ID) {
+ throw new Error(
+ "GITHUB_APP_INSTALLATION_ID not configured -- private-repo clone tokens are unavailable. " +
+ "See connectors/github/app_auth.js and config.js for setup."
+ );
+ }
+ const jwt = buildAppJwt();
+ const res = await fetch(`${GITHUB_API}/app/installations/${GITHUB_APP_INSTALLATION_ID}/access_tokens`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ repositories: [repo],
+ permissions: { contents: "read" },
+ }),
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(
+ `Failed to mint installation token for ${owner}/${repo} (${res.status}): ${detail || "(no response body)"}. ` +
+ `Common causes: the App isn't installed on this repo, or the installation ID is wrong.`
+ );
+ }
+ const data = await res.json();
+ return { token: data.token, expiresAt: data.expires_at }; // expires_at: ISO 8601 string
+}
+
+// Revokes an installation token early via GitHub's own revoke endpoint,
+// authenticated with the token itself (no App JWT needed for this call).
+// Best-effort: a failure here just means the token lives out its remaining
+// ~1hr GitHub-issued TTL instead of dying early -- never thrown, never
+// surfaced to the tool caller, since this always runs on a timer well after
+// the tool response has already been returned.
+async function revokeInstallationToken(token) {
+ try {
+ const res = await fetch(`${GITHUB_API}/installation/token`, {
+ method: "DELETE",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ });
+ // 401/404 here just means it's already invalid (expired naturally, or
+ // revoked some other way) -- not worth logging as an error.
+ if (!res.ok && res.status !== 401 && res.status !== 404) {
+ const detail = await res.text().catch(() => "");
+ console.error(`[app_auth] Failed to revoke clone token (${res.status}): ${detail || "(no response body)"}`);
+ }
+ } catch (err) {
+ console.error(`[app_auth] Error revoking clone token: ${err.message}`);
+ }
+}
+
+// Returns { token, expiresAt } for cloning owner/repo -- always a freshly
+// minted token (see ONE-TIME-USE note above; no server-side cache/reuse).
+// Schedules that token's revocation GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS
+// from now, so it stops working shortly after being handed off regardless
+// of GitHub's own ~1hr TTL. The timer is unref()'d so it never keeps the
+// process alive on its own.
+export async function getCloneToken(owner, repo) {
+ const minted = await mintInstallationToken(owner, repo);
+
+ // waitUntil (not a bare setTimeout/unref()) so the platform keeps this
+ // invocation alive long enough for the delayed revoke to actually run --
+ // Vercel can freeze/tear down a serverless invocation right after its
+ // response is sent, which would otherwise silently drop the revoke.
+ waitUntil(
+ new Promise((resolve) => {
+ setTimeout(() => {
+ revokeInstallationToken(minted.token).finally(resolve);
+ }, GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS * 1000);
+ })
+ );
+
+ return { token: minted.token, expiresAt: minted.expiresAt };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 | + + + + + + + + + +10x + + + + + + + +2x +2x +2x + + + +10x + + + + + + + + + + +2x +1x +1x + +1x +1x +1x + +2x + + + +2x + + + +10x + + + + + + + + + +2x +2x +2x +2x +1x + +2x + + + +10x + + + + + + + + +1x +2x + +1x + + + + +1x + + + + | // ---------------------------------------------------------------------------
+// connectors/github/branches.js — branches & commits tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "list_branches",
+ "List branches in a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/branches`);
+ const lines = data.map((b) => `${b.name}${b.protected ? " (protected)" : ""}`);
+ return { content: [{ type: "text", text: lines.join("\n") || "(no branches)" }] };
+ }
+ );
+
+ server.tool(
+ "create_branch",
+ "Create a new branch in a GitHub repository from an existing ref.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ branch: z.string().describe("Name of the new branch to create"),
+ from_branch: z.string().optional().describe("Branch, tag, or SHA to branch from (default: repo default branch)"),
+ },
+ async ({ owner, repo, branch, from_branch }) => {
+ let sha;
+ if (from_branch) {
+ const ref = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(from_branch)}`);
+ sha = ref.object.sha;
+ } else {
+ const repoData = await githubRequest(`/repos/${owner}/${repo}`);
+ const ref = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(repoData.default_branch)}`);
+ sha = ref.object.sha;
+ }
+ await githubRequest(`/repos/${owner}/${repo}/git/refs`, {
+ method: "POST",
+ body: { ref: `refs/heads/${branch}`, sha },
+ });
+ return { content: [{ type: "text", text: `Created branch '${branch}' in ${owner}/${repo} from ${sha.slice(0, 7)}.` }] };
+ }
+ );
+
+ server.tool(
+ "list_commits",
+ "List commits on a branch in a GitHub repository.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ branch: z.string().optional().describe("Branch name (default: repo default branch)"),
+ per_page: z.number().optional().describe("Number of commits to return, max 100 (default: 20)"),
+ },
+ async ({ owner, repo, branch, per_page = 20 }) => {
+ const query = new URLSearchParams({ per_page: String(per_page) });
+ if (branch) query.set("sha", branch);
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits?${query}`);
+ const lines = data.map((c) =>
+ `${c.sha.slice(0, 7)} — ${c.commit.message.split("\n")[0]} (${c.commit.author.name}, ${c.commit.author.date.slice(0, 10)})`
+ );
+ return { content: [{ type: "text", text: lines.join("\n") || "(no commits)" }] };
+ }
+ );
+
+ server.tool(
+ "get_commit",
+ "Get details of a specific commit in a GitHub repository.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ sha: z.string().describe("Commit SHA"),
+ },
+ async ({ owner, repo, sha }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits/${sha}`);
+ const files = data.files.map((f) => ` ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`).join("\n");
+ const text =
+ `Commit: ${data.sha.slice(0, 7)}\n` +
+ `Author: ${data.commit.author.name} <${data.commit.author.email}>\n` +
+ `Date: ${data.commit.author.date}\n` +
+ `Message: ${data.commit.message}\n\n` +
+ `Files changed (${data.files.length}):\n${files}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 | + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/ci_control.js — triggering & controlling CI runs, and
+// reading commit-level check state (as opposed to actions.js, which only
+// lists/reads runs that already exist and can't start, stop, or query the
+// checks/status API for a specific commit/ref).
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "trigger_workflow",
+ "DOES: Manually trigger a workflow_dispatch run on a branch/ref, with optional inputs.\n" +
+ "RULE: workflow file must have a workflow_dispatch trigger defined, or this fails.\n" +
+ "RULE: needing CI to run without a real change -> this, instead of opening a throwaway PR.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ workflow_id: z.string().describe("Workflow file name (e.g. 'ci.yml') or numeric workflow ID"),
+ ref: z.string().describe("Branch, tag, or SHA to run the workflow on"),
+ inputs: z.record(z.string()).optional().describe("Input parameters declared under `workflow_dispatch.inputs` in the workflow file, as string key/value pairs"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, workflow_id, ref, inputs }) => {
+ await githubRequest(`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow_id)}/dispatches`, {
+ method: "POST",
+ body: { ref, inputs },
+ });
+ // The dispatch endpoint returns no body (204), so poll the workflow's
+ // runs list briefly to hand back a run URL instead of a bare "ok".
+ let found;
+ for (let attempt = 0; attempt < 4 && !found; attempt++) {
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 1500));
+ const runs = await githubRequest(
+ `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow_id)}/runs?event=workflow_dispatch&branch=${encodeURIComponent(ref)}&per_page=1`
+ );
+ found = runs.workflow_runs?.[0];
+ }
+ return {
+ content: [{
+ type: "text",
+ text: found
+ ? `Triggered workflow '${workflow_id}' on ${ref}. Run #${found.run_number}: ${found.html_url}`
+ : `Triggered workflow '${workflow_id}' on ${ref}. GitHub hasn't listed the new run yet -- check list_workflow_runs shortly.`,
+ }],
+ };
+ }
+ );
+
+ server.tool(
+ "rerun_workflow",
+ "DOES: Rerun a workflow run -- whole run, or failed jobs only.\n" +
+ "RULE: retrying a flaky test without re-running steps that already passed -> failed_jobs_only=true.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ run_id: z.number().describe("Workflow run ID (from list_workflow_runs)"),
+ failed_jobs_only: z.boolean().optional().describe("If true, only rerun failed jobs instead of the entire run (default: false)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, run_id, failed_jobs_only = false }) => {
+ const endpoint = failed_jobs_only ? "rerun-failed-jobs" : "rerun";
+ await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/${endpoint}`, { method: "POST" });
+ return {
+ content: [{
+ type: "text",
+ text: `Requested rerun of ${failed_jobs_only ? "failed jobs in " : ""}run ${run_id}. Poll with list_workflow_runs or get_workflow_run_logs to see progress.`,
+ }],
+ };
+ }
+ );
+
+ server.tool(
+ "cancel_workflow_run",
+ "DOES: Cancel a queued or in-progress workflow run.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ run_id: z.number().describe("Workflow run ID (from list_workflow_runs)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, run_id }) => {
+ await githubRequest(`/repos/${owner}/${repo}/actions/runs/${run_id}/cancel`, { method: "POST" });
+ return { content: [{ type: "text", text: `Cancellation requested for run ${run_id}.` }] };
+ }
+ );
+
+ server.tool(
+ "get_check_runs",
+ "DOES: Individual check/status entries (pass/fail dots) for a commit/branch/tag -- the data behind GitHub's green check / red X.\n" +
+ "NOT: a list of Actions runs -> use list_workflow_runs for that.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ ref: z.string().describe("Commit SHA, branch name, or tag to get check runs for"),
+ per_page: z.number().optional().describe("Number of check runs to return, max 100 (default: 30)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, ref, per_page = 30 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/check-runs?per_page=${per_page}`);
+ if (!data.check_runs?.length) return { content: [{ type: "text", text: `No check runs found for ${ref}.` }] };
+ const icon = (s, c) => s !== "completed" ? "🔄" : c === "success" ? "✅" : c === "failure" ? "❌" : c === "skipped" ? "⏭️" : c === "cancelled" ? "🚫" : "⚪";
+ const lines = data.check_runs.map((c) =>
+ `${icon(c.status, c.conclusion)} ${c.name} — ${c.status}${c.conclusion ? `/${c.conclusion}` : ""}\n ${c.html_url}`
+ );
+ return { content: [{ type: "text", text: `${data.total_count} check run(s) for ${ref}:\n\n${lines.join("\n\n")}` }] };
+ }
+ );
+
+ server.tool(
+ "get_combined_status",
+ "DOES: Combined commit status for a ref -- overall pass/fail/pending rollup + each individual status context (the legacy Status API some CI systems/integrations use instead of, or alongside, Actions check-runs).",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ ref: z.string().describe("Commit SHA, branch name, or tag"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, ref }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/status`);
+ const icon = (s) => s === "success" ? "✅" : s === "failure" || s === "error" ? "❌" : "⏳";
+ const lines = (data.statuses || []).map((s) => `${icon(s.state)} ${s.context} — ${s.state}${s.description ? ` (${s.description})` : ""}`);
+ const text =
+ `Overall state: ${icon(data.state)} ${data.state} (${data.total_count} status(es))\n\n` +
+ (lines.length ? lines.join("\n") : "(no individual statuses reported)");
+ return { content: [{ type: "text", text }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 | + + + + + + + + + + + + + + +1x +1x + + + + + + + + + + + + + + + +5x +5x + + + + + + + + + + + + + + + + + + + + + + + +5x +4x +3x +3x +2x + +2x + + + + + +4x +4x +1x + +3x +3x +1x +1x + + +2x +2x + + + + + + + + + + + + + + + + + + + + + + +1x + + +1x + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + +1x + + | // ---------------------------------------------------------------------------
+// connectors/github/client.js
+// ---------------------------------------------------------------------------
+
+import https from "node:https";
+import { URL } from "node:url";
+import {
+ GITHUB_TOKEN,
+ GITHUB_API,
+ GITHUB_MIN_REQUEST_INTERVAL_MS,
+ GITHUB_MAX_RETRIES,
+ GITHUB_RETRY_BASE_MS,
+} from "../../config.js";
+
+function assertConfigured() {
+ Eif (!GITHUB_TOKEN) {
+ throw new Error(
+ "GITHUB_TOKEN is not set. Add it as an environment variable on the madmcp server."
+ );
+ }
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+// --- Throttle (fix #2) -----------------------------------------------------
+// GitHub's secondary rate limit is triggered by request burstiness /
+// concurrency, independent of remaining hourly quota. A single shared
+// promise chain serializes all outgoing requests and enforces a minimum gap
+// between them, so bursts of tool calls (even concurrent ones) get spaced
+// out automatically instead of hammering the API back-to-back.
+let throttleChain = Promise.resolve();
+let lastRequestAt = 0;
+
+function scheduleThrottled(fn) {
+ const run = async () => {
+ const wait = lastRequestAt + GITHUB_MIN_REQUEST_INTERVAL_MS - Date.now();
+ if (wait > 0) await sleep(wait);
+ lastRequestAt = Date.now();
+ return fn();
+ };
+ // Chain onto the shared queue regardless of whether prior requests
+ // succeeded or failed, so one failure doesn't jam the whole queue.
+ const result = throttleChain.then(run, run);
+ // Keep the chain alive without leaking rejections into unrelated callers.
+ throttleChain = result.then(() => {}, () => {});
+ return result;
+}
+
+// --- Retry with backoff (fix #1) -------------------------------------------
+// Only retries responses that indicate pacing problems (secondary rate
+// limit, primary quota exhaustion, or a plain 429) -- any other 4xx/5xx is a
+// real error and is thrown immediately, unretried.
+// Exported (previously module-private) so it's directly unit-testable --
+// see test/github-client.test.js.
+export function isRetryable(res, data) {
+ if (res.status === 429) return true;
+ if (res.status === 403) {
+ const msg = (data && (data.message || JSON.stringify(data))) || "";
+ if (/secondary rate limit/i.test(msg)) return true;
+ if (res.headers.get("x-ratelimit-remaining") === "0") return true;
+ }
+ return false;
+}
+
+// Exported (previously module-private) so it's directly unit-testable --
+// see test/github-client.test.js.
+export function retryDelayMs(res, attempt) {
+ const retryAfter = res.headers.get("retry-after");
+ if (retryAfter && !Number.isNaN(Number(retryAfter))) {
+ return Number(retryAfter) * 1000;
+ }
+ const resetAt = res.headers.get("x-ratelimit-reset");
+ if (resetAt) {
+ const ms = Number(resetAt) * 1000 - Date.now();
+ Eif (ms > 0 && ms < 15 * 60 * 1000) return ms; // sanity cap: don't wait >15min
+ }
+ // Exponential backoff with jitter as a fallback.
+ const jitter = Math.random() * 250;
+ return GITHUB_RETRY_BASE_MS * 2 ** attempt + jitter;
+}
+
+async function doFetch(path, { method, body, accept }) {
+ const res = await fetch(`${GITHUB_API}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${GITHUB_TOKEN}`,
+ Accept: accept || "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "Content-Type": "application/json",
+ "User-Agent": "madmcp-server",
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+ return { res, data };
+}
+
+export async function githubRequest(path, { method = "GET", body, accept } = {}) {
+ assertConfigured();
+
+ let lastErr;
+ for (let attempt = 0; attempt <= GITHUB_MAX_RETRIES; attempt++) {
+ const { res, data } = await scheduleThrottled(() => doFetch(path, { method, body, accept }));
+
+ if (res.ok) return data;
+
+ if (isRetryable(res, data) && attempt < GITHUB_MAX_RETRIES) {
+ await sleep(retryDelayMs(res, attempt));
+ lastErr = res;
+ continue;
+ }
+
+ const message = (data && (data.message || JSON.stringify(data))) || res.statusText;
+ throw new Error(`GitHub API error (${res.status}): ${message}`);
+ }
+
+ // Exhausted retries.
+ const message = lastErr ? lastErr.statusText : "rate limited";
+ throw new Error(`GitHub API error (${lastErr ? lastErr.status : 429}): ${message} -- exhausted ${GITHUB_MAX_RETRIES} retries`);
+}
+
+// GitHub's REST API has no endpoint to convert a draft PR to ready-for-review
+// -- that action only exists as the markPullRequestReadyForReview GraphQL
+// mutation. Reuses the same throttle queue as REST requests so it doesn't
+// bypass the burstiness protection above.
+export async function githubGraphQL(query, variables = {}) {
+ assertConfigured();
+
+ const doGraphQL = async () => {
+ const res = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${GITHUB_TOKEN}`,
+ "Content-Type": "application/json",
+ "User-Agent": "madmcp-server",
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+ return { res, data };
+ };
+
+ const { res, data } = await scheduleThrottled(doGraphQL);
+
+ if (!res.ok || (data && data.errors)) {
+ const message = data && data.errors
+ ? data.errors.map((e) => e.message).join("; ")
+ : res.statusText;
+ throw new Error(`GitHub GraphQL error: ${message}`);
+ }
+
+ return data.data;
+}
+
+// --- Binary tarball fetch (fix #4, 2026-07-28) -----------------------------
+// search.js's private-repo search_code fallback used to fetch one blob per
+// file through githubRequest -- up to 500 sequential, individually-throttled
+// requests for a single search. This replaces that with ONE request for the
+// whole repo via GitHub's tarball endpoint, which search.js decompresses and
+// greps locally instead.
+//
+// Deliberately uses node:https instead of the global fetch() used elsewhere
+// in this file: the tarball endpoint responds with a 302 to codeload.
+// github.com carrying the actual archive, and fetch()'s redirect: "manual"
+// mode returns a spec-mandated "opaqueredirect" filtered response (status 0,
+// empty headers, null body) -- there is no way to read the Location header
+// off it to follow the redirect ourselves. http.request has no such
+// filtering, so we can read the real status/headers and re-issue the
+// request to codeload.github.com directly.
+//
+// The Authorization header is re-attached on the codeload hop on purpose --
+// private-repo tarball downloads require it there too (public repos ignore
+// it harmlessly). NOTE: this hasn't been exercised against a live private
+// repo from this environment (github.com/codeload.github.com aren't in this
+// sandbox's egress allowlist) -- worth a real smoke test against a private
+// repo before relying on it, in case codeload's auth handling has changed.
+function httpGetBuffer(url, headers, redirectsLeft = 5) {
+ return new Promise((resolve, reject) => {
+ const req = https.request(url, { method: "GET", headers }, (res) => {
+ const status = res.statusCode;
+
+ if (status >= 300 && status < 400 && res.headers.location) {
+ res.resume(); // discard the (empty) redirect body
+ if (redirectsLeft <= 0) {
+ reject(new Error(`Too many redirects fetching ${url}`));
+ return;
+ }
+ const nextUrl = new URL(res.headers.location, url).toString();
+ resolve(httpGetBuffer(nextUrl, headers, redirectsLeft - 1));
+ return;
+ }
+
+ const chunks = [];
+ res.on("data", (chunk) => chunks.push(chunk));
+ res.on("end", () => {
+ if (status < 200 || status >= 300) {
+ reject(new Error(`GitHub tarball fetch error (${status}) for ${url}`));
+ return;
+ }
+ resolve(Buffer.concat(chunks));
+ });
+ res.on("error", reject);
+ });
+ req.on("error", reject);
+ req.end();
+ });
+}
+
+// Fetches a repo's full contents as a gzipped tarball (raw bytes -- caller
+// gunzips/parses). Shares the same throttleChain as githubRequest so it's
+// paced consistently with every other GitHub call this server makes, but
+// only occupies ONE slot for the whole repo instead of one per file.
+export async function githubFetchTarball(owner, repo, ref) {
+ assertConfigured();
+ return scheduleThrottled(() =>
+ httpGetBuffer(`${GITHUB_API}/repos/${owner}/${repo}/tarball/${ref}`, {
+ Authorization: `Bearer ${GITHUB_TOKEN}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "madmcp-server",
+ })
+ );
+}
+
+export function toBase64(str) {
+ return Buffer.from(str, "utf-8").toString("base64");
+}
+
+export function fromBase64(b64) {
+ return Buffer.from(b64, "base64").toString("utf-8");
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 | + + + + + + + + + + + +11x + + + + + + + + + + + + +8x +8x + +3x + +5x + +5x + + + +5x + + + + | // ---------------------------------------------------------------------------
+// connectors/github/clone_token.js — get_repo_clone_token tool.
+// See app_auth.js's file header for the full design rationale (why this
+// exists, why the token has to pass through the calling model, why it's
+// NOT cached server-side -- every call mints a fresh one-time token).
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { DEFAULT_OWNER } from "../../config.js";
+import { getCloneToken } from "./app_auth.js";
+
+export function register(server) {
+ server.tool(
+ "get_repo_clone_token",
+ "DOES: Mint a fresh, single-use, single-repo, read-only GitHub token for cloning a PRIVATE repo, plus the exact `git clone` command to run with it.\n" +
+ "RULE: PUBLIC repo -> don't use this. `git clone https://github.com/{owner}/{repo}.git` directly in the sandbox works with no token -- github.com/codeload.github.com/raw.githubusercontent.com are already on its network allowlist.\n" +
+ "RULE: every call mints a brand-new token -- there is no server-side reuse, so calling this again for the same repo costs a fresh mint each time.\n" +
+ "SCOPE: contents:read only, single repo, auto-revoked by this server a few minutes after minting regardless of GitHub's own ~1hr TTL -- effectively single-use, never write access.\n" +
+ "CAUTION: the token appears in your context via this tool's response -- use it immediately for the one clone command. It will stop working shortly after regardless of what you do with it, so there's no benefit to persisting it to a file, env var, or shell history entry, and no reason to repeat it back or log it anywhere else.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo }) => {
+ let result;
+ try {
+ result = await getCloneToken(owner, repo);
+ } catch (err) {
+ return { content: [{ type: "text", text: err.message }], isError: true };
+ }
+ const cloneUrl = `https://x-access-token:${result.token}@github.com/${owner}/${repo}.git`;
+ const text =
+ `Freshly minted token (contents:read, ${owner}/${repo} only), GitHub-issued expiry ${result.expiresAt} -- but this server will auto-revoke it a few minutes from now regardless, so it's single-use in practice, not just in intent.\n\n` +
+ `Run this in your sandbox to clone:\n` +
+ `git clone ${cloneUrl}\n\n` +
+ `Use it for this clone now -- it won't be reusable shortly after, whether or not you reference it again.`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/diff.js — diff_files tool
+// Compares two files (or two refs of the same file) using GitHub's compare API
+// or by fetching both versions and diffing them inline.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest, fromBase64 } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+// Minimal unified diff between two strings
+function unifiedDiff(aText, bText, aLabel, bLabel) {
+ const aLines = aText.split("\n");
+ const bLines = bText.split("\n");
+
+ const diff = [];
+ diff.push(`--- ${aLabel}`);
+ diff.push(`+++ ${bLabel}`);
+
+ const m = aLines.length;
+ const n = bLines.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = m - 1; i >= 0; i--)
+ for (let j = n - 1; j >= 0; j--)
+ dp[i][j] = aLines[i] === bLines[j]
+ ? dp[i + 1][j + 1] + 1
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
+
+ const hunks = [];
+ let i = 0, j = 0;
+ while (i < m || j < n) {
+ if (i < m && j < n && aLines[i] === bLines[j]) {
+ hunks.push({ type: "ctx", line: aLines[i] });
+ i++; j++;
+ } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
+ hunks.push({ type: "add", line: bLines[j] });
+ j++;
+ } else {
+ hunks.push({ type: "del", line: aLines[i] });
+ i++;
+ }
+ }
+
+ const CONTEXT = 3;
+ const changed = new Set(
+ hunks.map((h, idx) => (h.type !== "ctx" ? idx : -1)).filter((x) => x >= 0)
+ );
+ const shown = new Set();
+ for (const idx of changed)
+ for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(hunks.length - 1, idx + CONTEXT); k++)
+ shown.add(k);
+
+ let lastShown = -1;
+ for (const idx of [...shown].sort((a, b) => a - b)) {
+ if (lastShown !== -1 && idx > lastShown + 1) diff.push("@@ ... @@");
+ const h = hunks[idx];
+ diff.push(`${h.type === "add" ? "+" : h.type === "del" ? "-" : " "}${h.line}`);
+ lastShown = idx;
+ }
+
+ if (diff.length === 2) diff.push("(no differences)");
+ return diff.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Helper: read file via Blobs API (no 1MB limit)
+// ---------------------------------------------------------------------------
+async function readFileBlobForDiff(owner, repo, filePath, ref) {
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const branch = ref || repoInfo.default_branch;
+ let treeSha;
+ try {
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
+ treeSha = refData.object.sha;
+ } catch {
+ treeSha = branch;
+ }
+ const tree = await githubRequest(`/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
+ const entry = tree.tree.find((item) => item.path === filePath && item.type === "blob");
+ if (!entry) throw new Error(`File not found in tree: ${filePath}`);
+ const blob = await githubRequest(`/repos/${owner}/${repo}/git/blobs/${entry.sha}`);
+ return fromBase64(blob.content.replace(/\n/g, ""));
+}
+
+export function register(server) {
+
+ server.tool(
+ "diff_files",
+ "Compare two files or two versions of the same file in a GitHub repository and return a unified diff.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().optional().describe("File path to compare across two refs (use with base_ref and head_ref)"),
+ base_ref: z.string().optional().describe("Base ref (branch, tag, or SHA). Defaults to repo default branch."),
+ head_ref: z.string().optional().describe("Head ref to compare against base_ref."),
+ base_path: z.string().optional().describe("Path of the base file (use with head_path for cross-file diff)"),
+ head_path: z.string().optional().describe("Path of the head file (use with base_path for cross-file diff)"),
+ ref: z.string().optional().describe("Ref to use when comparing two different file paths (default: default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, base_ref, head_ref, base_path, head_path, ref }) => {
+ const crossFile = base_path && head_path;
+ const sameFile = path && head_ref;
+ if (!crossFile && !sameFile) {
+ return {
+ content: [{ type: "text", text: "Provide either:\n (a) path + head_ref (and optionally base_ref) to compare a file across two refs, or\n (b) base_path + head_path (and optionally ref) to compare two different files." }],
+ isError: true,
+ };
+ }
+
+ let aLabel, bLabel, aText, bText;
+
+ if (sameFile) {
+ const resolvedBase = base_ref || (await githubRequest(`/repos/${owner}/${repo}`)).default_branch;
+ [aText, bText] = await Promise.all([
+ readFileBlobForDiff(owner, repo, path, resolvedBase),
+ readFileBlobForDiff(owner, repo, path, head_ref),
+ ]);
+ aLabel = `${path} (${resolvedBase})`;
+ bLabel = `${path} (${head_ref})`;
+ } else {
+ const resolvedRef = ref || (await githubRequest(`/repos/${owner}/${repo}`)).default_branch;
+ [aText, bText] = await Promise.all([
+ readFileBlobForDiff(owner, repo, base_path, resolvedRef),
+ readFileBlobForDiff(owner, repo, head_path, resolvedRef),
+ ]);
+ aLabel = `${base_path} (${resolvedRef})`;
+ bLabel = `${head_path} (${resolvedRef})`;
+ }
+
+ const diff = unifiedDiff(aText, bText, aLabel, bLabel);
+ return { content: [{ type: "text", text: diff }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + +2x +2x +2x +1x + +2x + + +1x + + + +1x + + + +21x + + + + + + + + + + + + + + + + + + +9x +2x + + +7x +5x +5x + +5x +5x +6x +6x +5x +4x + + +5x +2x + +3x +1x + + +2x +5x +2x + + + + + +2x +2x +2x +2x +10x +2x +8x +32x +2x +2x +2x +11x +6x +3x + +2x +11x +2x +2x +6x +30x +2x +9x +11x +11x +11x +11x + +2x + +2x + + + + + +2x +2x +9x +1x + +2x + + + +2x + + + +21x + + + + + + + + + + + +3x +3x +2x + + + +2x + + + +21x + + + + + + + + + + + + +3x +3x +3x +3x +3x +3x +3x + + + +3x + + + + + + + + + +3x + + + +3x + + + +3x + + + +21x + + + + + + + + + + + + + + +1x +1x +1x +1x +1x +2x + + + + +1x + + + +2x + + +1x + + + +1x + + + +1x + + + + | // ---------------------------------------------------------------------------
+// connectors/github/files.js — file & directory tools
+//
+// NOTE ON "RULE for the calling model..." TEXT BELOW: these descriptions are
+// what the MCP-calling model (e.g. Claude) sees when deciding which tool to
+// use, and are the only place the "prefer delegate_agent" routing hints
+// live. They are read by a DIFFERENT model, for a DIFFERENT purpose, than
+// the FUNCTIONS declarations Gemini sees in connectors/gemini/agent_delegate.js
+// during its own internal tool-calling loop -- editing one has no effect on
+// the other. Do not assume changes here propagate to agent_delegate.js, and never
+// port this "use delegate_agent instead" phrasing onto agent_delegate.js's own
+// function declarations (see the warning at the top of that file for why).
+//
+// NOTE ON "clone via bash_tool" TEXT BELOW (added 2026-07-28, see Notion
+// entity_id madmcp-delegate-designer-plan): confirmed by direct test that
+// the calling model's own sandbox (bash_tool) can `git clone` a PUBLIC repo
+// straight from github.com/codeload.github.com/raw.githubusercontent.com --
+// all three are already on that sandbox's network allowlist. That's a THIRD
+// option alongside read_file and delegate_agent, and for its specific use
+// case (needing multiple files locally to run/test/lint, not just read) it
+// beats both: unlike read_file it doesn't put file contents in the calling
+// model's context at all (only command output does), and unlike
+// delegate_agent it lets the calling model actually EXECUTE the code
+// (npm test, eslint, etc.), not just read/summarize it. Only works for
+// PUBLIC repos -- the sandbox has no GitHub credentials, so a private repo
+// clone will simply fail auth, at which point read_file/delegate_agent are
+// still the right fallback.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest, toBase64 } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+import { readFileViaBlob, CHUNK_SIZE, CHUNK_THRESHOLD } from "./helpers.js";
+
+export function register(server) {
+
+ server.tool(
+ "read_file",
+ "USE: single, specifically-named file, exact path already known.\n" +
+ "RULE: >2 files needed, OR request = understand/review/summarize a repo or directory (any phrasing: 'read the repo', 'dig into it', 'get up to speed') -> delegate_agent instead. Never loop read_file manually for that.\n" +
+ "RULE: repo is PUBLIC and goal = run/test/lint code (not just read it) -> git clone via bash_tool instead (github.com/codeload.github.com/raw.githubusercontent.com allowlisted; zero context cost; can execute code). PUBLIC REPOS ONLY -- no GitHub creds in sandbox.\n" +
+ "DOES: reads a file's contents from a GitHub repository. Auto-chunks if >100,000 chars -- use read_file_chunked for subsequent pages.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo, e.g. 'src/server.js'"),
+ ref: z.string().optional().describe("Branch, tag, or commit SHA (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, ref }) => {
+ const content = await readFileViaBlob(owner, repo, path, ref);
+ const total = content.length;
+ if (total <= CHUNK_THRESHOLD) {
+ return { content: [{ type: "text", text: content }] };
+ }
+ const slice = content.slice(0, CHUNK_SIZE);
+ const remaining = total - CHUNK_SIZE;
+ const header =
+ `⚠️ File too large to return in full (${total.toLocaleString()} chars). ` +
+ `Returning first ${CHUNK_SIZE.toLocaleString()} chars. ` +
+ `Use read_file_chunked with char_offset=${CHUNK_SIZE} to continue.\n` +
+ `[File: ${path} | Total: ${total} chars | Offset: 0 | Returning: ${slice.length} chars | Remaining: ${remaining} chars]\n\n`;
+ return { content: [{ type: "text", text: header + slice }] };
+ }
+ );
+
+ server.tool(
+ "read_file_chunked",
+ "DOES: Read a slice of a large file. Use when read_file times out or is truncated.\n" +
+ "RULE: chunking through several large files for one open-ended question -> delegate_agent instead of many manual round-trips.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo"),
+ ref: z.string().optional().describe("Branch, tag, or commit SHA (default: repo default branch)"),
+ char_offset: z.number().optional().describe("Character offset to start reading from (default: 0)"),
+ char_limit: z.number().optional().describe("Maximum number of characters to return (default: 20000, max: 100000)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, ref, char_offset = 0, char_limit = 20000 }) => {
+ const safeLimit = Math.min(char_limit, 100000);
+ const content = await readFileViaBlob(owner, repo, path, ref);
+ const total = content.length;
+ const slice = content.slice(char_offset, char_offset + safeLimit);
+ const remaining = Math.max(0, total - char_offset - slice.length);
+ const header = `[File: ${path} | Total: ${total} chars | Offset: ${char_offset} | Returning: ${slice.length} chars | Remaining: ${remaining} chars]\n\n`;
+ return { content: [{ type: "text", text: header + slice }] };
+ }
+ );
+
+ server.tool(
+ "list_directory",
+ "DOES: List files/folders at a path.\n" +
+ "RULE: drilling into many directories one at a time to map an unfamiliar repo -> delegate_agent instead, server-side in one call.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().optional().describe("Directory path within the repo (default: repo root)"),
+ ref: z.string().optional().describe("Branch, tag, or commit SHA (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path = "", ref }) => {
+ const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
+ const data = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${query}`);
+ const items = Array.isArray(data) ? data : [data];
+ const lines = items.map((item) => `${item.type === "dir" ? "📁" : "📄"} ${item.path}`);
+ return { content: [{ type: "text", text: lines.join("\n") || "(empty)" }] };
+ }
+ );
+
+ server.tool(
+ "get_file_tree",
+ "USE: one-time single tree snapshot.\n" +
+ "RULE: result has >~10 files, OR next step = reading/searching multiple files from it -> STOP, use delegate_agent for the whole investigation instead. Applies regardless of phrasing ('thorough read', 'quick look', 'dig deeper' all count). Never chain this into manual read_file loops.\n" +
+ "RULE: repo is PUBLIC and goal = run/test/lint multiple files (not just read them) -> git clone via bash_tool instead (see read_file's description; zero context cost, can execute code, public repos only).\n" +
+ "DOES: recursively lists all files and folders in a GitHub repository (full tree).",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ ref: z.string().optional().describe("Branch, tag, or commit SHA (default: repo default branch)"),
+ },
+ async ({ owner, repo, ref }) => {
+ let treeSha;
+ if (ref) {
+ try {
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(ref)}`);
+ treeSha = refData.object.sha;
+ } catch { treeSha = ref; }
+ } else {
+ const repoData = await githubRequest(`/repos/${owner}/${repo}`);
+ const branchData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${repoData.default_branch}`);
+ treeSha = branchData.object.sha;
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
+ const lines = data.tree.map((item) => `${item.type === "tree" ? "📁" : "📄"} ${item.path}`);
+ const note = data.truncated ? "\n\n⚠️ Tree was truncated (repo too large)." : "";
+ return { content: [{ type: "text", text: lines.join("\n") + note || "(empty repository)" }] };
+ }
+ );
+
+ server.tool(
+ "create_repo_file",
+ "DOES: Write a brand-new file to a GitHub repo. NOT the sandbox filesystem -> use the computer-use create_file tool for that.\n" +
+ "RULE: fails if the path already exists -> edit_file to patch or fully replace it instead.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo"),
+ content: z.string().describe("Full content of the new file (plain text)"),
+ message: z.string().describe("Commit message"),
+ branch: z.string().optional().describe("Branch to commit to (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, content, message, branch }) => {
+ const query = branch ? `?ref=${encodeURIComponent(branch)}` : "";
+ try {
+ await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${query}`);
+ throw new Error(`${path} already exists in ${owner}/${repo}${branch ? `@${branch}` : ""}. Use edit_file to replace or patch it.`);
+ } catch (e) {
+ if (e.message?.includes("already exists")) throw e;
+ /* 404 means the path is free -- proceed */
+ }
+ const result = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ body: { message, content: toBase64(content), branch },
+ });
+ return { content: [{ type: "text", text: `Created ${path} in ${owner}/${repo} (commit ${result.commit.sha.slice(0, 7)}).` }] };
+ }
+ );
+
+ server.tool(
+ "edit_file",
+ "DOES: Edit an existing or new file's contents, committed in one call. Exactly one of two mutually exclusive modes:\n" +
+ " `content` (full overwrite) -- rewrites the whole file, creating it if it doesn't exist.\n" +
+ " `replacements` (targeted find/replace) -- only changed strings need to be sent; each `find` must appear exactly once in the file or the WHOLE call is rejected and nothing is committed; the file must already exist; returns a unified diff.\n" +
+ "RULE: must fail if the path already exists -> create_repo_file instead. Several files as one atomic commit -> overwrite_files.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo"),
+ content: z.string().optional().describe("Full new content of the file (plain text). Mutually exclusive with `replacements`. Creates the file if it doesn't exist."),
+ replacements: z.array(z.object({
+ find: z.string().describe("Exact string to find (must appear exactly once in the file)"),
+ replace: z.string().describe("String to replace it with"),
+ })).min(1).optional().describe("List of find-and-replace operations to apply sequentially. Mutually exclusive with `content`. The file must already exist."),
+ message: z.string().describe("Commit message"),
+ branch: z.string().optional().describe("Branch to commit to (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, content, replacements, message, branch }) => {
+ if ((content === undefined) === (replacements === undefined)) {
+ return { content: [{ type: "text", text: "Provide exactly one of `content` (full overwrite) or `replacements` (targeted find/replace)." }], isError: true };
+ }
+
+ if (replacements) {
+ const original = await readFileViaBlob(owner, repo, path, branch);
+ let updated = original;
+
+ const errors = [];
+ for (const { find, replace } of replacements) {
+ const count = updated.split(find).length - 1;
+ if (count === 0) { errors.push(`⚠️ String not found: ${JSON.stringify(find)}`); continue; }
+ if (count > 1) { errors.push(`⚠️ String found ${count} times (must be unique): ${JSON.stringify(find)}`); continue; }
+ updated = updated.replace(find, replace);
+ }
+
+ if (errors.length) {
+ return { content: [{ type: "text", text: `Aborted — fix these issues before committing:\n${errors.join("\n")}` }], isError: true };
+ }
+ if (updated === original) {
+ return { content: [{ type: "text", text: "No changes — all replacements produced identical content." }] };
+ }
+
+ const query = branch ? `?ref=${encodeURIComponent(branch)}` : "";
+ const existing = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${query}`);
+ const result = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ body: { message, content: toBase64(updated), branch, sha: existing.sha },
+ });
+
+ // Build unified diff
+ const aLines = original.split("\n");
+ const bLines = updated.split("\n");
+ const diffLines = [`--- ${path} (before)`, `+++ ${path} (after)`];
+ const m = aLines.length, n = bLines.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = m - 1; i >= 0; i--)
+ for (let j = n - 1; j >= 0; j--)
+ dp[i][j] = aLines[i] === bLines[j] ? dp[i+1][j+1] + 1 : Math.max(dp[i+1][j], dp[i][j+1]);
+ const hunks = [];
+ let i = 0, j = 0;
+ while (i < m || j < n) {
+ if (i < m && j < n && aLines[i] === bLines[j]) { hunks.push({ t: "ctx", l: aLines[i] }); i++; j++; }
+ else if (j < n && (i >= m || dp[i][j+1] >= dp[i+1][j])) { hunks.push({ t: "add", l: bLines[j] }); j++; }
+ else { hunks.push({ t: "del", l: aLines[i] }); i++; }
+ }
+ const CONTEXT = 3;
+ const changed = new Set(hunks.map((h, idx) => h.t !== "ctx" ? idx : -1).filter(x => x >= 0));
+ const shown = new Set();
+ for (const idx of changed)
+ for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(hunks.length - 1, idx + CONTEXT); k++)
+ shown.add(k);
+ let last = -1;
+ for (const idx of [...shown].sort((a, b) => a - b)) {
+ Iif (last !== -1 && idx > last + 1) diffLines.push("@@ ... @@");
+ const h = hunks[idx];
+ diffLines.push(`${h.t === "add" ? "+" : h.t === "del" ? "-" : " "}${h.l}`);
+ last = idx;
+ }
+ Iif (diffLines.length === 2) diffLines.push("(no differences)");
+
+ return {
+ content: [{ type: "text", text: `✅ Committed ${replacements.length} replacement(s) to ${path} (commit ${result.commit.sha.slice(0, 7)}).\n\n${diffLines.join("\n")}` }],
+ };
+ }
+
+ let sha;
+ try {
+ const query = branch ? `?ref=${encodeURIComponent(branch)}` : "";
+ const existing = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${query}`);
+ sha = existing.sha;
+ } catch { /* new file */ }
+ const result = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ body: { message, content: toBase64(content), branch, sha },
+ });
+ return { content: [{ type: "text", text: `${sha ? "Overwrote" : "Created"} ${path} in ${owner}/${repo} (commit ${result.commit.sha.slice(0, 7)}).` }] };
+ }
+ );
+
+ server.tool(
+ "delete_file",
+ "DOES: Delete a file from a repo.\n" +
+ "NOT: replacing/updating contents -> edit_file. NOT: creating a file -> create_repo_file.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo"),
+ message: z.string().describe("Commit message"),
+ branch: z.string().optional().describe("Branch to commit to (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, message, branch }) => {
+ const query = branch ? `?ref=${encodeURIComponent(branch)}` : "";
+ const existing = await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${query}`);
+ await githubRequest(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
+ method: "DELETE",
+ body: { message, sha: existing.sha, branch },
+ });
+ return { content: [{ type: "text", text: `Deleted ${path} from ${owner}/${repo}.` }] };
+ }
+ );
+
+ server.tool(
+ "rename_file",
+ "DOES: Rename/move a file in a repo.\n" +
+ "NOT: editing contents without moving -> edit_file (targeted or full rewrite). NOT: creating a new file -> create_repo_file.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ old_path: z.string().describe("Current file path"),
+ new_path: z.string().describe("New file path / destination"),
+ message: z.string().optional().describe("Commit message (default: 'rename <old> to <new>')"),
+ branch: z.string().optional().describe("Branch to commit to (default: repo default branch)"),
+ },
+ async ({ owner, repo, old_path, new_path, message, branch }) => {
+ const commitMessage = message || `rename ${old_path} to ${new_path}`;
+ const content = await readFileViaBlob(owner, repo, old_path, branch);
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const targetBranch = branch || repoInfo.default_branch;
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(targetBranch)}`);
+ const baseCommit = await githubRequest(`/repos/${owner}/${repo}/git/commits/${refData.object.sha}`);
+ const newBlob = await githubRequest(`/repos/${owner}/${repo}/git/blobs`, {
+ method: "POST",
+ body: { content: toBase64(content), encoding: "base64" },
+ });
+ const newTree = await githubRequest(`/repos/${owner}/${repo}/git/trees`, {
+ method: "POST",
+ body: {
+ base_tree: baseCommit.tree.sha,
+ tree: [
+ { path: new_path, mode: "100644", type: "blob", sha: newBlob.sha },
+ { path: old_path, mode: "100644", type: "blob", sha: null },
+ ],
+ },
+ });
+ const newCommit = await githubRequest(`/repos/${owner}/${repo}/git/commits`, {
+ method: "POST",
+ body: { message: commitMessage, tree: newTree.sha, parents: [refData.object.sha] },
+ });
+ await githubRequest(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(targetBranch)}`, {
+ method: "PATCH",
+ body: { sha: newCommit.sha },
+ });
+ return { content: [{ type: "text", text: `Renamed ${old_path} → ${new_path} in ${owner}/${repo} (commit ${newCommit.sha.slice(0, 7)}).` }] };
+ }
+ );
+
+ server.tool(
+ "overwrite_files",
+ "DOES: Create/overwrite multiple files as ONE atomic commit -- each file's full content written as-is.\n" +
+ "RULE: one file at a time -> use create_repo_file/edit_file instead (single-file equivalents).",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ branch: z.string().optional().describe("Branch to push to (default: repo default branch)"),
+ message: z.string().describe("Commit message"),
+ files: z.array(z.object({
+ path: z.string().describe("File path within the repo"),
+ content: z.string().describe("Full new content of the file (plain text)"),
+ })).min(1).describe("Files to include in this commit"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, branch, message, files }) => {
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const targetBranch = branch || repoInfo.default_branch;
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(targetBranch)}`);
+ const baseCommit = await githubRequest(`/repos/${owner}/${repo}/git/commits/${refData.object.sha}`);
+ const blobs = await Promise.all(files.map((f) =>
+ githubRequest(`/repos/${owner}/${repo}/git/blobs`, {
+ method: "POST",
+ body: { content: toBase64(f.content), encoding: "base64" },
+ })
+ ));
+ const newTree = await githubRequest(`/repos/${owner}/${repo}/git/trees`, {
+ method: "POST",
+ body: {
+ base_tree: baseCommit.tree.sha,
+ tree: files.map((f, i) => ({ path: f.path, mode: "100644", type: "blob", sha: blobs[i].sha })),
+ },
+ });
+ const newCommit = await githubRequest(`/repos/${owner}/${repo}/git/commits`, {
+ method: "POST",
+ body: { message, tree: newTree.sha, parents: [refData.object.sha] },
+ });
+ await githubRequest(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(targetBranch)}`, {
+ method: "PATCH",
+ body: { sha: newCommit.sha },
+ });
+ return { content: [{ type: "text", text: `Pushed ${files.length} file(s) to ${owner}/${repo}@${targetBranch} (commit ${newCommit.sha.slice(0, 7)}).` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 | + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x + | // ---------------------------------------------------------------------------
+// connectors/github/helpers.js — shared helpers for GitHub connector modules
+// ---------------------------------------------------------------------------
+
+import { githubRequest, fromBase64 } from "./client.js";
+
+export async function getFileBlobSha(owner, repo, filePath, ref) {
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const branch = ref || repoInfo.default_branch;
+ let treeSha;
+ try {
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
+ treeSha = refData.object.sha;
+ } catch {
+ treeSha = branch;
+ }
+ const tree = await githubRequest(`/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
+ const entry = tree.tree.find((item) => item.path === filePath && item.type === "blob");
+ if (!entry) throw new Error(`File not found in tree: ${filePath}`);
+ return { blobSha: entry.sha, treeSha };
+}
+
+export async function readFileViaBlob(owner, repo, filePath, ref) {
+ const { blobSha } = await getFileBlobSha(owner, repo, filePath, ref);
+ const blob = await githubRequest(`/repos/${owner}/${repo}/git/blobs/${blobSha}`);
+ return fromBase64(blob.content.replace(/\n/g, ""));
+}
+
+export const CHUNK_SIZE = 20000;
+export const CHUNK_THRESHOLD = 100000;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| actions.js | +
+
+ |
+ 5.08% | +3/59 | +0% | +0/69 | +7.69% | +1/13 | +6% | +3/50 | +
| app_auth.js | +
+
+ |
+ 96.96% | +32/33 | +94.73% | +18/19 | +90% | +9/10 | +100% | +31/31 | +
| branches.js | +
+
+ |
+ 100% | +28/28 | +100% | +12/12 | +100% | +8/8 | +100% | +25/25 | +
| ci_control.js | +
+
+ |
+ 14.7% | +5/34 | +0% | +0/43 | +9.09% | +1/11 | +18.51% | +5/27 | +
| client.js | +
+
+ |
+ 30.2% | +29/96 | +31.94% | +23/72 | +25% | +6/24 | +29.26% | +24/82 | +
| clone_token.js | +
+
+ |
+ 100% | +7/7 | +100% | +1/1 | +100% | +2/2 | +100% | +7/7 | +
| diff.js | +
+
+ |
+ 1.31% | +1/76 | +0% | +0/47 | +11.11% | +1/9 | +1.56% | +1/64 | +
| files.js | +
+
+ |
+ 76% | +114/150 | +65.11% | +56/86 | +66.66% | +12/18 | +74.6% | +94/126 | +
| helpers.js | +
+
+ |
+ 11.76% | +2/17 | +0% | +0/6 | +0% | +0/3 | +13.33% | +2/15 | +
| issues.js | +
+
+ |
+ 10.86% | +5/46 | +0% | +0/37 | +9.09% | +1/11 | +12.5% | +5/40 | +
| prs.js | +
+
+ |
+ 97.26% | +71/73 | +74.71% | +65/87 | +100% | +14/14 | +100% | +67/67 | +
| releases.js | +
+
+ |
+ 17.64% | +3/17 | +0% | +0/23 | +16.66% | +1/6 | +21.42% | +3/14 | +
| repo.js | +
+
+ |
+ 24% | +6/25 | +11.42% | +4/35 | +28.57% | +2/7 | +26.08% | +6/23 | +
| repo_mgmt.js | +
+
+ |
+ 13.88% | +5/36 | +0% | +0/23 | +14.28% | +1/7 | +15.62% | +5/32 | +
| resource.js | +
+
+ |
+ 5.55% | +1/18 | +0% | +0/8 | +25% | +1/4 | +6.25% | +1/16 | +
| review_control.js | +
+
+ |
+ 9.52% | +6/63 | +0% | +0/87 | +8.33% | +1/12 | +10.9% | +6/55 | +
| search.js | +
+
+ |
+ 39.33% | +59/150 | +30.43% | +28/92 | +33.33% | +7/21 | +42.85% | +54/126 | +
| tools.js | +
+
+ |
+ 100% | +13/13 | +100% | +0/0 | +100% | +1/1 | +100% | +13/13 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 | + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/issues.js — issues tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "get_issue",
+ "DOES: Full details of a single issue -- complete body text + comment thread.\n" +
+ "NOT: title/metadata snippets only -> that's search_issues/list_issues.\n" +
+ "RULE: assessing whether an issue is a good, well-scoped contribution candidate -> use this first.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ issue_number: z.number().describe("Issue number"),
+ include_comments: z.boolean().optional().describe("Whether to fetch and include the issue's comment thread (default: true)"),
+ max_comments: z.number().optional().describe("Max number of comments to include, most recent first (default: 20, max: 100)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, issue_number, include_comments = true, max_comments = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues/${issue_number}`);
+ if (data.pull_request) {
+ return { content: [{ type: "text", text: `#${issue_number} is a pull request, not an issue -- use get_pr_comments/get_pr_reviews instead.` }] };
+ }
+ const labels = data.labels.length ? data.labels.map((l) => l.name).join(", ") : "none";
+ const assignees = data.assignees.length ? data.assignees.map((a) => a.login).join(", ") : "none";
+ const lines = [
+ `#${data.number} [${data.state}] ${data.title}`,
+ `by ${data.user.login} | opened ${data.created_at.slice(0, 10)} | updated ${data.updated_at.slice(0, 10)}`,
+ `labels: ${labels} | assignees: ${assignees} | comments: ${data.comments}`,
+ data.html_url,
+ "",
+ "--- body ---",
+ data.body || "(no body)",
+ ];
+
+ if (include_comments && data.comments > 0) {
+ // NOTE: the issue-comments endpoint does NOT support sort/direction
+ // query params (unlike PR review-comments) -- it always returns
+ // oldest-first. To show the most recent `max_comments` when a issue
+ // has more comments than that, we must fetch the last page rather
+ // than the first.
+ const perPage = Math.min(Math.max(max_comments, 1), 100);
+ let page = 1;
+ if (data.comments > perPage) {
+ const totalPages = Math.ceil(data.comments / perPage);
+ page = totalPages; // last page = most recent comments
+ }
+ const commentsData = await githubRequest(
+ `/repos/${owner}/${repo}/issues/${issue_number}/comments?per_page=${perPage}&page=${page}`
+ );
+ lines.push("", `--- comments (${commentsData.length} most recent of ${data.comments} shown) ---`);
+ for (const c of commentsData) {
+ lines.push("", `[${c.user.login} | ${c.created_at.slice(0, 10)}]`, c.body || "(empty)");
+ }
+ } else if (include_comments) {
+ lines.push("", "--- comments ---", "(no comments)");
+ }
+
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ server.tool(
+ "list_issues",
+ "DOES: List issues in a single known repo (title/metadata only, no body/comments -> use get_issue for full detail).\n" +
+ "NOT: cross-repo discovery -> use search_issues for that.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ state: z.enum(["open", "closed", "all"]).optional().describe("Filter by state (default: open)"),
+ labels: z.string().optional().describe("Comma-separated list of label names to filter by"),
+ assignee: z.string().optional().describe("Filter by assignee username"),
+ per_page: z.number().optional().describe("Number of issues to return, max 100 (default: 20)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, state = "open", labels, assignee, per_page = 20 }) => {
+ const query = new URLSearchParams({ state, per_page: String(per_page) });
+ if (labels) query.set("labels", labels);
+ if (assignee) query.set("assignee", assignee);
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues?${query}`);
+ const issues = data.filter((i) => !i.pull_request);
+ if (!issues.length) return { content: [{ type: "text", text: `No ${state} issues found.` }] };
+ const lines = issues.map((i) =>
+ `#${i.number} [${i.state}] ${i.title}\n by ${i.user.login} | ${i.created_at.slice(0, 10)}` +
+ `${i.labels.length ? ` | labels: ${i.labels.map((l) => l.name).join(", ")}` : ""}` +
+ `${i.assignee ? ` | assigned: ${i.assignee.login}` : ""}\n ${i.html_url}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "create_issue",
+ "Open a new issue in a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ title: z.string().describe("Issue title"),
+ body: z.string().optional().describe("Issue body (markdown supported)"),
+ labels: z.array(z.string()).optional().describe("Labels to apply to the issue"),
+ assignees: z.array(z.string()).optional().describe("GitHub usernames to assign the issue to"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, title, body, labels, assignees }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues`, {
+ method: "POST",
+ body: { title, body, labels, assignees },
+ });
+ return { content: [{ type: "text", text: `Created issue #${data.number}: "${data.title}"\n${data.html_url}` }] };
+ }
+ );
+
+ server.tool(
+ "update_issue",
+ "Update an existing issue (close, reopen, retitle, relabel, or reassign).",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ issue_number: z.number().describe("Issue number"),
+ title: z.string().optional().describe("New title"),
+ body: z.string().optional().describe("New body"),
+ state: z.enum(["open", "closed"]).optional().describe("New state"),
+ labels: z.array(z.string()).optional().describe("Replacement label list"),
+ assignees: z.array(z.string()).optional().describe("Replacement assignee list"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, issue_number, title, body, state, labels, assignees }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues/${issue_number}`, {
+ method: "PATCH",
+ body: { title, body, state, labels, assignees },
+ });
+ return { content: [{ type: "text", text: `Updated issue #${data.number}: "${data.title}" [${data.state}]\n${data.html_url}` }] };
+ }
+ );
+
+ server.tool(
+ "add_issue_comment",
+ "Post a comment on an issue or pull request.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ issue_number: z.number().describe("Issue or PR number"),
+ body: z.string().describe("Comment body (markdown supported)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, issue_number, body }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/issues/${issue_number}/comments`, {
+ method: "POST",
+ body: { body },
+ });
+ return { content: [{ type: "text", text: `Posted comment #${data.id} on #${issue_number}.\n${data.html_url}` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 | + + + + + + + + + + + + + + + + + + +4x +3x + + + + +16x + + + + + + + + + + + + + + + + + + +5x +3x +3x +2x +2x + +2x + + +2x +2x + + + + + +5x +1x +1x + + +1x + + + + + +2x +1x +1x + + +1x + + + + + +2x +1x +1x + + +1x +1x +1x +1x + + + + + +2x + + + +16x + + + + + + + + + + + +1x + +1x +1x +1x + + +1x + + + + + +1x +1x +1x + + +1x + + + + + + +1x + + + +16x + + + + + + + + + + + + +1x + + + +1x + + + +16x + + + + + + + + + + + + + + + +4x +4x +4x +4x +4x + +4x +1x + + +3x + +3x +2x +2x +1x + +1x + + + +1x + + + +3x +1x + + + +1x +1x + + +3x + + + +16x + + + + + + + + + + + +1x + + + +1x + + + +16x + + + + + + + + + + + +1x + + + +1x + + + + | // ---------------------------------------------------------------------------
+// connectors/github/prs.js — pull request tools
+//
+// NOTE ON TOOL DESCRIPTIONS BELOW: rewritten into tagged DOES:/RULE:/NOTE:
+// format for faster LLM parsing (same convention as github/files.js and
+// frontend/designer_tools.js). Rationale/mechanism detail not needed at
+// call-selection time (how "ready" is implemented, what the commit
+// verification badge means) lives in code comments here instead of in the
+// description strings the calling model reads.
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest, githubGraphQL } from "./client.js";
+
+// GitHub's `state` field is only "open"/"closed" -- a closed-and-merged PR
+// and a closed-without-merging PR both report state: "closed". The list and
+// single-PR endpoints both also return `merged_at` (null unless merged), so
+// use that to tell the two apart instead of state alone.
+function prStatusLabel(pr) {
+ if (pr.state === "closed") return pr.merged_at ? "merged" : "closed";
+ return pr.state;
+}
+
+export function register(server) {
+
+ server.tool(
+ "get_pull_requests",
+ "DOES: List PRs in a repo, OR (pull_number given) fetch one PR's full details + comments + reviews + commits merged into one response.\n" +
+ "RULE: pull_number set -> state/per_page ignored. Use include_comments/include_reviews/include_commits=false to trim an unwanted section out of the response.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ state: z.enum(["open", "closed", "all"]).optional().describe("Filter by PR state when listing (default: open). Ignored if pull_number is given."),
+ per_page: z.number().optional().describe("Number of PRs to return when listing, max 100 (default: 20). Ignored if pull_number is given."),
+ pull_number: z.number().optional().describe("If provided, fetch this single PR's details instead of listing PRs."),
+ include_comments: z.boolean().optional().describe("When fetching a single PR, include its conversation comments (default: true)"),
+ include_reviews: z.boolean().optional().describe("When fetching a single PR, include its formal reviews (default: true)"),
+ // "Verified"/"Unverified" badge below matches the same GitHub-signature check GitHub's own UI shows on each commit.
+ include_commits: z.boolean().optional().describe("When fetching a single PR, include its commit list with signature verification status (default: true)"),
+ max_comments: z.number().optional().describe("Max comments to include, most recent first, when fetching a single PR (default: 20, max: 100)"),
+ max_reviews: z.number().optional().describe("Max reviews to include when fetching a single PR (default: 30, max: 100)"),
+ max_commits: z.number().optional().describe("Max commits to include when fetching a single PR (default: 100, max: 250)"),
+ },
+ async ({ owner, repo, state = "open", per_page = 20, pull_number, include_comments = true, include_reviews = true, include_commits = true, max_comments = 20, max_reviews = 30, max_commits = 100 }) => {
+ if (pull_number === undefined) {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls?state=${state}&per_page=${per_page}`);
+ if (!data.length) return { content: [{ type: "text", text: `No ${state} pull requests found.` }] };
+ const lines = data.map((pr) =>
+ `#${pr.number} [${prStatusLabel(pr)}] ${pr.title}\n ${pr.head.label} → ${pr.base.label} | by ${pr.user.login} | ${pr.created_at.slice(0, 10)}\n ${pr.html_url}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+
+ const pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`);
+ const sections = [
+ `#${pr.number} [${prStatusLabel(pr)}${pr.draft ? ", draft" : ""}] ${pr.title}\n` +
+ `${pr.head.label} → ${pr.base.label} | by ${pr.user.login} | opened ${pr.created_at.slice(0, 10)}\n` +
+ `${pr.html_url}\n\n${pr.body || "(no description)"}`
+ ];
+
+ if (include_comments) {
+ const comments = await githubRequest(`/repos/${owner}/${repo}/issues/${pull_number}/comments?per_page=${max_comments}`);
+ sections.push(
+ comments.length
+ ? `--- ${comments.length} comment(s) ---\n\n` + comments.map((c) =>
+ `${c.user.login} (${c.created_at.slice(0, 16).replace("T", " ")}):\n${c.body}`
+ ).join("\n\n")
+ : "--- No comments ---"
+ );
+ }
+
+ if (include_reviews) {
+ const reviews = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews?per_page=${max_reviews}`);
+ sections.push(
+ reviews.length
+ ? `--- ${reviews.length} review(s) ---\n\n` + reviews.map((r) =>
+ `${r.user.login} — ${r.state} (${(r.submitted_at || "").slice(0, 16).replace("T", " ")})${r.body ? `:\n${r.body}` : ""}`
+ ).join("\n\n")
+ : "--- No reviews yet ---"
+ );
+ }
+
+ if (include_commits) {
+ const commits = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/commits?per_page=${max_commits}`);
+ sections.push(
+ commits.length
+ ? `--- ${commits.length} commit(s) — signature verification ---\n\n` + commits.map((c) => {
+ const v = c.commit?.verification || {};
+ const badge = v.verified ? "✅ Verified" : `❌ Unverified${v.reason ? ` (${v.reason})` : ""}`;
+ const firstLine = (c.commit?.message || "").split("\n")[0];
+ return `${c.sha.slice(0, 7)} — ${badge}\n ${firstLine}\n author: ${c.commit?.author?.name || c.author?.login || "unknown"}`;
+ }).join("\n\n")
+ : "--- No commits found ---"
+ );
+ }
+
+ return { content: [{ type: "text", text: sections.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "get_pr_activity",
+ "DOES: PR conversation comments and/or formal reviews (approve/request-changes/comment verdicts). Use `type` to pick one or both.\n" +
+ "NOT: inline diff comments (none of these tools currently expose those).",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ type: z.enum(["comments", "reviews", "both"]).optional().describe("Which activity to fetch (default: both)"),
+ per_page: z.number().optional().describe("Number of items to return per type, max 100 (default: 30)"),
+ },
+ async ({ owner, repo, pull_number, type = "both", per_page = 30 }) => {
+ const sections = [];
+
+ Eif (type === "comments" || type === "both") {
+ const comments = await githubRequest(`/repos/${owner}/${repo}/issues/${pull_number}/comments?per_page=${per_page}`);
+ sections.push(
+ comments.length
+ ? `${comments.length} comment(s) on PR #${pull_number}:\n\n` + comments.map((c) =>
+ `${c.user.login} (${c.created_at.slice(0, 16).replace("T", " ")}):\n${c.body}\n ${c.html_url}`
+ ).join("\n\n---\n\n")
+ : `No comments on PR #${pull_number}.`
+ );
+ }
+
+ Eif (type === "reviews" || type === "both") {
+ const reviews = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews?per_page=${per_page}`);
+ sections.push(
+ reviews.length
+ ? `${reviews.length} review(s) on PR #${pull_number}:\n\n` + reviews.map((r) =>
+ `${r.user.login} — ${r.state} (${(r.submitted_at || "").slice(0, 16).replace("T", " ")})` +
+ `${r.body ? `:\n${r.body}` : ""}\n ${r.html_url}`
+ ).join("\n\n---\n\n")
+ : `No reviews on PR #${pull_number} yet.`
+ );
+ }
+
+ return { content: [{ type: "text", text: sections.join("\n\n===\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "create_pull_request",
+ "Open a new pull request in a GitHub repository.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ title: z.string().describe("PR title"),
+ head: z.string().describe("The branch containing the changes (source branch)"),
+ base: z.string().describe("The branch to merge into (target branch, e.g. 'main')"),
+ body: z.string().optional().describe("PR description body"),
+ draft: z.boolean().optional().describe("Open as a draft PR (default: false)"),
+ },
+ async ({ owner, repo, title, head, base, body, draft = false }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls`, {
+ method: "POST",
+ body: { title, head, base, body, draft },
+ });
+ return { content: [{ type: "text", text: `Created PR #${data.number}: "${data.title}"\n${data.html_url}` }] };
+ }
+ );
+
+ server.tool(
+ "update_pull_request",
+ "DOES: Edit title/body/base/open-closed-state/draft-status on an existing PR. Pass only the field(s) to change.\n" +
+ "RULE: ready=true only converts draft -> ready; no-op (with notice) if already non-draft. No API path exists to convert ready back to draft.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ title: z.string().optional().describe("New PR title"),
+ body: z.string().optional().describe("New PR description body (replaces the existing description entirely)"),
+ state: z.enum(["open", "closed"]).optional().describe("Set to 'closed' to close the PR without merging, or 'open' to reopen it"),
+ base: z.string().optional().describe("Change the base branch this PR merges into"),
+ // GitHub's REST API has no field for draft->ready, so this runs the markPullRequestReadyForReview GraphQL mutation under the hood instead.
+ ready: z.boolean().optional().describe("Set to true to convert a draft PR to ready for review (default: unchanged)"),
+ },
+ async ({ owner, repo, pull_number, title, body, state, base, ready }) => {
+ const patch = {};
+ if (title !== undefined) patch.title = title;
+ if (body !== undefined) patch.body = body;
+ Iif (state !== undefined) patch.state = state;
+ Iif (base !== undefined) patch.base = base;
+
+ if (Object.keys(patch).length === 0 && ready === undefined) {
+ return { content: [{ type: "text", text: "No fields provided to update — pass at least one of title, body, state, base, or ready." }] };
+ }
+
+ const results = [];
+
+ if (ready === true) {
+ const pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`);
+ if (!pr.draft) {
+ results.push(`PR #${pull_number} is already ready for review (not a draft) — no change made.`);
+ } else {
+ await githubGraphQL(
+ `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { number isDraft } } }`,
+ { id: pr.node_id }
+ );
+ results.push(`PR #${pull_number} converted from draft to ready for review.`);
+ }
+ }
+
+ if (Object.keys(patch).length > 0) {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`, {
+ method: "PATCH",
+ body: patch,
+ });
+ const updated = Object.keys(patch).join(", ");
+ results.push(`Updated PR #${pull_number} (${updated}).\n${data.html_url}`);
+ }
+
+ return { content: [{ type: "text", text: results.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "merge_pull_request",
+ "DOES: Merge a PR. RULE: irreversible via this tool -- no unmerge.",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ merge_method: z.enum(["merge", "squash", "rebase"]).optional().describe("Merge strategy (default: merge)"),
+ commit_title: z.string().optional().describe("Title for the merge commit"),
+ commit_message: z.string().optional().describe("Body for the merge commit"),
+ },
+ async ({ owner, repo, pull_number, merge_method = "merge", commit_title, commit_message }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/merge`, {
+ method: "PUT",
+ body: { merge_method, commit_title, commit_message },
+ });
+ return { content: [{ type: "text", text: `Merged PR #${pull_number}: ${data.message}\nCommit: ${data.sha?.slice(0, 7) ?? "n/a"}` }] };
+ }
+ );
+
+ server.tool(
+ "review_pull_request",
+ "DOES: Submit a formal review on a PR (APPROVE / REQUEST_CHANGES / COMMENT).\n" +
+ "NOT: a plain conversation reply -> use add_issue_comment for that (works on PRs too, since PRs are issues under the hood).",
+ {
+ owner: z.string().describe("Repository owner (user or org)"),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ event: z.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]).describe("Review action"),
+ body: z.string().optional().describe("Review comment body"),
+ },
+ async ({ owner, repo, pull_number, event, body }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews`, {
+ method: "POST",
+ body: { event, body },
+ });
+ return { content: [{ type: "text", text: `Submitted review #${data.id} (${event}) on PR #${pull_number}.` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 | + + + + + + + + + +3x + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/releases.js — releases & tags tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "list_releases",
+ "List releases in a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ per_page: z.number().optional().describe("Number of releases to return, max 100 (default: 10)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, per_page = 10 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/releases?per_page=${per_page}`);
+ if (!data.length) return { content: [{ type: "text", text: "No releases found." }] };
+ const lines = data.map((r) =>
+ `${r.tag_name} — ${r.name || "(no name)"}${r.draft ? " [DRAFT]" : ""}${r.prerelease ? " [PRE-RELEASE]" : ""}\n Published: ${r.published_at?.slice(0, 10) ?? "unpublished"} | ${r.html_url}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "create_release",
+ "Create a new release in a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ tag_name: z.string().describe("Tag name for the release (e.g. 'v1.2.0')"),
+ name: z.string().optional().describe("Release title"),
+ body: z.string().optional().describe("Release notes (markdown supported)"),
+ draft: z.boolean().optional().describe("Create as a draft release (default: false)"),
+ prerelease: z.boolean().optional().describe("Mark as a pre-release (default: false)"),
+ target_commitish: z.string().optional().describe("Branch or commit SHA the tag should point to"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, tag_name, name, body, draft = false, prerelease = false, target_commitish }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/releases`, {
+ method: "POST",
+ body: { tag_name, name, body, draft, prerelease, target_commitish },
+ });
+ return { content: [{ type: "text", text: `Created release "${data.name || data.tag_name}"${draft ? " (draft)" : ""}.\n${data.html_url}` }] };
+ }
+ );
+
+ server.tool(
+ "list_tags",
+ "List tags in a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ per_page: z.number().optional().describe("Number of tags to return, max 100 (default: 20)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, per_page = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/tags?per_page=${per_page}`);
+ if (!data.length) return { content: [{ type: "text", text: "No tags found." }] };
+ const lines = data.map((t) => `${t.name} ${t.commit.sha.slice(0, 7)}`);
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 | + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + +1x + + + + + + + + + +1x + + + +3x + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/repo.js — repo metadata tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "list_repos",
+ "List repositories for a GitHub user or organization.",
+ {
+ owner: z.string().describe("GitHub username or organization name"),
+ type: z.enum(["all", "owner", "member"]).optional().describe("Filter by repo type (default: all). Only meaningful for a user owner -- \"owner\" isn't a valid filter on GitHub's org-repos endpoint, so if `owner` turns out to be an organization, this is silently remapped to \"all\" rather than erroring."),
+ sort: z.enum(["created", "updated", "pushed", "full_name"]).optional().describe("Sort order (default: updated)"),
+ per_page: z.number().optional().describe("Number of repos to return, max 100 (default: 30)"),
+ },
+ async ({ owner, type = "all", sort = "updated", per_page = 30 }) => {
+ let data;
+ try {
+ data = await githubRequest(`/users/${owner}/repos?type=${type}&sort=${sort}&per_page=${per_page}`);
+ } catch {
+ // /orgs/:org/repos doesn't accept the same `type` values as
+ // /users/:username/repos — it has no "owner" value (valid values are
+ // all/public/private/forks/sources/member). "owner" is only meaningful
+ // for the user endpoint we just tried, so map it to "all" here rather
+ // than forwarding a value the org endpoint will 422 on. Other type
+ // values ("all", "member") are valid on both and pass through as-is.
+ const orgType = type === "owner" ? "all" : type;
+ data = await githubRequest(`/orgs/${owner}/repos?type=${orgType}&sort=${sort}&per_page=${per_page}`);
+ }
+ const lines = data.map((r) =>
+ `${r.private ? "🔒" : "🌐"} ${r.full_name}${r.description ? ` — ${r.description}` : ""} [${r.language || "unknown"}] ⭐${r.stargazers_count}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n") || "(no repositories found)" }] };
+ }
+ );
+
+ server.tool(
+ "get_repo",
+ "Get detailed metadata for a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo }) => {
+ const r = await githubRequest(`/repos/${owner}/${repo}`);
+ const text =
+ `${r.full_name} (${r.private ? "private" : "public"})\n` +
+ `Description: ${r.description || "(none)"}\n` +
+ `Default branch: ${r.default_branch}\n` +
+ `Language: ${r.language || "unknown"}\n` +
+ `Stars: ${r.stargazers_count} | Forks: ${r.forks_count} | Open issues: ${r.open_issues_count}\n` +
+ `Topics: ${r.topics?.join(", ") || "(none)"}\n` +
+ `Created: ${r.created_at.slice(0, 10)} | Last push: ${r.pushed_at.slice(0, 10)}\n` +
+ `URL: ${r.html_url}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "list_contributors",
+ "List contributors to a GitHub repository with commit counts.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ per_page: z.number().optional().describe("Number of contributors to return, max 100 (default: 20)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, per_page = 20 }) => {
+ const data = await githubRequest(`/repos/${owner}/${repo}/contributors?per_page=${per_page}`);
+ if (!data.length) return { content: [{ type: "text", text: "No contributors found." }] };
+ const lines = data.map((c, i) => `${i + 1}. ${c.login} — ${c.contributions} commit${c.contributions !== 1 ? "s" : ""}`);
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ server.tool(
+ "get_repo_topics",
+ "Get or replace the topics on a GitHub repository.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ set_topics: z.array(z.string()).optional().describe("If provided, replaces all existing topics with this list."),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, set_topics }) => {
+ if (set_topics !== undefined) {
+ await githubRequest(`/repos/${owner}/${repo}/topics`, {
+ method: "PUT",
+ body: { names: set_topics },
+ accept: "application/vnd.github.mercy-preview+json",
+ });
+ return { content: [{ type: "text", text: `Updated topics for ${owner}/${repo}: ${set_topics.join(", ") || "(none)"}` }] };
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/topics`, {
+ accept: "application/vnd.github.mercy-preview+json",
+ });
+ return { content: [{ type: "text", text: `Topics for ${owner}/${repo}: ${data.names?.join(", ") || "(none)"}` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/repo_mgmt.js — repo lifecycle + file-at-commit tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest, fromBase64 } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ // ── Create repo ──────────────────────────────────────────────────────────
+
+ server.tool(
+ "create_repo",
+ "Create a new GitHub repository under the authenticated user or an org.",
+ {
+ name: z.string().describe("Repository name (no spaces)"),
+ description: z.string().optional().describe("Short description of the repository"),
+ private: z.boolean().optional().describe("Whether the repo is private (default: false)"),
+ auto_init: z.boolean().optional().describe("Initialize with a README (default: false)"),
+ org: z.string().optional().describe("Organization to create the repo under. Omit to create under the authenticated user."),
+ },
+ async ({ name, description, private: isPrivate = false, auto_init = false, org }) => {
+ const endpoint = org ? `/orgs/${org}/repos` : "/user/repos";
+ const data = await githubRequest(endpoint, {
+ method: "POST",
+ body: { name, description, private: isPrivate, auto_init },
+ });
+ return {
+ content: [{
+ type: "text",
+ text: `Created ${data.private ? "private" : "public"} repo: ${data.full_name}\n${data.html_url}`,
+ }],
+ };
+ }
+ );
+
+ // ── Fork repo ────────────────────────────────────────────────────────────
+
+ server.tool(
+ "fork_repo",
+ "Fork a GitHub repository into the authenticated user's account or an org. Forking is async on GitHub's side — the returned repo may take a few seconds to become fully clone-able.",
+ {
+ owner: z.string().describe("Owner of the repository to fork (e.g. 'modelcontextprotocol')"),
+ repo: z.string().describe("Repository name to fork"),
+ organization: z.string().optional().describe("Org to fork into. Omit to fork into the authenticated user's account."),
+ name: z.string().optional().describe("Rename the fork. Omit to keep the original name."),
+ default_branch_only: z.boolean().optional().describe("Fork only the default branch (default: false — forks all branches)."),
+ },
+ async ({ owner, repo, organization, name, default_branch_only }) => {
+ const body = {};
+ if (organization) body.organization = organization;
+ if (name) body.name = name;
+ if (default_branch_only !== undefined) body.default_branch_only = default_branch_only;
+ const data = await githubRequest(`/repos/${owner}/${repo}/forks`, {
+ method: "POST",
+ body,
+ });
+ return {
+ content: [{
+ type: "text",
+ text: `Forked ${owner}/${repo} → ${data.full_name}\n${data.html_url}\n(fork may take a few seconds to finish populating)`,
+ }],
+ };
+ }
+ );
+
+ // ── Sync fork ────────────────────────────────────────────────────────────
+
+ server.tool(
+ "sync_fork",
+ "Fast-forward a branch on a fork so it matches its upstream parent repository, using GitHub's merge-upstream API. Only works on repos that are actual forks, and only fast-forwards (no conflict resolution) — if the branch has diverged with local commits ahead of upstream, this will report that a merge is needed instead of a fast-forward.",
+ {
+ owner: z.string().optional().describe(`Repository owner (the fork). Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name (the fork)"),
+ branch: z.string().optional().describe("Branch to sync (default: repo default branch)"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, branch }) => {
+ let targetBranch = branch;
+ if (!targetBranch) {
+ const repoData = await githubRequest(`/repos/${owner}/${repo}`);
+ targetBranch = repoData.default_branch;
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/merge-upstream`, {
+ method: "POST",
+ body: { branch: targetBranch },
+ });
+ return {
+ content: [{
+ type: "text",
+ text: `${data.merge_type === "fast-forward" ? "✅" : "ℹ️"} ${owner}/${repo}:${targetBranch} — ${data.message}\nMerge type: ${data.merge_type}\nNow at: ${data.base_branch}`,
+ }],
+ };
+ }
+ );
+
+ // ── Delete repo ──────────────────────────────────────────────────────────
+
+ server.tool(
+ "delete_repo",
+ "Permanently delete a GitHub repository. This is irreversible — use with caution.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name to delete"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo }) => {
+ await githubRequest(`/repos/${owner}/${repo}`, { method: "DELETE" });
+ return {
+ content: [{
+ type: "text",
+ text: `🗑️ Deleted ${owner}/${repo} permanently.`,
+ }],
+ };
+ }
+ );
+
+ // ── Get file at commit ───────────────────────────────────────────────────
+
+ server.tool(
+ "get_file_at_commit",
+ "Read a file's contents as it existed at a specific commit SHA.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ path: z.string().describe("File path within the repo"),
+ commit: z.string().describe("Commit SHA to read the file from"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, path, commit }) => {
+ // Walk the tree at the given commit SHA
+ const commitData = await githubRequest(`/repos/${owner}/${repo}/commits/${commit}`);
+ const treeSha = commitData.commit.tree.sha;
+ const tree = await githubRequest(`/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
+ const entry = tree.tree.find((item) => item.path === path && item.type === "blob");
+ if (!entry) {
+ return {
+ content: [{ type: "text", text: `File not found at commit ${commit.slice(0, 7)}: ${path}` }],
+ isError: true,
+ };
+ }
+ const blob = await githubRequest(`/repos/${owner}/${repo}/git/blobs/${entry.sha}`);
+ const content = fromBase64(blob.content.replace(/\n/g, ""));
+ const header = `[${path} @ ${commit.slice(0, 7)} | ${commitData.commit.author.date.slice(0, 10)} | ${content.length} chars]\n\n`;
+ return { content: [{ type: "text", text: header + content }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 | + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/resource.js — MCP resource provider for GitHub files
+// Exposes files as MCP resources via URI: github://{owner}/{repo}/{path}
+// Returns content as a base64 blob to avoid text truncation in MCP clients.
+// ---------------------------------------------------------------------------
+
+import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+function guessMime(path) {
+ const ext = path.split(".").pop().toLowerCase();
+ const map = {
+ js: "application/javascript",
+ ts: "application/typescript",
+ json: "application/json",
+ sh: "text/x-sh",
+ md: "text/markdown",
+ html: "text/html",
+ css: "text/css",
+ py: "text/x-python",
+ rs: "text/x-rust",
+ go: "text/x-go",
+ };
+ return map[ext] || "application/octet-stream";
+}
+
+export function register(server) {
+ server.resource(
+ "github-file",
+ new ResourceTemplate("github://{owner}/{repo}/{+path}", { list: undefined }),
+ async (uri, variables) => {
+ const owner = variables.owner || DEFAULT_OWNER;
+ const repo = variables.repo;
+ const path = variables.path;
+
+ // Resolve default branch
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const branch = repoInfo.default_branch;
+
+ // Get blob SHA from tree
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
+ const tree = await githubRequest(`/repos/${owner}/${repo}/git/trees/${refData.object.sha}?recursive=1`);
+ const entry = tree.tree.find((item) => item.path === path && item.type === "blob");
+ if (!entry) throw new Error(`File not found in tree: ${path}`);
+
+ // Fetch raw base64 directly from blob (GitHub already returns it base64-encoded)
+ const blob = await githubRequest(`/repos/${owner}/${repo}/git/blobs/${entry.sha}`);
+ // blob.content is already base64 with newlines — strip newlines for clean base64
+ const base64 = blob.content.replace(/\n/g, "");
+
+ return {
+ contents: [{
+ uri: uri.href,
+ mimeType: guessMime(path),
+ blob: base64,
+ }],
+ };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 | + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/review_control.js — reviewer assignment, merge
+// readiness, inline review comments, branch protection (read), and
+// notifications. Complements prs.js (which submits whole-PR reviews but
+// can't request reviewers or surface merge conflicts) and actions.js/
+// ci_control.js (commit-level CI state, not PR-level review state).
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { githubRequest } from "./client.js";
+import { DEFAULT_OWNER } from "../../config.js";
+
+export function register(server) {
+
+ server.tool(
+ "request_reviewers",
+ "DOES: Request review from users/teams on a PR (same as clicking 'Request review' in the GitHub UI).\n" +
+ "NOT: submitting a review verdict yourself -> use review_pull_request for that.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ reviewers: z.array(z.string()).optional().describe("GitHub usernames to request review from"),
+ team_reviewers: z.array(z.string()).optional().describe("Team slugs (org teams) to request review from, e.g. 'platform-team'"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, pull_number, reviewers, team_reviewers }) => {
+ if (!reviewers?.length && !team_reviewers?.length) {
+ throw new Error("Provide at least one of reviewers or team_reviewers.");
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/requested_reviewers`, {
+ method: "POST",
+ body: {
+ ...(reviewers?.length ? { reviewers } : {}),
+ ...(team_reviewers?.length ? { team_reviewers } : {}),
+ },
+ });
+ const requested = (data.requested_reviewers || []).map((r) => r.login);
+ const requestedTeams = (data.requested_teams || []).map((t) => t.slug);
+ const text =
+ `Requested review on PR #${pull_number}.\n` +
+ `Reviewers: ${requested.length ? requested.join(", ") : "(none)"}\n` +
+ `Teams: ${requestedTeams.length ? requestedTeams.join(", ") : "(none)"}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "remove_requested_reviewers",
+ "DOES: Cancel a pending review request on a PR. RULE: does not affect reviews already submitted.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ reviewers: z.array(z.string()).optional().describe("GitHub usernames to remove from the review request"),
+ team_reviewers: z.array(z.string()).optional().describe("Team slugs to remove from the review request"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, pull_number, reviewers, team_reviewers }) => {
+ if (!reviewers?.length && !team_reviewers?.length) {
+ throw new Error("Provide at least one of reviewers or team_reviewers.");
+ }
+ await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/requested_reviewers`, {
+ method: "DELETE",
+ body: {
+ ...(reviewers?.length ? { reviewers } : {}),
+ ...(team_reviewers?.length ? { team_reviewers } : {}),
+ },
+ });
+ return { content: [{ type: "text", text: `Removed review request(s) on PR #${pull_number}.` }] };
+ }
+ );
+
+ server.tool(
+ "get_pr_mergeability",
+ "DOES: Check mergeable state, conflicts, required-check status for a PR (retries briefly server-side since GitHub computes this async).\n" +
+ "RULE: use this instead of inferring conflicts from a failed merge attempt or a stale diff.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, pull_number }) => {
+ let pr;
+ let polls = 0;
+ for (let attempt = 0; attempt < 4; attempt++) {
+ pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}`);
+ polls++;
+ if (pr.mergeable !== null) break;
+ if (attempt < 3) await new Promise((r) => setTimeout(r, 1200));
+ }
+
+ const stateMeaning = {
+ clean: "No conflicts, all checks pass — ready to merge.",
+ dirty: "Merge conflicts — the branch needs to be updated before it can merge.",
+ unstable: "Mergeable, but some non-required checks are failing.",
+ blocked: "Blocked — a required check is failing or hasn't run, or a required review is missing.",
+ behind: "Branch is out of date with the base branch and needs updating (required by branch protection).",
+ draft: "PR is a draft.",
+ unknown: "GitHub is still computing mergeability — try again shortly.",
+ };
+
+ const mergeableLine = pr.mergeable === null
+ ? `mergeable: still computing (polled ${polls}x, ~${(polls - 1) * 1.2}s — GitHub hasn't finished; try again shortly)`
+ : `mergeable: ${pr.mergeable}${polls > 1 ? ` (resolved after ${polls} poll(s))` : ""}`;
+ const text =
+ `PR #${pull_number}: ${pr.title}\n` +
+ `${mergeableLine}\n` +
+ `mergeable_state: ${pr.mergeable_state}${stateMeaning[pr.mergeable_state] ? ` — ${stateMeaning[pr.mergeable_state]}` : ""}\n` +
+ `rebaseable: ${pr.rebaseable === null ? "unknown" : pr.rebaseable}\n` +
+ `${pr.head.label} → ${pr.base.label}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "add_review_comment",
+ "DOES: Inline comment anchored to a diff line (same as clicking a line in GitHub's 'Files changed' view).\n" +
+ "NOT: whole-PR verdict -> review_pull_request. NOT: general non-anchored conversation comment -> add_issue_comment.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ pull_number: z.number().describe("Pull request number"),
+ commit_id: z.string().describe("SHA of the commit being commented on — typically the PR's current head SHA (from get_pull_requests or list_commits)"),
+ path: z.string().describe("File path (relative to repo root) the comment applies to"),
+ line: z.number().describe("Line number in the file (as shown in the diff) to attach the comment to. For a multi-line comment, this is the LAST line of the range."),
+ side: z.enum(["LEFT", "RIGHT"]).optional().describe("Which side of the diff `line` refers to — RIGHT for the new/added version, LEFT for the old/removed version (default: RIGHT)"),
+ start_line: z.number().optional().describe("First line of a multi-line comment range. Omit for a single-line comment. Must be on the same side as `line` and less than it."),
+ start_side: z.enum(["LEFT", "RIGHT"]).optional().describe("Side of the diff `start_line` refers to (default: same as `side`). Only used with `start_line`."),
+ body: z.string().describe("Comment text"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, pull_number, commit_id, path, line, side = "RIGHT", start_line, start_side, body }) => {
+ const payload = { commit_id, path, line, side, body };
+ if (start_line !== undefined) {
+ if (start_line >= line) {
+ throw new Error("start_line must be less than line for a multi-line comment.");
+ }
+ payload.start_line = start_line;
+ payload.start_side = start_side || side;
+ }
+ const data = await githubRequest(`/repos/${owner}/${repo}/pulls/${pull_number}/comments`, {
+ method: "POST",
+ body: payload,
+ });
+ const rangeDesc = start_line !== undefined ? `${start_line}-${line}` : `${line}`;
+ return { content: [{ type: "text", text: `Added inline comment on ${path}:${rangeDesc} (PR #${pull_number}).\n${data.html_url}` }] };
+ }
+ );
+
+ server.tool(
+ "get_branch_protection",
+ "DOES: Read-only branch protection rules -- required checks/approvals, admin exemption, force-push/delete blocking.\n" +
+ "RULE: use this to see upfront why a PR might be gated, instead of discovering it from a rejected merge.",
+ {
+ owner: z.string().optional().describe(`Repository owner. Defaults to "${DEFAULT_OWNER}" if omitted.`),
+ repo: z.string().describe("Repository name"),
+ branch: z.string().describe("Branch name, e.g. 'main'"),
+ },
+ async ({ owner = DEFAULT_OWNER, repo, branch }) => {
+ let data;
+ try {
+ data = await githubRequest(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`);
+ } catch (err) {
+ if (/\(404\)/.test(err.message)) {
+ return { content: [{ type: "text", text: `Branch '${branch}' has no protection rules configured.` }] };
+ }
+ if (/\(403\)/.test(err.message)) {
+ return { content: [{ type: "text", text: `Can't read branch protection for '${branch}': the token lacks permission (403). Branch protection reads require admin access on the repo, even though the rules themselves may be visible in the GitHub UI.` }] };
+ }
+ throw err;
+ }
+
+ const reviews = data.required_pull_request_reviews;
+ const checks = data.required_status_checks;
+ const lines = [
+ `Branch protection for '${branch}':`,
+ ` Required approving reviews: ${reviews ? reviews.required_approving_review_count : 0}${reviews?.require_code_owner_reviews ? " (code owner review required)" : ""}`,
+ ` Dismiss stale reviews on new commits: ${reviews?.dismiss_stale_reviews ? "yes" : "no"}`,
+ ` Required status checks: ${checks?.contexts?.length ? checks.contexts.join(", ") : "(none)"}`,
+ ` Require branches up to date before merge: ${checks?.strict ? "yes" : "no"}`,
+ ` Enforce for admins: ${data.enforce_admins?.enabled ? "yes" : "no"}`,
+ ` Allow force pushes: ${data.allow_force_pushes?.enabled ? "yes" : "no"}`,
+ ` Allow deletions: ${data.allow_deletions?.enabled ? "yes" : "no"}`,
+ ` Linear history required: ${data.required_linear_history?.enabled ? "yes" : "no"}`,
+ ];
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ server.tool(
+ "list_notifications",
+ "DOES: Authenticated-token notification feed (mentions, review requests, replies, CI failures on watched runs) -- same feed as github.com/notifications.\n" +
+ "RULE: 'did anyone reply to me' / 'is anything waiting on me' -> this, instead of re-polling specific issues/PRs one at a time.",
+ {
+ all: z.boolean().optional().describe("If true, include notifications already marked as read (default: false — unread only)"),
+ participating: z.boolean().optional().describe("If true, only show notifications where the token owner is directly @mentioned or involved (not just watching) (default: false)"),
+ owner: z.string().optional().describe("Restrict to a single repository owner. Omit for all repos the token can see."),
+ repo: z.string().optional().describe("Restrict to a single repository (requires owner). Omit for all repos."),
+ per_page: z.number().optional().describe("Number of notifications to return, max 100 (default: 30)"),
+ },
+ async ({ all = false, participating = false, owner, repo, per_page = 30 }) => {
+ const query = new URLSearchParams({ all: String(all), participating: String(participating), per_page: String(per_page) });
+ const endpoint = owner && repo
+ ? `/repos/${owner}/${repo}/notifications?${query}`
+ : `/notifications?${query}`;
+ const data = await githubRequest(endpoint);
+ if (!data.length) return { content: [{ type: "text", text: all ? "No notifications." : "No unread notifications." }] };
+ const icon = (reason) => ({
+ mention: "💬", review_requested: "👀", assign: "📌", author: "✍️",
+ comment: "💬", state_change: "🔄", ci_activity: "🏗️",
+ }[reason] || "🔔");
+ const lines = data.map((n) =>
+ `${icon(n.reason)} [${n.reason}] ${n.subject.type}: ${n.subject.title}\n` +
+ ` ${n.repository.full_name} | updated ${n.updated_at.slice(0, 16).replace("T", " ")}${n.unread ? "" : " (read)"}`
+ );
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + +4x +4x + + + + + + + + + + + + +4x +4x + + + + + + + + + +2x +2x + + + + + + +3x + + + +8x +8x +8x +8x + + + + + + + + + +4x +4x +4x +5x +5x +5x +5x +4x +4x +4x +4x +4x +4x + +4x + + + + + + + + +5x +5x +5x + +5x +13x +2692x + +8x +8x +8x +8x +8x + +8x +8x + +8x +1x +1x +1x + +7x +1x +1x + +6x + +6x +13x + +13x + +5x + + + +5x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/github/search.js — search tools
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import zlib from "node:zlib";
+import { githubRequest, githubFetchTarball } from "./client.js";
+
+// --- search_code fallback ---------------------------------------------------
+// GitHub's REST /search/code endpoint reliably indexes public repos, but has
+// a long-documented gap for private repos: it returns an empty, 200-OK
+// result set even when the token has full read access to the repo's
+// contents (see e.g. github.com/orgs/community/discussions/113651). This
+// isn't a permissions or config issue on our end -- the same token's
+// contents/tree/blob endpoints (used elsewhere in this connector) work fine
+// against the same repos. There's no request header or query tweak that
+// fixes it; the only real workaround is to not depend on GitHub's search
+// index for private repos at all.
+//
+// So: when a query scopes to a single repo via `repo:owner/name` and the
+// real search API comes back empty, fall back to a direct content search of
+// that repo instead.
+//
+// 2026-07-28 fix: this fallback used to walk the repo's git tree and fetch
+// each eligible file individually via the Blobs API, one file per throttled
+// request (up to FALLBACK_MAX_FILES of them) -- for a 500-file scan, that's
+// roughly 500 * GITHUB_MIN_REQUEST_INTERVAL_MS of pure enforced pacing alone
+// (~150s), on top of per-request round-trip time. It now fetches the whole
+// repo ONCE as a tarball (githubFetchTarball, client.js) and greps the
+// decompressed contents locally instead -- one network request instead of
+// hundreds, with the repo@sha result cached in-process so a follow-up search
+// against an unchanged branch head doesn't refetch anything.
+const FALLBACK_MAX_BYTES = 400000; // skip individual files bigger than this (~400KB) when grepping
+// Safety cap on how many eligible files the local grep loop will walk. Since
+// files are already decompressed in memory by this point, this exists only
+// to bound worst-case CPU time on a pathologically large monorepo -- not to
+// limit network cost the way it used to.
+const FALLBACK_MAX_FILES = 20000;
+const BINARY_EXTENSIONS = new Set([
+ "png", "jpg", "jpeg", "gif", "ico", "webp", "bmp", "tiff",
+ "pdf", "zip", "tar", "gz", "bz2", "7z", "rar",
+ "woff", "woff2", "ttf", "eot", "otf",
+ "mp3", "mp4", "mov", "avi", "webm", "ogg", "wav",
+ "exe", "dll", "so", "dylib", "class", "jar", "wasm",
+ "sqlite", "db", "bin", "pyc", "lock",
+]);
+
+// In-process cache of parsed tarball entries, keyed by `owner/repo@sha`
+// (sha makes the key immutable, so no TTL/invalidation is needed -- a new
+// commit just gets a new key). Capped at a small number of repos since
+// each entry holds full decompressed file contents in memory.
+const TARBALL_CACHE_MAX_REPOS = 5;
+const tarballCache = new Map();
+
+function cacheEntries(key, entries) {
+ tarballCache.set(key, entries);
+ if (tarballCache.size > TARBALL_CACHE_MAX_REPOS) {
+ tarballCache.delete(tarballCache.keys().next().value); // evict oldest
+ }
+}
+
+export function extractRepoQualifier(query) {
+ const m = query.match(/(?:^|\s)repo:([^/\s]+)\/([^\s]+)/i);
+ return m ? { owner: m[1], repo: m[2] } : null;
+}
+
+// Strips `qualifier:value` tokens (repo:, filename:, extension:, language:,
+// etc. -- and their `-qualifier:` negated forms) out of a search query,
+// leaving just the free-text search term(s) a plain grep can use.
+export function stripQualifiers(query) {
+ return query.replace(/(^|\s)-?[a-zA-Z]+:\S+/g, " ").replace(/\s+/g, " ").trim();
+}
+
+function parseOctal(buf) {
+ const str = buf.toString("ascii").replace(/\0.*$/, "").trim();
+ Iif (!str) return 0;
+ const n = parseInt(str, 8);
+ return Number.isNaN(n) ? 0 : n;
+}
+
+// Parses a PAX extended-header block's content into its key/value fields.
+// Format is a sequence of `"<len> key=value\n"` records, where <len> is the
+// decimal byte length of the WHOLE record (including itself and the
+// trailing newline). Used for filenames longer than tar's classic 100-byte
+// field, which show up in some repos (deeply nested paths, long generated
+// filenames, etc).
+export function parsePaxHeader(text) {
+ const fields = {};
+ let offset = 0;
+ while (offset < text.length) {
+ const spaceIdx = text.indexOf(" ", offset);
+ Iif (spaceIdx === -1) break;
+ const len = parseInt(text.slice(offset, spaceIdx), 10);
+ if (!len || Number.isNaN(len) || len <= 0) break;
+ const record = text.slice(offset, offset + len);
+ const firstSpace = record.indexOf(" ");
+ const kv = record.slice(firstSpace + 1).replace(/\n$/, "");
+ const eq = kv.indexOf("=");
+ Eif (eq !== -1) fields[kv.slice(0, eq)] = kv.slice(eq + 1);
+ offset += len;
+ }
+ return fields;
+}
+
+// Minimal USTAR/PAX/GNU tar parser -- just enough to extract regular file
+// entries with their name and content from GitHub's tarball archives.
+// Deliberately hand-rolled rather than a dependency: it's a small, stable
+// format, and this repo can't rely on `npm install` picking up new packages
+// in every environment it runs in.
+export function parseTar(buffer) {
+ const entries = [];
+ let offset = 0;
+ let pendingLongName = null; // set by a preceding PAX ('x') or GNU ('L') header
+
+ while (offset + 512 <= buffer.length) {
+ const header = buffer.subarray(offset, offset + 512);
+ if (header.every((b) => b === 0)) break; // end-of-archive marker
+
+ const nameRaw = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/, "");
+ const size = parseOctal(header.subarray(124, 136));
+ const typeFlag = String.fromCharCode(header[156]);
+ const prefixRaw = header.subarray(345, 500).toString("utf-8").replace(/\0.*$/, "");
+ offset += 512;
+
+ const content = buffer.subarray(offset, offset + size);
+ offset += Math.ceil(size / 512) * 512; // advance past the padded data blocks
+
+ if (typeFlag === "x" || typeFlag === "X") {
+ const fields = parsePaxHeader(content.toString("utf-8"));
+ Eif (fields.path) pendingLongName = fields.path;
+ continue; // applies to the next entry, not a file itself
+ }
+ if (typeFlag === "L") {
+ pendingLongName = content.toString("utf-8").replace(/\0.*$/, "");
+ continue; // GNU long-name header, also applies to the next entry
+ }
+ Iif (typeFlag === "g") continue; // global PAX header, not needed here
+
+ const name = pendingLongName || (prefixRaw ? `${prefixRaw}/${nameRaw}` : nameRaw);
+ pendingLongName = null;
+
+ if (typeFlag === "0" || typeFlag === "\u0000") {
+ // Regular file -- directories ('5'), symlinks ('2'), etc. are skipped.
+ entries.push({ name, size, content });
+ }
+ }
+
+ return entries;
+}
+
+// Fetches (or returns from cache) every regular file in a repo at `ref`
+// (branch, tag, or commit SHA -- defaults to the repo's default branch) as
+// { name, size, content } entries, with GitHub's single wrapping top-level
+// directory (`<owner>-<repo>-<sha7>/...`) stripped off each name.
+async function getRepoEntries(owner, repo, ref) {
+ let sha;
+ if (ref) {
+ try {
+ const refData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(ref)}`);
+ sha = refData.object.sha;
+ } catch {
+ sha = ref; // not a branch name -- treat as a tag or commit SHA directly
+ }
+ } else {
+ const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
+ const branchData = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${repoInfo.default_branch}`);
+ sha = branchData.object.sha;
+ }
+ const cacheKey = `${owner}/${repo}@${sha}`;
+
+ const cached = tarballCache.get(cacheKey);
+ if (cached) return cached;
+
+ const gzipped = await githubFetchTarball(owner, repo, sha);
+ const tarBuffer = zlib.gunzipSync(gzipped);
+ const rawEntries = parseTar(tarBuffer);
+ const entries = rawEntries.map((e) => ({ ...e, name: e.name.replace(/^[^/]+\//, "") }));
+
+ cacheEntries(cacheKey, entries);
+ return entries;
+}
+
+export async function fallbackCodeSearch({ owner, repo, query, per_page, ref }) {
+ const searchTerm = stripQualifiers(query);
+ if (!searchTerm) return null; // qualifier-only query -- nothing to grep for
+
+ const entries = await getRepoEntries(owner, repo, ref);
+
+ const eligible = entries.filter((item) => {
+ if (item.size > FALLBACK_MAX_BYTES) return false;
+ const ext = item.name.includes(".") ? item.name.split(".").pop().toLowerCase() : "";
+ return !BINARY_EXTENSIONS.has(ext);
+ });
+ const candidates = eligible.slice(0, FALLBACK_MAX_FILES);
+
+ const needle = searchTerm.toLowerCase();
+ const matches = [];
+ for (const entry of candidates) {
+ if (matches.length >= per_page) break;
+ let text;
+ try { text = entry.content.toString("utf-8"); } catch { continue; }
+ // Skip anything that doesn't decode to plausible text (binary sneaking
+ // in without a recognized extension).
+ if (text.includes("\u0000")) continue;
+ const lines = text.split("\n");
+ const lineIdx = lines.findIndex((l) => l.toLowerCase().includes(needle));
+ if (lineIdx !== -1) {
+ matches.push({ path: entry.name, line: lineIdx + 1, snippet: lines[lineIdx].trim().slice(0, 200) });
+ }
+ }
+
+ return {
+ matches,
+ scanned: candidates.length,
+ truncated: eligible.length > FALLBACK_MAX_FILES,
+ };
+}
+
+export function register(server) {
+ server.tool(
+ "search_issues",
+ "DOES: Search issues/PRs cross-repo via GitHub issue-search syntax (label:, is:issue, is:pr, stars:>N, org:, -repo:, etc). Returns title, repo, state, labels, assignee, date, URL per result.\n" +
+ "RULE: cross-repo discovery (bounty hunting, good-first-issue scanning) -> this tool. Single known repo -> list_issues instead.\n" +
+ "RULE: broader open-ended hunt (many searches -> read candidates -> narrow down) -> delegate_agent instead of chaining this manually.",
+ {
+ query: z.string().describe("GitHub issue-search query string using standard qualifiers: label:, is:issue, is:pr, is:open, is:closed, stars:>N, org:, repo:, -repo: (exclude), -org: (exclude), created:, assignee:, no:assignee, etc. Combine with spaces (AND). e.g. 'label:bounty is:issue is:open stars:>100 -org:mergeos-bounties'"),
+ sort: z.enum(["created", "updated", "comments"]).optional().describe("Sort field (default: best-match relevance if omitted)"),
+ order: z.enum(["asc", "desc"]).optional().describe("Sort order (default: desc)"),
+ per_page: z.number().optional().describe("Number of results to return, max 100 (default: 20)"),
+ },
+ async ({ query, sort, order = "desc", per_page = 20 }) => {
+ let path = `/search/issues?q=${encodeURIComponent(query)}&order=${order}&per_page=${per_page}`;
+ if (sort) path += `&sort=${sort}`;
+ const data = await githubRequest(path);
+ if (!data.items?.length) return { content: [{ type: "text", text: "No results found." }] };
+ const lines = data.items.map((item) => {
+ const kind = item.pull_request ? "PR" : "Issue";
+ const labels = item.labels?.length ? ` [${item.labels.map((l) => l.name).join(", ")}]` : "";
+ const assignee = item.assignee ? ` (assigned: ${item.assignee.login})` : " (unassigned)";
+ return `${kind} #${item.number} [${item.state}] ${item.title}${labels}${assignee}\n ${item.repository_url.replace("https://api.github.com/repos/", "")} | created ${item.created_at.slice(0, 10)} | ${item.html_url}`;
+ });
+ return { content: [{ type: "text", text: `Found ${data.total_count} total result(s) (GitHub search caps at 1000), showing ${data.items.length}:\n\n${lines.join("\n\n")}` }] };
+ }
+ );
+
+ server.tool(
+ "search_code",
+ "DOES: Search code across GitHub repos.\n" +
+ "RULE: query scoped via repo:owner/name AND index returns nothing -> auto-falls back to a direct content search of that repo (handles GitHub's known private-repo search-index gap; fetches the repo as a tarball and greps it locally -- see fallbackCodeSearch).\n" +
+ "RULE: need to search a NON-default branch -> pass `ref` (branch, tag, or commit SHA) alongside a repo:owner/name qualifier in the query. GitHub's real /search/code index only ever covers the default branch, so any `ref` always uses the local content-search fallback directly (skips the real API call entirely) -- requires repo:owner/name in the query since there's no other way to know which repo to fetch.\n" +
+ "RULE: tracing something across many back-to-back searches (e.g. a symbol across a codebase) -> delegate_agent instead of chaining this manually.",
+ {
+ query: z.string().describe("Search query (e.g. 'VLESS filename:worker.js user:dumbCodesOnly')"),
+ per_page: z.number().optional().describe("Number of results to return, max 100 (default: 10)"),
+ ref: z.string().optional().describe("Branch, tag, or commit SHA to search instead of the default branch. Requires a repo:owner/name qualifier in `query`. GitHub's search index only covers the default branch, so setting this always uses the local content-search fallback rather than the real API."),
+ },
+ async ({ query, per_page = 10, ref }) => {
+ const scoped = extractRepoQualifier(query);
+
+ if (ref) {
+ if (!scoped) {
+ return { content: [{ type: "text", text: "`ref` requires a repo:owner/name qualifier in the query -- GitHub's search index only covers the default branch, so a specific repo must be named for the branch-aware fallback to know what to fetch." }], isError: true };
+ }
+ let fb;
+ try {
+ fb = await fallbackCodeSearch({ ...scoped, query, per_page, ref });
+ } catch (err) {
+ return { content: [{ type: "text", text: `Branch search failed: ${err?.message ?? String(err)}` }], isError: true };
+ }
+ if (fb?.matches.length) {
+ const lines = fb.matches.map((m) => `📄 ${scoped.owner}/${scoped.repo}/${m.path}:${m.line}\n ${m.snippet}`);
+ return {
+ content: [{
+ type: "text",
+ text: `Searched ${scoped.owner}/${scoped.repo}@${ref} directly (GitHub's code-search index only covers the default branch, so a \`ref\` always uses the local content-search fallback) -- scanned ${fb.scanned} file(s)` +
+ `${fb.truncated ? ", capped — repo has more than this covers" : ""}:\n\n${lines.join("\n\n")}`,
+ }],
+ };
+ }
+ return {
+ content: [{
+ type: "text",
+ text: `No results found on ${scoped.owner}/${scoped.repo}@${ref} (scanned ${fb?.scanned ?? 0} file(s)${fb?.truncated ? ", capped — repo has more" : ""}).`,
+ }],
+ };
+ }
+
+ const data = await githubRequest(`/search/code?q=${encodeURIComponent(query)}&per_page=${per_page}`);
+ if (data.items?.length) {
+ const lines = data.items.map((item) => `📄 ${item.repository.full_name}/${item.path} (${item.html_url})`);
+ return { content: [{ type: "text", text: `Found ${data.total_count} result(s), showing ${data.items.length}:\n\n${lines.join("\n")}` }] };
+ }
+
+ if (scoped) {
+ const fb = await fallbackCodeSearch({ ...scoped, query, per_page }).catch(() => null);
+ if (fb?.matches.length) {
+ const lines = fb.matches.map((m) => `📄 ${scoped.owner}/${scoped.repo}/${m.path}:${m.line}\n ${m.snippet}`);
+ return {
+ content: [{
+ type: "text",
+ text: `GitHub's code-search index returned nothing for this repo (a known gap for private repos), ` +
+ `so this used a direct content search instead (scanned ${fb.scanned} file(s)` +
+ `${fb.truncated ? ", capped — repo has more than this covers" : ""}):\n\n${lines.join("\n\n")}`,
+ }],
+ };
+ }
+ if (fb) {
+ return {
+ content: [{
+ type: "text",
+ text: `No results found. Also tried a direct content search of ${scoped.owner}/${scoped.repo} ` +
+ `(GitHub's search index can return empty for private repos regardless of permissions) — ` +
+ `scanned ${fb.scanned} file(s)${fb.truncated ? " (capped, repo has more)" : ""}, no match.`,
+ }],
+ };
+ }
+ }
+
+ return { content: [{ type: "text", text: "No results found." }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 | + + + + + + + + + + + + + + + + + + + + +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x + + | // ---------------------------------------------------------------------------
+// connectors/github/tools.js — orchestrator only.
+// Each domain is implemented in its own module; register them all here.
+// To add a new group: create connectors/github/<name>.js and call register().
+// ---------------------------------------------------------------------------
+
+import { register as registerFiles } from "./files.js";
+import { register as registerBranches } from "./branches.js";
+import { register as registerPRs } from "./prs.js";
+import { register as registerIssues } from "./issues.js";
+import { register as registerReleases } from "./releases.js";
+import { register as registerRepo } from "./repo.js";
+import { register as registerSearch } from "./search.js";
+import { register as registerActions } from "./actions.js";
+import { register as registerCiControl } from "./ci_control.js";
+import { register as registerReviewControl } from "./review_control.js";
+import { register as registerDiff } from "./diff.js";
+import { register as registerRepoMgmt } from "./repo_mgmt.js";
+import { register as registerCloneToken } from "./clone_token.js";
+
+export function register(server) {
+ registerFiles(server);
+ registerBranches(server);
+ registerPRs(server);
+ registerIssues(server);
+ registerReleases(server);
+ registerRepo(server);
+ registerSearch(server);
+ registerActions(server);
+ registerCiControl(server);
+ registerReviewControl(server);
+ registerDiff(server);
+ registerRepoMgmt(server);
+ registerCloneToken(server);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| security.js | +
+
+ |
+ 100% | +27/27 | +96.55% | +28/29 | +100% | +7/7 | +100% | +19/19 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 | + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/mem/client.js — Mem0 REST API (api.mem0.ai)
+// Docs: https://docs.mem0.ai/api-reference
+// Auth header: "Authorization: Token <api_key>"
+// ---------------------------------------------------------------------------
+
+import { MEM0_API_KEY, MEM0_API, MEM0_MIN_REQUEST_INTERVAL_MS, MEM0_MAX_RETRIES, MEM0_RETRY_BASE_MS } from "../../config.js";
+import { createThrottle, sleep, defaultRetryDelayMs } from "../shared/rate-limit.js";
+
+// --- Throttle + retry (fix #3, 2026-07-27) ----------------------------------
+// See connectors/notion/client.js's identical comment for the rationale --
+// same shared queue/backoff shape, just against Mem0 instead of Notion.
+const scheduleThrottled = createThrottle(MEM0_MIN_REQUEST_INTERVAL_MS);
+
+// Mem0 doesn't document its rate-limit response shape as precisely as
+// GitHub/Notion do, so this errs toward treating any 429 or 5xx as worth one
+// retry (a transient overload/rate-limit signal) -- 4xx other than 429
+// (bad request, auth, not found) still throws immediately, unretried.
+function isRetryableMem0(res) {
+ return res.status === 429 || res.status >= 500;
+}
+
+async function doMem0Fetch(path, { method, body }) {
+ const res = await fetch(`${MEM0_API}${path}`, {
+ method,
+ headers: {
+ Authorization: `Token ${MEM0_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+ return { res, data };
+}
+
+export async function mem0Request(path, { method = "GET", body } = {}) {
+ if (!MEM0_API_KEY) throw new Error("MEM0_API_KEY is not set. Add it as an environment variable on the madmcp server.");
+
+ let lastErr;
+ for (let attempt = 0; attempt <= MEM0_MAX_RETRIES; attempt++) {
+ const { res, data } = await scheduleThrottled(() => doMem0Fetch(path, { method, body }));
+
+ if (res.ok) return data;
+
+ if (isRetryableMem0(res) && attempt < MEM0_MAX_RETRIES) {
+ await sleep(defaultRetryDelayMs(res, attempt, MEM0_RETRY_BASE_MS));
+ lastErr = res;
+ continue;
+ }
+
+ const message = (data && (data.message || data.error || data.detail || JSON.stringify(data))) || res.statusText;
+ throw new Error(`Mem0 API error (${res.status}): ${message}`);
+ }
+
+ // Exhausted retries.
+ throw new Error(`Mem0 API error (${lastErr ? lastErr.status : 429}): rate limited -- exhausted ${MEM0_MAX_RETRIES} retries`);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + +3x + + +3x + + + +3x + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/mem/tools.js — Mem0 MCP tools
+// API reference: https://docs.mem0.ai/api-reference
+//
+// Key Mem0 concepts:
+// - Memories are scoped to a user_id (and optionally agent_id / run_id)
+// - POST /v3/memories/add/ → add memories from conversation messages
+// - POST /v3/memories/search/ → hybrid search (semantic + BM25 + entity)
+// - POST /v3/memories/ → filtered listing (paginated)
+// - GET /v1/memories/{id}/ → get single memory
+// - PUT /v1/memories/{id}/ → update single memory
+// - DELETE /v1/memories/{id}/ → delete single memory
+//
+// NOTE on "categories" (2026-07-07):
+// Mem0's /v3/memories/add/ endpoint does NOT accept a per-call `categories`
+// or `custom_categories` field — it's not in the documented request schema
+// (messages, user_id, agent_id, run_id, app_id, metadata, infer,
+// expiration_date only). `custom_categories` from the SDK examples is a
+// PROJECT-LEVEL setting (client.project.update(custom_categories=[...])),
+// applied once for the whole project's classifier going forward — it can't
+// tag a single memory at add-time. Sending either field to /v3/memories/add/
+// is silently ignored; Mem0 falls back to its own default classifier
+// (personal_details, technology, milestones, etc.) regardless.
+//
+// So our `categories` tool param is implemented as a client-side tag: it's
+// stored under `metadata.tags` (a field /v3/memories/add/ *does* support),
+// and mem0_list/mem0_search filter on it by fetching normally and checking
+// each result's metadata.tags for overlap with the requested list, since
+// Mem0's server-side metadata-filter operators (eq/contains/ne, top-level
+// keys only) aren't documented to reliably match inside an array field.
+//
+// NOTE on entity_id upsert (2026-07-07, Tier 1 of the anti-bloat plan):
+// mem0_add accepts an optional `entity_id` (e.g. "bug-4"), stored under
+// metadata.entity_id, same mechanism as tags. If a memory already exists
+// for that entity_id (checked via a client-side scan, same reasoning as
+// tags — metadata array/field filtering isn't reliably documented),
+// mem0_add refuses to create a duplicate. It does NOT attempt an automatic
+// text merge itself: merging old + new content correctly (keep everything
+// not explicitly contradicted) is a judgment call that needs an LLM in the
+// loop, and this server has no LLM call of its own. Instead it returns the
+// existing memory's id + full content back to the caller, who is expected
+// to merge and then call mem0_update. This is the deterministic/Tier-1 path
+// from the plan.
+//
+// NOTE on status field (2026-07-07, Part 3 of the anti-bloat plan):
+// mem0_add/mem0_update accept an optional `status` (open/resolved/
+// superseded), stored under metadata.status — same "store in metadata,
+// filter client-side" mechanism as tags/entity_id, for the same reason
+// (Mem0's own fields can't be repurposed for this, and metadata-array/field
+// filter operators aren't reliably documented). mem0_list/mem0_search
+// exclude status="superseded" by default; pass status_filter to override
+// (either to explicitly include "superseded", or to narrow to a specific
+// status like "open"). Memories with no status set are always shown by
+// default — the exclusion only applies to memories explicitly marked
+// superseded. mem0_update can update metadata.status without touching
+// content by fetching the current record first (Mem0's PUT replaces the
+// whole metadata object, so we merge client-side before writing back to
+// avoid clobbering tags/entity_id set at add-time).
+//
+// NOTE on version history (2026-07-07, Part 4 of the anti-bloat plan):
+// mem0_get_history is a thin wrapper around Mem0's own
+// GET /v1/memories/{id}/history/ endpoint, which already maintains an
+// audit trail (event type ADD/UPDATE/DELETE, old/new value, timestamp) for
+// every memory. No custom versioning was built — Mem0's native history
+// already satisfies the "don't destructively overwrite" requirement, so
+// this just surfaces it in the same compact format as the other tools.
+//
+// NOTE on Tier 2 duplicate flagging (2026-07-07, Part 2 of the anti-bloat plan;
+// revised 2026-07-13 to also cover new/non-matching entity_ids):
+// mem0_add/mem0_add_batch run a similarity check via Mem0's own
+// /v3/memories/search/ (with rerank:true for precision) against the new
+// content, scoped the same way the add is, whenever the call did NOT already
+// hit an exact entity_id match (an exact match short-circuits before this —
+// see findByEntityId/Tier 1 above, it refuses to add at all in that case).
+// Originally this was skipped whenever entity_id was given at all, on the
+// theory entity_id already gets exact-match protection — but a *new*
+// entity_id (one that doesn't match anything existing) got zero duplicate
+// protection under that scheme, since exact-match by definition can't catch
+// a semantic duplicate filed under a different key. That gap let a caller
+// invent a fresh entity_id for content that was really an update to an
+// existing entity, silently forking the record. Tier 2 now always runs
+// unless skip_duplicate_check is set, entity_id or not.
+// Deliberately non-blocking: unlike a true duplicate this can't be known
+// for certain without an LLM merge judgment (same reasoning as Tier 1), so
+// the memory is still added, but any candidate scoring at or above
+// duplicate_threshold (default 0.75, tunable per call) is recorded under
+// metadata.possible_duplicate_of (array of candidate IDs) and surfaced as a
+// warning in the tool response — check it before assuming a new entity_id
+// add didn't collide with something. Callers can skip the extra search call
+// entirely via skip_duplicate_check (e.g. for bulk/import scenarios where
+// latency matters more). mem0_list gained flagged_duplicates_only to
+// surface these for the Part 5 periodic consolidation pass; mem0_get and
+// compactLine both show a "⚠dup" indicator when the flag is present.
+//
+// REVISED 2026-07-13 (insert-reliability step of the anti-bloat plan rev 2):
+// "deliberately non-blocking" above is now only true in the 0.75–0.92 range.
+// A candidate scoring >= BLOCKING_DUPLICATE_THRESHOLD (0.92) is treated as a
+// near-certain duplicate and hard-blocks the add, the same way an exact
+// entity_id match does — the caller gets the existing memory's id + content
+// back and is expected to merge + mem0_update instead. This applies in both
+// mem0_add and mem0_add_batch. skip_duplicate_check still bypasses Tier 2
+// entirely (including this block), for callers who've already judged the
+// content distinct.
+//
+// NOTE on relations (2026-07-13, relational-info step of the anti-bloat plan
+// rev 2 — storage/write-side only, see madmcp-mem0-relations-plan):
+// mem0_add/mem0_add_batch/mem0_update accept an optional `relations` array
+// of {to_entity_id, relation}, stored under metadata.relations — same
+// "store in metadata, resolve client-side" mechanism as tags/entity_id/
+// status. Relation strings are canonicalized via a small static lookup map
+// (case/phrasing variants only; unrecognized strings pass through
+// unchanged — no hard enum, per the plan's explicit rejection of a rigid
+// schema). Self-loops (to_entity_id === this memory's own entity_id) are
+// dropped with a warning rather than blocking the whole add/update.
+// Dangling to_entity_id values (no matching entity_id found yet in scope)
+// are flagged non-blocking at write time, same reasoning as the existing
+// dangling-ref-on-add behavior. mem0_update's relations param is a REPLACE
+// of the whole array, not a merge — matching the plan's decided semantics.
+// NOT included in this step: findReferencingEntities, multi-hop traversal,
+// or surfacing relations on mem0_get/mem0_search/mem0_list — that's the
+// read/resolution side, still to be built per the plan.
+//
+// NOTE on metadata_patch/metadata_delete_keys (2026-07-13, closes the Part 5
+// tooling gap found during a live consolidation pass): mem0_update
+// previously had no way to touch metadata beyond status/relations — it
+// could merge in a new status or replace the whole relations array, both
+// additive/replace operations on specific known fields, but nothing generic.
+// That meant a memory flagged possible_duplicate_of at add-time (Tier 2)
+// stayed flagged forever once reviewed, even when the flag turned out to be
+// a false positive (candidate was topically related but not actually
+// duplicate content) or referenced a candidate ID that had since been
+// deleted — both observed on the same flagged memory during the first real
+// Part 5 pass. Rather than bolt on a single-purpose boolean for just that
+// one field, metadata_patch (shallow-merge arbitrary keys) and
+// metadata_delete_keys (remove arbitrary keys) generalize this the same way
+// mem0_add's own `metadata` param already does on write — so any future
+// custom field can be fixed or cleared without a new tool param each time.
+// clear_duplicate_flag is kept as a thin convenience alias (shorthand for
+// metadata_delete_keys: ['possible_duplicate_of']) since it was the
+// motivating case and reads more clearly for that specific action. Both
+// patch and delete use the same fetch-merge-PUT pattern as status/relations
+// (patch applied first, then deletes, so a key could theoretically be
+// patched and deleted in the same call, though that's not a real use case).
+//
+// NOTE on relations traversal/read-side (2026-07-13, completes
+// madmcp-mem0-relations-plan's relational-info step):
+// Adds findReferencingEntities (reverse lookup — who points AT this
+// entity_id, since relations are stored one-directional on the source
+// memory only), a resolveRelationTarget helper that distinguishes three
+// cases for an unresolved to_entity_id instead of a blank/not-found result
+// (never_existed / deleted / wrong_scope — see resolveRelationTarget's own
+// comment for how "deleted" is detected without proactive tracking), and
+// traverseRelations, a cycle-safe BFS walking both outgoing and incoming
+// edges up to a depth (default 3, per the plan's 3-hop minimum). Surfaced
+// in mem0_get (always, when the memory has an entity_id) and in
+// mem0_search/mem0_list (opt-in via include_relations, fully resolved only
+// for the top RELATION_RESOLVE_LIMIT results to avoid token blowup at
+// 3-hop depth — remaining results show an outgoing-relation COUNT only).
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { mem0Request } from "./client.js";
+import { MEM0_USER_ID } from "../../config.js";
+
+const STATUS_VALUES = ["open", "resolved", "superseded"];
+// Hard-stop threshold for Tier 2 duplicate detection (2026-07-13, insert-
+// reliability step of the anti-bloat plan rev 2): a candidate scoring at or
+// above this is treated as a near-certain duplicate and blocks the add
+// entirely, same as an exact entity_id match. Below this and down to a
+// call's duplicate_threshold (default 0.75), candidates are still flagged
+// but non-blocking, since that range isn't reliably a true duplicate
+// without an LLM merge judgment.
+const BLOCKING_DUPLICATE_THRESHOLD = 0.92;
+// Default depth for relation traversal (mem0_get, include_relations on
+// mem0_search/mem0_list) — matches the plan's 3-hop minimum requirement.
+const RELATION_TRAVERSAL_DEPTH = 3;
+// mem0_search/mem0_list with include_relations only fully resolve/traverse
+// this many top results; the rest show an outgoing-relation count only, to
+// avoid token blowup at 3-hop depth across a whole result page.
+const RELATION_RESOLVE_LIMIT = 5;
+
+// ---------------------------------------------------------------------------
+// Relations helpers (write-side only — see NOTE above)
+// ---------------------------------------------------------------------------
+
+// Small static lookup for common phrasing/case variants of the same relation
+// — applied at write time so "is blocking" / "blocking" / "Blocks" etc. don't
+// fragment into separate relation types. Unrecognized strings pass through
+// unchanged (no hard enum — relation vocabulary is still being discovered,
+// per the plan's explicit rejection of a rigid schema).
+const RELATION_CANONICALIZATION = {
+ "is blocking": "blocks",
+ "blocking": "blocks",
+ "blocks": "blocks",
+ "is blocked by": "blocked_by",
+ "blocked by": "blocked_by",
+ "blocked_by": "blocked_by",
+ "depends": "depends_on",
+ "depends on": "depends_on",
+ "depends_on": "depends_on",
+ "dependency of": "depends_on",
+ "relates to": "relates_to",
+ "related to": "relates_to",
+ "relates_to": "relates_to",
+};
+
+function canonicalizeRelation(relation) {
+ const key = relation.trim().toLowerCase();
+ return RELATION_CANONICALIZATION[key] || relation.trim();
+}
+
+// trim+lowercase, matching the normalization the plan specifies for both
+// entity_id and to_entity_id so relation lookups aren't case/whitespace
+// sensitive.
+function normalizeEntityId(id) {
+ return (id || "").trim().toLowerCase();
+}
+
+// Clean a raw `relations` param into what actually gets stored:
+// - normalize to_entity_id
+// - canonicalize the relation string via the map above
+// - drop self-loops (to_entity_id === this memory's own entity_id) — warns
+// and drops rather than hard-failing the whole add/update over one pair
+// - dedupe on the (to_entity_id, relation) pair within this one array
+// - flag (non-blocking) any to_entity_id that doesn't resolve in scope via
+// findByEntityId, same as the existing dangling-ref-on-add behavior
+// Returns { relations, warnings } — relations is the cleaned array to store
+// (possibly empty), warnings is a list of strings to surface in the response.
+async function processRelations(rawRelations, { ownEntityId, user_id, agent_id, run_id }) {
+ const warnings = [];
+ if (!rawRelations?.length) return { relations: [], warnings };
+ const ownNormalized = ownEntityId ? normalizeEntityId(ownEntityId) : null;
+ const seen = new Set();
+ const cleaned = [];
+ for (const { to_entity_id, relation } of rawRelations) {
+ const toNormalized = normalizeEntityId(to_entity_id);
+ const canonRelation = canonicalizeRelation(relation);
+ if (ownNormalized && toNormalized === ownNormalized) {
+ warnings.push(`Relation "${relation}" -> "${to_entity_id}" skipped — self-loop (entity can't relate to itself).`);
+ continue;
+ }
+ const dedupeKey = `${toNormalized}::${canonRelation}`;
+ if (seen.has(dedupeKey)) {
+ warnings.push(`Relation "${canonRelation}" -> "${to_entity_id}" skipped — duplicate within this call.`);
+ continue;
+ }
+ seen.add(dedupeKey);
+ const target = await findByEntityId({ user_id, agent_id, run_id, entity_id: toNormalized });
+ // resolved_at_write persists whether this target was resolvable in-scope
+ // right now, at write time — the read-side resolver (resolveRelationTarget)
+ // uses this later to tell "never existed" (false here) apart from
+ // "deleted since" (true here, but unresolvable when traversal runs).
+ cleaned.push({ to_entity_id: toNormalized, relation: canonRelation, resolved_at_write: !!target });
+ if (!target) {
+ warnings.push(`Relation "${canonRelation}" -> "${to_entity_id}" flagged dangling-ref — no memory with that entity_id found in scope yet. Stored anyway; this may resolve later, or may reflect a typo.`);
+ }
+ }
+ return { relations: cleaned, warnings };
+}
+
+// Compact one-line formatter shared by list/search to keep token usage low.
+function compactLine(m, { showScore = false } = {}) {
+ const preview = (m.memory || m.text || "").slice(0, 90).replace(/\n/g, " ");
+ const date = (m.created_at || "").slice(0, 10) || "?";
+ const tags = Array.isArray(m.metadata?.tags) && m.metadata.tags.length ? ` [${m.metadata.tags.join(",")}]` : "";
+ const eid = m.metadata?.entity_id ? ` {${m.metadata.entity_id}}` : "";
+ const status = m.metadata?.status ? ` (${m.metadata.status})` : "";
+ const dup = Array.isArray(m.metadata?.possible_duplicate_of) && m.metadata.possible_duplicate_of.length ? " ⚠dup" : "";
+ const score = showScore && typeof m.score === "number" ? ` (${m.score.toFixed(2)})` : "";
+ return `${m.id} | ${date}${tags}${eid}${status}${dup}${score} | ${preview}${preview.length >= 90 ? "…" : ""}`;
+}
+
+// Keep only memories whose metadata.tags intersects the requested categories.
+function filterByTags(memories, categories) {
+ if (!categories?.length) return memories;
+ const wanted = new Set(categories);
+ return memories.filter((m) => Array.isArray(m.metadata?.tags) && m.metadata.tags.some((t) => wanted.has(t)));
+}
+
+// Default: hide memories explicitly marked superseded. If status_filter is
+// given, narrow to exactly those statuses instead (this is how you'd
+// explicitly ask for superseded ones, or for e.g. only "open").
+// Memories with no status set are never hidden by the default behavior.
+function filterByStatus(memories, status_filter) {
+ if (status_filter?.length) {
+ const wanted = new Set(status_filter);
+ return memories.filter((m) => wanted.has(m.metadata?.status));
+ }
+ return memories.filter((m) => m.metadata?.status !== "superseded");
+}
+
+// Keep only memories flagged at add-time as possible duplicates of another
+// memory (metadata.possible_duplicate_of non-empty) — see mem0_list's
+// flagged_duplicates_only param, meant for a periodic consolidation pass.
+function filterFlaggedDuplicates(memories, flaggedOnly) {
+ if (!flaggedOnly) return memories;
+ return memories.filter((m) => Array.isArray(m.metadata?.possible_duplicate_of) && m.metadata.possible_duplicate_of.length);
+}
+
+// REGRESSION FIX (2026-07-13, see madmcp-mem0-relations-plan): the
+// /v3/memories/ list endpoint does not reliably surface metadata.relations
+// contents, even though it does reliably surface metadata.entity_id (which
+// is why entity_id matching below still works off list results directly).
+// Confirmed via live repro: a memory's relations array read correctly via
+// /v1/memories/{id}/ single-get, but the same array read off a list-page
+// result had undefined to_entity_id/relation fields. Any caller that needs
+// to trust match.metadata.relations must re-fetch the single record.
+async function fetchSingleForMetadata(listVersion) {
+ try {
+ return await mem0Request(`/v1/memories/${listVersion.id}/`);
+ } catch {
+ // Single-get failed (e.g. deleted between the list scan and this call)
+ // — fall back to the list version rather than throwing, since callers
+ // can still use it for id/basic fields even if relations is untrustworthy.
+ return listVersion;
+ }
+}
+
+// Look for an existing memory tagged with this entity_id, scoped the same
+// way the add call would be. Paginates through up to 1000 most recent
+// memories in scope (10 pages of 100) rather than only the first 100 —
+// fixed 2026-07-13 (insert-reliability step of the anti-bloat plan rev 2)
+// after the single-page version was found to miss entity_ids on older
+// memories once a scope grew past 100. Still not a substitute for a real
+// indexed lookup if a scope grows past ~1000 — revisit via D1/graph-DB
+// migration (previously rejected, not permanently) if that ever happens.
+//
+// Once a match is found via the list scan, re-fetches it via single-get
+// (fetchSingleForMetadata) before returning — see REGRESSION FIX note above.
+// This adds one extra API call per successful lookup (not per page scanned),
+// so it's cheap relative to the pagination cost already paid here.
+async function findByEntityId({ user_id, agent_id, run_id, entity_id }) {
+ const filters = { user_id };
+ if (agent_id) filters.agent_id = agent_id;
+ if (run_id) filters.run_id = run_id;
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 10;
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { filters, page, page_size: PAGE_SIZE } });
+ const memories = data.results || data.memories || data || [];
+ const match = memories.find((m) => m.metadata?.entity_id === entity_id);
+ if (match) return await fetchSingleForMetadata(match);
+ if (memories.length < PAGE_SIZE) break; // reached the last page
+ }
+ return null;
+}
+
+// Same lookup as findByEntityId but scoped to user_id only (no agent_id/
+// run_id filter) — used as the cross-scope fallback when a relation target
+// doesn't resolve within the caller's own agent_id/run_id scope, so a
+// cross-scope relation can still be found and correctly labeled rather than
+// reported as missing. Same pagination caveat as findByEntityId, and same
+// single-get re-fetch on match (fetchSingleForMetadata) — see the REGRESSION
+// FIX note above findByEntityId; without it, a cross-scope match's relations
+// would be just as untrustworthy as an in-scope one.
+async function findByEntityIdAnyScope({ user_id, entity_id }) {
+ const filters = { user_id };
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 10;
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { filters, page, page_size: PAGE_SIZE } });
+ const memories = data.results || data.memories || data || [];
+ const match = memories.find((m) => m.metadata?.entity_id === entity_id);
+ if (match) return await fetchSingleForMetadata(match);
+ if (memories.length < PAGE_SIZE) break;
+ }
+ return null;
+}
+
+// NEW helper (not a reuse of findByEntityId) — relations are stored
+// one-directional on the SOURCE memory's metadata.relations array, so
+// finding "who points at entity_id X" requires scanning every memory in
+// scope for a relations entry whose to_entity_id matches, rather than a
+// single direct lookup. Returns [{ fromEntityId, fromId, relation }, ...].
+// Same ~1000-memory-per-scope pagination ceiling as findByEntityId.
+//
+// REGRESSION FIX (2026-07-13, see madmcp-mem0-relations-plan and the note
+// above findByEntityId): list-page results can't be trusted for
+// metadata.relations, so every candidate in every page gets refetched via
+// fetchSingleForMetadata (single-get) before its relations are inspected.
+// This is a real N+1 cost — up to one /v1/memories/{id}/ call per memory
+// scanned, not just per eventual match, since there's no way to tell from
+// the list result alone which memories even have relations set. Fetched in
+// parallel per page via Promise.all to keep it to one round of latency per
+// page rather than serial. Acceptable at current scale (same ~100/page,
+// ~1000/scope ceiling as everything else here); revisit if this traversal
+// path becomes a real bottleneck.
+async function findReferencingEntities({ user_id, agent_id, run_id, entity_id }) {
+ const filters = { user_id };
+ if (agent_id) filters.agent_id = agent_id;
+ if (run_id) filters.run_id = run_id;
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 10;
+ const referencing = [];
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { filters, page, page_size: PAGE_SIZE } });
+ const memories = data.results || data.memories || data || [];
+ const fullRecords = await Promise.all(memories.map((m) => fetchSingleForMetadata(m)));
+ for (const m of fullRecords) {
+ const rels = Array.isArray(m.metadata?.relations) ? m.metadata.relations : [];
+ for (const rel of rels) {
+ if (rel.to_entity_id === entity_id) {
+ referencing.push({ fromEntityId: m.metadata?.entity_id || m.id, fromId: m.id, relation: rel.relation });
+ }
+ }
+ }
+ if (memories.length < PAGE_SIZE) break;
+ }
+ return referencing;
+}
+
+// Resolves a stored relation's to_entity_id into one of four outcomes
+// instead of a blank/not-found result:
+// "ok" — resolves within the caller's own scope
+// "wrong_scope" — resolves, but only outside the caller's agent_id/run_id
+// (found via findByEntityIdAnyScope)
+// "deleted" — does NOT resolve anywhere now, but resolved_at_write
+// was true — i.e. it existed when this relation was
+// written and has since been removed. Detected at
+// resolve-time by comparing against that stored bit,
+// not by any proactive delete-time tracking (matches
+// the plan's decision to keep the delete path itself
+// free of extra scans/writes).
+// "never_existed" — does NOT resolve anywhere now, and resolved_at_write
+// was already false at write time (or absent, for
+// relations written before this bit existed).
+async function resolveRelationTarget({ to_entity_id, resolved_at_write, user_id, agent_id, run_id }) {
+ const inScope = await findByEntityId({ user_id, agent_id, run_id, entity_id: to_entity_id });
+ if (inScope) return { status: "ok", memory: inScope };
+ const crossScope = await findByEntityIdAnyScope({ user_id, entity_id: to_entity_id });
+ if (crossScope) {
+ const scopeLabel = crossScope.agent_id || crossScope.run_id
+ ? [crossScope.agent_id && `agent_id=${crossScope.agent_id}`, crossScope.run_id && `run_id=${crossScope.run_id}`].filter(Boolean).join(", ")
+ : "different scope";
+ return { status: "wrong_scope", memory: crossScope, scopeLabel };
+ }
+ return { status: resolved_at_write ? "deleted" : "never_existed" };
+}
+
+// Cycle-safe BFS over relations, both directions:
+// outgoing — this entity's own memory.metadata.relations
+// incoming — findReferencingEntities(this entity_id)
+// Visited-set is mandatory: a cycle (A blocks B, B blocks C, C blocks A)
+// would otherwise infinite-loop a traversal with no depth cap on revisits.
+// Depth defaults to 3 per the plan's 3-hop minimum. Returns a flat list of
+// edges: { from, to, relation, direction, hop, status, scopeLabel? }.
+async function traverseRelations(startEntityId, { user_id, agent_id, run_id, depth = RELATION_TRAVERSAL_DEPTH }) {
+ const start = normalizeEntityId(startEntityId);
+ const visited = new Set([start]);
+ const queue = [{ entityId: start, hop: 0 }];
+ const edges = [];
+ while (queue.length) {
+ const { entityId, hop } = queue.shift();
+ if (hop >= depth) continue;
+ const ownMemory = await findByEntityId({ user_id, agent_id, run_id, entity_id: entityId });
+ const outgoing = Array.isArray(ownMemory?.metadata?.relations) ? ownMemory.metadata.relations : [];
+ for (const rel of outgoing) {
+ const resolution = await resolveRelationTarget({ to_entity_id: rel.to_entity_id, resolved_at_write: rel.resolved_at_write, user_id, agent_id, run_id });
+ edges.push({ from: entityId, to: rel.to_entity_id, relation: rel.relation, direction: "outgoing", hop: hop + 1, status: resolution.status, scopeLabel: resolution.scopeLabel });
+ if (resolution.status === "ok" && !visited.has(rel.to_entity_id)) {
+ visited.add(rel.to_entity_id);
+ queue.push({ entityId: rel.to_entity_id, hop: hop + 1 });
+ }
+ }
+ const referencing = await findReferencingEntities({ user_id, agent_id, run_id, entity_id: entityId });
+ for (const ref of referencing) {
+ edges.push({ from: ref.fromEntityId, to: entityId, relation: ref.relation, direction: "incoming", hop: hop + 1, status: "ok" });
+ if (!visited.has(ref.fromEntityId)) {
+ visited.add(ref.fromEntityId);
+ queue.push({ entityId: ref.fromEntityId, hop: hop + 1 });
+ }
+ }
+ }
+ return edges;
+}
+
+// Compact renderer shared by mem0_get and mem0_search/mem0_list. Labels
+// each unresolved reference with its specific reason per resolveRelationTarget
+// (never_existed / deleted / wrong_scope) instead of a silent blank.
+function formatRelatedEntities(edges) {
+ if (!edges.length) return "";
+ const lines = edges.map((e) => {
+ const arrow = e.direction === "outgoing" ? "→" : "←";
+ const other = e.direction === "outgoing" ? e.to : e.from;
+ let suffix = "";
+ if (e.status === "deleted") suffix = " (deleted)";
+ else if (e.status === "never_existed") suffix = " (not found)";
+ else if (e.status === "wrong_scope") suffix = ` (different scope: ${e.scopeLabel})`;
+ return ` [hop ${e.hop}] ${e.relation} ${arrow} ${other}${suffix}`;
+ });
+ return `Related entities (up to ${RELATION_TRAVERSAL_DEPTH} hops):\n${lines.join("\n")}`;
+}
+
+// Shared by mem0_search/mem0_list's include_relations option. Only the top
+// RELATION_RESOLVE_LIMIT results (by list position, i.e. rank) get a full
+// traversal; the rest just show how many outgoing relations they have,
+// unresolved, to avoid a full 3-hop resolution cost across an entire page
+// of results.
+async function buildRelationsSuffix(m, index, { user_id, agent_id, run_id }) {
+ const entityId = m.metadata?.entity_id;
+ if (!entityId) return "";
+ const relCount = Array.isArray(m.metadata?.relations) ? m.metadata.relations.length : 0;
+ if (index >= RELATION_RESOLVE_LIMIT) {
+ return relCount ? `\n (${relCount} outgoing relation${relCount === 1 ? "" : "s"}, unresolved — outside top ${RELATION_RESOLVE_LIMIT})` : "";
+ }
+ const edges = await traverseRelations(entityId, { user_id, agent_id, run_id });
+ const rendered = formatRelatedEntities(edges);
+ return rendered ? `\n${rendered}` : "";
+}
+
+// Tier 2: search for existing memories similar to new content (used when no
+// entity_id was given, since entity_id already gets exact-match handling
+// above). Uses Mem0's own hybrid search with reranking for precision rather
+// than any custom similarity logic — this server has no LLM/embedding call
+// of its own, so it leans on Mem0's engine the same way mem0_search does.
+// Excludes superseded memories from candidacy (a superseded memory being
+// similar to a new one isn't useful to flag).
+async function findPossibleDuplicates({ user_id, agent_id, run_id, content, threshold, limit = 3 }) {
+ const filters = { user_id };
+ if (agent_id) filters.agent_id = agent_id;
+ if (run_id) filters.run_id = run_id;
+ const data = await mem0Request("/v3/memories/search/", { method: "POST", body: { query: content, filters, top_k: limit, rerank: true } });
+ let memories = data.results || data.memories || data || [];
+ memories = filterByStatus(memories, undefined);
+ return memories.filter((m) => typeof m.score === "number" && m.score >= threshold);
+}
+
+// NOTE on add-then-verify (2026-07-10, following madmcp-mem0-add-silent-
+// failure-diagnostic): /v3/memories/add/ returning a 2xx with an event_id
+// only means Mem0 ACCEPTED the job, not that its async extraction/indexing
+// pipeline actually materialized the memory — that step has been observed
+// to silently drop a memory with no error surfaced anywhere. Since this
+// server has no webhook/callback for that job, the only way to check is to
+// poll for the memory to actually appear. Matches on entity_id (exact,
+// deterministic) when given, otherwise on exact verbatim content (reliable
+// since infer:false — the default — stores content unchanged; a caller
+// using infer:true won't get a reliable match here since Mem0 may have
+// rephrased it, so verification is best-effort in that case).
+//
+// Deliberately a SINGLE check after one wait, not a bounded retry loop —
+// this only ever costs one extra Mem0 API call per add (reduced 2026-07-10
+// from an up-to-4-attempt loop to cut call volume). A memory that takes
+// longer than the wait to materialize will report as unconfirmed even
+// though it may land moments later; that's an accepted false-negative
+// trade-off since the caller is already told to just re-check manually.
+async function verifyLanded({ user_id, agent_id, run_id, entity_id, content }, { delayMs = 3000 } = {}) {
+ const filters = { user_id };
+ if (agent_id) filters.agent_id = agent_id;
+ if (run_id) filters.run_id = run_id;
+ await new Promise((r) => setTimeout(r, delayMs));
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { filters, page: 1, page_size: 20 } });
+ const memories = data.results || data.memories || data || [];
+ return memories.find((m) =>
+ entity_id ? m.metadata?.entity_id === entity_id : (m.memory || m.text) === content
+ ) || null;
+}
+
+export function register(server) {
+
+ // ── List memories ────────────────────────────────────────────────────────
+ server.tool(
+ "mem0_list",
+ "List recent memories from your Mem0 workspace.",
+ {
+ user_id: z.string().optional().describe(`Mem0 user ID to scope memories (default: ${MEM0_USER_ID})`),
+ limit: z.number().optional().describe("Number of memories to return (default: 20)"),
+ page: z.number().optional().describe("Page number for pagination (default: 1)"),
+ categories: z.array(z.string()).optional().describe("Optional tag filters (memory must match any listed tag; matched client-side against metadata.tags, not Mem0's built-in classifier categories)"),
+ status_filter: z.array(z.enum(STATUS_VALUES)).optional().describe("Optional status filter (memory must match one of the listed statuses). If omitted, defaults to excluding status=\"superseded\" (memories with no status set are always included). Pass e.g. [\"superseded\"] to explicitly see superseded memories, or [\"open\"] to narrow to just open ones."),
+ fields: z.array(z.string()).optional().describe("Optional list of fields to return per memory (server-side projection to reduce payload size), e.g. ['id','memory','created_at']"),
+ flagged_duplicates_only: z.boolean().optional().describe("If true, only return memories flagged at add-time as possible duplicates of another memory (metadata.possible_duplicate_of non-empty) — useful for a periodic consolidation pass (Part 5 of the anti-bloat plan)."),
+ include_relations: z.boolean().optional().describe(`Default: false. If true, resolve and show each memory's related entities (up to ${RELATION_TRAVERSAL_DEPTH} hops, both outgoing and incoming) — but only for the top ${RELATION_RESOLVE_LIMIT} results by rank, to avoid a full multi-hop resolution cost across the whole page. Remaining results show an outgoing-relation count only. Unresolved targets are labeled deleted / not found / different scope rather than left blank.`),
+ },
+ async ({ user_id = MEM0_USER_ID, limit = 20, page = 1, categories, status_filter, fields, flagged_duplicates_only, include_relations = false }) => {
+ const filters = { user_id };
+ // Over-fetch a bit since tag/status filtering happens client-side.
+ const needsClientFilter = categories?.length || status_filter?.length || flagged_duplicates_only || true; // status default-filter always applies
+ const fetchSize = needsClientFilter ? Math.max(limit * 2, limit + 20) : limit;
+ const body = { filters, page, page_size: fetchSize };
+ if (fields?.length) body.fields = Array.from(new Set([...fields, "metadata"]));
+ const data = await mem0Request("/v3/memories/", { method: "POST", body });
+ let memories = data.results || data.memories || data || [];
+ memories = filterByTags(memories, categories);
+ memories = filterByStatus(memories, status_filter);
+ memories = filterFlaggedDuplicates(memories, flagged_duplicates_only).slice(0, limit);
+ if (!memories.length) return { content: [{ type: "text", text: "No memories found." }] };
+ if (!include_relations) {
+ return { content: [{ type: "text", text: memories.map((m) => compactLine(m)).join("\n") }] };
+ }
+ const lines = [];
+ for (let i = 0; i < memories.length; i++) {
+ const suffix = await buildRelationsSuffix(memories[i], i, { user_id });
+ lines.push(compactLine(memories[i]) + suffix);
+ }
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ // ── Get single memory ────────────────────────────────────────────────────
+ server.tool(
+ "mem0_get",
+ "Get the full content of a specific Mem0 memory by ID.",
+ {
+ memory_id: z.string().describe("The memory ID (from mem0_list or mem0_search)"),
+ },
+ async ({ memory_id }) => {
+ const m = await mem0Request(`/v1/memories/${memory_id}/`);
+ const cats = Array.isArray(m.categories) && m.categories.length ? `\nCategories: ${m.categories.join(", ")}` : "";
+ const tags = Array.isArray(m.metadata?.tags) && m.metadata.tags.length ? `\nTags: ${m.metadata.tags.join(", ")}` : "";
+ const eid = m.metadata?.entity_id ? `\nEntity ID: ${m.metadata.entity_id}` : "";
+ const status = m.metadata?.status ? `\nStatus: ${m.metadata.status}` : "";
+ const dup = Array.isArray(m.metadata?.possible_duplicate_of) && m.metadata.possible_duplicate_of.length ? `\nPossible duplicate of: ${m.metadata.possible_duplicate_of.join(", ")}` : "";
+ const meta = m.metadata && Object.keys(m.metadata).length ? `\n\nMetadata:\n${JSON.stringify(m.metadata, null, 2)}` : "";
+ let relatedSection = "";
+ if (m.metadata?.entity_id) {
+ const edges = await traverseRelations(m.metadata.entity_id, { user_id: m.user_id || MEM0_USER_ID, agent_id: m.agent_id, run_id: m.run_id });
+ const rendered = formatRelatedEntities(edges);
+ if (rendered) relatedSection = `\n\n${rendered}`;
+ }
+ const text =
+ `ID: ${m.id}\n` +
+ `Created: ${m.created_at?.slice(0, 10) || "unknown"} | Updated: ${m.updated_at?.slice(0, 10) || "unknown"}${cats}${tags}${eid}${status}${dup}\n\n` +
+ (m.memory || m.text || "(no content)") +
+ meta + relatedSection;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ // ── Get memory version history ───────────────────────────────────────────
+ server.tool(
+ "mem0_get_history",
+ "Get the version/audit history of a specific Mem0 memory by ID — every ADD/UPDATE/DELETE event recorded for it, with old/new values and timestamps. Wraps Mem0's native history endpoint.",
+ {
+ memory_id: z.string().describe("The memory ID (from mem0_list or mem0_search)"),
+ },
+ async ({ memory_id }) => {
+ const data = await mem0Request(`/v1/memories/${memory_id}/history/`);
+ const entries = data.results || data.history || data || [];
+ if (!entries.length) return { content: [{ type: "text", text: "No history found for this memory." }] };
+ const lines = entries.map((h) => {
+ const date = (h.created_at || h.updated_at || "").slice(0, 10) || "?";
+ const event = h.event || h.action || "?";
+ const trunc = (s) => (s || "").slice(0, 70).replace(/\n/g, " ") + ((s || "").length > 70 ? "…" : "");
+ const oldVal = h.prev_value ?? h.old_memory;
+ const newVal = h.new_value ?? h.new_memory;
+ const diff = oldVal || newVal ? ` | ${trunc(oldVal) || "(none)"} → ${trunc(newVal) || "(none)"}` : "";
+ return `${date} [${event}]${diff}`;
+ });
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ // ── Add memory ───────────────────────────────────────────────────────────
+ server.tool(
+ "mem0_add",
+ "Add a new memory to your Mem0 workspace. Mem0 uses LLM extraction to store facts from your message.",
+ {
+ content: z.string().describe("The text or fact to remember (markdown supported)"),
+ user_id: z.string().optional().describe(`Mem0 user ID to scope the memory (default: ${MEM0_USER_ID})`),
+ agent_id: z.string().optional().describe("Optional agent ID for finer-grained scoping (e.g. per-project), in addition to user_id"),
+ run_id: z.string().optional().describe("Optional run/session ID for finer-grained scoping"),
+ categories: z.array(z.string()).optional().describe("Optional tags to attach to this memory (e.g. ['manager.js','decisions']) — stored under metadata.tags and used for later tag-filtered list/search, since Mem0's own category classifier can't be overridden per-call"),
+ entity_id: z.string().optional().describe("Optional stable identifier for the fact/entity this memory is about (e.g. 'bug-4', 'nexus-file-naming'). BEFORE inventing a new one, search/list for an existing entity on the same topic — entity_id only prevents duplicates when it EXACTLY matches a string used before; a new entity_id for something that already has a different entity_id will NOT be caught by the exact-match check (though it will still get flagged by the Tier 2 similarity check below, so check the response for a possible_duplicate_of warning). If a memory already exists with this exact entity_id, mem0_add will NOT create a duplicate — it returns the existing memory's id and content instead, so you can merge old + new content yourself (keeping everything not explicitly contradicted) and call mem0_update. Use this whenever you're recording an update to something you've stored before, rather than adding a fresh mem0_add call."),
+ status: z.enum(STATUS_VALUES).optional().describe("Optional lifecycle status for this memory (open/resolved/superseded). Left unset by default. Memories marked \"superseded\" are hidden from mem0_list/mem0_search by default."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other entity this one relates to"),
+ relation: z.string().describe("The relation type, e.g. 'blocks', 'depends_on', 'relates_to' — free text; known synonyms/variants are canonicalized automatically, unrecognized strings pass through unchanged"),
+ })).optional().describe("Optional list of relations from this memory's entity to others, e.g. [{to_entity_id:'bug-4', relation:'blocks'}]. Stored under metadata.relations. Requires this memory's own entity_id to be set for self-loop protection. Dangling to_entity_id values (no matching entity_id found yet) are flagged non-blocking, same as the existing dangling-ref-on-add behavior."),
+ metadata: z.record(z.any()).optional().describe("Optional arbitrary metadata object to attach (e.g. {project: 'manager.js'})"),
+ infer: z.boolean().optional().describe("If true, uses Mem0's LLM extraction to atomize/rephrase the content into inferred facts instead of storing it verbatim. Default: false (stores content verbatim as a 'direct import') to prevent extraction from scattering or restructuring stored memories."),
+ skip_duplicate_check: z.boolean().optional().describe("If true, skip the Tier 2 similarity check against existing memories. Default: false — the check runs automatically, including when entity_id is given but doesn't exactly match anything existing (a new entity_id gets checked too, not just untagged adds). Set true for bulk/import scenarios where the extra search call's latency isn't worth it."),
+ duplicate_threshold: z.number().optional().describe("Minimum relevance score (0-1) for an existing memory to be flagged as a possible duplicate of this one. Default: 0.75. Applies whenever skip_duplicate_check is false, regardless of entity_id. Note: regardless of this value, a candidate scoring >= 0.92 hard-blocks the add entirely (same as an exact entity_id match) rather than just flagging — see mem0_add's description."),
+ },
+ async ({ content, user_id = MEM0_USER_ID, agent_id, run_id, categories, entity_id, status, relations, metadata, infer = false, skip_duplicate_check = false, duplicate_threshold = 0.75 }) => {
+ if (entity_id) {
+ const existing = await findByEntityId({ user_id, agent_id, run_id, entity_id });
+ if (existing) {
+ return {
+ content: [{
+ type: "text",
+ text:
+ `Not adding — a memory already exists for entity_id "${entity_id}" (id: ${existing.id}). No duplicate was created.\n\n` +
+ `Existing content:\n${existing.memory || existing.text || "(no content)"}\n\n` +
+ `New content you were about to add:\n${content}\n\n` +
+ `Next step: merge these two yourself — keep everything from the existing content that the new content doesn't explicitly contradict — then call mem0_update with memory_id="${existing.id}" and the merged text.`,
+ }],
+ };
+ }
+ }
+ let duplicateWarning = "";
+ const meta = { ...metadata };
+ if (categories?.length) meta.tags = categories;
+ if (entity_id) meta.entity_id = entity_id;
+ if (status) meta.status = status;
+ let relationWarnings = [];
+ if (relations?.length) {
+ const { relations: cleanedRelations, warnings } = await processRelations(relations, { ownEntityId: entity_id, user_id, agent_id, run_id });
+ if (cleanedRelations.length) meta.relations = cleanedRelations;
+ relationWarnings = warnings;
+ }
+ // Runs regardless of entity_id now — see the Tier 2 NOTE above. An
+ // exact entity_id match already returned early, so reaching here with
+ // entity_id set means it's a *new* entity_id, which still needs this
+ // semantic check the same as an untagged add would.
+ if (!skip_duplicate_check) {
+ const candidates = await findPossibleDuplicates({ user_id, agent_id, run_id, content, threshold: duplicate_threshold });
+ const blocking = candidates.filter((c) => c.score >= BLOCKING_DUPLICATE_THRESHOLD);
+ if (blocking.length) {
+ const top = blocking[0];
+ return {
+ content: [{
+ type: "text",
+ text:
+ `Not adding — content is near-identical (score ${top.score.toFixed(2)} >= ${BLOCKING_DUPLICATE_THRESHOLD}) to existing memory ${top.id}. Hard-blocked, same as an exact entity_id match — no duplicate was created.\n\n` +
+ `Existing content:\n${top.memory || top.text || "(no content)"}\n\n` +
+ `New content you were about to add:\n${content}\n\n` +
+ `Next step: merge these two yourself — keep everything from the existing content that the new content doesn't explicitly contradict — then call mem0_update with memory_id="${top.id}" and the merged text. If this really is distinct content despite the score, retry with skip_duplicate_check:true.`,
+ }],
+ };
+ }
+ if (candidates.length) {
+ meta.possible_duplicate_of = candidates.map((c) => c.id);
+ duplicateWarning =
+ `\n\n⚠ Possible duplicate(s) found — added anyway (not blocked), flagged for review:\n` +
+ candidates.map((c) => ` ${c.id} (score ${c.score.toFixed(2)}): ${(c.memory || c.text || "").slice(0, 70)}`).join("\n") +
+ `\nCheck with mem0_get; if it's a real duplicate, merge via mem0_update and mark the stale one status="superseded".`;
+ }
+ }
+ const messages = [{ role: "user", content }];
+ const body = { messages, user_id, infer };
+ if (agent_id) body.agent_id = agent_id;
+ if (run_id) body.run_id = run_id;
+ if (Object.keys(meta).length) body.metadata = meta;
+ const data = await mem0Request("/v3/memories/add/", { method: "POST", body });
+ const eventId = data.event_id || data.id;
+ const landed = await verifyLanded({ user_id, agent_id, run_id, entity_id, content });
+ const landedNote = landed
+ ? ` Confirmed landed (id: ${landed.id}).`
+ : `\n\n⚠ Could not confirm this memory landed after several verification attempts — Mem0's async job may have silently failed (see madmcp-mem0-add-silent-failure-diagnostic). Re-run mem0_search/mem0_list shortly to check, and retry mem0_add if it's still missing.`;
+ const relationNote = relationWarnings.length ? `\n\n⚠ Relations:\n${relationWarnings.map((w) => ` ${w}`).join("\n")}` : "";
+ return {
+ content: [{
+ type: "text",
+ text: (eventId
+ ? `Memory extraction started (event_id: ${eventId}).${landedNote}`
+ : `Memory added: ${JSON.stringify(data)}`) + duplicateWarning + relationNote,
+ }],
+ };
+ }
+ );
+
+ // ── Add multiple memories in one call ──────────────────────────────────────
+ server.tool(
+ "mem0_add_batch",
+ "Add multiple memories to your Mem0 workspace in a single call, to reduce round trips. Each item is submitted as its own extraction request.",
+ {
+ items: z.array(z.object({
+ content: z.string().describe("The text or fact to remember (markdown supported)"),
+ user_id: z.string().optional().describe(`Mem0 user ID to scope this memory (default: ${MEM0_USER_ID})`),
+ agent_id: z.string().optional().describe("Optional agent ID for finer-grained scoping"),
+ run_id: z.string().optional().describe("Optional run/session ID for finer-grained scoping"),
+ categories: z.array(z.string()).optional().describe("Optional tags for this memory — stored under metadata.tags (see mem0_add for why)"),
+ entity_id: z.string().optional().describe("Optional stable identifier for this fact/entity — see mem0_add. If a memory already exists for it, this item is skipped (not duplicated) and the existing id + content is reported instead."),
+ status: z.enum(STATUS_VALUES).optional().describe("Optional lifecycle status (open/resolved/superseded) — see mem0_add."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other entity this one relates to"),
+ relation: z.string().describe("The relation type — see mem0_add's relations param."),
+ })).optional().describe("Optional list of relations for this item — see mem0_add's relations param."),
+ metadata: z.record(z.any()).optional().describe("Optional arbitrary metadata object for this memory"),
+ infer: z.boolean().optional().describe("If true, uses Mem0's LLM extraction to atomize/rephrase the content instead of storing it verbatim. Default: false."),
+ skip_duplicate_check: z.boolean().optional().describe("If true, skip the Tier 2 similarity check for this item (only relevant when no entity_id is given). Default: false."),
+ duplicate_threshold: z.number().optional().describe("Minimum relevance score (0-1) to flag an existing memory as a possible duplicate of this item. Default: 0.75."),
+ })).min(1).describe("List of memories to add"),
+ },
+ async ({ items }) => {
+ const results = await Promise.allSettled(items.map(async ({ content, user_id = MEM0_USER_ID, agent_id, run_id, categories, entity_id, status, relations, metadata, infer = false, skip_duplicate_check = false, duplicate_threshold = 0.75 }) => {
+ if (entity_id) {
+ const existing = await findByEntityId({ user_id, agent_id, run_id, entity_id });
+ if (existing) {
+ return { skipped: true, entity_id, existingId: existing.id, existingContent: existing.memory || existing.text || "(no content)" };
+ }
+ }
+ const meta = { ...metadata };
+ if (categories?.length) meta.tags = categories;
+ if (entity_id) meta.entity_id = entity_id;
+ if (status) meta.status = status;
+ let relationWarnings = [];
+ if (relations?.length) {
+ const { relations: cleanedRelations, warnings } = await processRelations(relations, { ownEntityId: entity_id, user_id, agent_id, run_id });
+ if (cleanedRelations.length) meta.relations = cleanedRelations;
+ relationWarnings = warnings;
+ }
+ let duplicatesFlagged = null;
+ // See Tier 2 NOTE above — runs regardless of entity_id, since a *new*
+ // entity_id needs semantic dup protection just as much as an untagged add.
+ if (!skip_duplicate_check) {
+ const candidates = await findPossibleDuplicates({ user_id, agent_id, run_id, content, threshold: duplicate_threshold });
+ const blocking = candidates.filter((c) => c.score >= BLOCKING_DUPLICATE_THRESHOLD);
+ if (blocking.length) {
+ const top = blocking[0];
+ return { skipped: true, blocked: true, existingId: top.id, existingScore: top.score, existingContent: top.memory || top.text || "(no content)" };
+ }
+ if (candidates.length) {
+ meta.possible_duplicate_of = candidates.map((c) => c.id);
+ duplicatesFlagged = candidates.map((c) => c.id);
+ }
+ }
+ const body = { messages: [{ role: "user", content }], user_id, infer };
+ if (agent_id) body.agent_id = agent_id;
+ if (run_id) body.run_id = run_id;
+ if (Object.keys(meta).length) body.metadata = meta;
+ const result = await mem0Request("/v3/memories/add/", { method: "POST", body });
+ const landed = await verifyLanded({ user_id, agent_id, run_id, entity_id, content });
+ return { ...result, duplicatesFlagged, relationWarnings, landed: !!landed, landedId: landed?.id };
+ }));
+ const lines = results.map((r, i) => {
+ const title = (items[i].content || "").split("\n")[0].slice(0, 60);
+ if (r.status === "fulfilled") {
+ if (r.value?.skipped) {
+ if (r.value.blocked) {
+ return `⛔ [${i}] "${title}" — blocked, near-identical (score ${r.value.existingScore.toFixed(2)}) to existing memory (id: ${r.value.existingId}). No duplicate created. Merge and call mem0_update yourself if this content adds anything new.`;
+ }
+ return `⏭ [${i}] "${title}" — skipped, entity_id "${r.value.entity_id}" already exists (id: ${r.value.existingId}). Merge and call mem0_update yourself if this content adds anything new.`;
+ }
+ const eventId = r.value.event_id || r.value.id || "ok";
+ const dupNote = r.value.duplicatesFlagged?.length ? ` ⚠ flagged as possible duplicate of ${r.value.duplicatesFlagged.join(", ")}` : "";
+ const relNote = r.value.relationWarnings?.length ? ` ⚠ relations: ${r.value.relationWarnings.join("; ")}` : "";
+ const landedNote = r.value.landed ? ` — confirmed landed (id: ${r.value.landedId})` : ` — ⚠ could not confirm this landed, check manually`;
+ return `✓ [${i}] "${title}" — event_id: ${eventId}${dupNote}${relNote}${landedNote}`;
+ }
+ return `✗ [${i}] "${title}" — error: ${r.reason?.message || r.reason}`;
+ });
+ return { content: [{ type: "text", text: lines.join("\n") }] };
+ }
+ );
+
+ // ── Search memories ──────────────────────────────────────────────────────
+ server.tool(
+ "mem0_search",
+ "Search memories in your Mem0 workspace using hybrid semantic + keyword retrieval.",
+ {
+ query: z.string().describe("Search query string"),
+ user_id: z.string().optional().describe(`Mem0 user ID to scope search (default: ${MEM0_USER_ID})`),
+ agent_id: z.string().optional().describe("Optional agent ID to scope search (e.g. per-project), in addition to user_id. Scoping at query time — not just at write time — meaningfully improves precision by excluding irrelevant projects/entities from the candidate pool before ranking even starts."),
+ run_id: z.string().optional().describe("Optional run/session ID to scope search, in addition to user_id."),
+ limit: z.number().optional().describe("Number of results to return (default: 10)"),
+ categories: z.array(z.string()).optional().describe("Optional tag filters (memory must match any listed tag; matched client-side against metadata.tags, not Mem0's built-in classifier categories)"),
+ status_filter: z.array(z.enum(STATUS_VALUES)).optional().describe("Optional status filter (memory must match one of the listed statuses). If omitted, defaults to excluding status=\"superseded\" (memories with no status set are always included). Pass e.g. [\"superseded\"] to explicitly see superseded memories."),
+ rerank: z.boolean().optional().describe("Whether to apply Mem0's relevance reranking on top of hybrid retrieval. Default: true — reranking meaningfully improves precision and is now the connector default rather than opt-in; pass false to skip it if latency matters more than precision for a given call."),
+ threshold: z.number().optional().describe("Minimum relevance score (0-1) — results below this are dropped. Default: 0.35 (raised from Mem0 v3's own default of 0.1, which let through too much low-relevance noise). Pass 0 explicitly to disable filtering and see everything Mem0 returns."),
+ },
+ async ({ query, user_id = MEM0_USER_ID, agent_id, run_id, limit = 10, categories, status_filter, rerank = true, threshold = 0.35 }) => {
+ const filters = { user_id };
+ if (agent_id) filters.agent_id = agent_id;
+ if (run_id) filters.run_id = run_id;
+ // Over-fetch since tag/status filtering happens client-side (status
+ // default-exclusion of "superseded" always applies, so always over-fetch
+ // a bit even with no explicit categories/status_filter given).
+ const fetchLimit = Math.max(limit * 3, limit + 20);
+ const body = { query, filters, top_k: fetchLimit };
+ if (rerank) body.rerank = true;
+ if (threshold > 0) body.threshold = threshold;
+ const data = await mem0Request("/v3/memories/search/", { method: "POST", body });
+ let memories = data.results || data.memories || data || [];
+ memories = filterByTags(memories, categories);
+ memories = filterByStatus(memories, status_filter).slice(0, limit);
+ if (!memories.length) return { content: [{ type: "text", text: "No memories found matching your query." }] };
+ return { content: [{ type: "text", text: memories.map((m) => compactLine(m, { showScore: true })).join("\n") }] };
+ }
+ );
+
+ // ── Update memory ────────────────────────────────────────────────────────
+ // NOTE on `replacements` (2026-07-13): mem0_update previously only
+ // supported a full-content replace, which meant any edit — even a
+ // one-clause fix — required the caller to regenerate and resend the
+ // entire memory body. Mem0's own PUT /v1/memories/{id}/ endpoint is a
+ // full replace with no field/substring PATCH, so a GET+PUT round trip is
+ // unavoidable either way — but the *caller-side* cost of reproducing the
+ // whole document was the real bottleneck, not the API call count. This
+ // mirrors the github connector's edit_file `replacements` mode: send only
+ // find/replace pairs, apply them to the fetched current content, PUT the
+ // result. Each `find` must appear exactly once in the current content
+ // (same safety rule as edit_file) — ambiguous or missing matches fail loudly
+ // rather than silently no-op'ing or replacing the wrong occurrence.
+ // Mutually exclusive with `content`: pick one mode per call.
+ server.tool(
+ "mem0_update",
+ "Update an existing Mem0 memory by ID: replace its content (in full, or via targeted find/replace edits), change its status, change its relations, patch or delete arbitrary metadata keys, or any combination. At least one of content/replacements/status/relations/metadata_patch/metadata_delete_keys/clear_duplicate_flag must be given. `content` and `replacements` are mutually exclusive — use `replacements` for small edits to avoid resending the whole memory body.",
+ {
+ memory_id: z.string().describe("The memory ID to update"),
+ content: z.string().optional().describe("New content for the memory (replaces existing content in full). Omit to change only the status, or use `replacements` for a targeted edit instead. Mutually exclusive with `replacements`."),
+ replacements: z.array(z.object({
+ find: z.string().describe("Exact string to find in the current memory content — must appear exactly once"),
+ replace: z.string().describe("String to replace it with"),
+ })).optional().describe("List of find-and-replace operations to apply sequentially to the memory's current content, without resending the full body. Each `find` must match exactly once in the content at the time it's applied (fails loudly on zero or multiple matches, same rule as the github edit_file tool's `replacements` mode). Mutually exclusive with `content`."),
+ status: z.enum(STATUS_VALUES).optional().describe("New lifecycle status (open/resolved/superseded) for this memory. Omit to leave status unchanged. Existing tags/entity_id/other metadata are preserved regardless."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other entity this one relates to"),
+ relation: z.string().describe("The relation type — see mem0_add's relations param."),
+ })).optional().describe("New relations for this memory's entity — REPLACES the existing metadata.relations array whole (not merged). Omit to leave relations unchanged. Canonicalized the same way as mem0_add's relations param. Pass an empty array to clear all relations."),
+ metadata_patch: z.record(z.any()).optional().describe("Arbitrary metadata keys to merge into this memory's existing metadata (shallow merge — each key you supply overwrites that key only, everything else in metadata is preserved). Use this to add/fix a custom field without knowing or resending the whole metadata object. Applied before metadata_delete_keys if both are given."),
+ metadata_delete_keys: z.array(z.string()).optional().describe("Arbitrary metadata keys to remove outright from this memory (e.g. ['possible_duplicate_of'] to clear a stale Tier-2 duplicate flag during a Part-5 consolidation pass, or any other custom key that no longer applies). Applied after metadata_patch, so a key can be patched and then deleted in the same call if that's ever useful, though normally you'd only use one or the other for a given key."),
+ clear_duplicate_flag: z.boolean().optional().describe("Shorthand for metadata_delete_keys including 'possible_duplicate_of' — kept for convenience/back-compat. Equivalent to adding 'possible_duplicate_of' to metadata_delete_keys."),
+ },
+ async ({ memory_id, content, replacements, status, relations, metadata_patch, metadata_delete_keys, clear_duplicate_flag }) => {
+ const deleteKeys = new Set(metadata_delete_keys || []);
+ if (clear_duplicate_flag) deleteKeys.add("possible_duplicate_of");
+ if (content === undefined && replacements === undefined && status === undefined && relations === undefined && metadata_patch === undefined && deleteKeys.size === 0) {
+ return {
+ content: [{ type: "text", text: "Nothing to update — provide content, replacements, status, relations, metadata_patch, metadata_delete_keys, or clear_duplicate_flag." }],
+ isError: true,
+ };
+ }
+ if (content !== undefined && replacements !== undefined) {
+ return {
+ content: [{ type: "text", text: "Provide either content or replacements, not both — they're mutually exclusive update modes." }],
+ isError: true,
+ };
+ }
+ // Mem0's PUT replaces the whole metadata object, so fetch current
+ // metadata first and merge in the status change client-side, rather
+ // than risk wiping out tags/entity_id set at add-time. This fetch also
+ // supplies the base text that `replacements` is applied against.
+ const current = await mem0Request(`/v1/memories/${memory_id}/`);
+ let finalText = current.memory || current.text || "";
+ if (content !== undefined) {
+ finalText = content;
+ } else if (replacements !== undefined) {
+ for (const { find, replace } of replacements) {
+ const count = finalText.split(find).length - 1;
+ if (count === 0) {
+ return {
+ content: [{ type: "text", text: `Update aborted, nothing written — "${find.slice(0, 60)}${find.length > 60 ? "…" : ""}" was not found in the current memory content. Content may have changed since you last read it — re-fetch with mem0_get and retry.` }],
+ isError: true,
+ };
+ }
+ if (count > 1) {
+ return {
+ content: [{ type: "text", text: `Update aborted, nothing written — "${find.slice(0, 60)}${find.length > 60 ? "…" : ""}" appears ${count} times in the current memory content, but must be unique. Include more surrounding context in "find" to disambiguate.` }],
+ isError: true,
+ };
+ }
+ finalText = finalText.replace(find, replace);
+ }
+ }
+ let relationWarnings = [];
+ const metadataUpdates = { ...(status !== undefined ? { status } : {}) };
+ if (relations !== undefined) {
+ const { relations: cleanedRelations, warnings } = await processRelations(relations, { ownEntityId: current.metadata?.entity_id, user_id: current.user_id || MEM0_USER_ID, agent_id: current.agent_id, run_id: current.run_id });
+ metadataUpdates.relations = cleanedRelations;
+ relationWarnings = warnings;
+ }
+ const finalMetadata = { ...current.metadata, ...metadataUpdates, ...metadata_patch };
+ for (const key of deleteKeys) delete finalMetadata[key];
+ const body = { text: finalText };
+ if (Object.keys(finalMetadata).length) body.metadata = finalMetadata;
+ const data = await mem0Request(`/v1/memories/${memory_id}/`, { method: "PUT", body });
+ const parts = [];
+ if (content !== undefined) parts.push("content replaced in full");
+ if (replacements !== undefined) parts.push(`${replacements.length} targeted edit${replacements.length === 1 ? "" : "s"} applied`);
+ if (status !== undefined) parts.push(`status set to "${status}"`);
+ if (relations !== undefined) parts.push(`relations replaced (${metadataUpdates.relations.length} stored)`);
+ if (metadata_patch !== undefined) parts.push(`metadata patched (${Object.keys(metadata_patch).join(", ")})`);
+ if (deleteKeys.size) parts.push(`metadata keys removed (${[...deleteKeys].join(", ")})`);
+ const relationNote = relationWarnings.length ? `\n\n⚠ Relations:\n${relationWarnings.map((w) => ` ${w}`).join("\n")}` : "";
+ return { content: [{ type: "text", text: `Updated memory (ID: ${data.id || memory_id}) — ${parts.join(", ")}.\nUpdated: ${data.updated_at?.slice(0, 10) || "unknown"}${relationNote}` }] };
+ }
+ );
+
+ // ── Delete memory ────────────────────────────────────────────────────────
+ server.tool(
+ "mem0_delete",
+ "Permanently delete a specific Mem0 memory by ID.",
+ {
+ memory_id: z.string().describe("The memory ID to delete"),
+ },
+ async ({ memory_id }) => {
+ await mem0Request(`/v1/memories/${memory_id}/`, { method: "DELETE" });
+ return { content: [{ type: "text", text: `Deleted memory (ID: ${memory_id}).` }] };
+ }
+ );
+
+ // ── Bulk delete by filter (server-side, no IDs needed) ────────────────────
+ server.tool(
+ "mem0_delete_all",
+ "Bulk-delete every memory matching the given filters in a single server-side call (Mem0's DELETE /v1/memories) — no need to list or fetch IDs first. At least one filter must resolve (defaults to your own user_id if none are given). Pass '*' as a filter value to match ALL entities of that type (e.g. user_id: '*' deletes memories for every user in the whole project) — combine all four id filters with '*' for a full project wipe. Irreversible; requires confirm: true.",
+ {
+ user_id: z.string().optional().describe(`Filter by user ID. Pass '*' to delete memories for all users. Defaults to ${MEM0_USER_ID} if no filters are given at all.`),
+ agent_id: z.string().optional().describe("Filter by agent ID. Pass '*' to delete memories for all agents."),
+ app_id: z.string().optional().describe("Filter by app ID. Pass '*' to delete memories for all apps."),
+ run_id: z.string().optional().describe("Filter by run ID. Pass '*' to delete memories for all runs."),
+ metadata: z.record(z.any()).optional().describe("Filter by metadata (exact match on the given key/value pairs)."),
+ confirm: z.boolean().describe("Must be explicitly set to true to execute the deletion. Safety guard against accidental bulk wipes — the tool refuses to run without it."),
+ },
+ async ({ user_id, agent_id, app_id, run_id, metadata, confirm }) => {
+ if (!confirm) {
+ return {
+ content: [{ type: "text", text: "Refused: this would bulk-delete memories server-side and cannot be undone. Re-call with confirm: true to proceed." }],
+ isError: true,
+ };
+ }
+ // Mem0 itself rejects a filterless call, but fail fast with a clearer
+ // message and a safe default (caller's own scope) rather than letting
+ // an empty filter set fall through to an ambiguous 400 from the API.
+ if (!user_id && !agent_id && !app_id && !run_id && !metadata) {
+ user_id = MEM0_USER_ID;
+ }
+ const params = new URLSearchParams();
+ if (user_id) params.set("user_id", user_id);
+ if (agent_id) params.set("agent_id", agent_id);
+ if (app_id) params.set("app_id", app_id);
+ if (run_id) params.set("run_id", run_id);
+ if (metadata) params.set("metadata", JSON.stringify(metadata));
+ const data = await mem0Request(`/v1/memories/?${params.toString()}`, { method: "DELETE" });
+ const wildcardScope = [user_id, agent_id, app_id, run_id].includes("*");
+ const scopeDesc = [
+ user_id && `user_id=${user_id}`,
+ agent_id && `agent_id=${agent_id}`,
+ app_id && `app_id=${app_id}`,
+ run_id && `run_id=${run_id}`,
+ metadata && `metadata=${JSON.stringify(metadata)}`,
+ ].filter(Boolean).join(", ");
+ return {
+ content: [{
+ type: "text",
+ text: `${data?.message || "Memories deleted."} (scope: ${scopeDesc})${wildcardScope ? " — wildcard used, this may have affected multiple entities." : ""}`,
+ }],
+ };
+ }
+ );
+
+ // ── Delete multiple memories in one call ─────────────────────────────────
+ server.tool(
+ "mem0_delete_batch",
+ "Permanently delete multiple Mem0 memories in a single call. Returns a per-item success/failure report.",
+ {
+ memory_ids: z.array(z.string()).min(1).describe("List of memory IDs to delete"),
+ },
+ async ({ memory_ids }) => {
+ const results = await Promise.allSettled(
+ memory_ids.map((id) => mem0Request(`/v1/memories/${id}/`, { method: "DELETE" }))
+ );
+ const lines = results.map((r, i) =>
+ r.status === "fulfilled"
+ ? `✓ Deleted: ${memory_ids[i]}`
+ : `✗ Failed: ${memory_ids[i]} — ${r.reason?.message || r.reason}`
+ );
+ const deleted = results.filter((r) => r.status === "fulfilled").length;
+ return {
+ content: [{
+ type: "text",
+ text: `${deleted}/${memory_ids.length} deleted.\n\n${lines.join("\n")}`,
+ }],
+ };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 | + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/notion/client.js
+// ---------------------------------------------------------------------------
+
+import { NOTION_TOKEN, NOTION_API, NOTION_VERSION, NOTION_INDEX_DATABASE_ID, NOTION_MIN_REQUEST_INTERVAL_MS, NOTION_MAX_RETRIES, NOTION_RETRY_BASE_MS } from "../../config.js";
+import { createThrottle, sleep, defaultRetryDelayMs } from "../shared/rate-limit.js";
+
+// --- Throttle + retry (fix #3, 2026-07-27) ----------------------------------
+// One shared queue for the whole process -- see createThrottle's own header
+// for why a fresh throttle per call would be pointless. Spaces out every
+// outgoing Notion request (even ones issued concurrently, e.g. several
+// Notion calls in the same parallelized delegate_agent step) by at least
+// NOTION_MIN_REQUEST_INTERVAL_MS, and retries 429/transient-5xx responses
+// with backoff instead of throwing on the first hit. Mirrors
+// connectors/github/client.js's scheduleThrottled + retry loop, which Notion
+// (and Mem0, see connectors/mem/client.js) previously had no equivalent of.
+const scheduleThrottled = createThrottle(NOTION_MIN_REQUEST_INTERVAL_MS);
+
+// 429 is Notion's documented rate-limit response. 502/503/504 are treated as
+// transient upstream/proxy hiccups worth one retry, same spirit as GitHub's
+// isRetryable -- anything else (400 malformed request, 401/403 auth, 404
+// unknown resource) is a real error and should surface immediately,
+// unretried.
+function isRetryableNotion(res) {
+ return res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
+}
+
+async function doNotionFetch(path, { method, body }) {
+ const res = await fetch(`${NOTION_API}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${NOTION_TOKEN}`,
+ "Notion-Version": NOTION_VERSION,
+ "Content-Type": "application/json",
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ let data;
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
+ return { res, data };
+}
+
+export async function notionRequest(path, { method = "GET", body } = {}) {
+ if (!NOTION_TOKEN) throw new Error("NOTION_TOKEN is not set. Add it as an environment variable on the madmcp server.");
+
+ let lastErr;
+ for (let attempt = 0; attempt <= NOTION_MAX_RETRIES; attempt++) {
+ const { res, data } = await scheduleThrottled(() => doNotionFetch(path, { method, body }));
+
+ if (res.ok) return data;
+
+ if (isRetryableNotion(res) && attempt < NOTION_MAX_RETRIES) {
+ await sleep(defaultRetryDelayMs(res, attempt, NOTION_RETRY_BASE_MS));
+ lastErr = res;
+ continue;
+ }
+
+ const message = (data && (data.message || JSON.stringify(data))) || res.statusText;
+ throw new Error(`Notion API error (${res.status}): ${message}`);
+ }
+
+ // Exhausted retries.
+ throw new Error(`Notion API error (${lastErr ? lastErr.status : 429}): rate limited -- exhausted ${NOTION_MAX_RETRIES} retries`);
+}
+
+export function notionRichTextToString(richText = []) {
+ return richText.map((t) => t.plain_text || "").join("");
+}
+
+// ---------------------------------------------------------------------------
+// Rich-text chunking (2026-07-18, bug found via live sync_mem0_to_notion
+// test -- a 2217-char mem0 memory line was sent as a single rich_text
+// segment and rejected outright by Notion's API, which caps
+// rich_text[].text.content at 2000 chars PER SEGMENT). Every paragraph-block
+// builder in this file that wraps arbitrary-length text (mem0 content,
+// append_content, direct notion_create_page content) must go through this
+// instead of building a single {text:{content}} segment, since none of
+// those inputs have a length guarantee. Multiple segments in one rich_text
+// array render as one continuous paragraph, so this doesn't change how the
+// content looks -- it just avoids the hard API rejection.
+const RICH_TEXT_MAX = 2000;
+
+export function chunkRichText(text) {
+ const chunks = [];
+ let rest = text;
+ while (rest.length > RICH_TEXT_MAX) {
+ // Prefer breaking at the last space within the limit so words aren't
+ // split mid-word; fall back to a hard cut if there's no space at all
+ // (e.g. a single unbroken token longer than the limit).
+ let cut = rest.lastIndexOf(" ", RICH_TEXT_MAX);
+ if (cut <= 0) cut = RICH_TEXT_MAX;
+ chunks.push(rest.slice(0, cut));
+ rest = rest.slice(cut).replace(/^ /, "");
+ }
+ chunks.push(rest);
+ return chunks.map((c) => ({ type: "text", text: { content: c } }));
+}
+
+// Shared paragraph-block builder using the chunking above. Every spot in
+// this file and tools.js that was building `{ object: "block", type:
+// "paragraph", paragraph: { rich_text: [{ type: "text", text: { content:
+// text } }] } }` for arbitrary-length input now goes through this instead.
+export function textBlock(text) {
+ return { object: "block", type: "paragraph", paragraph: { rich_text: chunkRichText(text) } };
+}
+
+export function notionPageTitle(page) {
+ const titleProp = Object.values(page.properties || {}).find((p) => p.type === "title");
+ return titleProp ? notionRichTextToString(titleProp.title) : "(untitled)";
+}
+
+// Databases carry their title directly on the object (a top-level `title`
+// rich-text array), not nested inside `properties` like pages -- so this
+// can't reuse notionPageTitle().
+export function notionDatabaseTitle(database) {
+ return notionRichTextToString(database.title) || "(untitled)";
+}
+
+// ---------------------------------------------------------------------------
+// Entity marker convention (2026-07-17, notion connector gap-closing plan --
+// see mem0 entity_id: madmcp-notion-connector-gaps-roadmap, gaps #1/#2/#3).
+// Notion pages outside a database only have a single built-in property
+// (title) -- there's no way to attach a real entity_id/status field the way
+// mem0's metadata object does. Instead both are stored as plain,
+// human-readable marker paragraph blocks at the very top of a page's
+// content:
+// 🔑 entity_id: some-stable-key
+// 🏷️ status: open|resolved|superseded
+// This is a convention, not a Notion API feature -- visible to humans
+// browsing the page (unlike hiding it in a code block), and searchable via
+// notion_search's normal query mechanism, though (same caveat mem0's own
+// tags/entity_id-in-metadata carries) that search is best-effort, not a
+// guaranteed exact-match index -- see findPageByEntityId in tools.js.
+const ENTITY_MARKER_PREFIX = "🔑 entity_id:";
+const STATUS_MARKER_PREFIX = "🏷️ status:";
+
+export function buildMarkerBlocks({ entity_id, status } = {}) {
+ const blocks = [];
+ if (entity_id) {
+ blocks.push({
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: `${ENTITY_MARKER_PREFIX} ${entity_id}` } }] },
+ });
+ }
+ if (status) {
+ blocks.push({
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: `${STATUS_MARKER_PREFIX} ${status}` } }] },
+ });
+ }
+ return blocks;
+}
+
+export function statusMarkerBlock(status) {
+ return {
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: `${STATUS_MARKER_PREFIX} ${status}` } }] },
+ };
+}
+
+// Same pattern as statusMarkerBlock -- lets a caller PATCH the entity_id
+// marker block in place (notion_update_page's new entity_id param, bug fix
+// 2026-08-07: see notion_create_page's findPageByEntityId comment). Needed
+// so that correcting an entity_id post-creation goes through the SAME
+// marker-block text this file's own parseMarkers reads, instead of a caller
+// hand-building the marker text via generic `replacements` (which edits the
+// visible block but has no way to also touch the dedup index -- that
+// disconnect was the root cause of relation targets resolving as "dangling"
+// even after the visible marker was corrected).
+export function entityMarkerBlock(entity_id) {
+ return {
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: `${ENTITY_MARKER_PREFIX} ${entity_id}` } }] },
+ };
+}
+
+// Generic plain-text extractor for any block type -- used by both marker
+// parsing below and the replacements find/replace matching in
+// notion_update_page, so all three features see block text consistently.
+// Returns raw unprefixed text (not the "# " / "• " display formatting
+// notionBlocksToText adds), since this is for exact-match comparison, not
+// rendering.
+export function notionBlockPlainText(b) {
+ const type = b.type;
+ const block = b[type];
+ if (!block) return "";
+ if (type === "child_page" || type === "child_database") return block.title || "";
+ return notionRichTextToString(block.rich_text || []);
+}
+
+// Scans a page's top-level blocks for our marker convention. Only matches
+// paragraph blocks starting with the known prefixes -- doesn't try to infer
+// markers out of arbitrary user-written paragraphs that happen to look
+// similar. Returns the block IDs too so callers can PATCH them directly
+// instead of re-searching by text (avoids the replacements uniqueness
+// requirement for what's already an unambiguous, known-location marker).
+export function parseMarkers(blocks = []) {
+ const result = { entity_id: null, status: null, entityBlockId: null, statusBlockId: null };
+ for (const b of blocks) {
+ if (b.type !== "paragraph") continue;
+ const text = notionRichTextToString(b.paragraph?.rich_text || []);
+ if (text.startsWith(ENTITY_MARKER_PREFIX) && !result.entity_id) {
+ result.entity_id = text.slice(ENTITY_MARKER_PREFIX.length).trim();
+ result.entityBlockId = b.id;
+ } else if (text.startsWith(STATUS_MARKER_PREFIX) && !result.status) {
+ result.status = text.slice(STATUS_MARKER_PREFIX.length).trim();
+ result.statusBlockId = b.id;
+ }
+ }
+ return result;
+}
+
+// Index-entry marker format -- legacy, from when the dedup index lived on a
+// single page (see NOTION_INDEX_DATABASE_ID's comment in config.js for the
+// 2026-07-24 move to a real database). One paragraph block per tracked
+// entity_id: "📇 entity_id | page_id | url". No longer written anywhere in
+// this codebase (queryAllIndexEntries reads the database instead), but left
+// defined in case any external content still uses this format.
+const INDEX_ENTRY_PREFIX = "📇 ";
+const INDEX_TAGS_PREFIX = "tags:";
+
+// tags param is optional -- omitted entirely (no 4th segment) for entries
+// that have no tags, rather than writing an empty "tags:" segment, so old
+// entries and untagged entries look identical on the page.
+export function buildIndexEntryText({ entity_id, page_id, url, tags }) {
+ const base = `${INDEX_ENTRY_PREFIX}${entity_id} | ${page_id} | ${url || ""}`;
+ if (!tags || !tags.length) return base;
+ return `${base} | ${INDEX_TAGS_PREFIX}${tags.join(",")}`;
+}
+
+// Backward compatible with the original 3-field format (entity_id | page_id
+// | url) -- a 4th "tags:..." segment is read if present, otherwise tags
+// comes back as an empty array rather than the parse failing.
+export function parseIndexEntryText(text) {
+ if (!text || !text.startsWith(INDEX_ENTRY_PREFIX)) return null;
+ const rest = text.slice(INDEX_ENTRY_PREFIX.length);
+ const parts = rest.split("|").map((s) => (s || "").trim());
+ const [entity_id, page_id, url, tagsField] = parts;
+ if (!entity_id || !page_id) return null;
+ const tags = (tagsField || "").startsWith(INDEX_TAGS_PREFIX)
+ ? tagsField.slice(INDEX_TAGS_PREFIX.length).split(",").map((t) => t.trim().toLowerCase()).filter(Boolean)
+ : [];
+ return { entity_id, page_id, url, tags };
+}
+
+// ---------------------------------------------------------------------------
+// Reads every row of the Entity Index database (NOTION_INDEX_DATABASE_ID),
+// paginated. Added 2026-07-24 alongside the page->database dedup-index
+// migration (see config.js's NOTION_INDEX_DATABASE_ID comment) to give
+// callers that need the FULL set of tracked entities -- not a single
+// entity_id lookup (that's findPageByEntityId in tools.js) -- a supported
+// way to read it. Before this, linking.js's findTagOverlapCandidates and
+// sync/mem0_notion.js's readSyncedIndexEntries both kept reading raw blocks
+// off the old page-based index directly instead, which silently stopped
+// reflecting reality the moment writes moved to the database: new entries
+// were never appended to the old page, so both call sites were scanning an
+// index that could only shrink (relative to ground truth) over time, and
+// broke outright once that old page was archived. Same 10-page/100-row-
+// per-page ceiling as listAllMemories in mem0_notion.js.
+export async function queryAllIndexEntries() {
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 10;
+ const entries = [];
+ let cursor;
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const body = { page_size: PAGE_SIZE };
+ if (cursor) body.start_cursor = cursor;
+ const data = await notionRequest(`/databases/${NOTION_INDEX_DATABASE_ID}/query`, { method: "POST", body });
+ for (const row of data.results || []) {
+ const entity_id = notionRichTextToString(row.properties?.EntityId?.rich_text || []);
+ const page_id = notionRichTextToString(row.properties?.PageId?.rich_text || []);
+ const url = row.properties?.Url?.url || "";
+ const tagsRaw = notionRichTextToString(row.properties?.Tags?.rich_text || []);
+ const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean) : [];
+ if (entity_id && page_id) entries.push({ entity_id, page_id, url, tags });
+ }
+ if (!data.has_more) break;
+ cursor = data.next_cursor;
+ }
+ return entries;
+}
+
+// ---------------------------------------------------------------------------
+// Changelog convention (2026-07-17, gap #4 -- see mem0 entity_id:
+// madmcp-notion-connector-gaps-roadmap). Notion's API exposes no page/block
+// revision-history endpoint (confirmed via docs review -- unlike mem0's
+// native GET /v1/memories/{id}/history/, there's nothing to wrap here), so
+// this is the FIX PLAN's documented fallback: an append-only changelog kept
+// as plain paragraph blocks on the tracked page itself, one entry per
+// state-changing notion_update_page call. Deliberately NOT gated to only
+// entity_id-tracked pages (the original plan's suggestion) -- doing that
+// gate correctly would need an extra blocks-fetch on every title-only/
+// append-only update just to check for a marker, which defeats the point of
+// keeping simple updates cheap. Instead this logs on every page any caller
+// chooses to update via these tools; a page nobody ever calls
+// notion_update_page on accumulates no changelog noise.
+const CHANGELOG_PREFIX = "📜 ";
+
+export function buildChangelogEntryText(summary) {
+ const ts = new Date().toISOString().replace("T", " ").slice(0, 16);
+ return `${CHANGELOG_PREFIX}${ts} UTC — ${summary}`;
+}
+
+export function isChangelogEntryText(text) {
+ return !!text && text.startsWith(CHANGELOG_PREFIX);
+}
+
+// ---------------------------------------------------------------------------
+// Relations convention (2026-07-17, gap #5 -- see mem0 entity_id:
+// madmcp-notion-connector-gaps-roadmap). Mirrors mem0_add's relations param:
+// a list of { relation, to_entity_id } pairs describing outgoing links from
+// this page's entity to another tracked entity. Stored like the
+// entity_id/status markers above -- one visible paragraph block per
+// relation:
+// 🔗 relation_type -> to_entity_id
+// SCOPE NOTE: unlike mem0_list's include_relations (which resolves both
+// outgoing AND incoming relations up to 3 hops), this only supports
+// outgoing relations stored directly on the page. Incoming/reverse lookups
+// ("what points TO this entity") would require scanning every tracked
+// page's blocks via the index page -- a real feature in its own right, not
+// implemented here.
+const RELATION_MARKER_PREFIX = "🔗 ";
+const RELATION_SEPARATOR = " -> ";
+
+export function buildRelationBlocks(relations = []) {
+ return relations.map(({ relation, to_entity_id }) => ({
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: `${RELATION_MARKER_PREFIX}${relation}${RELATION_SEPARATOR}${to_entity_id}` } }] },
+ }));
+}
+
+export function parseRelationBlocks(blocks = []) {
+ const relations = [];
+ for (const b of blocks) {
+ if (b.type !== "paragraph") continue;
+ const text = notionRichTextToString(b.paragraph?.rich_text || []);
+ if (!text.startsWith(RELATION_MARKER_PREFIX)) continue;
+ const rest = text.slice(RELATION_MARKER_PREFIX.length);
+ const sepIdx = rest.indexOf(RELATION_SEPARATOR);
+ if (sepIdx === -1) continue;
+ relations.push({
+ relation: rest.slice(0, sepIdx).trim(),
+ to_entity_id: rest.slice(sepIdx + RELATION_SEPARATOR.length).trim(),
+ blockId: b.id,
+ });
+ }
+ return relations;
+}
+
+// ---------------------------------------------------------------------------
+// Synced-range marker convention (2026-07-18, mem0->Notion Sync Tool spec --
+// see mem0 entity_id: mem0-notion-sync-tool-spec, "PROTECTING MANUAL EDITS"
+// section, the one piece this spec flagged as needing real design work
+// since nothing else in this file solves "replace this whole block range"
+// -- doUpdatePage's `replacements` only does single-block exact-text swaps).
+//
+// Content written by the sync tool lives between two literal marker blocks:
+// ⬇️ SYNCED FROM MEM0 (mem0_synced_at: <ISO timestamp>) — DO NOT EDIT BELOW, WILL BE OVERWRITTEN ⬇️
+// ...synced content blocks...
+// ⬆️ END SYNCED CONTENT ⬆️
+// Sync logic (replaceSyncedRange, notion/tools.js) only ever touches blocks
+// strictly BETWEEN these two markers -- anything a person adds above the
+// start marker, below the end marker, or as a genuinely separate block
+// elsewhere on the page, is never read or written by the sync tool and
+// survives every future run. The timestamp lives ON the start marker itself
+// (not a separate block) so a re-sync can read the current value and skip
+// the write entirely when the source memory's updated_at hasn't changed --
+// avoiding the no-op rewrite + changelog spam the spec calls out.
+const SYNC_START_PREFIX = "⬇️ SYNCED FROM MEM0 (mem0_synced_at: ";
+const SYNC_START_SUFFIX = ") — DO NOT EDIT BELOW, WILL BE OVERWRITTEN ⬇️";
+const SYNC_END_TEXT = "⬆️ END SYNCED CONTENT ⬆️";
+
+export function buildSyncStartText(synced_at) {
+ return `${SYNC_START_PREFIX}${synced_at}${SYNC_START_SUFFIX}`;
+}
+
+function parseSyncStartText(text) {
+ if (!text || !text.startsWith(SYNC_START_PREFIX) || !text.endsWith(SYNC_START_SUFFIX)) return null;
+ return text.slice(SYNC_START_PREFIX.length, text.length - SYNC_START_SUFFIX.length);
+}
+
+export function isSyncEndText(text) {
+ return text === SYNC_END_TEXT;
+}
+
+// Builds the full [start marker, ...content blocks, end marker] block list
+// for a brand-new synced range (page has none yet). contentLines is split
+// into one paragraph block per non-empty line, same convention as every
+// other plain-text content writer in this file.
+export function buildSyncRangeBlocks({ synced_at, contentLines }) {
+ const contentBlocks = (contentLines || []).filter(Boolean).map(textBlock);
+ return [textBlock(buildSyncStartText(synced_at)), ...contentBlocks, textBlock(SYNC_END_TEXT)];
+}
+
+// Scans a page's top-level blocks (same 100-block-page caveat as
+// parseMarkers/parseRelationBlocks above) for an existing synced range.
+// Returns null if no start marker is found, or a match with block IDs so
+// callers can delete/insert around the range without re-searching by text.
+// A start marker with no matching end marker (page edited unexpectedly, or
+// truncated by the 100-block read) is treated as not-found -- safer to
+// append a fresh range than to guess where an unterminated one ends and
+// risk deleting content past it.
+export function findSyncRange(blocks = []) {
+ let startIdx = -1;
+ let synced_at = null;
+ for (let i = 0; i < blocks.length; i++) {
+ const b = blocks[i];
+ if (b.type !== "paragraph") continue;
+ const text = notionRichTextToString(b.paragraph?.rich_text || []);
+ const parsed = parseSyncStartText(text);
+ if (parsed !== null) { startIdx = i; synced_at = parsed; break; }
+ }
+ if (startIdx === -1) return null;
+ for (let i = startIdx + 1; i < blocks.length; i++) {
+ const b = blocks[i];
+ if (b.type !== "paragraph") continue;
+ const text = notionRichTextToString(b.paragraph?.rich_text || []);
+ if (isSyncEndText(text)) {
+ return {
+ synced_at,
+ startBlockId: blocks[startIdx].id,
+ endBlockId: blocks[i].id,
+ // Blocks strictly between start and end -- exactly what a re-sync
+ // is allowed to delete/replace.
+ innerBlockIds: blocks.slice(startIdx + 1, i).map((bb) => bb.id),
+ };
+ }
+ }
+ return null; // start with no matching end -- treat as not-found, see above
+}
+
+export function notionBlocksToText(blocks = []) {
+ return blocks
+ .map((b) => {
+ const type = b.type;
+ const block = b[type];
+ if (!block) return "";
+ if (type === "child_page") return `📄 [Subpage] ${block.title || "(untitled)"} — id: ${b.id}`;
+ if (type === "child_database") return `🗄️ [Subdatabase] ${block.title || "(untitled)"} — id: ${b.id}`;
+ const text = notionRichTextToString(block.rich_text || []);
+ if (type === "heading_1") return `# ${text}`;
+ if (type === "heading_2") return `## ${text}`;
+ if (type === "heading_3") return `### ${text}`;
+ if (type === "bulleted_list_item") return `• ${text}`;
+ if (type === "numbered_list_item") return `1. ${text}`;
+ if (type === "to_do") return `[${block.checked ? "x" : " "}] ${text}`;
+ if (type === "code") return `\`\`\`${block.language || ""}\n${text}\n\`\`\``;
+ if (type === "divider") return "---";
+ return text;
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| client.js | +
+
+ |
+ 6.21% | +12/193 | +0% | +0/170 | +0% | +0/36 | +8.16% | +12/147 | +
| linking.js | +
+
+ |
+ 4.16% | +5/120 | +0% | +0/86 | +0% | +0/14 | +5% | +5/100 | +
| tools.js | +
+
+ |
+ 4.65% | +17/365 | +0% | +0/305 | +1.96% | +1/51 | +5.39% | +17/315 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 | + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/notion/linking.js
+// ---------------------------------------------------------------------------
+// Deterministic (no-LLM) related-page detection for notion_create_page.
+//
+// Decision context (2026-07-21): this was originally going to route through
+// the mem0->Notion Memory Index (sync/mem0_notion.js output), but scope was
+// corrected -- Notion tooling is meant to become independent of mem0. This
+// module uses ONLY notion_search + page content already reachable via
+// existing Notion API calls (notionRequest), plus the Entity Index database
+// (NOTION_INDEX_DATABASE_ID, via queryAllIndexEntries) for Signal 3 -- see
+// the structural-fix comment on findTagOverlapCandidates below. No mem0
+// read, no LLM call, no external API key. See Notion plan page (entity_id: plan-notion-autolink-heuristic) for
+// the full writeup and tradeoffs.
+//
+// KNOWN LIMITATION: purely syntactic, no semantic/conceptual matching. Will
+// miss related pages that share no identifier, explicit cross-reference, or
+// tags (e.g. two investigations into related bugs worded differently, in
+// different repos). Accepted tradeoff for zero added latency/cost -- see
+// plan page for the "workers-sdk RPC leak wouldn't have been caught" example.
+// ---------------------------------------------------------------------------
+
+import { notionRequest, notionPageTitle, notionBlocksToText, parseMarkers, queryAllIndexEntries } from "./client.js";
+
+const STOPWORDS = new Set([
+ "the", "a", "an", "and", "or", "for", "to", "of", "in", "on", "with",
+ "is", "are", "pr", "status", "update", "fix", "issue", "bug",
+]);
+
+// repo#123 or repo-123 (dash form requires 3+ digits to avoid matching
+// ordinary hyphenated words like "co-op" or version strings like "v2-1").
+const ID_PATTERNS = [
+ /([a-zA-Z0-9_.-]+)#(\d+)/g,
+ /([a-zA-Z0-9_.-]+)-(\d{3,})/g,
+];
+
+export function extractIdentifiers(text = "") {
+ const found = new Set();
+ for (const pat of ID_PATTERNS) {
+ pat.lastIndex = 0;
+ let m;
+ while ((m = pat.exec(text))) {
+ found.add(`${m[1].toLowerCase()}#${m[2]}`);
+ }
+ }
+ return found;
+}
+
+export function titleTokens(title = "") {
+ return new Set(
+ (title.toLowerCase().match(/[a-z]+/g) || [])
+ .filter((w) => !STOPWORDS.has(w) && w.length > 2)
+ );
+}
+
+// Pages in this system carry tags as a visible "Tags: a, b, c" text line
+// (see e.g. the contribution-candidates pages) rather than a real Notion
+// property, so tag comparison has to be scraped from body text.
+export function extractTags(content = "") {
+ const tags = new Set();
+ const re = /Tags:\s*(.+)/gi;
+ let m;
+ while ((m = re.exec(content))) {
+ m[1].split(",").map((t) => t.trim().toLowerCase()).filter(Boolean).forEach((t) => tags.add(t));
+ }
+ return tags;
+}
+
+function idKey(idStr) {
+ const [repo, num] = idStr.split("#");
+ return { repo, num: Number(num) };
+}
+
+function bodyMentionsId(body, idStr) {
+ const { repo, num } = idKey(idStr);
+ const haystack = (body || "").toLowerCase();
+ return haystack.includes(`${repo}#${num}`) || haystack.includes(`${repo}-${num}`);
+}
+
+// Scores one candidate page against the new page being created.
+// Returns { tier, reason } where tier is "strong" | "medium" | "weak" | null.
+// Only "strong" and "medium" are meant to be acted on by callers -- "weak"
+// is returned for visibility/testing but should be treated as noise.
+export function scoreCandidate({ title, content, tags, createdAt }, candidate) {
+ const newIds = new Set([...extractIdentifiers(title), ...extractIdentifiers(content || "")]);
+ const candIds = new Set([...extractIdentifiers(candidate.title), ...extractIdentifiers(candidate.content || "")]);
+
+ // Signal 1: identifier overlap -- same repo, same or adjacent number.
+ for (const a of newIds) {
+ const { repo: r1, num: n1 } = idKey(a);
+ for (const b of candIds) {
+ const { repo: r2, num: n2 } = idKey(b);
+ if (r1 === r2 && Math.abs(n1 - n2) <= 1) {
+ return { tier: "strong", reason: `identifier overlap: ${a} ~ ${b}` };
+ }
+ }
+ }
+
+ // Signal 2: cross-reference text scan -- either page's body literally
+ // mentions the other's identifier (mirrors how GitHub auto-links "fixes #N").
+ for (const b of candIds) {
+ if (bodyMentionsId(content, b)) return { tier: "strong", reason: `new page body references ${b}` };
+ }
+ for (const a of newIds) {
+ if (bodyMentionsId(candidate.content, a)) return { tier: "strong", reason: `candidate body references ${a}` };
+ }
+
+ // Signal 3: tag overlap within a 7-day window. Medium confidence only --
+ // never auto-linked, just surfaced as a candidate. NOTE: this in-memory
+ // path only ever fires when both pages happen to already be in hand (e.g.
+ // a candidate surfaced via the Signal 1/2 notion_search pass also happens
+ // to share tags). The primary, reliable path for Signal 3 is
+ // findTagOverlapCandidates below, which reads the dedup index directly
+ // instead of depending on notion_search turning the candidate up at all.
+ const candTags = candidate.tags || extractTags(candidate.content || "");
+ const shared = [...(tags || new Set())].filter((t) => candTags.has(t));
+ if (shared.length >= 2 && createdAt && candidate.createdAt) {
+ const days = Math.abs((new Date(createdAt) - new Date(candidate.createdAt)) / 86400000);
+ if (days <= 7) return { tier: "medium", reason: `shared tags [${shared.join(", ")}], ${Math.round(days)}d apart` };
+ }
+
+ // Signal 4: title token Jaccard similarity. Weak by design -- crude and
+ // prone to false positives (generic words like "PR"/"status" recur across
+ // unrelated pages), so this is informational only and never actionable.
+ const t1 = titleTokens(title), t2 = titleTokens(candidate.title);
+ if (t1.size && t2.size) {
+ const union = new Set([...t1, ...t2]).size;
+ const inter = [...t1].filter((w) => t2.has(w)).length;
+ const jaccard = inter / union;
+ if (jaccard > 0.3) return { tier: "weak", reason: `title token overlap (jaccard=${jaccard.toFixed(2)})` };
+ }
+
+ return { tier: null, reason: null };
+}
+
+const MAX_CANDIDATES_TO_SCORE = 8;
+const MAX_TAG_CANDIDATES_TO_RESOLVE = 8;
+const TAG_OVERLAP_WINDOW_DAYS = 7;
+
+// ---------------------------------------------------------------------------
+// STRUCTURAL FIX FOR SIGNAL 3 (2026-07-21, see Notion plan page entity_id:
+// plan-notion-autolink-heuristic).
+//
+// ROOT CAUSE (confirmed via live test): Notion's /search endpoint matches on
+// page TITLE, not body full-text. Tags live only in body text ("Tags: a, b,
+// c" lines), never in titles -- so a tag string can never be found by
+// notion_search, no matter how the query is built. The earlier fix attempt
+// (commit a558fff, querying notion_search per tag) is harmless but
+// ineffective and is superseded by this function.
+//
+// FIX: reuse this codebase's existing precedent for the exact same class of
+// problem -- findPageByEntityId (tools.js) already solved "notion_search has
+// real lag / doesn't reliably find things" for entity_id dedup by
+// maintaining a dedicated index, read directly rather than searched. Signal
+// 3 gets the same fix: index entries carry each tracked page's tags, so
+// tag-overlap discovery reads the index directly instead of calling
+// notion_search at all.
+//
+// UPDATE (2026-07-24): the index itself moved from a page (read via
+// /blocks/{id}/children, capped at ~100 blocks) to a real database (read via
+// queryAllIndexEntries in client.js, not subject to that cap) -- see
+// config.js's NOTION_INDEX_DATABASE_ID comment. This function was updated to
+// match; the underlying fix rationale above (direct read, not search) is
+// unchanged.
+//
+// SCOPE LIMIT (accepted tradeoff): this only makes tag overlap discoverable
+// for entity_id-tracked pages, since only those get an index entry at all.
+// A freeform/untracked (one_off) page can't participate in tag-based
+// discovery -- same limitation relations/auto-linking already have.
+// Consistent with the rest of this system's design (tracking is opt-in via
+// entity_id).
+export async function findTagOverlapCandidates({ tags, createdAt }) {
+ if (!tags || !tags.size) return [];
+
+ let indexEntries;
+ try {
+ indexEntries = await queryAllIndexEntries();
+ } catch {
+ // Best-effort, same as the rest of findLinkCandidates -- an unreachable
+ // index shouldn't block page creation, it just means Signal 3 finds
+ // nothing this time.
+ return [];
+ }
+
+ const overlapping = [];
+ for (const entry of indexEntries) {
+ if (!entry.tags.length) continue;
+ const shared = entry.tags.filter((t) => tags.has(t));
+ if (shared.length >= 2) overlapping.push({ entry, shared });
+ }
+ if (!overlapping.length) return [];
+
+ const candidates = [];
+ for (const { entry, shared } of overlapping.slice(0, MAX_TAG_CANDIDATES_TO_RESOLVE)) {
+ let page;
+ try {
+ page = await notionRequest(`/pages/${entry.page_id}`);
+ } catch {
+ continue; // stale index entry (target page deleted/archived) -- skip, same as findPageByEntityId
+ }
+ if (createdAt && page.created_time) {
+ const days = Math.abs((new Date(createdAt) - new Date(page.created_time)) / 86400000);
+ if (days > TAG_OVERLAP_WINDOW_DAYS) continue; // same 7-day window as the in-memory scoreCandidate path
+ }
+ candidates.push({
+ pageId: entry.page_id,
+ title: notionPageTitle(page),
+ url: entry.url || page.url,
+ entity_id: entry.entity_id,
+ reason: `shared tags [${shared.join(", ")}] (via dedup index)`,
+ });
+ }
+ return candidates;
+}
+
+// Searches Notion for plausible candidates and scores them against the new
+// page. Signals 1/2 (identifier overlap, cross-reference) run via
+// notion_search on the identifier's repo name -- unchanged, and confirmed
+// working live for both title-adjacency and body-only cross-reference
+// cases. Signal 3 (tag overlap) runs separately via findTagOverlapCandidates
+// above, reading the dedup index directly instead of notion_search, since a
+// tag-overlapping page may share no identifier at all with the new page.
+//
+// If the new page has neither an identifier nor any tags, there's nothing
+// deterministic to search on -- notion_search would just return keyword
+// noise -- so we skip straight to "no candidates" instead of guessing.
+//
+// Returns { strong: [...], medium: [...] } -- weak/null candidates are
+// dropped entirely, not surfaced to callers.
+export async function findLinkCandidates({ title, content }) {
+ const idTokens = [...extractIdentifiers(title), ...extractIdentifiers(content || "")];
+ const newTags = extractTags(content || "");
+ if (!idTokens.length && !newTags.size) return { strong: [], medium: [] };
+
+ const strong = [];
+ const medium = [];
+ const seenPageIds = new Set();
+ const nowIso = new Date().toISOString();
+
+ if (idTokens.length) {
+ // Repo name is the most specific short deterministic query we can build
+ // from an identifier -- Notion's search is keyword-based and a full
+ // sentence dilutes relevance.
+ const [primaryRepo] = idTokens[0].split("#");
+ let searchData = { results: [] };
+ try {
+ searchData = await notionRequest("/search", {
+ method: "POST",
+ body: { query: primaryRepo, page_size: MAX_CANDIDATES_TO_SCORE },
+ });
+ } catch {
+ // Search unreachable -- fall through with no identifier-based
+ // candidates rather than aborting the whole create.
+ }
+ for (const hit of searchData.results || []) {
+ if (hit.object !== "page" || seenPageIds.has(hit.id)) continue;
+ seenPageIds.add(hit.id);
+ let candContent, candMarkers;
+ try {
+ const blocksData = await notionRequest(`/blocks/${hit.id}/children?page_size=100`);
+ const blocks = blocksData.results || [];
+ candContent = notionBlocksToText(blocks);
+ candMarkers = parseMarkers(blocks);
+ } catch {
+ continue; // unreadable candidate (archived/permissions) -- skip, don't fail the create
+ }
+ const candTitle = notionPageTitle(hit);
+ const { tier, reason } = scoreCandidate(
+ { title, content, tags: newTags, createdAt: nowIso },
+ { title: candTitle, content: candContent, createdAt: hit.created_time }
+ );
+ const entryObj = { pageId: hit.id, title: candTitle, url: hit.url, entity_id: candMarkers.entity_id || null, reason };
+ if (tier === "strong") strong.push(entryObj);
+ else if (tier === "medium") medium.push(entryObj);
+ }
+ }
+
+ // Signal 3, structural fix -- see findTagOverlapCandidates above.
+ try {
+ const tagMatches = await findTagOverlapCandidates({ tags: newTags, createdAt: nowIso });
+ for (const c of tagMatches) {
+ if (seenPageIds.has(c.pageId)) continue;
+ seenPageIds.add(c.pageId);
+ medium.push(c);
+ }
+ } catch {
+ // swallow -- best-effort, mirrors the rest of this function
+ }
+
+ return { strong, medium };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 | + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/notion/tools.js
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { NOTION_INDEX_DATABASE_ID } from "../../config.js";
+import {
+ notionRequest, notionPageTitle, notionDatabaseTitle, notionRichTextToString,
+ notionBlocksToText, buildMarkerBlocks, statusMarkerBlock, entityMarkerBlock, notionBlockPlainText, parseMarkers,
+ buildChangelogEntryText, isChangelogEntryText,
+ buildRelationBlocks, parseRelationBlocks,
+ buildSyncStartText, buildSyncRangeBlocks, findSyncRange, textBlock,
+} from "./client.js";
+import { findLinkCandidates, extractTags } from "./linking.js";
+
+const STATUS_VALUES = ["open", "resolved", "superseded"];
+
+// ---------------------------------------------------------------------------
+// Slug-like-title guard (2026-08-07 bug fix -- workspace audit found 6
+// confirmed empty orphan pages: madmcp-cloudflare-workers-migration-plan,
+// joblead-liliana-model-n8n, jobreq-liliana-model-n8n-detail,
+// candidate-release-plz-release-plz-2130, laborx-scenium-94796,
+// madmcp-generate-lockfile-workflow-recreation-2026-07-26). Root cause: the
+// caller typed the STRING THEY MEANT AS entity_id into `title` instead,
+// leaving entity_id unset (one_off either omitted or set true either way --
+// the existing "entity_id or one_off" guard doesn't catch this shape at
+// all, since one_off:true alone already satisfies it). Every confirmed
+// orphan's title was lowercase, hyphen-separated, no spaces -- i.e. it was
+// literally an entity_id, just in the wrong field. This regex mirrors that
+// shape: 2+ lowercase/digit/hyphen segments, hyphen-joined, nothing else.
+// Deliberately requires 2+ hyphens (not 1) so ordinary short hyphenated
+// titles a human might actually type ("follow-up", "day-1") don't trip it --
+// every real orphan had 3+ segments.
+const SLUG_LIKE_TITLE = /^[a-z0-9]+(?:-[a-z0-9]+){2,}$/;
+
+// ---------------------------------------------------------------------------
+// Dedup/upsert lookup (2026-07-17, gap #1; database rewrite 2026-07-24 --
+// see mem0 entity_id: madmcp-notion-connector-gaps-roadmap).
+//
+// FIRST FIX 2026-07-17: the original implementation leaned on notion_search
+// to find candidate pages by entity_id text. Live testing confirmed that's
+// fundamentally broken -- Notion's search index has real lag, and searching
+// for an entity_id string immediately after creating that page (the most
+// common dedup scenario) reliably returns zero results. Fixed by reading a
+// dedicated index page's own blocks directly (uncached, no search lag).
+//
+// SECOND FIX 2026-07-24: the page-based index inherited a new gap it
+// documented at the time -- /blocks/{id}/children pagination caps a single
+// page's readable blocks at 100, so an index page with more than ~100
+// tracked entities would silently stop finding older entries. A real Notion
+// database queried via /databases/{id}/query with a filter on EntityId is
+// just as immediately-consistent (no search-index lag either way, since
+// this never goes through notion_search) but isn't bound by that 100-block
+// limit -- database queries paginate independently of page block counts.
+export async function findPageByEntityId(entity_id) {
+ let rows;
+ try {
+ const data = await notionRequest(`/databases/${NOTION_INDEX_DATABASE_ID}/query`, {
+ method: "POST",
+ body: { filter: { property: "EntityId", rich_text: { equals: entity_id } }, page_size: 1 },
+ });
+ rows = data.results || [];
+ } catch (err) {
+ // Fail loudly rather than silently falling back to nothing found --
+ // silently treating "index unreachable" as "no duplicate exists" would
+ // just reintroduce the exact bug this fix is for.
+ throw new Error(`Notion entity index database (${NOTION_INDEX_DATABASE_ID}) is unreachable, so entity_id dedup can't be verified: ${err.message}. Fix NOTION_INDEX_DATABASE_ID / the database's sharing settings before creating entity-tracked pages.`, { cause: err });
+ }
+ if (!rows.length) return null;
+ const row = rows[0];
+ const page_id = notionRichTextToString(row.properties?.PageId?.rich_text || []);
+ if (!page_id) return null;
+ try {
+ const page = await notionRequest(`/pages/${page_id}`);
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=20`);
+ const markers = parseMarkers(blocksData.results || []);
+ return { pageId: page_id, title: notionPageTitle(page), url: page.url, markers };
+ } catch {
+ // Stale index row (target page deleted/archived outside these tools) --
+ // treat as not-found so a fresh page can be created, rather than
+ // erroring out on a dangling reference.
+ return null;
+ }
+}
+
+// Records a new entity_id -> page_id mapping as a row in the Entity Index
+// database. Best-effort: if this fails, the page itself was still created
+// successfully, so we don't throw -- but the caller surfaces the failure in
+// its response text since it means the NEXT dedup check for this entity_id
+// won't find it. Unlike the old page-based index, this has no pagination
+// gap -- database rows aren't capped the way a single page's blocks are.
+async function appendIndexEntry({ entity_id, page_id, url, tags }) {
+ try {
+ await notionRequest("/pages", {
+ method: "POST",
+ body: {
+ parent: { database_id: NOTION_INDEX_DATABASE_ID },
+ properties: {
+ Name: { title: [{ text: { content: entity_id } }] },
+ EntityId: { rich_text: [{ text: { content: entity_id } }] },
+ PageId: { rich_text: [{ text: { content: page_id } }] },
+ Url: { url: url || null },
+ Tags: { rich_text: [{ text: { content: (tags || []).join(",") } }] },
+ },
+ },
+ });
+ return null;
+ } catch (err) {
+ return err.message;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Backfill/repair path (2026-07-24, index reset -- see Notion plan page
+// "Entity Index Migration"). notion_create_page/appendIndexEntry always
+// index the page THEY just created -- there was no way to add an index row
+// for a page that already exists elsewhere (e.g. after the index database
+// was reset empty and existing entity_id-marked pages needed backfilling).
+// Reuses appendIndexEntry unchanged, same idempotent skip-if-present check
+// as doCreatePage, just without also creating a page.
+export async function upsertIndexEntry({ entity_id, page_id, url, tags }) {
+ const existing = await findPageByEntityId(entity_id);
+ if (existing) return { skipped: true, existingId: existing.pageId };
+ const error = await appendIndexEntry({ entity_id, page_id, url, tags: tags || [] });
+ return { skipped: false, error };
+}
+
+// ---------------------------------------------------------------------------
+// Shared create-page logic (2026-07-17, gap #6 -- see mem0 entity_id:
+// madmcp-notion-connector-gaps-roadmap). Extracted out of notion_create_page's
+// handler so notion_create_pages_batch can reuse the exact same dedup +
+// marker + index-recording behavior per item, mirroring how mem/tools.js's
+// mem0_add and mem0_add_batch share logic. Returns a plain result object
+// instead of an MCP content block -- callers format the response.
+// 2026-07-18: NOT a hard entity_id requirement -- forcing entity_id on
+// every page would pollute the index with genuine scratch/one-off content
+// (test pages, quick notes) that was never meant to be deduped or tracked,
+// which defeats the index's purpose and doesn't match the same tradeoff
+// mem0_add already makes (entity_id optional there too, for the same
+// reason). Instead: require an EXPLICIT choice. Omitting entity_id AND
+// one_off is the actual failure mode worth catching -- someone forgetting
+// to track a thing that should be tracked -- so that case now throws
+// instead of silently creating an untracked page. Passing one_off: true is
+// the deliberate opt-out for real one-offs.
+export async function doCreatePage({ parent_id, parent_type, title, content, entity_id, status, relations, one_off, properties }) {
+ if (!entity_id && !one_off) {
+ throw new Error(`Refusing to create "${title}" without a tracking decision -- pass either entity_id (if this represents an ongoing/stable thing that should be deduped and indexed) or one_off: true (if it's genuinely disposable, e.g. a scratch note or test page). This is a deliberate choice, not a bug -- see notion_create_page's entity_id and one_off param descriptions.`);
+ }
+ // See SLUG_LIKE_TITLE comment above -- this fires regardless of one_off,
+ // since the observed bug happened with one_off both set and unset. A
+ // title this shaped is almost never a real human-readable title.
+ if (!entity_id && SLUG_LIKE_TITLE.test(title)) {
+ throw new Error(`Refusing to create a page titled "${title}" -- this looks like an entity_id (lowercase, hyphen-separated, no spaces), not a human-readable title, and entity_id is unset. This is almost always a mistake: pass this exact string as entity_id instead, and give "title" an actual readable name (e.g. title: "Cloudflare Workers migration plan", entity_id: "${title}"). If "${title}" is genuinely, deliberately meant to be the literal page title, pass entity_id explicitly (it can be any string, including this same one) to confirm that's intentional -- entity_id is still required or one_off must be true per the check above.`);
+ }
+ if (entity_id) {
+ const existing = await findPageByEntityId(entity_id);
+ if (existing) {
+ return { skipped: true, entity_id, existingId: existing.pageId, existingTitle: existing.title, existingUrl: existing.url };
+ }
+ }
+
+ // Deterministic (no-LLM, no-mem0) related-page detection -- see
+ // linking.js header comment and Notion plan page (entity_id:
+ // plan-notion-autolink-heuristic). Best-effort: a failure here (e.g.
+ // Notion search unreachable) should never block page creation, since this
+ // is a convenience layer on top of an otherwise-complete create call.
+ let linkCandidates = { strong: [], medium: [] };
+ try {
+ linkCandidates = await findLinkCandidates({ title, content });
+ } catch {
+ // swallow -- see comment above
+ }
+ const explicitRelations = relations || [];
+ const explicitTargets = new Set(explicitRelations.map((r) => r.to_entity_id));
+ const autoRelations = linkCandidates.strong
+ .filter((c) => c.entity_id && c.entity_id !== entity_id && !explicitTargets.has(c.entity_id))
+ .map((c) => ({ to_entity_id: c.entity_id, relation: "relates_to" }));
+ const mergedRelations = [...explicitRelations, ...autoRelations];
+
+ const parent = parent_type === "database" ? { database_id: parent_id } : { page_id: parent_id };
+ const pageProperties = parent_type === "database"
+ ? { Name: { title: [{ text: { content: title } }] }, ...(properties || {}) }
+ : { title: { title: [{ text: { content: title } }] } };
+ const markerBlocks = buildMarkerBlocks({ entity_id, status });
+ const relationBlocks = buildRelationBlocks(mergedRelations);
+ const contentBlocks = content
+ ? content.split("\n").filter(Boolean).map(textBlock)
+ : [];
+ const children = [...markerBlocks, ...relationBlocks, ...contentBlocks];
+
+ // Notion's page-create endpoint rejects more than 100 children blocks in
+ // a single call (live failure 2026-07-25: a long delegate_agent
+ // transcript produced 199 blocks and got a 400 "body.children.length
+ // should be <= 100"). Create the page with the first 100, then PATCH the
+ // rest on afterward in further batches of <=100 -- same post-creation
+ // append pattern appendChangelogEntry/replaceSyncedRange already use --
+ // instead of silently truncating long content.
+ const firstBatch = children.slice(0, 100);
+ const remainingBatches = [];
+ for (let i = 100; i < children.length; i += 100) {
+ remainingBatches.push(children.slice(i, i + 100));
+ }
+ const data = await notionRequest("/pages", {
+ method: "POST",
+ body: { parent, properties: pageProperties, children: firstBatch },
+ });
+
+ // If a later batch fails (rate limit, network blip), the page ITSELF
+ // still exists on Notion at this point -- letting the error propagate
+ // immediately, before the index write below, would orphan it from the
+ // dedup index. A retry with the same entity_id would then find nothing
+ // and create a genuine duplicate page, exactly the failure mode the index
+ // exists to prevent. So: capture the failure, still record the index
+ // entry (the page really was created), then surface the partial-content
+ // failure with the page's id/url so the caller can finish it via
+ // notion_update_page instead of losing track of it.
+ let batchError = null;
+ for (const batch of remainingBatches) {
+ try {
+ await notionRequest(`/blocks/${data.id}/children`, { method: "PATCH", body: { children: batch } });
+ } catch (err) {
+ batchError = err;
+ break;
+ }
+ }
+
+ let indexError = null;
+ if (entity_id) {
+ indexError = await appendIndexEntry({ entity_id, page_id: data.id, url: data.url, tags: [...extractTags(content || "")] });
+ }
+
+ if (batchError) {
+ throw new Error(
+ `Page "${title}" was created (id: ${data.id}, url: ${data.url}) but is missing some content -- a later content batch failed: ${batchError.message}. ` +
+ (entity_id
+ ? `It IS recorded in the dedup index${indexError ? ` (though that index write also failed: ${indexError})` : ""}, so retrying notion_create_page with the same entity_id will find this page rather than creating a duplicate -- use notion_update_page (append_content) on id ${data.id} to add the missing content instead.`
+ : `No entity_id was set, so there's no dedup protection -- check the page at the URL above before retrying, to avoid creating a duplicate, and use notion_update_page (append_content) on id ${data.id} to add the missing content.`)
+ );
+ }
+
+ return { skipped: false, id: data.id, url: data.url, title, markerCount: markerBlocks.length, relationCount: relationBlocks.length, entity_id, status, indexError, linkCandidates, autoRelations };
+}
+
+// Sequential batch runner, mimicking Promise.allSettled's per-item
+// {status, value|reason} shape so callers don't need to change their
+// result-formatting code. NOT run in parallel -- BUG FOUND 2026-07-17 live
+// testing: notion_create_pages_batch originally used Promise.allSettled,
+// which let two items sharing the same entity_id both pass
+// findPageByEntityId's dedup check concurrently (neither had written its
+// index entry yet when the other checked), creating two pages for one
+// entity_id in a single batch call. Running strictly in order guarantees
+// each item's dedup check sees every earlier item's completed index write.
+// Trades batch throughput for correctness -- acceptable at this tool's
+// scale (personal/small-team usage, not high-volume bulk import).
+async function runSequentially(items, fn) {
+ const results = [];
+ for (const item of items) {
+ try {
+ const value = await fn(item);
+ results.push({ status: "fulfilled", value });
+ } catch (reason) {
+ results.push({ status: "rejected", reason });
+ }
+ }
+ return results;
+}
+
+const EDITABLE_BLOCK_TYPES = ["paragraph", "heading_1", "heading_2", "heading_3", "bulleted_list_item", "numbered_list_item", "to_do"];
+
+// ---------------------------------------------------------------------------
+// Synced-range block replace (2026-07-18, mem0->Notion Sync Tool spec --
+// see mem0 entity_id: mem0-notion-sync-tool-spec). See client.js's
+// "Synced-range marker convention" comment for the marker format and why
+// this exists (protecting manual edits from being clobbered by a re-sync).
+// Same 100-block-page read limitation as findPageByEntityId/parseMarkers
+// elsewhere in this file -- a range on a page with >100 total blocks may
+// not be found; treated as not-found (append fresh range) rather than a
+// silent corruption risk, same reasoning as findSyncRange's unterminated-
+// range case.
+export async function replaceSyncedRange({ page_id, contentLines, synced_at }) {
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const blocks = blocksData.results || [];
+ const range = findSyncRange(blocks);
+
+ if (!range) {
+ const children = buildSyncRangeBlocks({ synced_at, contentLines });
+ await notionRequest(`/blocks/${page_id}/children`, { method: "PATCH", body: { children } });
+ return { action: "created", blockCount: children.length };
+ }
+
+ if (range.synced_at === synced_at) {
+ return { action: "skipped", reason: `already up to date (mem0_synced_at: ${synced_at})` };
+ }
+
+ // Delete every block strictly between the markers -- never the markers
+ // themselves, and never anything past the end marker.
+ for (const blockId of range.innerBlockIds) {
+ await notionRequest(`/blocks/${blockId}`, { method: "DELETE" });
+ }
+
+ // Insert new content right after the start marker via Notion's `after`
+ // cursor, so it lands inside the range regardless of what (if anything)
+ // sits below the end marker.
+ const contentBlocks = (contentLines || []).filter(Boolean).map(textBlock);
+ if (contentBlocks.length) {
+ await notionRequest(`/blocks/${page_id}/children`, {
+ method: "PATCH",
+ body: { children: contentBlocks, after: range.startBlockId },
+ });
+ }
+
+ // Update the start marker's own text in place with the new timestamp --
+ // same single-block PATCH doUpdatePage uses for the status marker.
+ await notionRequest(`/blocks/${range.startBlockId}`, {
+ method: "PATCH",
+ body: { paragraph: { rich_text: [{ type: "text", text: { content: buildSyncStartText(synced_at) } }] } },
+ });
+
+ return { action: "updated", removed: range.innerBlockIds.length, added: contentBlocks.length, previousSyncedAt: range.synced_at };
+}
+
+// Best-effort changelog append (gap #4) -- swallows its own errors rather
+// than throwing, since a failed history write shouldn't roll back or block
+// an otherwise-successful page update. Returns an error string (for the
+// caller to optionally surface) or null on success.
+async function appendChangelogEntry(page_id, summary) {
+ try {
+ await notionRequest(`/blocks/${page_id}/children`, {
+ method: "PATCH",
+ body: { children: [{
+ object: "block", type: "paragraph",
+ paragraph: { rich_text: [{ type: "text", text: { content: buildChangelogEntryText(summary) } }] },
+ }] },
+ });
+ return null;
+ } catch (err) {
+ return err.message;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Shared update-page logic (2026-07-17, gap #6 -- mirrors doCreatePage above).
+// Extracted out of notion_update_page's handler so notion_update_pages_batch
+// can reuse the exact same title/append_content/archived/replacements/status
+// behavior per item. Returns an array of result strings on success, or
+// THROWS on any abort condition (ambiguous/missing replacement match,
+// unsupported block type) -- the single-item tool catches this to preserve
+// its existing isError response shape; the batch tool lets Promise.allSettled
+// catch it per item, same pattern as mem/tools.js.
+export async function doUpdatePage({ page_id, title, append_content, archived, replacements, status, entity_id, relations, properties }) {
+ const results = [];
+ // Unarchive (or a title-only change) runs first, same as before -- this
+ // leaves the page editable for any block-level edits below. Archiving
+ // (archived: true) is deliberately NOT handled here -- see the bottom of
+ // this function for why it's deferred to run last.
+ if (title !== undefined || archived === false || (properties !== undefined && archived !== true)) {
+ const body = {};
+ if (archived !== undefined) body.archived = archived;
+ const propUpdates = {};
+ if (title !== undefined) propUpdates.title = { title: [{ text: { content: title } }] };
+ if (properties !== undefined) Object.assign(propUpdates, properties);
+ if (Object.keys(propUpdates).length) body.properties = propUpdates;
+ const data = await notionRequest(`/pages/${page_id}`, { method: "PATCH", body });
+ results.push(`Updated page "${notionPageTitle(data)}" (ID: ${data.id}).`);
+ }
+ if (append_content) {
+ const children = append_content.split("\n").filter(Boolean).map(textBlock);
+ await notionRequest(`/blocks/${page_id}/children`, { method: "PATCH", body: { children } });
+ results.push(`Appended ${children.length} paragraph(s) to page.`);
+ }
+ if (replacements?.length) {
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const blocks = blocksData.results || [];
+ for (const { find, replace } of replacements) {
+ const matches = blocks.filter((b) => notionBlockPlainText(b) === find);
+ const trunc = (s) => s.slice(0, 60) + (s.length > 60 ? "…" : "");
+ if (matches.length === 0) {
+ throw new Error(`Update aborted, nothing further written — "${trunc(find)}" was not found among this page's top-level blocks (first 100). It may be nested inside a toggle/column, or the page may have more than 100 blocks — re-check with notion_get_page.`);
+ }
+ if (matches.length > 1) {
+ throw new Error(`Update aborted, nothing further written — "${trunc(find)}" matches ${matches.length} blocks, but must be unique. Include more surrounding context in "find" to disambiguate.`);
+ }
+ const block = matches[0];
+ const type = block.type;
+ if (!EDITABLE_BLOCK_TYPES.includes(type)) {
+ throw new Error(`Update aborted, nothing further written — matched block is type "${type}", which notion_update_page can't edit in place yet (supported: ${EDITABLE_BLOCK_TYPES.join(", ")}).`);
+ }
+ const patchBody = { [type]: { rich_text: [{ type: "text", text: { content: replace } }] } };
+ if (type === "to_do") patchBody[type].checked = block.to_do?.checked ?? false;
+ await notionRequest(`/blocks/${block.id}`, { method: "PATCH", body: patchBody });
+ results.push(`Replaced block ("${trunc(find)}" → "${trunc(replace)}").`);
+ }
+ }
+ if (status !== undefined) {
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const markers = parseMarkers(blocksData.results || []);
+ if (markers.statusBlockId) {
+ await notionRequest(`/blocks/${markers.statusBlockId}`, {
+ method: "PATCH",
+ body: statusMarkerBlock(status),
+ });
+ results.push(`Status updated to "${status}" (was "${markers.status}").`);
+ } else {
+ await notionRequest(`/blocks/${page_id}/children`, { method: "PATCH", body: { children: [statusMarkerBlock(status)] } });
+ results.push(`Status marker added: "${status}" (page had none before).`);
+ }
+ }
+ // entity_id (bug fix 2026-08-07, see doCreatePage/findPageByEntityId --
+ // this is the missing counterpart to that: a supported way to CORRECT an
+ // entity_id after creation that keeps the marker block and the Entity
+ // Index database in sync, instead of a caller hand-editing the marker via
+ // `replacements` and silently leaving the index stale/pointing nowhere.
+ // Same marker-block-in-place pattern as `status` above.
+ if (entity_id !== undefined) {
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const markers = parseMarkers(blocksData.results || []);
+ const previousEntityId = markers.entity_id;
+ if (markers.entityBlockId) {
+ await notionRequest(`/blocks/${markers.entityBlockId}`, { method: "PATCH", body: { paragraph: entityMarkerBlock(entity_id).paragraph } });
+ } else {
+ await notionRequest(`/blocks/${page_id}/children`, { method: "PATCH", body: { children: [entityMarkerBlock(entity_id)] } });
+ }
+ // Re-fetch the page for its (stable) url -- appendIndexEntry needs it
+ // and none of the branches above are guaranteed to have fetched it.
+ const page = await notionRequest(`/pages/${page_id}`);
+ const indexError = await appendIndexEntry({ entity_id, page_id, url: page.url, tags: [] });
+ // NOTE: this does not delete/update the OLD entity_id's index row (if
+ // any) -- best-effort, same tradeoff appendIndexEntry's own callers
+ // already accept elsewhere in this file. The old row still resolves
+ // lookups made against the old (now-wrong) entity_id to this page,
+ // which is stale but not actively harmful; the bug this fixes is
+ // specifically that lookups against the CORRECTED entity_id were
+ // failing, and those now succeed immediately since appendIndexEntry
+ // writes synchronously before this call returns.
+ results.push(
+ `Entity ID updated to "${entity_id}"${previousEntityId ? ` (was "${previousEntityId}")` : " (page had none before)"}.` +
+ (indexError ? ` \u26a0\ufe0f index write failed: ${indexError} -- relation lookups against "${entity_id}" may still report dangling until this is retried.` : "")
+ );
+ }
+ // relations REPLACES the existing set whole (not merged), same contract as
+ // mem0_update's relations param. Requires reading current blocks to find
+ // the existing relation blocks to remove -- reuses blocksData if a
+ // replacements/status branch above already fetched it, to avoid a
+ // redundant call.
+ if (relations !== undefined) {
+ const blocksData = await notionRequest(`/blocks/${page_id}/children?page_size=100`);
+ const existingRelations = parseRelationBlocks(blocksData.results || []);
+ for (const r of existingRelations) {
+ await notionRequest(`/blocks/${r.blockId}`, { method: "DELETE" });
+ }
+ const newBlocks = buildRelationBlocks(relations);
+ if (newBlocks.length) {
+ await notionRequest(`/blocks/${page_id}/children`, { method: "PATCH", body: { children: newBlocks } });
+ }
+ results.push(`Relations replaced: ${existingRelations.length} removed, ${newBlocks.length} added.`);
+ }
+ // Archiving (archived: true) runs LAST, after append_content/
+ // replacements/status/relations have all completed above -- confirmed via
+ // live testing 2026-07-23 that Notion rejects block-level edits on an
+ // already-archived page ("Can't edit block that is archived"), so doing
+ // this step first (as the old code did, bundled with title) let the
+ // archive silently succeed while a later block edit in the same call
+ // threw, producing a confusing partial-success-then-error result. Title
+ // is included here too if it wasn't already applied above, so a single
+ // call with {title, archived: true} still sets both in one PATCH.
+ if (archived === true) {
+ const body = { archived: true };
+ const propUpdates = {};
+ if (title !== undefined) propUpdates.title = { title: [{ text: { content: title } }] };
+ if (properties !== undefined) Object.assign(propUpdates, properties);
+ if (Object.keys(propUpdates).length) body.properties = propUpdates;
+ const data = await notionRequest(`/pages/${page_id}`, { method: "PATCH", body });
+ results.push(`Updated page "${notionPageTitle(data)}" (ID: ${data.id}).`);
+ }
+ // Skip the changelog write when this call archived the page -- Notion
+ // rejects block edits on an already-archived page ("Can't edit block that
+ // is archived"), confirmed via live testing 2026-07-17. Unarchiving
+ // (archived: false) is fine since the page is editable again by then.
+ if (results.length && archived !== true) {
+ const changelogError = await appendChangelogEntry(page_id, results.join("; "));
+ if (changelogError) results.push(`(\u26a0\ufe0f changelog entry not recorded: ${changelogError})`);
+ }
+ return results;
+}
+
+export function register(server) {
+
+ server.tool(
+ "notion_search",
+ "RULE for the calling model: use this only for a single, targeted lookup. If you'll need to search and then read more than 2 pages, or the request asks you to understand, review, or summarize a whole area of the Notion workspace -- regardless of how it's phrased ('go through our notes on X', 'get up to speed on the workspace', 'dig into our docs', etc. all count) -- use delegate_gemini instead of looping notion_search and notion_get_page manually. Tool description: searches pages and databases in your Notion workspace.",
+ {
+ query: z.string().describe("Search query string"),
+ filter_type: z.enum(["page", "database"]).optional().describe("Filter results to only pages or only databases (default: both)"),
+ page_size: z.number().optional().describe("Number of results to return (default: 10, max: 100)"),
+ },
+ async ({ query, filter_type, page_size = 10 }) => {
+ const body = { query, page_size };
+ if (filter_type) body.filter = { value: filter_type, property: "object" };
+ const data = await notionRequest("/search", { method: "POST", body });
+ if (!data.results?.length) return { content: [{ type: "text", text: "No results found." }] };
+ const lines = data.results.map((r) => {
+ const title = r.object === "page"
+ ? notionPageTitle(r)
+ : (notionRichTextToString(r.title) || "(untitled)");
+ return `[${r.object}] ${title}\n ID: ${r.id}\n URL: ${r.url || ""}`;
+ });
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "notion_list",
+ "List recent pages and/or databases in your Notion workspace, sorted by most recently edited first -- no search query needed. Use this (not notion_search) when the ask is 'get the latest entry/page' or 'what's new in Notion', since notion_search requires a keyword and doesn't guarantee recency ordering.",
+ {
+ filter_type: z.enum(["page", "database"]).optional().describe("Restrict results to only pages or only databases (default: both)"),
+ page_size: z.number().optional().describe("Number of results to return (default 10, max 100). Pass 1 to get just the single latest entry."),
+ },
+ async ({ filter_type, page_size = 10 }) => {
+ const body = { query: "", sort: { direction: "descending", timestamp: "last_edited_time" }, page_size };
+ if (filter_type) body.filter = { value: filter_type, property: "object" };
+ const data = await notionRequest("/search", { method: "POST", body });
+ if (!data.results?.length) return { content: [{ type: "text", text: "No pages or databases found." }] };
+ const lines = data.results.map((r) => {
+ const title = r.object === "page"
+ ? notionPageTitle(r)
+ : (notionRichTextToString(r.title) || "(untitled)");
+ return `[${r.object}] ${title}\n ID: ${r.id}\n URL: ${r.url || ""}\n Last edited: ${r.last_edited_time?.slice(0, 16)}`;
+ });
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
+ }
+ );
+
+ server.tool(
+ "notion_get_page",
+ "RULE for the calling model: only call this directly for a single, specifically-named page whose ID you already have. If you'll need to read more than 2 pages, or the task involves understanding or reviewing a whole area of the workspace rather than one known page, use delegate_gemini instead of looping notion_get_page across pages. Tool description: gets a Notion page's properties and content blocks.",
+ {
+ page_id: z.string().describe("Notion page ID (UUID format, e.g. from notion_search)"),
+ cursor: z.string().optional().describe("Pagination cursor from a previous call's response (see the more-blocks note) -- fetches the next page of up to 100 blocks instead of starting over. Omit for the first call."),
+ },
+ async ({ page_id, cursor }) => {
+ const blocksPath = `/blocks/${page_id}/children?page_size=100${cursor ? `&start_cursor=${encodeURIComponent(cursor)}` : ""}`;
+ const [page, blocksData] = await Promise.all([
+ notionRequest(`/pages/${page_id}`),
+ notionRequest(blocksPath),
+ ]);
+ const title = notionPageTitle(page);
+ const allBlocks = blocksData.results || [];
+ // Changelog entries (gap #4) are kept out of the normal content view --
+ // they're an operational log, not page content -- surfaced instead via
+ // notion_get_page_history. Filtered only from what's *shown* here, not
+ // from the raw block count, since they still occupy real block slots.
+ const blocks = allBlocks.filter((b) => !(b.type === "paragraph" && isChangelogEntryText(notionRichTextToString(b.paragraph?.rich_text || []))));
+ const changelogCount = allBlocks.length - blocks.length;
+ const content = notionBlocksToText(blocks);
+ const hasMore = blocksData.has_more
+ ? `\n\n⚠️ Page has more blocks — call notion_get_page again with cursor: "${blocksData.next_cursor}" to see the next page.`
+ : "";
+ const subPages = blocks.filter((b) => b.type === "child_page").length;
+ const subDatabases = blocks.filter((b) => b.type === "child_database").length;
+ const childSummary = (subPages || subDatabases)
+ ? `\n\n🔗 ${subPages} subpage(s), ${subDatabases} subdatabase(s) found — use notion_get_page on their IDs above to view them.`
+ : "";
+ const changelogNote = changelogCount ? `\n📜 ${changelogCount} changelog entr${changelogCount === 1 ? "y" : "ies"} on this page (this view) — use notion_get_page_history to see them.` : "";
+ const markers = parseMarkers(allBlocks);
+ const markerLine = (markers.entity_id || markers.status)
+ ? `\n${markers.entity_id ? `Entity ID: ${markers.entity_id}` : ""}${markers.entity_id && markers.status ? " | " : ""}${markers.status ? `Status: ${markers.status}` : ""}`
+ : "";
+ // Relations (gap #5) -- resolve up to 5 outgoing relations to their
+ // target's title/url via the same dedup index lookup findPageByEntityId
+ // uses, so a person reading this doesn't have to manually chase each
+ // to_entity_id. Capped at 5 to bound the extra API calls this costs
+ // (each resolution is a full findPageByEntityId, itself 1-2 calls);
+ // remaining relations are still listed, just unresolved.
+ const relations = parseRelationBlocks(allBlocks);
+ let relationsBlock = "";
+ if (relations.length) {
+ const toResolve = relations.slice(0, 5);
+ const resolved = await Promise.all(toResolve.map(async (r) => {
+ try {
+ const target = await findPageByEntityId(r.to_entity_id);
+ return target ? ` 🔗 ${r.relation} -> ${r.to_entity_id} ("${target.title}", ${target.url})` : ` 🔗 ${r.relation} -> ${r.to_entity_id} (not found -- dangling reference)`;
+ } catch {
+ return ` 🔗 ${r.relation} -> ${r.to_entity_id} (couldn't resolve -- index unreachable)`;
+ }
+ }));
+ const remaining = relations.length - toResolve.length;
+ relationsBlock = `\n\nRelations:\n${resolved.join("\n")}${remaining ? `\n … and ${remaining} more (not resolved, showing first 5)` : ""}`;
+ }
+ const text =
+ `# ${title}\n` +
+ `ID: ${page.id}\n` +
+ `URL: ${page.url}\n` +
+ `Created: ${page.created_time?.slice(0, 10)} | Last edited: ${page.last_edited_time?.slice(0, 10)}${markerLine}${changelogNote}\n\n` +
+ (content || "(no content)") + hasMore + childSummary + relationsBlock;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "notion_get_page_history",
+ "Get the version/change history of a Notion page -- every notion_update_page call recorded against it, with a summary of what changed and when. Notion's API has no native page-revision endpoint (unlike mem0_get_history, which wraps one), so this reads back the append-only changelog blocks notion_update_page writes on every successful change. Only covers changes made through these tools, not edits made directly in the Notion UI or by other integrations.",
+ {
+ page_id: z.string().describe("Notion page ID (UUID format, e.g. from notion_search)"),
+ cursor: z.string().optional().describe("Pagination cursor from a previous call, to see older history beyond the first 100 blocks scanned. Omit for the first call."),
+ },
+ async ({ page_id, cursor }) => {
+ const blocksPath = `/blocks/${page_id}/children?page_size=100${cursor ? `&start_cursor=${encodeURIComponent(cursor)}` : ""}`;
+ const blocksData = await notionRequest(blocksPath);
+ const blocks = blocksData.results || [];
+ const entries = blocks
+ .filter((b) => b.type === "paragraph")
+ .map((b) => notionRichTextToString(b.paragraph?.rich_text || []))
+ .filter(isChangelogEntryText);
+ const hasMore = blocksData.has_more
+ ? `\n\n⚠️ More blocks exist beyond this page — call again with cursor: "${blocksData.next_cursor}" to scan further (older changelog entries, if any, may be further in).`
+ : "";
+ if (!entries.length) {
+ return { content: [{ type: "text", text: `No changelog entries found on this page (within the blocks scanned).${hasMore}` }] };
+ }
+ return { content: [{ type: "text", text: entries.join("\n") + hasMore }] };
+ }
+ );
+
+ server.tool(
+ "notion_create_page",
+ "Create a new Notion page inside a parent page or database. Pass entity_id to get upsert-style dedup protection (mirrors mem0_add): if a page already carries that entity_id marker, this refuses to create a duplicate and returns the existing page instead. Recommended whenever this page represents a stable, ongoing thing (a tracked PR, an issue, a recurring report) rather than a genuine one-off. When parent_type is 'database', pass `properties` to set real database column values (select/rich_text/url/etc) -- see notion_get_database first for the schema.",
+ {
+ parent_id: z.string().describe("ID of the parent page or database"),
+ parent_type: z.enum(["page", "database"]).describe("Whether the parent is a page or a database"),
+ title: z.string().describe("Title of the new page"),
+ content: z.string().optional().describe("Plain text content to add as paragraph blocks"),
+ entity_id: z.string().optional().describe("Optional stable identifier for the thing this page represents (e.g. 'pr-workers-sdk-14714'). BEFORE inventing a new one, use notion_search for an existing page on the same topic -- entity_id dedup only catches an EXACT marker match. If a page already exists with this entity_id, notion_create_page will NOT create a duplicate -- it returns the existing page's id/url/content instead, so you can call notion_update_page (append_content or replacements) on it instead of creating a new one. Stored as a visible '🔑 entity_id: ...' marker paragraph at the top of the page, since Notion pages outside a database have no real custom-property field to use instead."),
+ status: z.enum(STATUS_VALUES).optional().describe("Optional lifecycle status (open/resolved/superseded) for this page. Stored as a visible '🏷️ status: ...' marker paragraph, same convention as entity_id."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other tracked page this one relates to"),
+ relation: z.string().describe("The relation type, e.g. 'blocks', 'depends_on', 'relates_to' -- free text"),
+ })).optional().describe("Optional list of outgoing relations from this page's entity to others, e.g. [{to_entity_id:'bug-4', relation:'blocks'}]. Stored as visible '🔗 relation -> to_entity_id' marker paragraphs. Only outgoing relations are supported -- see notion_get_page's Relations section for resolved targets."),
+ one_off: z.boolean().optional().describe("Set true to explicitly opt this page OUT of entity_id tracking -- required if entity_id is omitted. This tool refuses to create a page without either entity_id or one_off: true, so omitting entity_id by accident (rather than on purpose) is caught immediately instead of silently producing an untracked, un-deduped page. Use for genuine one-offs: scratch notes, test pages, throwaway content that will never need dedup or update-in-place."),
+ properties: z.record(z.any()).optional().describe("Optional Notion database property VALUES to set when parent_type is 'database' (ignored for parent_type 'page', which has no custom properties). Keys are property names exactly as they appear in the database schema; values must be in Notion's property-value format, e.g. { \"Status\": { \"select\": { \"name\": \"open\" } }, \"Apply Link\": { \"url\": \"https://...\" }, \"Comp / Rate\": { \"rich_text\": [{ \"text\": { \"content\": \"$10-60/hr\" } }] } }. Call notion_get_database first to see available property names and types."),
+ },
+ async ({ parent_id, parent_type, title, content, entity_id, status, relations, one_off, properties }) => {
+ let result;
+ try {
+ result = await doCreatePage({ parent_id, parent_type, title, content, entity_id, status, relations, one_off, properties });
+ } catch (err) {
+ return { content: [{ type: "text", text: err.message }], isError: true };
+ }
+ if (result.skipped) {
+ return {
+ content: [{
+ type: "text",
+ text:
+ `Not creating — a page already exists for entity_id "${entity_id}" (id: ${result.existingId}, title: "${result.existingTitle}"). No duplicate was created.\n` +
+ `URL: ${result.existingUrl}\n\n` +
+ `Next step: call notion_get_page on this id to review current content, then notion_update_page (append_content or replacements) to update it instead of creating a new page.`,
+ }],
+ };
+ }
+ const indexNote = result.indexError
+ ? `\n\n\u26a0\ufe0f Page created, but recording it in the dedup index failed: ${result.indexError}. Future notion_create_page calls with entity_id "${entity_id}" may not detect this page as a duplicate.`
+ : "";
+ const markerNote = result.markerCount ? ` (with ${entity_id ? "entity_id" : ""}${entity_id && status ? " + " : ""}${status ? "status" : ""} marker${result.markerCount > 1 ? "s" : ""})` : "";
+ const autoLinkNote = result.autoRelations?.length
+ ? `\n\n\ud83d\udd17 Auto-linked (identifier/cross-reference match): ${result.autoRelations.map((r) => r.to_entity_id).join(", ")}`
+ : "";
+ const unlinkableStrong = (result.linkCandidates?.strong || []).filter((c) => !c.entity_id);
+ const unlinkedNote = unlinkableStrong.length
+ ? `\n\n\ud83d\udd0e Strong match found but not auto-linked (candidate has no entity_id to attach a relation to): ${unlinkableStrong.map((c) => `"${c.title}" (${c.url}) -- ${c.reason}`).join("; ")}`
+ : "";
+ const candidateNote = result.linkCandidates?.medium?.length
+ ? `\n\n\ud83e\udd14 Possible related page(s) (tag overlap, not auto-linked): ${result.linkCandidates.medium.map((c) => `"${c.title}" (${c.url})`).join("; ")}`
+ : "";
+ return { content: [{ type: "text", text: `Created Notion page "${title}"${markerNote}\nID: ${result.id}\nURL: ${result.url}${indexNote}${autoLinkNote}${unlinkedNote}${candidateNote}` }] };
+ }
+ );
+
+ server.tool(
+ "notion_create_pages_batch",
+ "Create multiple Notion pages in a single call, to reduce round trips. Each item is created independently -- entity_id dedup, marker blocks, dedup-index recording, and database `properties` all apply per item exactly as in notion_create_page. One item failing (e.g. bad parent_id) does not block the others.",
+ {
+ items: z.array(z.object({
+ parent_id: z.string().describe("ID of the parent page or database"),
+ parent_type: z.enum(["page", "database"]).describe("Whether the parent is a page or a database"),
+ title: z.string().describe("Title of the new page"),
+ content: z.string().optional().describe("Plain text content to add as paragraph blocks"),
+ entity_id: z.string().optional().describe("Optional stable identifier for this page -- see notion_create_page. If a page already exists with this entity_id, this item is skipped (not duplicated) and the existing id/url is reported instead."),
+ status: z.enum(STATUS_VALUES).optional().describe("Optional lifecycle status (open/resolved/superseded) -- see notion_create_page."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other tracked page this one relates to"),
+ relation: z.string().describe("The relation type -- see notion_create_page"),
+ })).optional().describe("Optional outgoing relations for this page -- see notion_create_page."),
+ one_off: z.boolean().optional().describe("Required if entity_id is omitted -- see notion_create_page."),
+ properties: z.record(z.any()).optional().describe("Optional database property values for this page -- see notion_create_page."),
+ })).min(1).describe("List of pages to create"),
+ },
+ async ({ items }) => {
+ const results = await runSequentially(items, doCreatePage);
+ const lines = results.map((r, i) => {
+ const label = items[i].title;
+ if (r.status === "rejected") return `\u2717 [${i}] "${label}" — error: ${r.reason?.message || r.reason}`;
+ const v = r.value;
+ if (v.skipped) return `\u23ed [${i}] "${label}" — skipped, entity_id "${v.entity_id}" already exists (id: ${v.existingId}, title: "${v.existingTitle}").`;
+ const idxNote = v.indexError ? ` \u26a0\ufe0f index record failed: ${v.indexError}` : "";
+ return `\u2713 [${i}] "${label}" — id: ${v.id}${idxNote}`;
+ });
+ const created = results.filter((r) => r.status === "fulfilled" && !r.value.skipped).length;
+ return { content: [{ type: "text", text: `${created}/${items.length} created.\n\n${lines.join("\n")}` }] };
+ }
+ );
+
+ server.tool(
+ "notion_create_database",
+ "Create a new Notion database inside a parent page, with a given property schema. One-off/setup tool -- most workflows should use notion_create_page instead.",
+ {
+ parent_page_id: z.string().describe("ID of the parent page to create the database under"),
+ title: z.string().describe("Title of the new database"),
+ properties: z.record(z.any()).describe("Notion property schema object, e.g. { \"Name\": { \"title\": {} }, \"Status\": { \"select\": { \"options\": [{ \"name\": \"open\" }] } } }"),
+ },
+ async ({ parent_page_id, title, properties }) => {
+ const data = await notionRequest("/databases", {
+ method: "POST",
+ body: {
+ parent: { type: "page_id", page_id: parent_page_id },
+ title: [{ type: "text", text: { content: title } }],
+ properties,
+ },
+ });
+ return { content: [{ type: "text", text: `Created database "${title}"\nID: ${data.id}\nURL: ${data.url}` }] };
+ }
+ );
+
+ server.tool(
+ "notion_get_database",
+ "Get a Notion database's schema (title and property definitions) and basic info. Use this before notion_query_database or before notion_create_page with parent_type: 'database', to see what properties are available and their types.",
+ {
+ database_id: z.string().describe("Notion database ID (UUID format, e.g. from notion_search with filter_type: 'database')"),
+ },
+ async ({ database_id }) => {
+ const data = await notionRequest(`/databases/${database_id}`);
+ const title = notionDatabaseTitle(data);
+ const propLines = Object.entries(data.properties || {}).map(([name, def]) => ` ${name}: ${def.type}`);
+ const text = `# ${title}\nID: ${data.id}\nURL: ${data.url}\nCreated: ${data.created_time?.slice(0, 10)} | Last edited: ${data.last_edited_time?.slice(0, 10)}\n\nProperties:\n${propLines.join("\n") || "(none)"}`;
+ return { content: [{ type: "text", text }] };
+ }
+ );
+
+ server.tool(
+ "notion_query_database",
+ "Query rows from a Notion database, with an optional filter. Returns each row's properties in readable form. Call notion_get_database first to see available property names/types for building a filter.",
+ {
+ database_id: z.string().describe("Notion database ID"),
+ filter: z.record(z.any()).optional().describe("Optional Notion filter object, e.g. { property: 'EntityId', rich_text: { equals: 'some-id' } }"),
+ page_size: z.number().optional().describe("Number of rows to return (default 20, max 100)"),
+ cursor: z.string().optional().describe("Pagination cursor from a previous call's next_cursor, to fetch the next page of rows"),
+ },
+ async ({ database_id, filter, page_size = 20, cursor }) => {
+ const body = { page_size };
+ if (filter) body.filter = filter;
+ if (cursor) body.start_cursor = cursor;
+ const data = await notionRequest(`/databases/${database_id}/query`, { method: "POST", body });
+ if (!data.results?.length) return { content: [{ type: "text", text: "No rows found." }] };
+ const displayProp = (val) => {
+ if (val.type === "title") return notionRichTextToString(val.title);
+ if (val.type === "rich_text") return notionRichTextToString(val.rich_text);
+ if (val.type === "url") return val.url || "";
+ if (val.type === "select") return val.select?.name || "";
+ if (val.type === "multi_select") return (val.multi_select || []).map((s) => s.name).join(",");
+ if (val.type === "checkbox") return val.checkbox ? "true" : "false";
+ if (val.type === "number") return String(val.number ?? "");
+ return JSON.stringify(val[val.type] ?? "");
+ };
+ const lines = data.results.map((row) => {
+ const props = Object.entries(row.properties || {}).map(([name, val]) => `${name}: ${displayProp(val)}`).join(" | ");
+ return `- ${props}\n (row id: ${row.id})`;
+ });
+ const hasMore = data.has_more ? `\n\n\u26a0\ufe0f More rows exist -- call again with cursor: "${data.next_cursor}" to see the next page.` : "";
+ return { content: [{ type: "text", text: lines.join("\n") + hasMore }] };
+ }
+ );
+
+ server.tool(
+ "notion_update_database",
+ "Update a Notion database's title, or archive/restore it. Use this instead of notion_update_page for database IDs -- databases live at a separate API endpoint from pages, so notion_update_page returns a 404 if given a database ID.",
+ {
+ database_id: z.string().describe("Notion database ID (UUID format, e.g. from notion_search with filter_type: 'database')"),
+ title: z.string().optional().describe("New title for the database"),
+ archived: z.boolean().optional().describe("Set true to archive (trash) the database, false to restore"),
+ },
+ async ({ database_id, title, archived }) => {
+ const body = {};
+ if (archived !== undefined) body.archived = archived;
+ if (title !== undefined) body.title = [{ type: "text", text: { content: title } }];
+ if (Object.keys(body).length === 0) {
+ return { content: [{ type: "text", text: "No changes made." }] };
+ }
+ const data = await notionRequest(`/databases/${database_id}`, { method: "PATCH", body });
+ return { content: [{ type: "text", text: `Updated database "${notionDatabaseTitle(data)}" (ID: ${data.id}).` }] };
+ }
+ );
+
+ server.tool(
+ "notion_update_page",
+ "Update a Notion page's title, append text content to it, make a targeted in-place edit to an existing block (replacements), change its lifecycle status marker, or set real database column values via `properties` (select/rich_text/url/etc, if this page is a row in a database).",
+ {
+ page_id: z.string().describe("Notion page ID to update"),
+ title: z.string().optional().describe("New title for the page"),
+ append_content: z.string().optional().describe("Plain text to append as new paragraph blocks"),
+ archived: z.boolean().optional().describe("Set true to archive (trash) the page, false to restore"),
+ replacements: z.array(z.object({
+ find: z.string().describe("Exact plain text of an existing top-level block (paragraph, heading, list item, or to-do) -- must match exactly one block"),
+ replace: z.string().describe("New plain text for that block"),
+ })).optional().describe("List of find-and-replace operations for targeted in-place block edits, instead of appending new content. Each `find` must match exactly one of the page's top-level blocks (first 100) by plain text -- fails loudly (no changes made) on zero or multiple matches, same uniqueness rule as mem0_update's replacements and the github edit_file tool's `replacements` mode. Only text-style blocks can be edited this way (paragraph/heading/list-item/to-do); code blocks, subpages, etc. are not supported and will report an error instead of being silently skipped."),
+ status: z.enum(STATUS_VALUES).optional().describe("Set this page's lifecycle status (open/resolved/superseded). Updates the existing '🏷️ status: ...' marker block in place if one exists, or appends a new marker block if the page has none yet."),
+ entity_id: z.string().optional().describe("Correct or set this page's entity_id marker. Use this (not `replacements` on the marker text) whenever an entity_id was wrong or missing -- this updates the visible '🔑 entity_id: ...' marker block in place AND upserts the Entity Index database entry that notion_create_page's dedup check and notion_get_page's relation resolution both read from. Editing the marker text directly via `replacements` only changes what's visible on the page; it does NOT update the index, so other pages' relations pointing at the corrected entity_id will keep resolving as 'dangling' until this param is used instead."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other entity this one relates to"),
+ relation: z.string().describe("The relation type, e.g. 'blocks', 'depends_on', 'relates_to' -- free text"),
+ })).optional().describe("New outgoing relations for this page -- REPLACES the existing relation set whole (not merged). Omit to leave relations unchanged. Pass an empty array to clear all relations."),
+ properties: z.record(z.any()).optional().describe("Database property VALUES to set/update on this page (only meaningful if the page is a row in a database). Keys are property names exactly as they appear in the database schema; values must be in Notion's property-value format, e.g. { \"Status\": { \"select\": { \"name\": \"resolved\" } } }. Merged with any title change into a single PATCH. Call notion_get_database first to see available property names and types."),
+ },
+ async ({ page_id, title, append_content, archived, replacements, status, entity_id, relations, properties }) => {
+ try {
+ const results = await doUpdatePage({ page_id, title, append_content, archived, replacements, status, entity_id, relations, properties });
+ return { content: [{ type: "text", text: results.join("\n") || "No changes made." }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: err.message }], isError: true };
+ }
+ }
+ );
+
+ server.tool(
+ "notion_sync_content",
+ "Write content into a marked, machine-managed range on a Notion page, without disturbing anything a person has added elsewhere on the page. On first use, appends a new range (start marker + content + end marker) to the end of the page. On later calls with the same synced_at, does nothing (already up to date). On later calls with a different synced_at, replaces only the blocks between the markers -- content above the start marker or below the end marker is never read or touched. This is the low-level primitive behind mem0->Notion sync; call directly for testing, or to sync arbitrary external content into a page.",
+ {
+ page_id: z.string().describe("Notion page ID to write the synced range onto"),
+ content: z.string().describe("Plain text content for the synced range, one paragraph block per newline-separated line"),
+ synced_at: z.string().describe("Version/timestamp identifying this content revision (e.g. an ISO timestamp or a source system's updated_at). If this matches what's already on the page, the call is a no-op."),
+ },
+ async ({ page_id, content, synced_at }) => {
+ const contentLines = content.split("\n");
+ let result;
+ try {
+ result = await replaceSyncedRange({ page_id, contentLines, synced_at });
+ } catch (err) {
+ return { content: [{ type: "text", text: err.message }], isError: true };
+ }
+ if (result.action === "created") return { content: [{ type: "text", text: `Created new synced range (${result.blockCount} blocks) on page ${page_id}.` }] };
+ if (result.action === "skipped") return { content: [{ type: "text", text: `No changes made — ${result.reason}.` }] };
+ return { content: [{ type: "text", text: `Synced range updated on page ${page_id}: ${result.removed} block(s) removed, ${result.added} added (was mem0_synced_at: ${result.previousSyncedAt}, now: ${synced_at}). Content above/below the markers was left untouched.` }] };
+ }
+ );
+
+ server.tool(
+ "notion_index_entries_add_batch",
+ "Backfill/repair tool: add entries to the Entity Index database for pages that already exist and already carry an entity_id marker, but aren't yet recorded in the index (e.g. after the index was reset, or a write silently failed earlier). Skips (no duplicate row) any entity_id already indexed. NOT for normal page creation -- notion_create_page/notion_create_pages_batch already index automatically when you pass entity_id there; use this only to backfill pre-existing pages.",
+ {
+ items: z.array(z.object({
+ entity_id: z.string().describe("The entity_id marker already present on the target page"),
+ page_id: z.string().describe("Notion page ID of the existing page this entity_id refers to"),
+ url: z.string().optional().describe("Notion URL of the page"),
+ tags: z.array(z.string()).optional().describe("Tags for this entity, if any (lowercase, no # prefix)"),
+ })).min(1).describe("List of index entries to backfill"),
+ },
+ async ({ items }) => {
+ const results = await runSequentially(items, upsertIndexEntry);
+ const lines = results.map((r, i) => {
+ const label = items[i].entity_id;
+ if (r.status === "rejected") return `\u2717 [${i}] ${label} \u2014 ${r.reason?.message || r.reason}`;
+ if (r.value.skipped) return `\u23ed [${i}] ${label} \u2014 already indexed (page ${r.value.existingId})`;
+ if (r.value.error) return `\u26a0\ufe0f [${i}] ${label} \u2014 write failed: ${r.value.error}`;
+ return `\u2713 [${i}] ${label} \u2014 indexed`;
+ });
+ const added = results.filter((r) => r.status === "fulfilled" && !r.value.skipped && !r.value.error).length;
+ return { content: [{ type: "text", text: `${added}/${items.length} added.\n\n${lines.join("\n")}` }] };
+ }
+ );
+
+ server.tool(
+ "notion_update_pages_batch",
+ "Update multiple Notion pages in a single call, to reduce round trips. Each item supports the same title/append_content/archived/replacements/status/properties behavior as notion_update_page. One item failing (e.g. an ambiguous replacement match) does not block the others.",
+ {
+ items: z.array(z.object({
+ page_id: z.string().describe("Notion page ID to update"),
+ title: z.string().optional().describe("New title for the page"),
+ append_content: z.string().optional().describe("Plain text to append as new paragraph blocks"),
+ archived: z.boolean().optional().describe("Set true to archive (trash) the page, false to restore"),
+ replacements: z.array(z.object({
+ find: z.string().describe("Exact plain text of an existing top-level block -- must match exactly one block"),
+ replace: z.string().describe("New plain text for that block"),
+ })).optional().describe("Targeted find/replace edits for this page -- see notion_update_page for matching rules."),
+ status: z.enum(STATUS_VALUES).optional().describe("Set this page's lifecycle status -- see notion_update_page."),
+ entity_id: z.string().optional().describe("Correct or set this page's entity_id marker, reindexing it in the Entity Index database -- see notion_update_page."),
+ relations: z.array(z.object({
+ to_entity_id: z.string().describe("The entity_id of the other entity this one relates to"),
+ relation: z.string().describe("The relation type -- see notion_update_page"),
+ })).optional().describe("New outgoing relations for this page -- see notion_update_page (whole-set replace)."),
+ properties: z.record(z.any()).optional().describe("Database property values to set/update on this page -- see notion_update_page."),
+ })).min(1).describe("List of page updates to apply"),
+ },
+ async ({ items }) => {
+ const results = await runSequentially(items, doUpdatePage);
+ const lines = results.map((r, i) => {
+ const label = items[i].page_id;
+ if (r.status === "rejected") return `\u2717 [${i}] ${label} — ${r.reason?.message || r.reason}`;
+ return `\u2713 [${i}] ${label} — ${r.value.join("; ") || "no changes made"}`;
+ });
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
+ return { content: [{ type: "text", text: `${succeeded}/${items.length} updated.\n\n${lines.join("\n")}` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 | + + + + + + + + +7x +3x +43x +3x + + + +104x + + + +41x + + + +19x +19x + + + + + +17x +14x +19x +12x +19x + + + + +17x +15x + + + + + + + + + +13x +13x +13x +13x + + | // ---------------------------------------------------------------------------
+// connectors/security.js -- IP allowlist + shared-key auth helpers, extracted
+// from server.js so they're unit-testable without spinning up the HTTP
+// server. Behavior is unchanged from the original server.js implementation;
+// see test/security.test.js for coverage.
+// ---------------------------------------------------------------------------
+
+// Constant-time-ish comparison to avoid trivial timing leaks on the shared key.
+export function safeEqual(a, b) {
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
+ let mismatch = 0;
+ for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ return mismatch === 0;
+}
+
+export function ipToLong(ip) {
+ return ip.split(".").reduce((acc, octet) => (acc << 8) + (parseInt(octet, 10) & 0xff), 0) >>> 0;
+}
+
+export function isIpv4(ip) {
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip);
+}
+
+export function isIpInCidr(ip, cidr) {
+ const [range, bitsStr] = cidr.split("/");
+ if (!isIpv4(ip) || !isIpv4(range)) return false;
+ // Reject anything that isn't a bare, in-range IPv4 prefix length (0-32)
+ // instead of letting parseInt/NaN/negative/>32 values fall through to JS's
+ // shift-amount-mod-32 semantics, which silently computes the WRONG mask
+ // instead of erroring -- e.g. "/33" would otherwise behave like "/1",
+ // "/xyz" like "/32", and "/24abc" would parse as a valid /24.
+ if (bitsStr !== undefined && !/^\d{1,2}$/.test(bitsStr)) return false;
+ const bits = bitsStr === undefined ? 32 : parseInt(bitsStr, 10);
+ if (bits > 32) return false;
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
+ return (ipToLong(ip) & mask) === (ipToLong(range) & mask);
+}
+
+// Strips the ::ffff: prefix Node sometimes adds to IPv4 addresses on dual-stack sockets.
+export function normalizeIp(ip) {
+ if (typeof ip !== "string") return "";
+ return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
+}
+
+// Reads the client IP from X-Forwarded-For (leftmost = original client) when
+// present, falling back to the raw socket address. NOTE: this trusts
+// X-Forwarded-For, which is only safe because the deploy platform sits in
+// front of this server as the sole entry point (it overwrites/sets this
+// header itself). If that ever changes, this needs `app.set('trust proxy', ...)`
+// tuned to the actual number of trusted hops, or the header becomes spoofable.
+export function getClientIp(req) {
+ const forwarded = req.headers["x-forwarded-for"];
+ const forwardedIp = forwarded ? forwarded.split(",")[0].trim() : "";
+ const raw = forwardedIp || req.socket.remoteAddress;
+ return normalizeIp(raw || "");
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| rate-limit.js | +
+
+ |
+ 15.78% | +3/19 | +0% | +0/6 | +12.5% | +1/8 | +17.64% | +3/17 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 | + + + + + + + + + + + + + + + + + + + + + + + + +6x +6x + + + + + + + + + + + + + + + + +6x + + + + + + + + + + + + + + + + + | // --------------------------------------------------------------------------- +// connectors/shared/rate-limit.js -- reusable request throttling + retry +// building blocks, used by connectors whose upstream API doesn't have its +// own bespoke rate-limit handling (currently Notion and Mem0; GitHub's +// connectors/github/client.js keeps its own independent, unit-tested +// implementation -- see the commit message / this file's header for why). +// +// Each connector that uses this should call createThrottle() ONCE at module +// load time and keep the returned `schedule` function for the lifetime of +// the process -- a fresh throttle per request would defeat the whole point +// (there'd be nothing to serialize against). +// --------------------------------------------------------------------------- + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Returns a `schedule(fn)` function that runs `fn` no sooner than +// `minIntervalMs` after the previously scheduled call started, regardless of +// how many callers invoke `schedule` concurrently -- a shared promise chain +// serializes them. This is what actually protects against a burst of +// parallel tool calls (e.g. several Notion calls landing in the same +// delegate_agent step, see connectors/gemini/agent_delegate.js's 2026-07-26 +// parallelization) hitting the upstream API all at once. +export function createThrottle(minIntervalMs) { + let chain = Promise.resolve(); + let lastStartedAt = 0; + + function schedule(fn) { + const run = async () => { + const wait = lastStartedAt + minIntervalMs - Date.now(); + if (wait > 0) await sleep(wait); + lastStartedAt = Date.now(); + return fn(); + }; + // Chain onto the shared queue regardless of whether prior requests + // succeeded or failed, so one failure doesn't jam the whole queue. + const result = chain.then(run, run); + // Keep the chain alive without leaking rejections into unrelated callers. + chain = result.then(() => {}, () => {}); + return result; + } + + return schedule; +} + +// Generic Retry-After-aware backoff: honors a numeric `retry-after` header +// (seconds) if the response provides one, otherwise falls back to +// exponential backoff with jitter. `res` only needs a `headers.get(name)` +// method (matches both the Fetch API's Headers object and the test mocks in +// test/github-client.test.js's style, though this function itself isn't +// used by that test file -- see this file's header). +export function defaultRetryDelayMs(res, attempt, baseMs) { + const retryAfter = res.headers?.get?.("retry-after"); + if (retryAfter && !Number.isNaN(Number(retryAfter))) { + return Number(retryAfter) * 1000; + } + const jitter = Math.random() * 250; + return baseMs * 2 ** attempt + jitter; +} + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| mem0_notion.js | +
+
+ |
+ 1.8% | +2/111 | +0% | +0/92 | +4.34% | +1/23 | +2.1% | +2/95 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 | + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// connectors/sync/mem0_notion.js — one-way mem0 -> Notion sync
+// See: SPEC: mem0 -> Notion Sync Tool (Notion entity_id: mem0-notion-sync-tool-spec)
+// ---------------------------------------------------------------------------
+
+import { z } from "zod";
+import { mem0Request } from "../mem/client.js";
+import { notionRequest, parseRelationBlocks, queryAllIndexEntries } from "../notion/client.js";
+import { findPageByEntityId, doCreatePage, doUpdatePage, replaceSyncedRange } from "../notion/tools.js";
+import { MEM0_USER_ID, NOTION_SYNC_PARENT_PAGE_ID } from "../../config.js";
+
+const MEM0_ENTITY_PREFIX = "mem0:";
+
+function notionEntityIdFor(memory) {
+ return `${MEM0_ENTITY_PREFIX}${memory.metadata?.entity_id || memory.id}`;
+}
+
+function titleFor(memory) {
+ const text = (memory.memory || memory.text || "").trim();
+ const firstLine = text.split("\n")[0];
+ return (firstLine.slice(0, 60) || "(mem0 memory)") + (firstLine.length > 60 ? "…" : "");
+}
+
+function contentLinesFor(memory) {
+ const lines = (memory.memory || memory.text || "(no content)").split("\n").filter(Boolean);
+ const tags = Array.isArray(memory.metadata?.tags) ? memory.metadata.tags : [];
+ if (tags.length) lines.push(`Tags: ${tags.join(", ")}`);
+ return lines;
+}
+
+// mem0 status values line up 1:1 with Notion's -- see spec's TAG / STATUS /
+// RELATION MAPPING section.
+function statusFor(memory) {
+ return memory.metadata?.status || undefined;
+}
+
+// DANGLING RELATIONS: to_entity_id is passed through with the mem0: prefix
+// without pre-checking it resolves. Notion's own relation resolution
+// (findPageByEntityId, used by notion_get_page) already reports "not found
+// -- dangling reference" for anything unresolvable, so a second check here
+// would just duplicate that at sync time for no real benefit -- the spec
+// left this as an open "skip vs note" decision; passing through is the
+// simpler of the two and defers to machinery that already exists.
+function relationsFor(memory) {
+ const relations = Array.isArray(memory.metadata?.relations) ? memory.metadata.relations : [];
+ return relations.map((r) => ({ to_entity_id: `${MEM0_ENTITY_PREFIX}${r.to_entity_id}`, relation: r.relation }));
+}
+
+function relationsEqual(a = [], b = []) {
+ const norm = (list) => list.map((r) => `${r.to_entity_id}::${r.relation}`).sort().join("|");
+ return norm(a) === norm(b);
+}
+
+// Paginates the full set of memories in scope, same 100/page * up-to-10-page
+// ceiling as findByEntityId/mem0_list elsewhere in this codebase. Optional
+// entity_ids filters to specific mem0 entity_ids (not the mem0:-prefixed
+// Notion form) after fetching, same client-side-filter tradeoff mem0_list
+// already makes for tags/status.
+async function listAllMemories({ user_id, entity_ids }) {
+ const filters = { user_id };
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 10;
+ const all = [];
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const data = await mem0Request("/v3/memories/", { method: "POST", body: { filters, page, page_size: PAGE_SIZE } });
+ const memories = data.results || data.memories || data || [];
+ all.push(...memories);
+ if (memories.length < PAGE_SIZE) break;
+ }
+ if (entity_ids?.length) {
+ const wanted = new Set(entity_ids);
+ return all.filter((m) => m.metadata?.entity_id && wanted.has(m.metadata.entity_id));
+ }
+ return all;
+}
+
+// Reads every mem0:-prefixed entry off the Entity Index database (see
+// notion/client.js's queryAllIndexEntries) -- the set of Notion pages this
+// sync tool has ever created. Used only for hard-deletion detection (an
+// entity_id present here but no longer in mem0's current memory set).
+// UPDATE (2026-07-24): previously read blocks directly off the old
+// page-based index (NOTION_INDEX_PAGE_ID); moved to the database read now
+// that all index writes go there -- reading the old page here would have
+// silently gone stale (no new entries were being written to it) and broke
+// outright once that page was archived. Same 10-page/100-row-per-page
+// pagination ceiling as listAllMemories above.
+async function readSyncedIndexEntries() {
+ const entries = await queryAllIndexEntries();
+ return entries.filter((e) => e.entity_id?.startsWith(MEM0_ENTITY_PREFIX));
+}
+
+async function syncOneMemory(memory, { dry_run }) {
+ const notionEntityId = notionEntityIdFor(memory);
+ const synced_at = memory.updated_at || memory.created_at || new Date().toISOString();
+ const status = statusFor(memory);
+ const relations = relationsFor(memory);
+ const contentLines = contentLinesFor(memory);
+
+ const existing = await findPageByEntityId(notionEntityId);
+
+ // Superseded -> archive and stop; don't also try to write/update content
+ // on a page we're archiving in the same pass.
+ if (status === "superseded") {
+ if (!existing) return { entity_id: notionEntityId, action: "skip-superseded-no-page" };
+ if (dry_run) return { entity_id: notionEntityId, action: "would-archive", pageUrl: existing.url };
+ await doUpdatePage({ page_id: existing.pageId, archived: true });
+ return { entity_id: notionEntityId, action: "archived", pageUrl: existing.url };
+ }
+
+ if (!existing) {
+ if (dry_run) return { entity_id: notionEntityId, action: "would-create" };
+ const created = await doCreatePage({
+ parent_id: NOTION_SYNC_PARENT_PAGE_ID, parent_type: "page",
+ title: titleFor(memory), entity_id: notionEntityId, status, relations,
+ });
+ if (created.skipped) {
+ // Lost a create-vs-create race against another sync run -- fall
+ // through to the update path against the page that won.
+ const range = await replaceSyncedRange({ page_id: created.existingId, contentLines, synced_at });
+ return { entity_id: notionEntityId, action: `race-then-${range.action}`, pageUrl: created.existingUrl };
+ }
+ const range = await replaceSyncedRange({ page_id: created.id, contentLines, synced_at });
+ return { entity_id: notionEntityId, action: `created-and-${range.action}`, pageUrl: created.url };
+ }
+
+ // Existing page: sync content (self-no-ops on unchanged synced_at), then
+ // only touch status/relations if they actually differ, to avoid the same
+ // needless-write/changelog-spam problem synced_at solves for content.
+ if (dry_run) {
+ const blocksData = await notionRequest(`/blocks/${existing.pageId}/children?page_size=100`);
+ const blocks = blocksData.results || [];
+ const currentRelations = parseRelationBlocks(blocks).map((r) => ({ to_entity_id: r.to_entity_id, relation: r.relation }));
+ const statusChanged = (existing.markers.status || undefined) !== status;
+ const relationsChanged = !relationsEqual(currentRelations, relations);
+ return { entity_id: notionEntityId, action: "would-update", pageUrl: existing.url, statusChanged, relationsChanged };
+ }
+
+ const range = await replaceSyncedRange({ page_id: existing.pageId, contentLines, synced_at });
+
+ const blocksData = await notionRequest(`/blocks/${existing.pageId}/children?page_size=100`);
+ const blocks = blocksData.results || [];
+ const currentRelations = parseRelationBlocks(blocks).map((r) => ({ to_entity_id: r.to_entity_id, relation: r.relation }));
+ const statusChanged = (existing.markers.status || undefined) !== status;
+ const relationsChanged = !relationsEqual(currentRelations, relations);
+ if (statusChanged || relationsChanged) {
+ await doUpdatePage({
+ page_id: existing.pageId,
+ status: statusChanged ? status : undefined,
+ relations: relationsChanged ? relations : undefined,
+ });
+ }
+ return { entity_id: notionEntityId, action: range.action, pageUrl: existing.url, statusChanged, relationsChanged };
+}
+
+export function register(server) {
+ server.tool(
+ "sync_mem0_to_notion",
+ "One-way sync from mem0 into the Notion Memory Index (mem0 -> Notion only, no reverse direction) -- creates/updates a Notion page per mem0 memory, protecting any manual edits a person has added directly on those pages. Reuses the existing entity_id dedup index, marker conventions, and synced-content-range mechanism (notion_sync_content) rather than any new lookup/write logic. Skips no-op writes automatically (unchanged content isn't rewritten). Superseded mem0 memories get their Notion page archived, not deleted. Full syncs (no entity_ids filter) also detect and archive Notion pages whose source memory has been hard-deleted from mem0 entirely.",
+ {
+ dry_run: z.boolean().optional().describe("If true, report what WOULD change (create/update/archive counts + per-item detail) without writing anything to Notion. Default: false."),
+ entity_ids: z.array(z.string()).optional().describe("Optional filter to sync only mem0 memories with one of these mem0 entity_ids (not the mem0:-prefixed Notion form), instead of the full workspace. NOTE: using this filter disables hard-deletion detection for this run, since a partial sync can't tell 'deleted from mem0' apart from 'not in this batch'."),
+ },
+ async ({ dry_run = false, entity_ids }) => {
+ const memories = await listAllMemories({ user_id: MEM0_USER_ID, entity_ids });
+ const results = [];
+ // Sequential, not Promise.all -- every item's dedup check reads the
+ // same shared index page, so concurrent items can race the same way
+ // notion_create_pages_batch's items did before that was fixed
+ // 2026-07-17 (see runSequentially in notion/tools.js). Trades
+ // throughput for correctness at this tool's expected scale.
+ for (const memory of memories) {
+ try {
+ results.push(await syncOneMemory(memory, { dry_run }));
+ } catch (err) {
+ results.push({ entity_id: notionEntityIdFor(memory), action: "error", error: err.message });
+ }
+ }
+
+ let deletionLines = [];
+ if (!entity_ids?.length) {
+ const currentEntityIds = new Set(memories.map((m) => notionEntityIdFor(m)));
+ const indexEntries = await readSyncedIndexEntries();
+ const orphaned = indexEntries.filter((e) => !currentEntityIds.has(e.entity_id));
+ for (const entry of orphaned) {
+ if (dry_run) {
+ deletionLines.push(` would-archive (source deleted from mem0): ${entry.entity_id} — ${entry.url}`);
+ continue;
+ }
+ try {
+ await doUpdatePage({ page_id: entry.page_id, archived: true });
+ deletionLines.push(` archived (source deleted from mem0): ${entry.entity_id} — ${entry.url}`);
+ } catch (err) {
+ // Notion rejects re-archiving a page that's already archived
+ // ("Can't edit block that is archived") -- that's the correct
+ // end state already reached (e.g. a prior run or a superseded-
+ // status archive already handled it), not a real failure. Only
+ // surface an error for anything else.
+ if (/already archived|Can't edit block that is archived/i.test(err.message)) {
+ deletionLines.push(` already archived (no change needed): ${entry.entity_id} — ${entry.url}`);
+ } else {
+ deletionLines.push(` ✗ failed to archive ${entry.entity_id} — ${err.message}`);
+ }
+ }
+ }
+ }
+
+ const counts = results.reduce((acc, r) => {
+ acc[r.action] = (acc[r.action] || 0) + 1;
+ return acc;
+ }, {});
+ const summary = Object.entries(counts).map(([action, n]) => `${action}: ${n}`).join(", ");
+ const lines = results.map((r) =>
+ r.action === "error"
+ ? ` ✗ ${r.entity_id} — ${r.error}`
+ : ` ${r.action} — ${r.entity_id}${r.pageUrl ? ` (${r.pageUrl})` : ""}${r.statusChanged ? " [status changed]" : ""}${r.relationsChanged ? " [relations changed]" : ""}`
+ );
+ const header = `${dry_run ? "[DRY RUN] " : ""}Synced ${memories.length} memor${memories.length === 1 ? "y" : "ies"}. ${summary || "nothing to do"}.`;
+ const deletionHeader = deletionLines.length ? `\n\nHard-deletion check:\n${deletionLines.join("\n")}` : (entity_ids?.length ? "\n\n(Hard-deletion check skipped — entity_ids filter was used.)" : "");
+ return { content: [{ type: "text", text: `${header}\n\n${lines.join("\n")}${deletionHeader}` }] };
+ }
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 | + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x + + + + + + + + + + + + + + +1x +1x +1x +1x +1x +1x + + + + + + + + + + + +32x +32x +32x +32x +1x + +31x + + + + + + + + + + +3x + + + + + + + + + + + +3x + + + + + + +3x +3x + + +3x + + + + + +3x + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + +3x +3x + +3x + + + +3x + + + + + + + + + + + + + + + + + + + + | // ---------------------------------------------------------------------------
+// server.js -- HTTP server + MCP bootstrap only.
+// To add a new connector: create connectors/<n>/tools.js and register below.
+// ---------------------------------------------------------------------------
+
+import express from "express";
+import helmet from "helmet";
+import rateLimit from "express-rate-limit";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+
+import { GITHUB_TOKEN, NOTION_TOKEN, MEM0_API_KEY, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CONTEXT7_API_KEY, GEMINI_API_KEY, MCP_SHARED_KEY, IP_ALLOWLIST_ENABLED, ALLOWED_IP_RANGES, TRUST_PROXY_HOPS, GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY } from "./config.js";
+import { safeEqual, isIpInCidr, getClientIp } from "./connectors/security.js";
+import * as github from "./connectors/github/tools.js";
+import * as resource from "./connectors/github/resource.js";
+import * as notion from "./connectors/notion/tools.js";
+import * as mem0 from "./connectors/mem/tools.js";
+import * as fetch from "./connectors/fetch/tools.js";
+import * as cloudflare from "./connectors/cloudflare/tools.js";
+import * as context7 from "./connectors/context7/tools.js";
+import * as agent from "./connectors/gemini/agent_tools.js";
+import * as research from "./connectors/exa/research_tools.js";
+import * as frontend from "./connectors/frontend/designer_tools.js";
+import * as sync from "./connectors/sync/mem0_notion.js";
+
+// Build the MCP server once at startup and reuse it across all requests.
+const mcpServer = new McpServer({
+ name: "madmcp-server",
+ version: "2.1.0",
+});
+
+github.register(mcpServer);
+resource.register(mcpServer);
+notion.register(mcpServer);
+mem0.register(mcpServer);
+fetch.register(mcpServer);
+cloudflare.register(mcpServer);
+context7.register(mcpServer);
+agent.register(mcpServer);
+research.register(mcpServer);
+frontend.register(mcpServer);
+sync.register(mcpServer);
+
+// Adding a new connector:
+// import * as myThing from "./connectors/myThing/tools.js";
+// myThing.register(mcpServer);
+
+// --- IP allowlist -----------------------------------------------------
+// Restricts inbound requests to known client CIDR ranges (e.g. Anthropic's
+// published range for Claude connector traffic) BEFORE the key check runs,
+// so a leaked/guessed MCP_SHARED_KEY alone isn't enough to reach the server
+// from an untrusted network. IPv4 only; extend if you need IPv6 ranges too.
+// (safeEqual, isIpInCidr, getClientIp now live in ./connectors/security.js,
+// covered by test/security.test.js.)
+
+function requireAllowedIp(req, res, next) {
+ Iif (!IP_ALLOWLIST_ENABLED) return next();
+ const ip = getClientIp(req);
+ const allowed = ip && ALLOWED_IP_RANGES.some((cidr) => isIpInCidr(ip, cidr));
+ Iif (allowed) return next();
+ console.warn(`Blocked request from non-allowlisted IP: ${ip || "(unknown)"}`);
+ res.status(403).json({
+ jsonrpc: "2.0",
+ error: { code: -32002, message: "Forbidden: source IP not allowlisted" },
+ id: null,
+ });
+}
+
+// Accepts the key via header OR as a URL path segment via /mcp/:key.
+// Path-based auth is back because Claude.ai's custom connector UI does not
+// currently support request-header auth for MCP servers on this account.
+// Prefer the header for any client that does support it.
+function requireMcpKey(req, res, next) {
+ Iif (!MCP_SHARED_KEY) return next();
+ const headerKey = req.get("x-manufact-key");
+ const pathKey = req.params.key;
+ if ((headerKey && safeEqual(headerKey, MCP_SHARED_KEY)) || (pathKey && safeEqual(pathKey, MCP_SHARED_KEY))) {
+ return next();
+ }
+ res.status(401).json({
+ jsonrpc: "2.0",
+ error: { code: -32001, message: "Unauthorized: missing or invalid MCP key" },
+ id: null,
+ });
+}
+
+// Rate limit auth attempts / tool calls on /mcp so a leaked or guessed key
+// can't be used to hammer GitHub/Cloudflare/etc, and the key itself can't be
+// brute-forced freely. Applied before requireMcpKey so failed-auth attempts
+// count against the limit too.
+const mcpLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 30,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: {
+ jsonrpc: "2.0",
+ error: { code: -32000, message: "Rate limit exceeded. Try again shortly." },
+ id: null,
+ },
+});
+
+const app = express();
+// Trust TRUST_PROXY_HOPS reverse-proxy hops (default 1, matching Render and
+// most single-CDN-hop platforms) so X-Forwarded-For is read consistently
+// with getClientIp() below. Fixes express-rate-limit throwing
+// ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on every request. If deploying behind a
+// different proxy chain, set TRUST_PROXY_HOPS to match instead of assuming
+// this default is universally correct.
+app.set("trust proxy", TRUST_PROXY_HOPS);
+app.use(helmet());
+// Raise body size limit from the 100kb default to 10mb so that push_files
+// and create_or_update_file can handle large source files without truncation.
+app.use(express.json({ limit: "10mb" }));
+
+// Gated behind auth: previously exposed which connectors were configured
+// (github/notion/mem0/cloudflare/auth booleans) to anyone with the URL, which
+// is free recon for an attacker probing the server. Now requires a valid key,
+// same as /mcp. /health stays open and info-free for uptime checks.
+app.get("/", requireMcpKey, requireAllowedIp, (_req, res) => {
+ res.json({
+ status: "ok",
+ service: "madmcp-server",
+ version: "2.1.0",
+ configured: {
+ github: Boolean(GITHUB_TOKEN),
+ notion: Boolean(NOTION_TOKEN),
+ mem0: Boolean(MEM0_API_KEY),
+ cloudflare: Boolean(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ACCOUNT_ID),
+ context7: true, // works unauthenticated at lower rate limits, so always "configured"
+ gemini: Boolean(GEMINI_API_KEY),
+ frontend: Boolean(GEMINI_API_KEY), // delegate_designer's agent loop runs on the Gemini connector -- no separate frontend provider config anymore
+ auth: Boolean(MCP_SHARED_KEY),
+ },
+ });
+});
+
+app.get("/health", (_req, res) => res.status(200).json({ status: "ok" }));
+
+async function handleMcp(req, res) {
+ try {
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
+ res.on("close", () => { transport.close(); });
+ await mcpServer.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ } catch (err) {
+ console.error("MCP request error:", err);
+ if (!res.headersSent) {
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
+ }
+ }
+}
+
+app.post("/mcp", mcpLimiter, requireMcpKey, requireAllowedIp, handleMcp);
+app.post("/mcp/:key", mcpLimiter, requireMcpKey, requireAllowedIp, handleMcp);
+
+const PORT = process.env.PORT || 8080;
+// Gated so importing this module (e.g. from tests via supertest, or the MCP
+// integration test's InMemoryTransport) never binds a real port. Tests set
+// NODE_ENV=test before importing server.js.
+Iif (process.env.NODE_ENV !== "test" && !process.env.VERCEL) {
+ app.listen(PORT, () => {
+ console.log(`madmcp-server v2.1.0 listening on port ${PORT}`);
+ if (!GITHUB_TOKEN) console.warn("WARNING: GITHUB_TOKEN is not set.");
+ if (!NOTION_TOKEN) console.warn("WARNING: NOTION_TOKEN is not set. Notion tools will fail.");
+ if (!MEM0_API_KEY) console.warn("WARNING: MEM0_API_KEY is not set. Mem0 tools will fail.");
+ if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID) console.warn("WARNING: CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID not set. Cloudflare tools will fail.");
+ if (!CONTEXT7_API_KEY) console.warn("NOTE: CONTEXT7_API_KEY is not set. Context7 tools will work but at lower, unauthenticated rate limits.");
+ if (!GEMINI_API_KEY) console.warn("WARNING: GEMINI_API_KEY is not set. delegate_agent will fail entirely, and delegate_research's precision mode (url+question) will fail (wide mode is Exa-backed and unaffected).");
+ if (!MCP_SHARED_KEY) console.warn("WARNING: MCP_SHARED_KEY is not set. The /mcp, /mcp/:key, and / endpoints are OPEN to anyone who has the URL.");
+ if (!GITHUB_APP_ID || !GITHUB_APP_INSTALLATION_ID || !GITHUB_APP_PRIVATE_KEY) console.warn("NOTE: GITHUB_APP_ID/GITHUB_APP_INSTALLATION_ID/GITHUB_APP_PRIVATE_KEY not fully set. get_repo_clone_token (private-repo sandbox clone) will fail until the GitHub App is configured.");
+ console.log(`IP allowlist: ${IP_ALLOWLIST_ENABLED ? `ENABLED (${ALLOWED_IP_RANGES.join(", ")})` : "DISABLED"}`);
+ });
+}
+
+// Default export so Vercel's Node runtime can invoke this as a serverless
+// function handler (Express apps are callable as (req, res) => {}). Named
+// exports are kept for tests/other tooling that import { app, mcpServer }.
+export default app;
+export { app, mcpServer };
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| app | +
+
+ |
+ 73.1% | +87/119 | +66.33% | +67/101 | +66.66% | +8/12 | +80.95% | +85/105 | +
| app/connectors | +
+
+ |
+ 100% | +27/27 | +96.55% | +28/29 | +100% | +7/7 | +100% | +19/19 | +
| app/connectors/cloudflare | +
+
+ |
+ 17.05% | +37/217 | +0% | +0/210 | +15.38% | +8/52 | +19.68% | +37/188 | +
| app/connectors/context7 | +
+
+ |
+ 7.14% | +2/28 | +0% | +0/35 | +20% | +1/5 | +8% | +2/25 | +
| app/connectors/exa | +
+
+ |
+ 29.1% | +39/134 | +36.29% | +49/135 | +15.38% | +2/13 | +30.89% | +38/123 | +
| app/connectors/fetch | +
+
+ |
+ 96.84% | +92/95 | +92.47% | +86/93 | +100% | +13/13 | +97.4% | +75/77 | +
| app/connectors/frontend | +
+
+ |
+ 83.33% | +250/300 | +67.08% | +159/237 | +83.33% | +35/42 | +84.11% | +233/277 | +
| app/connectors/gemini | +
+
+ |
+ 18.73% | +104/555 | +11.83% | +67/566 | +9.92% | +13/131 | +20.13% | +92/457 | +
| app/connectors/github | +
+
+ |
+ 41.44% | +390/941 | +27.71% | +207/747 | +38.12% | +69/181 | +43.29% | +352/813 | +
| app/connectors/mem | +
+
+ |
+ 3.44% | +16/465 | +0% | +0/490 | +1.63% | +1/61 | +4.27% | +16/374 | +
| app/connectors/notion | +
+
+ |
+ 5.01% | +34/678 | +0% | +0/561 | +0.99% | +1/101 | +6.04% | +34/562 | +
| app/connectors/shared | +
+
+ |
+ 15.78% | +3/19 | +0% | +0/6 | +12.5% | +1/8 | +17.64% | +3/17 | +
| app/connectors/sync | +
+
+ |
+ 1.8% | +2/111 | +0% | +0/92 | +4.34% | +1/23 | +2.1% | +2/95 | +
This is a paragraph.
+ +