Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<a href="https://allocsys.github.io/madmcp/demo.html">
Expand Down Expand Up @@ -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

Expand All @@ -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`) |
Expand Down
7 changes: 7 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions connectors/jules/client.js
Original file line number Diff line number Diff line change
@@ -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: <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;
}
151 changes: 151 additions & 0 deletions connectors/jules/tools.js
Original file line number Diff line number Diff line change
@@ -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 }] };
}
);
}
4 changes: 4 additions & 0 deletions docs/API_KEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading