Skip to content
Open
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: 8 additions & 0 deletions .changeset/mcp-market-tools-and-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'lens': minor
---

Expand MCP server with market discovery tools, upstream timeout/format validation, and documentation:
- Expose `lens_list_pairs`, `lens_get_pools`, and `lens_screen_pairs` MCP tools sharing implementation with corresponding REST routes (`/pairs`, `/pools`, `/screener`) with full asset issuer fidelity and pagination bounding (#409).
- Enforce outbound deadline via `AbortSignal.timeout` on upstream calls in `bazaar_search`, rejecting non-2xx and non-JSON payloads prior to parsing and surfacing structured error descriptions naming the upstream endpoint (#349).
- Add comprehensive documentation and worked client agent example in `examples/mcp-server/README.md`, prominently highlighting mainnet spending safeguards, opt-in procedures, and linking from root `README.md` (#422).
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ ORACLE_RELAY_ASSET_A=XLM
ORACLE_RELAY_ASSET_B=USDC
# How often the relay polls Lens and updates the contract.
ORACLE_RELAY_INTERVAL_MS=60000
# Soroban RPC URL for the oracle contract calls (default: https://soroban-testnet.stellar.org)
ORACLE_RELAY_RPC_URL=https://soroban-testnet.stellar.org
# Stellar network passphrase for the oracle relay (default: testnet passphrase)
ORACLE_RELAY_NETWORK_PASSPHRASE=Test SDF Network ; September 2015


# --- Price Alert Bot Example ---
# Lens WebSocket endpoint the alert bot connects to.
Expand Down Expand Up @@ -225,3 +230,30 @@ MCP_PAID_CALL_ALLOWED_HOSTS=
# against a table that has never been pruned cannot hold a long lock or blow
# the WAL. Default 1000.
SNAPSHOT_PRUNE_BATCH_SIZE=1000

# ─── Model Context Protocol (MCP) Server ───────────────────────────────────
# Stellar secret key used by the MCP agent to sign testnet payments for the paid_call tool.
# Leave empty to run the MCP server in read-only mode on testnet.
MCP_AGENT_SECRET_KEY_TESTNET=

# Stellar secret key used by the MCP agent to sign mainnet payments for the paid_call tool.
# WARNING: Setting this variable allows the MCP agent to spend real funds from this account!
# Mainnet execution is strictly opt-in: setting this variable alone will NOT activate
# mainnet unless MCP_ENABLE_MAINNET=true is also explicitly set.
MCP_AGENT_SECRET_KEY_MAINNET=

# Explicit opt-in flag required to activate stellar:pubnet signing with MCP_AGENT_SECRET_KEY_MAINNET.
# When set to true, logs a clear warning at startup that live funds can be spent. Default: false.
MCP_ENABLE_MAINNET=false

# Maximum price the MCP agent is permitted to pay for a single paid_call (default: $1.00).
MCP_MAX_PAYMENT_PRICE=$1.00

# Maximum number of concurrent in-flight MCP tool calls before throttling (default: 5).
MCP_MAX_CONCURRENCY=5

# Per-tool rate limits in requests per minute for MCP server calls.
# Exceeding these limits returns a structured ERR_RATE_LIMITED error with a retry hint.
MCP_RATE_LIMIT_PAID_CALL=10
MCP_RATE_LIMIT_BAZAAR_SEARCH=30

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity
| POST | `/webhooks` | Subscribe to a price-threshold webhook |
| DELETE | `/webhooks/:id` | Delete a webhook subscription |
| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) |
| stdio | MCP Server | Model Context Protocol server for AI agents (`bazaar_search`, `paid_call`, `lens_list_pairs`, `lens_get_pools`, `lens_screen_pairs`, docs: [`examples/mcp-server/README.md`](examples/mcp-server/README.md)) |


Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml);
`tests/openapi.test.ts` boots the server and fails if a registered route is
Expand Down
224 changes: 84 additions & 140 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -1,192 +1,136 @@
# Bazaar MCP Server Example
# Lens Model Context Protocol (MCP) Server & Agent Example

This directory contains two runnable examples that expose the Lens Bazaar
catalog as a [Model Context Protocol](https://modelcontextprotocol.io)
(MCP) server:
This directory contains the reference Model Context Protocol (MCP) server implementation (`run.ts`) and client agent demonstration (`agent.ts`) for Lens.

The MCP server exposes Lens's unified price discovery, liquidity pool metrics, pair screening, and x402 payment execution capabilities directly to AI agents via the standard [Model Context Protocol](https://modelcontextprotocol.io/).

| Command | File | What it does |
|---|---|---|
| `npm run mcp:server` | `run.ts` | Starts the Bazaar MCP server on stdio. It signs x402 payment challenges with two Stellar secret keys — one for testnet, one for mainnet — and forwards paid API calls to Bazaar resources, handling the 402 → sign → 200 loop automatically. |
| `npm run mcp:agent` | `agent.ts` | Starts a mock Bazaar API on `http://localhost:3000`, connects an MCP client to the server, lists the available tools, calls `bazaar_search`, then makes a `paid_call` to the discovered resource. End-to-end demonstration of an agent discovering and paying for a resource. |

## ⚠️ Warning: this signs with a real mainnet key

`run.ts` requires `MCP_AGENT_SECRET_KEY_MAINNET` and builds a signer for
`stellar:pubnet` (mainnet). Any payment the server signs spends **real XLM**
from the account that key controls — there is no sandbox, no dry-run, and no
confirmation prompt.
---

**Do not run these commands with a funded mainnet key until you have read the
"Safe way to try it first" section below.**
## ⚠️ CRITICAL SPENDING WARNING & MAINNET SAFEGUARDS

The MCP server can spend up to `MCP_MAX_PAYMENT_PRICE` (default `$1.00`) per
paid call from the mainnet account.
> **IMPORTANT:** The `paid_call` tool signs and submits real Stellar transactions on behalf of the agent to settle x402 payment challenges.
>
> When configured with `MCP_AGENT_SECRET_KEY_MAINNET` and `MCP_ENABLE_MAINNET=true`, **THE AGENT HAS ACCESS TO AND CAN SPEND REAL FUNDS FROM THAT ACCOUNT.**
>
> An AI agent can enter a loop or execute rapid requests. To prevent accidental loss of capital:
> 1. **Always test on testnet first** using `MCP_AGENT_SECRET_KEY_TESTNET`.
> 2. **Mainnet is strictly opt-in:** setting `MCP_AGENT_SECRET_KEY_MAINNET` alone will **not** enable mainnet signing unless `MCP_ENABLE_MAINNET=true` is also explicitly set.
> 3. The server starts safely in **read-only mode** if no keys are provided. You can inspect tools, discover pairs, query liquidity pools, and read resources without risking funds.
> 4. Keep your `MCP_MAX_PAYMENT_PRICE` ceiling low (default is `$1.00`).
> 5. **Never commit, log, or share real secret keys.**

## Configuration
---

| Variable | Required | Description |
|---|---|---|
| `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` |
| `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** |
| `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) |
## Configuration (`.env`)

Both keys are required: `run.ts` exits with an error if either is unset.
Configure the following environment variables in your `.env` file:

The server talks to two Stellar networks:
- `stellar:testnet` — safe, friendbot-funded play money
- `stellar:pubnet` — mainnet, real XLM
| Variable | Required | Default | Description |
| :--- | :---: | :---: | :--- |
| `MCP_AGENT_SECRET_KEY_TESTNET` | Optional | `""` | Stellar secret key used to sign payments on `stellar:testnet`. When empty, testnet paid calls refuse with `ERR_MISSING_KEY`. |
| `MCP_AGENT_SECRET_KEY_MAINNET` | Optional | `""` | Stellar secret key used to sign payments on `stellar:pubnet`. **Spends real funds!** |
| `MCP_ENABLE_MAINNET` | Optional | `false` | Explicit opt-in flag required to activate `stellar:pubnet` signing. Defaults to `false`. |
| `MCP_PAID_CALL_ALLOWED_HOSTS` | Optional | `""` | Comma-separated allowlist of origins/hosts that `paid_call` may pay for. If empty, all paid calls fail closed. |
| `MCP_MAX_PAYMENT_PRICE` | Optional | `"$1.00"` | Maximum price limit per paid tool call. Requests exceeding this price fail closed. |
| `MCP_MAX_CONCURRENCY` | Optional | `5` | Maximum number of in-flight concurrent tool executions before throttling with `ERR_CONCURRENCY_LIMIT_EXCEEDED`. |
| `MCP_RATE_LIMIT_PAID_CALL` | Optional | `10` | Rate limit for `paid_call` in requests per minute. Exceeding returns `ERR_RATE_LIMITED` with retry hint. |
| `MCP_RATE_LIMIT_BAZAAR_SEARCH` | Optional | `30` | Rate limit for `bazaar_search` in requests per minute. |

## Safe way to try it first
---

Never test with a funded mainnet key. Instead:
## Available MCP Tools

1. Create a fresh testnet account and fund it via [friendbot](https://docs.stellar.org/create-account) so it holds testnet XLM. Set `MCP_AGENT_SECRET_KEY_TESTNET` to that testnet secret.
2. For `MCP_AGENT_SECRET_KEY_MAINNET`, use a brand-new mainnet account with **zero XLM** (or any account you can afford to lose). A payment attempt against an unfunded account fails deterministically with `ERR_INSUFFICIENT_BALANCE` instead of spending money.
3. Leave `MCP_MAX_PAYMENT_PRICE` at its default `$1.00`, or lower it.
The server advertises the following tools to connected agents:

Only once you have confirmed the flow behaves as expected on testnet should you consider pointing `MCP_AGENT_SECRET_KEY_MAINNET` at a funded mainnet account — and only with an amount you are prepared to spend.
1. **`bazaar_search`**
- **Purpose:** Search for discovery endpoints and API services across Stellar Bazaar nodes.
- **When to use:** Use this to find available endpoints matching an asset query before making payments.

Never log, echo, print, or commit a real secret key. This README shows env var names only; it never shows a key value.
2. **`paid_call`**
- **Purpose:** Execute HTTP calls to x402-gated resources, automatically handling 402 challenge negotiation, transaction signing, and settlement replay.
- **When to use:** When accessing paid data feeds requiring micro-payment authorization.

## Worked example: connect a client and call each tool
3. **`lens_list_pairs`**
- **Purpose:** List active trading pairs watched by Lens on Stellar, including base and quote asset codes, full issuer addresses, and latest price metadata.
- **When to use:** Discover available markets before requesting prices or order book depth. Results are bounded and paginated (`limit`, `offset`).

Prerequisites:
4. **`lens_get_pools`**
- **Purpose:** Inspect liquidity pool reserves, spot prices, fee basis points, and token reserves across AMM and Soroswap pools.
- **When to use:** Analyze underlying liquidity pool depth and reserve ratios for a pair.

- Node.js installed
- Dependencies installed at the repo root (`npm install`)
- The two secret-key env vars set (see "Safe way to try it first" above)
5. **`lens_screen_pairs`**
- **Purpose:** Screen and rank trading pairs on Stellar by 24-hour volume, price changes, or liquidity pool depth.
- **When to use:** Identify market movers, highest-volume pairs, or filter pairs by minimum liquidity.

Run the server in one terminal:

```bash
export MCP_AGENT_SECRET_KEY_TESTNET=<your-testnet-secret-key>
export MCP_AGENT_SECRET_KEY_MAINNET=<your-empty-mainnet-secret-key>
npm run mcp:server
```
---

The server starts on stdio and logs:
## Available MCP Resources

```
[mcp-server] Starting Bazaar MCP server on stdio...
[mcp-server] Agent configured for stellar:testnet and stellar:pubnet
```
In addition to tools, the server advertises read-only resources:

In another terminal, run the agent example, which connects an MCP client,
lists the tools, and calls both of them:
- **`lens://pairs`** (`application/json`): Active watched pairs with full issuer addresses and latest price timestamps.
- **`lens://venues`** (`application/json`): Supported execution venues (SDEX orderbook, Stellar classic AMM, Soroswap, Aquarius).
- **`lens://openapi`** (`application/json`): Complete OpenAPI 3.0 specification for the Lens REST API.
- **`lens://status`** (`application/json`): Health check and service status.

```bash
npm run mcp:agent
```
---

Expected output (shortened for readability here; the actual run prints full
JSON):
## Error Codes

```
[mock-server] Started mock Bazaar API on http://localhost:3000
[agent] Starting MCP server child process...
[agent] Connected to Bazaar MCP server.
[agent] Available tools: bazaar_search, paid_call

[agent] 1. Discovering resources at http://localhost:3000...
[agent] Executing tool: bazaar_search
[agent] Search result: {"resources":[{"name":"Mock Price Feed",...}]}

[agent] 2. Making a paid call to http://localhost:3000/api/v1/price...
[agent] Executing tool: paid_call
[agent] The MCP server will handle the 402 loop automatically...
[agent] Paid call succeeded! Retrieved data:
{"asset":"XLM","price":0.15,"timestamp":"..."}

[agent] End to end demonstration complete.
```
Paid-call and tool failures return deterministic, machine-readable codes (see `src/mcp/codes.ts`):

### What each tool does

- `bazaar_search` — searches a Bazaar node's discovery endpoint. Takes `url`
(base URL of the Bazaar API) and an optional `query`. Returns the catalog of
discoverable resources, or a structured error such as `ERR_NO_RESULTS` or
`ERR_SERVICE_UNREACHABLE`.
- `paid_call` — makes a paid API call to a Bazaar resource, handling the x402
402 payment loop automatically. Takes `url` (full URL of the resource) and an
optional `method` (default `GET`). Returns the resource body, or a structured
error such as `ERR_PAYMENT_REJECTED`, `ERR_INSUFFICIENT_BALANCE`,
`ERR_MISSING_TRUSTLINE`, `ERR_EXPIRED_AUTHORISATION`, or
`ERR_SERVICE_UNREACHABLE`.
| Code | Meaning |
|---|---|
| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed |
| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay |
| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset |
| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission |
| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached |
| `ERR_NO_RESULTS` | The search returned no resources |
| `ERR_BAD_REQUEST` | The request failed with a non-402 error status or invalid arguments |
| `ERR_MISSING_KEY` | Server has no payment keys configured for the requested network |
| `ERR_RATE_LIMITED` | Per-tool rate limit exceeded |
| `ERR_CONCURRENCY_LIMIT_EXCEEDED` | Maximum in-flight concurrent requests exceeded |
| `ERR_URL_BLOCKED` | Destination URL blocked by SSRF defense |
| `ERR_HOST_NOT_ALLOWED` | Target host not on the operator's paid_call allowlist |
| `ERR_INTERNAL_FAILURE` | An unknown internal error occurred |

### Standalone client example
---

You can also connect your own MCP client to the server. This TypeScript
example connects via stdio, lists tools, and calls both tools:
## Client Example

```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import path from 'node:path';

async function main() {
async function runClient() {
const transport = new StdioClientTransport({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: ['tsx', 'examples/mcp-server/run.ts'],
args: ['tsx', path.resolve(__dirname, 'run.ts')],
});

const client = new Client(
{ name: 'bazaar-client-example', version: '1.0.0' },
{ capabilities: {} },
{ name: 'lens-example-client', version: '1.0.0' },
{ capabilities: {} }
);

await client.connect(transport);
console.log('Connected to Lens MCP server.');

const tools = await client.listTools();
console.log('Available tools:', tools.tools.map((t) => t.name).join(', '));
console.log('Available tools:', tools.tools.map((t) => t.name));

// 1. Search for resources
const search = await client.callTool({
name: 'bazaar_search',
arguments: { url: 'http://localhost:3000', query: 'XLM price' },
});
console.log('Search:', search.content[0].text);

// 2. Make a paid call to the discovered resource
const paid = await client.callTool({
name: 'paid_call',
arguments: { url: 'http://localhost:3000/api/v1/price' },
});
console.log('Paid call:', paid.content[0].text);
const resources = await client.listResources();
console.log('Available resources:', resources.resources.map((r) => r.uri));

await client.close();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
runClient().catch(console.error);
```

Run it with `npx tsx <file>` from the repo root, with the secret-key env vars
set. The server process is spawned automatically by the transport.

## Error codes

Paid-call failures return deterministic, machine-readable codes (see
`src/mcp/codes.ts`):

| Code | Meaning |
|---|---|
| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed |
| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay |
| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset |
| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission |
| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached |
| `ERR_NO_RESULTS` | The search returned no resources |
| `ERR_BAD_REQUEST` | The request failed with a non-402 error status |
| `ERR_INTERNAL_FAILURE` | An unknown error occurred during the paid call |

An agent is the least able caller to notice a wrong answer: the server
prefers returning a structured error over guessing a best-effort result, so
always check `isError` before acting on a tool response.

## Related

- MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts)
- Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts)
- Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts)
- Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol)
Loading