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
12 changes: 6 additions & 6 deletions packages/payments/celcoin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ Add to `.cursor/mcp.json` or `.vscode/mcp.json`:
| `create_pix_cobv` | Create a Pix due charge (cobv) — boleto-like Pix with due date |
| `lookup_pix_dict` | Lookup a Pix DICT key — resolves a Pix key to account holder + bank info |
| `create_pix_devolution` | Create a Pix devolução (refund) — refund a received Pix transaction |
| `cancel_boleto` | Cancel an issued boleto by transactionId |
| `read_barcode` | Read a boleto / concessionária barcode (digitable line) — returns due date, amount, beneficiary |
| `pay_bill` | Pay a bill (boleto bancário or concessionária) by barcode / digitable line |
| `cancel_boleto` | Cancel a boleto issued via the bill-issuance product by id |
| `read_barcode` | Authorize (consult) a boleto / concessionária barcode before paying — returns transactionId, amount, totalUpdated, dueDate |
| `pay_bill` | Confirm payment of a previously authorized bill — pass the read_barcode transactionId as transactionIdAuthorize |
| `get_statement` | Get account statement (extrato) for a date range |
| `list_topup_providers` | List telecom top-up providers (operadoras) available for recargas |
| `create_boleto` | Create a boleto payment via Celcoin |
| `get_boleto` | Get boleto details by transaction ID |
| `create_boleto` | Issue a boleto via the bill-issuance product |
| `get_boleto` | Get an issued boleto's details by id |
| `create_transfer` | Create a bank transfer (TED/DOC) via Celcoin |
| `get_balance` | Get account balance at Celcoin |
| `list_banks` | List available banks in Brazil (ISPB codes) |
Expand All @@ -82,7 +82,7 @@ Celcoin uses OAuth2 client credentials. The server automatically manages token r

## Sandbox / Testing

Celcoin provides a sandbox at `sandbox-api.celcoin.com.br`. Set `CELCOIN_SANDBOX=true` to use it.
Celcoin provides a sandbox at `sandbox.openfinance.celcoin.dev`. Set `CELCOIN_SANDBOX=true` to use it.

### Get your credentials

Expand Down
2 changes: 1 addition & 1 deletion packages/payments/celcoin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codespar/mcp-celcoin",
"version": "0.2.2",
"version": "0.2.3",
"description": "MCP server for Celcoin — Pix, boleto, transfers, bill payments, top-ups",
"type": "module",
"main": "./dist/index.js",
Expand Down
4 changes: 2 additions & 2 deletions packages/payments/celcoin/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
"source": "github",
"subfolder": "packages/payments/celcoin"
},
"version": "0.2.2",
"version": "0.2.3",
"packages": [
{
"registryType": "npm",
"identifier": "@codespar/mcp-celcoin",
"version": "0.2.2",
"version": "0.2.3",
"transport": {
"type": "stdio"
},
Expand Down
99 changes: 99 additions & 0 deletions packages/payments/celcoin/src/__tests__/contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { it, expect, beforeAll, vi } from "vitest";
import {
loadContractEnv,
describeContract,
parseToolResult,
} from "../../../../../test-utils/contract.js";

// --- Capture the tools/call handler; do NOT mock fetch (real network) ---
let callToolHandler: Function;

vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
class FakeServer {
constructor() {}
setRequestHandler(schema: any, handler: Function) {
if (JSON.stringify(schema).includes("tools/call")) callToolHandler = handler;
}
connect() {
return Promise.resolve();
}
}
return { Server: FakeServer };
});

vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: class {},
}));

await loadContractEnv();

async function call(name: string, args: Record<string, unknown> = {}) {
if (!callToolHandler) {
throw new Error(
"callToolHandler not registered — did beforeAll (vi.resetModules + import ../index.js) complete?",
);
}
const result = await callToolHandler({ params: { name, arguments: args } });
return parseToolResult(result);
}

// This block is SKIPPED in CI (no CELCOIN_CLIENT_ID). Run locally with real
// sandbox credentials in <repo-root>/.env:
// CELCOIN_CLIENT_ID=...
// CELCOIN_CLIENT_SECRET=...
// CELCOIN_SANDBOX=true
// CELCOIN_TEST_DIGITABLE=<a valid sandbox digitable line> (optional)
// It codifies the real authorize -> confirm handshake against the sandbox host
// (sandbox.openfinance.celcoin.dev).
describeContract("mcp-celcoin", "CELCOIN_CLIENT_ID", () => {
// A ficha-de-compensação digitable line (type 2). Override with a barcode
// valid for your sandbox account via CELCOIN_TEST_DIGITABLE.
const DIGITABLE =
process.env.CELCOIN_TEST_DIGITABLE ||
"34191790010104351004791020150008291070026000";

beforeAll(async () => {
process.env.CELCOIN_SANDBOX = "true";
vi.resetModules();
callToolHandler = undefined as any;
await import("../index.js");
});

it(
"read_barcode -> pay_bill: authorize then confirm handshake",
async () => {
// Step 1: authorize (consult). Real sandbox returns a transactionId that
// opens the reservation window, or a structured error for an unpayable
// barcode. Either way the route must resolve (no 404 on the old GET).
const authorized = await call("read_barcode", {
barcode: DIGITABLE,
barcodeType: 2,
});
expect(authorized.text).not.toMatch(/404/);

// If authorize did not yield a transactionId (e.g. the sample barcode is
// not payable in this sandbox account), we have still proven the
// authorize route resolves; there is nothing to confirm.
const transactionId = (authorized.json as any)?.transactionId;
const totalUpdated = (authorized.json as any)?.totalUpdated;
if (!transactionId) {
expect(authorized.isError).toBe(true);
return;
}

// Step 2: confirm. The confirm MUST carry transactionIdAuthorize + billData;
// without them Celcoin answers errorCode 44 "Valor nao permitido".
const paid = await call("pay_bill", {
barcode: DIGITABLE,
barcodeType: 2,
transactionIdAuthorize: String(transactionId),
amount: totalUpdated,
originalValue: totalUpdated,
cpfcnpj: process.env.CELCOIN_TEST_CPFCNPJ || "12345678909",
});
// The confirm route must resolve; the errorCode-44 body bug must not recur.
expect(paid.text).not.toMatch(/errorCode.*44|Valor nao permitido/i);
},
30000,
);
});
201 changes: 190 additions & 11 deletions packages/payments/celcoin/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

let listToolsHandler: Function;
let callToolHandler: Function;
Expand All @@ -23,15 +23,36 @@ process.env.CELCOIN_CLIENT_SECRET = "test-secret";
const mockFetch = vi.fn();
global.fetch = mockFetch as any;

beforeEach(async () => {
// The first fetch a Celcoin call makes is always POST /v5/token. Queue the OAuth
// token, then the endpoint's own response, and assert on the LAST fetch call.
const tokenResponse = () => ({ ok: true, json: () => Promise.resolve({ access_token: "tok", expires_in: 3600 }) });
const okJson = (body: unknown) => ({ ok: true, json: () => Promise.resolve(body) });

function lastCall() {
return mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
}
function lastBody() {
return JSON.parse(lastCall()[1].body);
}

async function loadServer() {
vi.resetModules();
listToolsHandler = undefined as any;
callToolHandler = undefined as any;
await import("../index.js");
}

beforeEach(async () => {
delete process.env.CELCOIN_SANDBOX;
mockFetch.mockReset();
global.fetch = mockFetch as any;
// Mock OAuth token response for all requests
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ access_token: "tok", expires_in: 3600 }) });
await import("../index.js");
// Default: every fetch returns a token (enough for the "18 tools" listing test).
mockFetch.mockResolvedValue(tokenResponse());
await loadServer();
});

afterEach(() => {
delete process.env.CELCOIN_SANDBOX;
});

describe("mcp-celcoin", () => {
Expand All @@ -41,14 +62,172 @@ describe("mcp-celcoin", () => {
});

it("should call correct API endpoint for get_balance", async () => {
// First call is OAuth token, second is the actual request
mockFetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ access_token: "tok", expires_in: 3600 }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ balance: 1000 }) });
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ balance: 1000 }));

await callToolHandler({ params: { name: "get_balance", arguments: {} } });

const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
expect(lastCall[0]).toContain("/v5/merchant/balance");
expect(lastCall()[0]).toContain("/v5/merchant/balance");
});

describe("read_barcode (authorize)", () => {
it("POSTs to /v5/transactions/billpayments/authorize with barCode {type, digitable}", async () => {
mockFetch.mockReset();
mockFetch
.mockResolvedValueOnce(tokenResponse())
.mockResolvedValueOnce(okJson({ transactionId: 9001, totalUpdated: 150.5, allowChangeValue: false }));

await callToolHandler({
params: { name: "read_barcode", arguments: { barcode: "846700000017", barcodeType: 1 } },
});

const [url, opts] = lastCall();
expect(url).toContain("/v5/transactions/billpayments/authorize");
expect(url).not.toContain("?barCode=");
expect(opts.method).toBe("POST");
expect(lastBody()).toMatchObject({ barCode: { type: 1, digitable: "846700000017" } });
});

it("defaults barcode type to 2 (ficha de compensação) when omitted", async () => {
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ transactionId: 1 }));

await callToolHandler({ params: { name: "read_barcode", arguments: { barcode: "34191790010104351004791020150008291070026000" } } });

expect(lastBody().barCode.type).toBe(2);
});
});

describe("pay_bill (confirm)", () => {
it("carries transactionIdAuthorize, cpfcnpj, billData and externalNSU (errorCode-44 regression guard)", async () => {
mockFetch.mockReset();
mockFetch
.mockResolvedValueOnce(tokenResponse())
.mockResolvedValueOnce(okJson({ transactionId: 9001, status: "CONFIRMED" }));

await callToolHandler({
params: {
name: "pay_bill",
arguments: {
barcode: "846700000017",
barcodeType: 1,
transactionIdAuthorize: "9001",
amount: 150.5,
originalValue: 150.5,
dueDate: "2026-07-10",
cpfcnpj: "12345678909",
externalNSU: 42,
},
},
});

const [url, opts] = lastCall();
expect(url).toContain("/v5/transactions/billpayments");
expect(url).not.toContain("/authorize");
expect(opts.method).toBe("POST");
const body = lastBody();
expect(body).toMatchObject({
transactionIdAuthorize: "9001",
cpfcnpj: "12345678909",
externalNSU: 42,
barCode: { type: 1, digitable: "846700000017" },
billData: { value: 150.5, originalValue: 150.5 },
dueDate: "2026-07-10",
});
// The old bare `amount` field must be gone — its absence is the fix for
// Celcoin errorCode 44 "Valor nao permitido".
expect(body.amount).toBeUndefined();
});
});

describe("create_boleto (issuance)", () => {
it("POSTs to /billissuance/v1/bill", async () => {
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ transactionId: "b1" }));

await callToolHandler({
params: {
name: "create_boleto",
arguments: { amount: 99, dueDate: "2026-08-01", payerName: "Ana", payerDocument: "12345678909" },
},
});

const [url, opts] = lastCall();
expect(url).toContain("/billissuance/v1/bill");
expect(url).not.toContain("/v5/transactions/billpayments/bankslip");
expect(opts.method).toBe("POST");
});
});

describe("get_boleto / cancel_boleto (issuance)", () => {
it("get_boleto GETs /billissuance/v1/bill/:id", async () => {
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ id: "b1" }));

await callToolHandler({ params: { name: "get_boleto", arguments: { transactionId: "b1" } } });

const [url, opts] = lastCall();
expect(url).toContain("/billissuance/v1/bill/b1");
expect(opts.method).toBe("GET");
});

it("cancel_boleto DELETEs /billissuance/v1/bill/:id", async () => {
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ status: "CANCELLED" }));

await callToolHandler({ params: { name: "cancel_boleto", arguments: { transactionId: "b1" } } });

const [url, opts] = lastCall();
expect(url).toContain("/billissuance/v1/bill/b1");
expect(opts.method).toBe("DELETE");
});
});

describe("sandbox host", () => {
it("uses sandbox.openfinance.celcoin.dev (not the dead sandbox-api host) when CELCOIN_SANDBOX=true", async () => {
process.env.CELCOIN_SANDBOX = "true";
await loadServer();
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ balance: 1 }));

await callToolHandler({ params: { name: "get_balance", arguments: {} } });

const url = lastCall()[0];
expect(url).toContain("sandbox.openfinance.celcoin.dev");
expect(url).not.toContain("sandbox-api.celcoin.com.br");
});

it("uses the production host when CELCOIN_SANDBOX is unset", async () => {
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(okJson({ balance: 1 }));

await callToolHandler({ params: { name: "get_balance", arguments: {} } });

const url = lastCall()[0];
expect(url).toContain("api-sec.celcoin.com.br");
expect(url).not.toContain("sandbox");
});
});

describe("error handling", () => {
it("returns isError true on a 400 confirm response", async () => {
mockFetch.mockReset();
mockFetch
.mockResolvedValueOnce(tokenResponse())
.mockResolvedValueOnce({ ok: false, status: 400, text: () => Promise.resolve('{"errorCode":"44"}') });

const result = await callToolHandler({
params: { name: "pay_bill", arguments: { barcode: "1", amount: 1, transactionIdAuthorize: "1" } },
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("400");
});

it("returns isError true for an unknown tool", async () => {
const result = await callToolHandler({ params: { name: "nonexistent", arguments: {} } });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown tool");
});
});
});
Loading
Loading