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
25 changes: 23 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ jobs:
(github.event_name == 'workflow_dispatch' && github.event.inputs.package == 'mcp')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
contents: write # gh release create/upload for the .dxt bundle
id-token: write # npm provenance
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -117,6 +117,27 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Build .dxt bundle
if: github.event_name == 'push'
run: pnpm --filter @leadbay/dxt build

- name: Upload .dxt to GitHub Release
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
# gh release upload needs a release to exist. Create it if the tag
# wasn't already promoted to a release (auto-tag.yml only pushes tags).
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" \
--title "$TAG" \
--notes "Automated release for $TAG — see CHANGELOG.md"
fi
gh release upload "$TAG" packages/dxt/dist/*.dxt --clobber
echo "Uploaded .dxt to release $TAG"

publish-leadclaw:
name: Publish @leadbay/leadclaw to npm + ClawHub
needs: preflight-npm
Expand Down
37 changes: 31 additions & 6 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ export function createClient(config: CreateClientConfig = {}): LeadbayClient {
return new LeadbayClient(baseUrl, config.token, region);
}

// Human-readable login error. Backends sometimes return 401 with an empty body;
// naive `${baseUrl}: ${body}` leaves a dangling colon. Attach a status-specific
// hint so non-technical users see "wrong email or password?" instead of a bare
// status code.
export function formatLoginError(
status: number,
body: string,
baseUrl: string
): string {
const trimmed = body.trim();
const head = `login failed (${status}) at ${baseUrl}`;
const hint =
status === 401 ? " (wrong email or password?)"
: status === 429 ? " (rate-limited; wait and retry)"
: status >= 500 ? " (server error; try again shortly)"
: "";
if (!trimmed) return head + hint;
return `${head}: ${trimmed.slice(0, 200)}${hint}`;
}

// Probe both regions to find which one this email/password works on.
// Returns the region (us|fr) and bearer token. Throws if neither succeeds.
export async function resolveRegion(
Expand All @@ -103,7 +123,9 @@ export async function resolveRegion(
const order: Array<"us" | "fr"> =
startWith === "fr" ? ["fr", "us"] : ["us", "fr"];

let lastErr: any = null;
let lastErr: { kind: "http"; status: number; body: string; region: "us" | "fr"; baseUrl: string } |
{ kind: "network"; error: unknown; region: "us" | "fr"; baseUrl: string } |
null = null;
for (const region of order) {
const baseUrl = REGIONS[region];
const body = JSON.stringify({ email, password });
Expand All @@ -125,16 +147,19 @@ export async function resolveRegion(
};
}
}
lastErr = { status: res.status, body: res.body, region };
lastErr = { kind: "http", status: res.status, body: res.body, region, baseUrl };
} catch (e) {
lastErr = { error: e, region };
lastErr = { kind: "network", error: e, region, baseUrl };
}
}

const detail = lastErr?.kind === "http"
? formatLoginError(lastErr.status, lastErr.body, lastErr.baseUrl)
: lastErr?.kind === "network"
? `network error at ${lastErr.baseUrl}: ${(lastErr.error as Error)?.message ?? String(lastErr.error)}`
: "no attempts made";
throw new Error(
`Leadbay login failed in both regions (us, fr). Last response: ${JSON.stringify(
lastErr
)}`
`Leadbay login failed in both regions (us, fr). ${detail}`
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
LeadbayClient,
createClient,
resolveRegion,
formatLoginError,
getMockJournal,
clearMockJournal,
REGIONS,
Expand Down
73 changes: 73 additions & 0 deletions packages/core/test/unit/login-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Unit tests for formatLoginError.
*
* Regression guard for #3504: when the backend returns 401 with an empty body,
* the previous message ended with a dangling colon ("login failed (401) at
* https://api-fr.leadbay.app:"). This formatter has to stay user-legible for
* the main failure modes: bad credentials, rate-limiting, server error.
*/
import { describe, it, expect } from "vitest";
import { formatLoginError } from "../../src/client.js";

const URL_US = "https://api-us.leadbay.app";
const URL_FR = "https://api-fr.leadbay.app";

describe("formatLoginError", () => {
it("401 with empty body → suggests wrong credentials, no dangling colon", () => {
const msg = formatLoginError(401, "", URL_FR);
expect(msg).toBe(
"login failed (401) at https://api-fr.leadbay.app (wrong email or password?)"
);
expect(msg).not.toMatch(/:\s*$/);
expect(msg).not.toMatch(/:\s*\(/); // no " : (" sequence
});

it("401 with body → includes body AND the credentials hint", () => {
const msg = formatLoginError(401, '{"error":"invalid_credentials"}', URL_FR);
expect(msg).toContain("login failed (401) at https://api-fr.leadbay.app");
expect(msg).toContain('{"error":"invalid_credentials"}');
expect(msg).toContain("(wrong email or password?)");
});

it("429 with empty body → rate-limit hint", () => {
const msg = formatLoginError(429, "", URL_US);
expect(msg).toBe(
"login failed (429) at https://api-us.leadbay.app (rate-limited; wait and retry)"
);
});

it("500 with body → server error hint", () => {
const msg = formatLoginError(500, "internal server error", URL_US);
expect(msg).toContain("login failed (500)");
expect(msg).toContain("internal server error");
expect(msg).toContain("(server error; try again shortly)");
});

it("502 with empty body → server error hint, no dangling colon", () => {
const msg = formatLoginError(502, "", URL_FR);
expect(msg).toBe(
"login failed (502) at https://api-fr.leadbay.app (server error; try again shortly)"
);
});

it("403 (uncommon) with empty body → no hint, but also no dangling colon", () => {
const msg = formatLoginError(403, "", URL_US);
expect(msg).toBe("login failed (403) at https://api-us.leadbay.app");
});

it("very long body is truncated at 200 chars", () => {
const long = "x".repeat(500);
const msg = formatLoginError(401, long, URL_US);
// 200 chars of body + separator/hint framing.
expect(msg).toContain("x".repeat(200));
expect(msg).not.toContain("x".repeat(201));
expect(msg).toContain("(wrong email or password?)");
});

it("body with surrounding whitespace is treated as empty", () => {
const msg = formatLoginError(401, " \n\t ", URL_FR);
expect(msg).toBe(
"login failed (401) at https://api-fr.leadbay.app (wrong email or password?)"
);
});
});
71 changes: 71 additions & 0 deletions packages/dxt/manifest.template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"dxt_version": "0.2",
"name": "leadbay",
"display_name": "Leadbay",
"version": "{{VERSION}}",
"description": "AI lead discovery, qualification, and outreach prep. Ask Claude to find, research, and prepare outreach on B2B prospects using your Leadbay account.",
"long_description": "Leadbay MCP surfaces your Leadbay inbox to Claude — fresh daily batches of AI-qualified leads, deep research on any company or contact, outreach-ready prep (email + LinkedIn), and outreach logging. Requires a Leadbay account (https://leadbay.ai).",
"icon": "icon.png",
"author": {
"name": "Leadbay",
"email": "support@leadbay.ai",
"url": "https://leadbay.ai"
},
"homepage": "https://github.com/leadbay/leadclaw",
"documentation": "https://github.com/leadbay/leadclaw#readme",
"support": "https://github.com/leadbay/leadclaw/issues",
"repository": {
"type": "git",
"url": "https://github.com/leadbay/leadclaw"
},
"license": "MIT",
"keywords": [
"leadbay",
"mcp",
"b2b",
"lead-generation",
"sales"
],
"server": {
"type": "node",
"entry_point": "server/index.js",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/server/index.js"],
"env": {
"LEADBAY_TOKEN": "${user_config.leadbay_token}",
"LEADBAY_REGION": "${user_config.leadbay_region}",
"LEADBAY_MCP_WRITE": "${user_config.leadbay_mcp_write}"
}
}
},
"user_config": {
"leadbay_token": {
"type": "string",
"title": "Leadbay bearer token",
"description": "Mint one with: npx -y @leadbay/mcp login --email <you> --region <us|fr> --write-config ~/leadbay.json (then copy the token from that file)",
"sensitive": true,
"required": true
},
"leadbay_region": {
"type": "string",
"title": "Region",
"description": "Leadbay backend for your account (us or fr). If you don't know, try fr first — EU accounts are the default.",
"enum": ["us", "fr"],
"default": "fr",
"required": true
},
"leadbay_mcp_write": {
"type": "boolean",
"title": "Enable write tools",
"description": "Let Claude mutate your Leadbay account (refine_prompt, adjust_audience, report_outreach). Off by default — enable only when you want the agent to modify state.",
"default": false,
"required": false
}
},
"compatibility": {
"runtimes": {
"node": ">=22"
}
}
}
17 changes: 17 additions & 0 deletions packages/dxt/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@leadbay/dxt",
"version": "0.2.4",
"private": true,
"type": "module",
"description": "Builds the Leadbay .dxt bundle (Claude Desktop 2026 extension format) from @leadbay/mcp.",
"scripts": {
"build": "node scripts/build.mjs",
"typecheck": "node -e \"1\"",
"test": "node -e \"1\""
},
"devDependencies": {
"@leadbay/mcp": "workspace:*",
"archiver": "^7.0.1",
"esbuild": "^0.25.0"
}
}
101 changes: 101 additions & 0 deletions packages/dxt/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env node
// Build the Leadbay .dxt bundle.
//
// Output: packages/dxt/dist/leadbay-<mcpVersion>.dxt
//
// The .dxt is a zip with:
// - manifest.json (from manifest.template.json, version substituted)
// - server/index.js (esbuild bundle of packages/mcp/src/bin.ts,
// plus a "stdio-entry.mjs"-style wrapper that
// always runs the MCP server, never the CLI)
// - icon.png (from packages/leadclaw/logo.png)
// - README.md (from packages/mcp/README.md)
//
// Why bundle bin.ts? Its `isEntrypoint` guard already handles the dual CLI /
// server mode. When DXT invokes `node server/index.js`, process.argv[1] is
// server/index.js and no subcommand is passed → the server starts.
import { mkdirSync, writeFileSync, readFileSync, rmSync, createWriteStream, existsSync, copyFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import archiver from "archiver";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DXT_DIR = dirname(__dirname); // packages/dxt
const REPO_ROOT = dirname(dirname(DXT_DIR)); // <repo root>
const MCP_DIR = join(REPO_ROOT, "packages", "mcp");
const LEADCLAW_DIR = join(REPO_ROOT, "packages", "leadclaw");
const DIST_DIR = join(DXT_DIR, "dist");
const STAGE_DIR = join(DIST_DIR, "stage");

function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}

async function main() {
const mcpPkg = readJson(join(MCP_DIR, "package.json"));
const version = mcpPkg.version;

// Fresh staging.
if (existsSync(DIST_DIR)) rmSync(DIST_DIR, { recursive: true, force: true });
mkdirSync(join(STAGE_DIR, "server"), { recursive: true });

// 1. Render manifest.
const manifestTpl = readFileSync(join(DXT_DIR, "manifest.template.json"), "utf8");
const manifest = manifestTpl.replaceAll("{{VERSION}}", version);
// Validate it parses.
JSON.parse(manifest);
writeFileSync(join(STAGE_DIR, "manifest.json"), manifest, "utf8");

// 2. Bundle bin.ts into a single server/index.js.
// - platform: node, format: esm (bin.ts uses top-level await + import.meta).
// - target: node22 (DXT ships Node 22+ in Claude Desktop).
// - External: nothing. DXT bundles are self-contained; we don't want the
// Claude Desktop runtime resolving our deps.
// - Define __LEADBAY_MCP_VERSION__ the same way tsup does.
await build({
entryPoints: [join(MCP_DIR, "src", "bin.ts")],
bundle: true,
platform: "node",
format: "esm",
target: "node22",
outfile: join(STAGE_DIR, "server", "index.js"),
external: [],
define: {
__LEADBAY_MCP_VERSION__: JSON.stringify(version),
},
banner: {
js: "#!/usr/bin/env node",
},
logLevel: "info",
});

// 3. Assets.
copyFileSync(join(LEADCLAW_DIR, "logo.png"), join(STAGE_DIR, "icon.png"));
if (existsSync(join(MCP_DIR, "README.md"))) {
copyFileSync(join(MCP_DIR, "README.md"), join(STAGE_DIR, "README.md"));
}

// 4. Zip.
const dxtPath = join(DIST_DIR, `leadbay-${version}.dxt`);
await new Promise((resolve, reject) => {
const out = createWriteStream(dxtPath);
const archive = archiver("zip", { zlib: { level: 9 } });
out.on("close", resolve);
out.on("error", reject);
archive.on("error", reject);
archive.pipe(out);
archive.directory(STAGE_DIR, false);
archive.finalize();
});

const { statSync } = await import("node:fs");
const bytes = statSync(dxtPath).size;
console.log(`\n✓ built ${dxtPath} (${(bytes / 1024).toFixed(1)} KB)`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
4 changes: 4 additions & 0 deletions packages/leadclaw/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog — @leadbay/leadclaw

## 0.2.4 — 2026-04-22

Version kept in sync with `@leadbay/mcp@0.2.4`. Picks up `@leadbay/core`'s new `formatLoginError` helper so login failures surface a readable error instead of a dangling colon ([product#3504](https://github.com/leadbay/product/issues/3504)). No OpenClaw-facing contract changes in this release.

## 0.2.3 — 2026-04-21

Bug fix release. Picks up `@leadbay/core@0.2.2` underneath.
Expand Down
2 changes: 1 addition & 1 deletion packages/leadclaw/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "leadclaw",
"name": "LeadClaw",
"description": "Leadbay for AI agents: a daily sales-lead inbox with firmographic + AI qualification layers, plus on-demand deeper qualification and contact enrichment. Each login delivers a fresh batch of leads paced by the user's recent consumption; the agent skims, deepens promising ones, and proposes outreach.",
"version": "0.2.3",
"version": "0.2.4",
"contracts": {
"tools": [
"leadbay_login",
Expand Down
Loading
Loading