From b8087aa327e2af59dc10016c1e8851616659b97e Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:29:37 +0330 Subject: [PATCH 1/7] Add Jules connector: client --- connectors/jules/client.js | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 connectors/jules/client.js diff --git a/connectors/jules/client.js b/connectors/jules/client.js new file mode 100644 index 0000000..2f196c3 --- /dev/null +++ b/connectors/jules/client.js @@ -0,0 +1,40 @@ +// --------------------------------------------------------------------------- +// connectors/jules/client.js — Jules REST API (jules.googleapis.com) +// Docs: https://jules.google/docs/api/reference/ +// Auth header: "x-goog-api-key: " (required — no unauthenticated tier). +// Alpha API per Google's own docs: endpoint shapes may change without notice. +// --------------------------------------------------------------------------- + +import { JULES_API_KEY, JULES_API } from "../../config.js"; + +export async function julesRequest(path, { method = "GET", params = {}, body } = {}) { + if (!JULES_API_KEY) { + throw new Error("JULES_API_KEY is not set — Jules tools are unavailable until it's configured."); + } + + const url = new URL(`${JULES_API}${path}`); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, value); + } + } + + const headers = { "x-goog-api-key": JULES_API_KEY }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + + const res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + 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 || data.message || JSON.stringify(data))) || res.statusText; + throw new Error(`Jules API error (${res.status}): ${message}`); + } + return data; +} From 8ab613a62789c2813b369e44231a866ed22c3a2d Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:09 +0330 Subject: [PATCH 2/7] Add Jules connector: tools --- connectors/jules/tools.js | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 connectors/jules/tools.js diff --git a/connectors/jules/tools.js b/connectors/jules/tools.js new file mode 100644 index 0000000..93509f2 --- /dev/null +++ b/connectors/jules/tools.js @@ -0,0 +1,151 @@ +// --------------------------------------------------------------------------- +// connectors/jules/tools.js — delegate autonomous coding tasks to Jules +// (Google's async coding agent), fire-and-forget style: create a session +// against a connected GitHub repo, walk away, poll for status/output later. +// +// Distinct from delegate_agent/delegate_designer in this repo: those are +// synchronous, read-only (or frontend-fenced) loops that return one answer +// within a single tool call. A Jules session is asynchronous and can WRITE +// arbitrary code across a whole repo over several minutes in its own +// sandboxed VM, independent of this server's request lifecycle — you create +// it, then check back with jules_get_session / jules_get_activities. +// +// Deliberately NOT including plan-approval tooling (approvePlan/sendMessage) +// for this first pass — fire-and-forget implies automationMode: +// AUTO_CREATE_PR with plans auto-approved (the API's default), not a +// supervised back-and-forth. Add jules_approve_plan / jules_send_message +// later if a gated workflow is ever needed. +// --------------------------------------------------------------------------- + +import { z } from "zod"; +import { julesRequest } from "./client.js"; + +export function register(server) { + + server.tool( + "jules_list_sources", + "DOES: List the GitHub repositories connected to your Jules account (sources), each with its resource name (e.g. 'sources/github-owner-repo') needed for jules_create_session.\n" + + "RULE: call this first if you don't already know the exact source name for the repo you want to target.", + { + page_size: z.number().optional().describe("Max sources to return per page (default: server default)"), + page_token: z.string().optional().describe("Pagination token from a previous call's response, to fetch the next page"), + }, + async ({ page_size, page_token }) => { + const data = await julesRequest("/sources", { params: { pageSize: page_size, pageToken: page_token } }); + const sources = data?.sources || []; + if (!sources.length) { + return { content: [{ type: "text", text: "No sources connected to this Jules account." }] }; + } + const lines = sources.map((s) => { + const repo = s.githubRepo; + const repoDesc = repo ? `${repo.owner}/${repo.repo}${repo.isPrivate ? " (private)" : ""}${repo.defaultBranch?.displayName ? `, default branch: ${repo.defaultBranch.displayName}` : ""}` : "(non-GitHub source)"; + return `${s.name} — ${repoDesc}`; + }); + const more = data?.nextPageToken ? `\n\n(more available — next page_token: ${data.nextPageToken})` : ""; + return { content: [{ type: "text", text: lines.join("\n") + more }] }; + } + ); + + server.tool( + "jules_create_session", + "DOES: Create a Jules session — hand off a coding task (prompt) against a connected repo to run autonomously in Jules's own sandboxed VM. Fire-and-forget by default: automation_mode defaults to AUTO_CREATE_PR and plans auto-approve, so the session runs unattended and opens a PR when done, with no approval step required from this tool.\n" + + "RULE: need the source resource name first -> jules_list_sources, UNLESS you already know it (format: 'sources/github-owner-repo').\n" + + "RULE: this only STARTS the session — it does not wait for completion. Poll jules_get_session or jules_get_activities afterward to check progress and retrieve the resulting PR URL.", + { + source: z.string().describe("Resource name of the source repo, e.g. 'sources/github-owner-repo' (from jules_list_sources)"), + prompt: z.string().describe("The coding task for Jules to execute, described with enough detail to act on without further clarification (Jules cannot ask follow-up questions mid-session unless you send one via a later message)"), + title: z.string().optional().describe("Optional session title. If omitted, Jules generates one from the prompt."), + starting_branch: z.string().optional().describe("Branch to start the session from (default: the repo's default branch)"), + automation_mode: z.enum(["AUTO_CREATE_PR", "AUTOMATION_MODE_UNSPECIFIED"]).optional().describe("AUTO_CREATE_PR (default here) opens a PR automatically once code changes are ready — the fire-and-forget path. AUTOMATION_MODE_UNSPECIFIED leaves PR creation manual."), + require_plan_approval: z.boolean().optional().describe("If true, the session pauses in AWAITING_PLAN_APPROVAL until a plan is explicitly approved. Default false (plans auto-approve) — set true only for a supervised, non-fire-and-forget run."), + }, + async ({ source, prompt, title, starting_branch, automation_mode, require_plan_approval }) => { + const body = { + prompt, + sourceContext: { + source, + githubRepoContext: starting_branch ? { startingBranch: starting_branch } : undefined, + }, + automationMode: automation_mode || "AUTO_CREATE_PR", + }; + if (title) body.title = title; + if (require_plan_approval !== undefined) body.requirePlanApproval = require_plan_approval; + + const session = await julesRequest("/sessions", { method: "POST", body }); + const lines = [ + `Session created: ${session.name}`, + session.title ? `Title: ${session.title}` : null, + `State: ${session.state}`, + session.url ? `View in Jules: ${session.url}` : null, + `Check back with jules_get_session (session: "${session.name}") or jules_get_activities to track progress.`, + ].filter(Boolean); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "jules_list_sessions", + "DOES: List recent Jules sessions for the authenticated account, with state (e.g. RUNNING, AWAITING_PLAN_APPROVAL, COMPLETED, FAILED) and, for finished sessions, output PR URLs.\n" + + "RULE: 'has anything Jules is working on finished' / 'what's Jules doing' -> this, instead of guessing from a single session ID.", + { + page_size: z.number().optional().describe("Max sessions to return per page (default: server default)"), + page_token: z.string().optional().describe("Pagination token from a previous call's response"), + }, + async ({ page_size, page_token }) => { + const data = await julesRequest("/sessions", { params: { pageSize: page_size, pageToken: page_token } }); + const sessions = data?.sessions || []; + if (!sessions.length) { + return { content: [{ type: "text", text: "No Jules sessions found." }] }; + } + const lines = sessions.map((s) => { + const prs = (s.outputs || []).map((o) => o.pullRequest?.url).filter(Boolean); + return `${s.name} — "${s.title || s.prompt}" — ${s.state}${prs.length ? ` — PR: ${prs.join(", ")}` : ""}`; + }); + const more = data?.nextPageToken ? `\n\n(more available — next page_token: ${data.nextPageToken})` : ""; + return { content: [{ type: "text", text: lines.join("\n") + more }] }; + } + ); + + server.tool( + "jules_get_session", + "DOES: Get full details of one Jules session by resource name — state, the original prompt, session URL, and (once available) outputs such as the created pull request's URL.\n" + + "RULE: checking whether a specific fire-and-forget session has finished -> this, rather than jules_list_sessions, once you have its name.", + { + session: z.string().describe("Resource name of the session, e.g. 'sessions/1234567' (returned by jules_create_session or jules_list_sessions)"), + }, + async ({ session }) => { + const name = session.startsWith("sessions/") ? session : `sessions/${session}`; + const data = await julesRequest(`/${name}`); + const prs = (data.outputs || []).map((o) => o.pullRequest?.url).filter(Boolean); + const lines = [ + `${data.name} — "${data.title || data.prompt}"`, + `State: ${data.state}`, + data.url ? `View in Jules: ${data.url}` : null, + prs.length ? `Pull request(s): ${prs.join(", ")}` : null, + ].filter(Boolean); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.tool( + "jules_get_activities", + "DOES: List the activity timeline for a Jules session — plan generation, progress updates, messages, and completion/failure events — in chronological order.\n" + + "RULE: want to see WHAT Jules actually did (not just its current state) -> this, in addition to jules_get_session.", + { + session: z.string().describe("Resource name of the session, e.g. 'sessions/1234567'"), + page_size: z.number().optional().describe("Max activities to return per page (default: server default)"), + page_token: z.string().optional().describe("Pagination token from a previous call's response"), + }, + async ({ session, page_size, page_token }) => { + const name = session.startsWith("sessions/") ? session : `sessions/${session}`; + const data = await julesRequest(`/${name}/activities`, { params: { pageSize: page_size, pageToken: page_token } }); + const activities = data?.activities || []; + if (!activities.length) { + return { content: [{ type: "text", text: "No activities recorded yet for this session." }] }; + } + const lines = activities.map((a) => `[${a.createTime}] ${a.originator}: ${a.description || Object.keys(a).find((k) => k.endsWith("Generated") || k.endsWith("Update") || k.endsWith("Message")) || "(event)"}`); + const more = data?.nextPageToken ? `\n\n(more available — next page_token: ${data.nextPageToken})` : ""; + return { content: [{ type: "text", text: lines.join("\n") + more }] }; + } + ); +} From 323e15ea8c4316323ea98e74a5d01a0f2e777d1d Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:17 +0330 Subject: [PATCH 3/7] Add JULES_API_KEY/JULES_API config --- config.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config.js b/config.js index ba20f9c..d563d25 100644 --- a/config.js +++ b/config.js @@ -261,6 +261,13 @@ export const GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY; // getting cut off mid-transfer. export const GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS = Number(process.env.GITHUB_APP_TOKEN_REVOKE_GRACE_SECONDS) || 30; +// Jules (Google's async coding agent) REST API. Alpha API -- no +// unauthenticated tier, unlike Context7 above; JULES_API_KEY is required +// for any jules_* tool to work. Get a key from the Jules web app's Settings +// page (https://jules.google.com/settings#api), max 3 keys per account. +export const JULES_API_KEY = process.env.JULES_API_KEY; +export const JULES_API = "https://jules.googleapis.com/v1alpha"; + export const MCP_SHARED_KEY = process.env.MCP_SHARED_KEY; // IP allowlist for /mcp, /mcp/:key, and /. Restricts inbound requests to From 497445fdf574110ecd6e35e2c49be6b5d06f4cbd Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:29 +0330 Subject: [PATCH 4/7] Register Jules connector in server.js --- server.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 6789b1d..2462cce 100644 --- a/server.js +++ b/server.js @@ -9,7 +9,7 @@ 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 { GITHUB_TOKEN, NOTION_TOKEN, MEM0_API_KEY, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CONTEXT7_API_KEY, GEMINI_API_KEY, JULES_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"; @@ -22,6 +22,7 @@ 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"; +import * as jules from "./connectors/jules/tools.js"; // Build the MCP server once at startup and reuse it across all requests. const mcpServer = new McpServer({ @@ -40,6 +41,7 @@ agent.register(mcpServer); research.register(mcpServer); frontend.register(mcpServer); sync.register(mcpServer); +jules.register(mcpServer); // Adding a new connector: // import * as myThing from "./connectors/myThing/tools.js"; @@ -130,6 +132,7 @@ app.get("/", requireMcpKey, requireAllowedIp, (_req, res) => { 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 + jules: Boolean(JULES_API_KEY), auth: Boolean(MCP_SHARED_KEY), }, }); @@ -167,6 +170,7 @@ if (process.env.NODE_ENV !== "test" && !process.env.VERCEL) { 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 (!JULES_API_KEY) console.warn("WARNING: JULES_API_KEY is not set. jules_* tools will fail."); 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"}`); From c810d1846be07bcbd43c8b2673a2ad70b770405b Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:41 +0330 Subject: [PATCH 5/7] Add Jules API key section to docs --- docs/API_KEYS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API_KEYS.md b/docs/API_KEYS.md index a7ce6a6..1f2484c 100644 --- a/docs/API_KEYS.md +++ b/docs/API_KEYS.md @@ -35,6 +35,10 @@ None of these providers support auto-injecting the key back into our env — you ### Context7 — `CONTEXT7_API_KEY` (optional) Works unauthenticated at low rate limits — only provision this if you're hitting limits. +### Jules — `JULES_API_KEY` +[![Create Jules API Key](https://img.shields.io/badge/Create-Jules_API_Key-4285F4?style=for-the-badge&logo=googlegemini)](https://jules.google.com/settings#api) +> Unlike Context7, there's no unauthenticated tier — required for any `jules_*` tool to work. Max 3 keys per account. Alpha API per Google's own docs, so endpoint shapes may change without notice. + ### Upstash Redis / Vercel KV — `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` (or `KV_REST_API_URL` + `KV_REST_API_TOKEN`) (optional) [![Create Upstash Redis](https://img.shields.io/badge/Create-Upstash_Redis-00E9A3?style=for-the-badge)](https://console.upstash.com/redis) > Optional — persists Gemini's per-model rate-limit cooldowns and `delegate_agent` resume checkpoints across calls. Fails open (no cross-call memory, but nothing breaks) if unset. On Vercel, easier to add via **Storage → Create Database → Upstash for Redis** (Marketplace integration) instead of the link above — either path works, just note which var names your integration hands you (the two naming pairs above are interchangeable, `connectors/gemini/cooldown.js` accepts either). From 6e2bc053570594c5cf2ac1ef1b4fea4ab7776066 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:30:55 +0330 Subject: [PATCH 6/7] Document Jules connector in README --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad87851..3db5468 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ # 🔌 madmcp -**An MCP server built around Gemini-powered delegation — hand Claude an open-ended, multi-step investigation instead of chaining 5-10 manual tool calls — plus direct tool access to GitHub, Cloudflare, Notion, Mem0, Context7, and the web. Reflexive by construction: a connected agent has write access to this very repo, so it can read its own source, diagnose a gap, and patch it through the same connection.** +**An MCP server built around Gemini-powered delegation — hand Claude an open-ended, multi-step investigation instead of chaining 5-10 manual tool calls — plus direct tool access to GitHub, Cloudflare, Notion, Mem0, Context7, Jules, and the web. Reflexive by construction: a connected agent has write access to this very repo, so it can read its own source, diagnose a gap, and patch it through the same connection.** [![CI](https://github.com/allocsys/madmcp/actions/workflows/ci.yml/badge.svg)](https://github.com/allocsys/madmcp/actions/workflows/ci.yml) [![Protocol](https://img.shields.io/badge/protocol-MCP-E8A33D?style=flat-square)](https://modelcontextprotocol.io) [![Node](https://img.shields.io/badge/node-%E2%89%A518-6FBF8B?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org) -[![Connectors](https://img.shields.io/badge/connectors-GitHub%20%C2%B7%20Cloudflare%20%C2%B7%20Notion%20%C2%B7%20Mem0%20%C2%B7%20Context7%20%C2%B7%20Gemini%20%C2%B7%20Fetch-7CA6D6?style=flat-square)](#connectors--tools) +[![Connectors](https://img.shields.io/badge/connectors-GitHub%20%C2%B7%20Cloudflare%20%C2%B7%20Notion%20%C2%B7%20Mem0%20%C2%B7%20Context7%20%C2%B7%20Gemini%20%C2%B7%20Jules%20%C2%B7%20Fetch-7CA6D6?style=flat-square)](#connectors--tools) [![License](https://img.shields.io/badge/license-AGPL--3.0%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) @@ -260,6 +260,9 @@ limits; `CONTEXT7_API_KEY` is optional. Notion page per Mem0 memory, archives pages for superseded or hard-deleted memories, and leaves any manual edits on those pages untouched. +### Jules +`jules_list_sources`, `jules_create_session`, `jules_list_sessions`, `jules_get_session`, `jules_get_activities` — hand off a coding task to Google's Jules agent, fire-and-forget: it works in its own sandboxed VM against a connected GitHub repo and (by default, via `automation_mode: AUTO_CREATE_PR`) opens a PR when done, with plans auto-approved. Distinct from `delegate_agent`/`delegate_designer` in this repo: those are synchronous read-only (or frontend-fenced) loops returning one answer per call; a Jules session is asynchronous and can write arbitrary code across a whole repo over several minutes, independent of this server's request lifecycle — create it, then poll `jules_get_session`/`jules_get_activities` later. Alpha API (Google's own designation), no unauthenticated tier. + ### Fetch `web_fetch` — fetch a public URL and return text/JSON/stripped HTML @@ -275,6 +278,7 @@ All tokens are optional independently — a connector's tools fail at call time | `MEM0_API_KEY` | Mem0 tools (`MEM0_USER_ID` optional, defaults to `default`) | | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare tools | | `CONTEXT7_API_KEY` | Context7 tools (optional — works unauthenticated at low rate limits) | +| `JULES_API_KEY` | Jules tools (`jules_*`) — required, no unauthenticated tier | | `GEMINI_API_KEY` | Gemini tools (`delegate_agent`, `delegate_research`) — required, throws if unset | | `GEMINI_MODEL` | Primary Gemini model for delegation (default `gemini-flash-latest`) | | `GEMINI_FALLBACK_MODELS` | Comma-separated fallback model list used on 429s (default `gemini-3.5-flash-lite,gemini-3.1-flash-lite`) | From 475ee3a1540e75c350127cdaba771531953f0672 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:31:15 +0330 Subject: [PATCH 7/7] Add Jules connector unit tests --- test/jules-client.test.js | 139 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/jules-client.test.js diff --git a/test/jules-client.test.js b/test/jules-client.test.js new file mode 100644 index 0000000..b9beea2 --- /dev/null +++ b/test/jules-client.test.js @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// config.js reads JULES_API_KEY at import time via process.env, so set it +// before importing the client. +process.env.JULES_API_KEY = "test-jules-key"; + +const { julesRequest } = await import("../connectors/jules/client.js"); +const { register } = await import("../connectors/jules/tools.js"); + +function makeFakeServer() { + const tools = {}; + return { + tool: (name, _description, _schema, handler) => { + tools[name] = handler; + }, + tools, + }; +} + +describe("Jules Connector - client", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends the x-goog-api-key header and no body on GET", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ sources: [] }), + }); + + await julesRequest("/sources"); + + expect(fetch).toHaveBeenCalledTimes(1); + const [url, opts] = fetch.mock.calls[0]; + expect(url.toString()).toBe("https://jules.googleapis.com/v1alpha/sources"); + expect(opts.headers["x-goog-api-key"]).toBe("test-jules-key"); + expect(opts.body).toBeUndefined(); + }); + + it("sends a JSON body and Content-Type on POST", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ name: "sessions/1" }), + }); + + await julesRequest("/sessions", { method: "POST", body: { prompt: "do the thing" } }); + + const [, opts] = fetch.mock.calls[0]; + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(opts.body)).toEqual({ prompt: "do the thing" }); + }); + + it("appends query params, skipping undefined/null/empty", async () => { + fetch.mockResolvedValueOnce({ ok: true, status: 200, text: async () => "{}" }); + + await julesRequest("/sessions", { params: { pageSize: 5, pageToken: undefined, foo: "" } }); + + const [url] = fetch.mock.calls[0]; + expect(url.searchParams.get("pageSize")).toBe("5"); + expect(url.searchParams.has("pageToken")).toBe(false); + expect(url.searchParams.has("foo")).toBe(false); + }); + + it("throws a descriptive error on non-ok response", async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: async () => JSON.stringify({ error: { message: "session not found" } }), + }); + + await expect(julesRequest("/sessions/nope")).rejects.toThrow("Jules API error (404): session not found"); + }); +}); + +describe("Jules Connector - tools", () => { + let server; + + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + server = makeFakeServer(); + register(server); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("jules_create_session defaults to AUTO_CREATE_PR", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ name: "sessions/42", state: "RUNNING", url: "https://jules.google.com/session/42" }), + }); + + const result = await server.tools.jules_create_session({ + source: "sources/github-owner-repo", + prompt: "Add rate limiting", + }); + + const [, opts] = fetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.automationMode).toBe("AUTO_CREATE_PR"); + expect(result.content[0].text).toContain("sessions/42"); + expect(result.content[0].text).toContain("RUNNING"); + }); + + it("jules_get_session normalizes a bare session id and surfaces PR output", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + name: "sessions/42", + title: "Add rate limiting", + state: "COMPLETED", + outputs: [{ pullRequest: { url: "https://github.com/owner/repo/pull/9" } }], + }), + }); + + const result = await server.tools.jules_get_session({ session: "42" }); + + const [url] = fetch.mock.calls[0]; + expect(url.toString()).toBe("https://jules.googleapis.com/v1alpha/sessions/42"); + expect(result.content[0].text).toContain("COMPLETED"); + expect(result.content[0].text).toContain("https://github.com/owner/repo/pull/9"); + }); + + it("jules_list_sources reports an empty account clearly", async () => { + fetch.mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify({ sources: [] }) }); + + const result = await server.tools.jules_list_sources({}); + expect(result.content[0].text).toContain("No sources connected"); + }); +});