Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
91d4b17
Rename legacy manufact branding to madmcp (service name, JSON info re…
allocsys Jul 25, 2026
629b0df
Update version-sync test's startup-log regex to match the renamed mad…
allocsys Jul 25, 2026
7242abb
Fix stale assertion message text left over from the manufact rename
allocsys Jul 25, 2026
f9b440e
Rename package name from manufact to madmcp
allocsys Jul 25, 2026
3235413
Sync package-lock.json's name/version to match package.json (manufact…
allocsys Jul 25, 2026
ac54dc4
Rename render.yaml service name from manufact-mcp-server to madmcp-se…
allocsys Jul 25, 2026
8eb4fa8
Rename fetch client User-Agent from manufact-mcp-server to madmcp-server
allocsys Jul 25, 2026
701f34b
Rename User-Agent strings and error message from manufact to madmcp i…
allocsys Jul 25, 2026
ae253d6
Rename User-Agent and error messages from manufact to madmcp in Cloud…
allocsys Jul 25, 2026
6598930
Rename error message from Manufact to madmcp in Notion client
allocsys Jul 25, 2026
9610e74
Rename error message from Manufact to madmcp in Mem0 client
allocsys Jul 25, 2026
2a08260
Rename error message from Manufact to madmcp in Gemini client
allocsys Jul 25, 2026
deaa5a9
Rename query-id prefix from manufact- to madmcp- in observability tel…
allocsys Jul 25, 2026
d90f88b
Rename legacy plan/diagnostic references from manufact to madmcp in m…
allocsys Jul 25, 2026
987bbb5
Catch remaining manufact references missed in first pass: two REGRESS…
allocsys Jul 25, 2026
6f1bd4a
Update Vercel deploy button's suggested project-name/repository-name …
allocsys Jul 25, 2026
16ead9c
Fix delegate_gemini schema/docs contradiction: make task optional (it…
allocsys Jul 25, 2026
f1a2adc
runInvestigation: track and persist the effective task text (so tools…
allocsys Jul 25, 2026
5e2c5e4
Recreate Generate lockfile workflow, PR-based this time.
allocsys Jul 26, 2026
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
51 changes: 51 additions & 0 deletions .github/workflows/generate-lockfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Generate lockfile

# Manual, on-demand regeneration of package-lock.json.
#
# History: an earlier version of this workflow (removed in PR #10) pushed
# the regenerated lockfile straight to main using the built-in GITHUB_TOKEN.
# That's broken by design: GITHUB_TOKEN-authored pushes do NOT trigger other
# workflow runs (a deliberate GitHub Actions anti-recursion safeguard), so
# the branch ruleset's required "test" check never ran on those commits --
# meaning the bot's own pushes were likely to get blocked by the very
# ruleset meant to protect main. This version avoids that by opening a PR
# instead of pushing directly: a PR created by GITHUB_TOKEN DOES trigger the
# normal push/pull_request CI workflow on its branch, so the required check
# runs like it would for any human-authored change, and the PR merges (or
# not) through the normal ruleset-gated path.
on:
workflow_dispatch: {}

permissions:
contents: write
pull-requests: write

jobs:
generate-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Regenerate package-lock.json
run: |
set -e
rm -f package-lock.json
npm install

- name: Open PR with the regenerated lockfile
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: regenerate package-lock.json"
branch: chore/regenerate-lockfile
delete-branch: true
title: "chore: regenerate package-lock.json"
body: |
Automated lockfile regeneration, triggered manually via the "Generate lockfile" workflow.

Opened as a PR (not pushed directly to main) so the required CI check runs on these commits before they merge -- see the workflow file's header comment for why the old push-to-main version couldn't do that.
labels: dependencies
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ yet, open `demo.html` directly.)
## Deploy & connect (quickstart)

[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/allocsys/madmcp)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/allocsys/madmcp&env=MCP_SHARED_KEY,GITHUB_TOKEN,NOTION_TOKEN,MEM0_API_KEY,CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID&envDescription=Generate+a+long+random+string+yourself+for+MCP_SHARED_KEY+(Vercel+can%27t+auto-generate+it).+Leave+any+connector+token+blank+to+skip+it.&envLink=https://github.com/allocsys/madmcp%23configuration&project-name=manufact-mcp-server&repository-name=manufact-mcp-server)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/allocsys/madmcp&env=MCP_SHARED_KEY,GITHUB_TOKEN,NOTION_TOKEN,MEM0_API_KEY,CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID&envDescription=Generate+a+long+random+string+yourself+for+MCP_SHARED_KEY+(Vercel+can%27t+auto-generate+it).+Leave+any+connector+token+blank+to+skip+it.&envLink=https://github.com/allocsys/madmcp%23configuration&project-name=madmcp-server&repository-name=madmcp-server)

Get tokens, deploy, connect to Claude — in that order.

Expand Down
6 changes: 3 additions & 3 deletions connectors/cloudflare/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API } from "../
function assertConfigured() {
if (!CLOUDFLARE_API_TOKEN) {
throw new Error(
"CLOUDFLARE_API_TOKEN is not set. Add it as an environment variable on the Manufact server."
"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 Manufact server."
"CLOUDFLARE_ACCOUNT_ID is not set. Add it as an environment variable on the madmcp server."
);
}
}
Expand All @@ -29,7 +29,7 @@ export async function cfRequest(path, { method = "GET", body, accept } = {}) {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
Accept: accept || "application/json",
"Content-Type": "application/json",
"User-Agent": "manufact-mcp-server",
"User-Agent": "madmcp-server",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
Expand Down
2 changes: 1 addition & 1 deletion connectors/cloudflare/observability.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export async function queryTelemetry({
const allFilters = rawFilters.map(normalizeFilter);

const body = {
queryId: query_id || `manufact-${Date.now()}`,
queryId: query_id || `madmcp-${Date.now()}`,
view,
datasets: [dataset],
timeframe: { from: toEpochMillis(timeframe_from), to: toEpochMillis(timeframe_to) },
Expand Down
2 changes: 1 addition & 1 deletion connectors/fetch/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export async function fetchUrl(url, { method = "GET", headers = {}, body } = {})
const res = await fetch(parsed, {
method,
headers: {
"User-Agent": "manufact-mcp-server/2.0",
"User-Agent": "madmcp-server/2.0",
...headers,
},
body: body === undefined ? undefined : (typeof body === "string" ? body : JSON.stringify(body)),
Expand Down
2 changes: 1 addition & 1 deletion connectors/gemini/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { GEMINI_API_KEY, GEMINI_API, GEMINI_MODEL, GEMINI_FALLBACK_MODELS } from
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 Manufact server.");
if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set. Add it as an environment variable on the madmcp server.");

const res = await fetch(`${GEMINI_API}/models/${model}:generateContent`, {
method: "POST",
Expand Down
45 changes: 38 additions & 7 deletions connectors/gemini/delegate.js
Original file line number Diff line number Diff line change
Expand Up @@ -797,16 +797,29 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) {
let contents;
let transcript;
let startStep = 1;
// 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;

const checkpoint = resume_run_id ? await loadCheckpoint(resume_run_id) : null;
if (checkpoint) {
contents = checkpoint.contents;
transcript = checkpoint.transcript;
startStep = checkpoint.stepsDone + 1;
// 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 {
// Either no resume_run_id was given, or the checkpoint had already
// expired/wasn't found -- start a fresh run either way rather than
// erroring, since `task` is always available to build one.
// expired/wasn't found -- 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 = [];
Expand All @@ -822,12 +835,13 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) {
// 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.
await saveCheckpoint(runId, { contents, transcript, stepsDone: step - 1 });
await saveCheckpoint(runId, { contents, transcript, stepsDone: step - 1, task: effectiveTask });
return {
answer: `(Gemini call failed on step ${step}: ${err.message} -- ${transcript.length} tool call(s) already completed this run are saved. Call gemini_investigate 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,
};
}
Expand All @@ -839,9 +853,9 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) {
const answer = parts.map((p) => p.text || "").join("").trim();
await deleteCheckpoint(runId);
if (!answer) {
return { answer: `(Gemini stopped without a final answer -- finishReason: ${candidate.finishReason || "unknown"})`, steps: step, transcript, runId };
return { answer: `(Gemini stopped without a final answer -- finishReason: ${candidate.finishReason || "unknown"})`, steps: step, transcript, runId, task: effectiveTask };
}
return { answer, steps: step, transcript, runId };
return { answer, steps: step, transcript, runId, task: effectiveTask };
}

// Record the model's turn (including its functionCall parts) before
Expand Down Expand Up @@ -870,13 +884,30 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) {
// original functionCall.id so the API can thread multi-call turns correctly.
responseParts.push({ functionResponse: { name, id, response: { result: resultText } } });
}
// 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 <= 1) {
responseParts.push({
text: `[SYSTEM NOTE: only ${remainingAfterThisStep} step(s) remain before this investigation is forced to stop. 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.]`,
});
}

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.
await saveCheckpoint(runId, { contents, transcript, stepsDone: step });
await saveCheckpoint(runId, { contents, transcript, stepsDone: step, task: effectiveTask });
}

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 };
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 };
}
25 changes: 22 additions & 3 deletions connectors/gemini/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,27 +97,46 @@ export function register(server) {
"delegate_gemini",
"Delegate an open-ended, multi-step READ-ONLY investigation to Gemini instead of doing it yourself one tool call at a time. Gemini runs its own loop server-side -- reading GitHub files/trees/commits, Cloudflare Workers logs, and Notion pages/databases across as many turns as it needs (bounded by max_steps) -- and returns one synthesized answer. Use this for things like \"why is CI failing on PR #42\" or \"summarize what changed in this repo over the last week\" where you'd otherwise need 5-10 separate manual tool calls. Not for anything requiring a write -- this tool is read-only by design. If a run fails partway through (e.g. Gemini rate-limited), the response includes a resume_run_id -- pass it back on a follow-up call to continue from the last completed step instead of starting over. (Formerly named gemini_investigate.)",
{
task: z.string().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)."),
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 6, 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."),
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 gemini_investigate call. If its checkpoint is still live (1 hour TTL), continues that run's conversation instead of starting fresh."),
},
async ({ task, max_steps = 6, log_to_notion = false, resume_run_id }) => {
// 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,
};
}

let result;
try {
result = await runInvestigation({ task, max_steps, resume_run_id });
} catch (err) {
return { content: [{ type: "text", text: `Investigation failed: ${err.message}` }], 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: "}${task.slice(0, 80)}`,
content: `Task: ${task}\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}`,
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})`;
Expand Down
6 changes: 3 additions & 3 deletions connectors/github/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
function assertConfigured() {
if (!GITHUB_TOKEN) {
throw new Error(
"GITHUB_TOKEN is not set. Add it as an environment variable on the Manufact server."
"GITHUB_TOKEN is not set. Add it as an environment variable on the madmcp server."
);
}
}
Expand Down Expand Up @@ -87,7 +87,7 @@ async function doFetch(path, { method, body, accept }) {
Accept: accept || "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
"User-Agent": "manufact-mcp-server",
"User-Agent": "madmcp-server",
},
body: body ? JSON.stringify(body) : undefined,
});
Expand Down Expand Up @@ -135,7 +135,7 @@ export async function githubGraphQL(query, variables = {}) {
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
"Content-Type": "application/json",
"User-Agent": "manufact-mcp-server",
"User-Agent": "madmcp-server",
},
body: JSON.stringify({ query, variables }),
});
Expand Down
2 changes: 1 addition & 1 deletion connectors/mem/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { MEM0_API_KEY, MEM0_API } from "../../config.js";

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 Manufact server.");
if (!MEM0_API_KEY) throw new Error("MEM0_API_KEY is not set. Add it as an environment variable on the madmcp server.");
const res = await fetch(`${MEM0_API}${path}`, {
method,
headers: {
Expand Down
Loading
Loading