Skip to main content
Glama
README.md
# kushbitx-mcp-agent

An AI-agent integration for [`@kushbitx/sdk`](https://www.npmjs.com/package/@kushbitx/sdk), built for the
`$50 USDC` integration bounty in [kushBitxHQ/kushbitx-sdk#1](https://github.com/kushBitxHQ/kushbitx-sdk/issues/1).

The model decides what to call. There is no fixed sequence: the agent loop appends the model's tool
calls, executes them, feeds the results back, and repeats until the model stops asking for tools or a
SpendGuard decision stops the run. The same three capabilities are also exposed as an MCP stdio server.

**Nothing here can sign a payment or move funds.** The paid path stops at reading an x402 challenge out
of a `402` response: no signature is created, no transaction is sent, and no private key is ever read.

## What it demonstrates

| Capability | Endpoint | Cost | Used for |
| --- | --- | --- | --- |
| `preview_token` | `/api/token-preview` | free | Live Base token market data (no wallet) |
| `evaluate_spend` | `/api/spendguard/evaluate` | free | Policy evaluation before money moves (advisory) |
| `discover_payment_challenge` | `/api/token-risk`, `/api/transaction-preflight`, `/api/verify-payment` | free to inspect | Reads the `402` `PAYMENT-REQUIRED` terms; never pays |

## Quick start

Node.js 22 or later.

```bash
npm install
cp .env.example .env          # then set your LLM key, or export DEEPSEEK_API_KEY
npm run start                 # run the agent end to end
npm test                      # offline suite (no network, no credentials)
npm run mcp                   # run the MCP stdio server
```

The LLM is any OpenAI-compatible chat-completions endpoint with tool calling. The default is
`deepseek-chat`; set `LLM_BASE_URL` / `LLM_MODEL` to point elsewhere.

### Environment

| Variable | Required | Meaning |
| --- | --- | --- |
| `DEEPSEEK_API_KEY` | for `npm start` | LLM key. Alternatively `DSH_CREDENTIALS` may point at a credentials file whose `refs:` block contains it. |
| `LLM_BASE_URL` | no | Default `https://api.deepseek.com` |
| `LLM_MODEL` | no | Default `deepseek-chat` |
| `KUSHBITX_PROXY` | no | Route SDK traffic through a proxy, e.g. `http://127.0.0.1:7897`. See “Network note” below. |
| `KUSHBITX_LIVE` | no | `1` enables the live MCP integration tests. |
| `KUSHBITX_PROXY` | required by `npm run test:live` | The live suites dial `kushbitx.com`; on a network where the Cloudflare edge rejects this host's egress, the suite fails with `403` until this is set. The one proxy-only check skips itself (with a message) when the variable is absent rather than assuming a port. |

### MCP host configuration

```json
{
  "mcpServers": {
    "kushbitx": { "command": "node", "args": ["mcp-server.mjs"] }
  }
}
```

## Files

| Path | Purpose |
| --- | --- |
| `agent.mjs` | The tool-calling loop, the dispatch table and the chat-completions caller. |
| `tools.mjs` | Tool schemas plus a thin mapping layer over the published SDK. |
| `mcp-server.mjs` | The same three capabilities over MCP stdio. |
| `proxy-fetch.mjs` | Proxy transports: `curl -K -` (every request value, including headers, travels on the child's **stdin**, never in its argv) and an in-process `CONNECT` tunnel as a fallback for hosts without curl. Selected by `KUSHBITX_TRANSPORT=auto\|curl\|native`. |
| `credentials.mjs` | Resolves the LLM key from the environment or a credentials file; never logs it. |
| `run.mjs` / `main.mjs` | End-to-end run and CLI entry point; writes sanitized evidence. |
| `test/agent.test.mjs` | Offline suite: the agent loop, the toolkit mapping, input shaping and the x402 decode. |
| `test/proxy-fetch.test.mjs` | The argv-leak regression: a stub child records its own argv and stdin. |
| `test/credentials.test.mjs` | The credentials parser: quoting, block boundaries, env precedence. |
| `test/mcp.test.mjs` | Live suite: drives the real MCP server over the official stdio transport. |
| `scripts/run-live-tests.mjs` | Cross-platform launcher for the live suites (`npm run test:live`). |
| `audit.mjs` | Adversarial review probe: injection, edge cases, contract drift. |
| `test/fixtures/verify-argv-leak.mjs` | Manual helper that prints what the child process actually saw. |

## Test report

Offline suite — no network, no credentials, the SDK is driven through its `fetchFn` seam:

```
$ npm test
✔ toolkit maps a real SDK preview response into the reported shape
✔ toolkit rejects a malformed token address before any request
✔ toolkit reports the x402 challenge without signing or paying
✔ toolkit surfaces an unknown paid service as an error
✔ agent follows the model's tool order — forward
✔ agent follows the model's tool order — reversed, proving order is not hardcoded
✔ agent ends immediately when the model needs no tools
✔ a non-APPROVE spend decision stops the run without signing or paying
✔ a tool error is reported to the model, not thrown
✔ an unknown tool name is reported back to the model
✔ maxSteps bounds a model that never stops calling tools
✔ normalizeSpendInput applies policy defaults and keeps the caller allowlist
✔ buildPaidInput shapes per-service payloads
✔ every tool schema declares a name and an object schema
✔ the child process argv carries no secret (the leak this fixes)
✔ the stub proves the secret survived the round trip
✔ the transport source spawns a child but passes it no arguments carrying data
✔ every other transport in the repo avoids child processes entirely
✔ parseRawResponse keeps the last HTTP block and its headers
✔ transport selection: explicit modes win, auto is the fallback wrapper
✔ auto falls back to the native tunnel when curl is missing entirely
✔ auto falls back per-request when the curl exec fails without a response
✔ the native fallback tunnel works end to end (no setup needed without curl)

ℹ tests 36   ℹ pass 35   ℹ fail 0   ℹ skipped 1
```

Live suite — the real MCP server against the hosted endpoints, through the proxy transport.
Requires `KUSHBITX_PROXY` (see the environment table): without it these checks get `403`
from Cloudflare in this environment.


```
$ npm run test:live
ℹ tests 16   ℹ pass 16   ℹ fail 0
```

### Sanitized end-to-end run

`npm start` executed the live flow. The model chose the order itself, producing:

```
$ node main.mjs
stopped     : model-finished
tools used  : preview_token -> discover_payment_challenge -> evaluate_spend
steps       : 3 | elapsed: 11693ms
   0. preview_token             token=USDC priceUsd=null   (source.stale=true)
   1. discover_payment_challenge x402 v2 exact eip155:8453 amount=250000 timeout=300s signed=false paid=false
   2. evaluate_spend            decision=APPROVE findings=0 executionAuthorized=false
```

Final answer (abridged — this is verbatim from the run above; `npm start` reproduces it and writes the
full sanitized trajectory to `evidence/agent-run.json`, which is git-ignored like any run artifact):

```
**Token checked:** USDC on Base (0x8335…2913) — Aerodrome USDC/USDbC, price $1.000021,
liquidity $146,020.43; source DexScreener, flagged stale at 182s.

**Spend decision:** APPROVE (advisory only; executionAuthorized: false)
• 1.00 USDC, policy max/tx 5.00, daily 20.00, human approval above 2.00, unknown recipients blocked
• Findings: none.

**x402 terms (token-risk, /api/token-risk):** version 2, scheme exact, network eip155:8453 (Base),
amount 250000 base units = 0.25 USDC, timeout 300s, extra {name: "USD Coin", version: "2"}.

**Confirmation:** nothing was signed and nothing was paid (signed: false, paid: false).
```

## Security model

- No private key is read, stored, or requested anywhere in this repository. There is no code path that
  signs a payment or sends a transaction.
- The paid capability is limited to reading the `402` challenge; `signed` and `paid` are returned as
  `false` by construction and asserted in both suites.
- Addresses are redacted (`0x8335…2913`) in the committed evidence; the full values are the public
  USDC-on-Base and x402 recipient constants already published in the SDK README and `agent.mjs`.
- The LLM key is read from the environment (or a credentials file) at runtime and is never logged.

## Network note

On some networks the Cloudflare edge in front of `kushbitx.com` answers `403` to the machine's default
egress (`cf-ray …-HKG` in my case), and the hosted endpoints answer `403` to a request sent from Node's
own TLS stack through a proxy that curl succeeds with on the same host — the block page, not the API,
is what rejects it. So when a proxy is configured, requests are sent through `curl -K -`:

```bash
KUSHBITX_PROXY=http://127.0.0.1:7897 npm start
```

**The whole configuration — including any `Authorization` header — is written to curl's stdin, never to
its argument list.** An earlier revision passed the header as `curl -H "authorization: …"`, which put
the model API key in the child's argv where `/proc/<pid>/cmdline` or `ps` can read it; that code is gone
from this repo. The property is now pinned by tests that run a stub child process and assert what it
actually saw:

```
child argv      : ["-sS","-K","-"]
secret in argv  : no
secret in stdin : yes (expected)
url in argv     : no
proxy in argv   : no
```

Reproduce with `node test/fixtures/verify-argv-leak.mjs`. With `KUSHBITX_PROXY` unset the code uses the
global `fetch` and spawns no child process at all, so a normal environment pays nothing for this.

## License

Apache-2.0, matching the SDK.

### Why `auto` prefers curl

The proxy transport is selectable:

```
KUSHBITX_TRANSPORT=auto           (default) curl -K -, with a real fallback: the native
                                  tunnel is used when the curl probe fails, and
                                  per-request when a curl exec yields no response
KUSHBITX_TRANSPORT=curl           the stdin-only curl transport, no fallback
KUSHBITX_TRANSPORT=native         in-process CONNECT tunnel, no child process
KUSHBITX_TRANSPORT=auto-nocurl    native, without probing for curl
```

Three cases reach the fallback and are covered by tests: curl missing from PATH, a curl
exec that fails without producing a response, and the forced modes above. Once a
fallback has happened the transport stays on native, so a host without curl pays the
failed exec at most once.

Measured on this host through the same proxy, same endpoint:

```
curl (OpenSSL)          -> HTTP 200
node (native tunnel)    -> HTTP 403  (Cloudflare block page)
```

So the native tunnel is a portability fallback, not an equivalent transport: it is what
runs on a host without curl, and on a network where the origin accepts Node's TLS
fingerprint it behaves identically. `KUSHBITX_TRANSPORT=native` forces it for testing;
`KUSHBITX_TRANSPORT=auto-nocurl` selects it without probing for curl.