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
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ This starts an HTTP server with:
- `POST /mcp` — MCP endpoint (Streamable HTTP with SSE)
- `GET /health` — Health check

Each client gets its own session: `initialize` returns an `Mcp-Session-Id` that
subsequent requests must send back. A client that restarts can simply
`initialize` again — existing sessions are unaffected and the server does not
need restarting. Bind address and port come from `HOST` and `PORT` (`MCP_PORT`
is accepted as an alias for `PORT`).

## Configuration

> **Note:** `BLOCKFROST_API_KEY` is required for write operations (transaction building). Read-only tools work without it. Get a free key at [blockfrost.io](https://blockfrost.io/).
Expand Down Expand Up @@ -307,8 +313,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco
| Tool | Description | Parameters |
| ----------------- | ---------------------------------------------------- | ---------------------------------- |
| `get_assets` | Get all Indigo iAssets with prices and interest data | None |
| `get_asset` | Get details for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY |
| `get_asset_price` | Get the current price for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY |
| `get_asset` | Get details for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA |
| `get_asset_price` | Get the current price for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA |
| `get_ada_price` | Get the current ADA price in USD | None |
| `get_indy_price` | Get the current INDY token price in ADA and USD | None |

Expand All @@ -323,9 +329,19 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco

### CDP Write Tools

> **Pyth-priced iAssets have a submission deadline.** Every iAsset is currently
> priced through Pyth, and the on-chain feed validator only accepts a transaction
> for **280 seconds** after the price update it embeds. Price-dependent write
> tools (`open_cdp`, `withdraw_cdp`, `mint_cdp`, `redeem_cdp`, `freeze_cdp`,
> `leverage_cdp`, `redeem_rob`) therefore return a `summary.pyth` block with the
> price, its timestamp and a `submitBefore` deadline. If signing takes longer
> than that — a hardware wallet, or a human approving in a browser — rebuild the
> transaction rather than submitting a stale one. A price update that has already
> expired is rejected at build time with a retry hint.

| Tool | Description | Parameters |
| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `open_cdp` | Open a new CDP position (returns unsigned CBOR tx) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `collateralAmount`: lovelace; `mintAmount`: iAsset smallest unit |
| `open_cdp` | Open a new CDP position (returns unsigned CBOR tx) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `collateralAmount`: lovelace; `mintAmount`: iAsset smallest unit |
| `deposit_cdp` | Deposit additional collateral into a CDP | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index; `amount`: lovelace |
| `withdraw_cdp` | Withdraw collateral from a CDP | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index; `amount`: lovelace |
| `close_cdp` | Close a CDP and reclaim collateral | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index |
Expand All @@ -334,8 +350,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco

| Tool | Description | Parameters |
| ---------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mint_cdp` | Mint additional iAssets from an existing CDP (increases debt) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit |
| `burn_cdp` | Burn iAssets to reduce CDP debt | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit |
| `mint_cdp` | Mint additional iAssets from an existing CDP (increases debt) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit |
| `burn_cdp` | Burn iAssets to reduce CDP debt | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit |

### CDP Liquidation & Redemption Tools

Expand All @@ -357,7 +373,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco
| Tool | Description | Parameters |
| ----------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- |
| `get_stability_pools` | Get the latest stability pool state for each iAsset | None |
| `get_stability_pool_accounts` | Get all open stability pool accounts, optionally filtered by iAsset | `asset?`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY |
| `get_stability_pool_accounts` | Get all open stability pool accounts, optionally filtered by iAsset | `asset?`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA |
| `get_sp_account_by_owner` | Get stability pool accounts for specific owners | `owners`: array of payment key hashes or bech32 addresses |

### Staking Tools
Expand Down Expand Up @@ -414,7 +430,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco
| ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
| `get_order_book` | Get open ROB (redemption order book) positions | `asset?`: iAsset filter; `owners?`: array of payment key hashes |
| `get_redemption_orders` | Get executed redemption orders | `asset?`: iAsset filter; `limit?`: max records (default 100) |
| `get_redemption_queue` | Get open ROB order-book entries for an iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY |
| `get_redemption_queue` | Get open ROB order-book entries for an iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA |

### ROB Write Tools

Expand Down Expand Up @@ -457,7 +473,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco
| Tool | Description | Parameters |
| ------------------- | ----------------------------------------------------------------- | ----------------------------------- |
| `get_oracle_price` | On-chain price for an iAsset (OracleNft / Delisted / Pyth) | `asset` |
| `get_pyth_price` | Read the Pyth price-feed config for an iAsset | `asset` |
| `get_pyth_price` | Current Pyth price for an iAsset, plus its on-chain feed config | `asset` |
| `feed_price_oracle` | Feed a new price to an OracleNft-backed price oracle (admin) | `address`, `oracleTxHash`, price |

### Stableswap Tools (v3)
Expand All @@ -476,7 +492,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco
| `BLOCKFROST_API_KEY` | For write ops | — | Blockfrost project ID for transaction building |
| `CARDANO_NETWORK` | No | `mainnet` | Cardano network: `mainnet`, `preprod`, or `preview` |
| `MCP_TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` |
| `PORT` | No | `3000` | HTTP server port (only used when `MCP_TRANSPORT=http`) |
| `PORT` | No | `3000` | HTTP server port (only used when `MCP_TRANSPORT=http`); `MCP_PORT` is accepted as an alias |
| `HOST` | No | `0.0.0.0` | HTTP bind address (only used when `MCP_TRANSPORT=http`); set `127.0.0.1` to bind locally only |
| `X402_PRIVATE_KEY` | No | — | EVM private key (`0x…`) of the payer wallet — enables auto-payment via split flow |
| `PAYMENT_SERVER` | No | `https://mcp.openmm.io` | Settlement worker / proxy URL |
| `X402_TESTNET` | No | `false` | Use Base Sepolia testnet |
Expand Down Expand Up @@ -507,7 +524,7 @@ When connected to an LLM agent, you can ask natural language questions like:

### Prerequisites

- Node.js >= 18
- Node.js >= 20 (the bundled `undici` requires the `File` global, added in Node 20)
- npm

### Setup
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"smithery:publish": "bash scripts/smithery-build.sh && npx smithery mcp publish --name indigoprotocol/indigo-mcp --from-build .smithery/stdio"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"repository": {
"type": "git",
Expand Down
125 changes: 102 additions & 23 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { createServer as createHttpServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { registerTools } from './tools/index.js';
import { registerResources } from './resources/index.js';
import { applyPaymentGate } from './payment.js';
import packageJson from '../package.json' with { type: 'json' };

const SERVER_NAME = 'indigo-mcp';
const SERVER_VERSION = '0.2.0';
// Read from package.json so `initialize` and /health cannot drift from the
// published version, as they had (advertising 0.2.0 against a 0.3.0 package).
const SERVER_VERSION = packageJson.version;

export function createServer(): McpServer {
const server = new McpServer({
Expand All @@ -24,10 +29,91 @@ export function createServer(): McpServer {
return server;
}

async function startHttpServer(port: number): Promise<void> {
/** Read and parse a JSON request body. Returns `undefined` for an empty body. */
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
if (chunks.length === 0) return undefined;
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

function sendJsonRpcError(
res: ServerResponse,
status: number,
code: number,
message: string
): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
}

async function startHttpServer(port: number, host: string): Promise<void> {
process.stderr.write(`Indigo MCP starting HTTP server...\n`);

// Start HTTP server immediately so Fly.io sees the port open
/**
* One transport (and one MCP server) per client session. A client that
* reconnects — after a restart, say — simply sends a fresh `initialize` and
* gets a new session, rather than being locked out until this process is
* restarted.
*/
const transports = new Map<string, StreamableHTTPServerTransport>();

async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
let body: unknown;
if (req.method === 'POST') {
try {
body = await readJsonBody(req);
} catch {
sendJsonRpcError(res, 400, -32700, 'Parse error: request body is not valid JSON');
return;
}
}

const sessionId = req.headers['mcp-session-id'];
const existing = typeof sessionId === 'string' ? transports.get(sessionId) : undefined;

if (existing) {
await existing.handleRequest(req, res, body);
return;
}

// No live session for this request. An `initialize` always starts a fresh
// one — including when the client replayed a session id this process no
// longer knows about, so a restarted client can always get back in.
if (req.method !== 'POST' || !isInitializeRequest(body)) {
if (typeof sessionId === 'string') {
sendJsonRpcError(
res,
404,
-32001,
`Session not found: ${sessionId}. Send an initialize request to start a new session.`
);
} else {
sendJsonRpcError(res, 400, -32000, 'Bad Request: Mcp-Session-Id header is required');
}
return;
}

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, transport);
},
onsessionclosed: (id) => {
transports.delete(id);
},
});
transport.onclose = () => {
if (transport.sessionId) transports.delete(transport.sessionId);
};

const server = createServer();
await server.connect(transport);
await transport.handleRequest(req, res, body);
}

const httpServer = createHttpServer((req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);

Expand All @@ -38,31 +124,23 @@ async function startHttpServer(port: number): Promise<void> {
}

if (url.pathname === '/mcp') {
if (!transport) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Server initializing' }));
return;
}
transport.handleRequest(req, res);
handleMcpRequest(req, res).catch((error: unknown) => {
process.stderr.write(`Indigo MCP request error: ${error}\n`);
if (!res.headersSent) {
sendJsonRpcError(res, 500, -32603, 'Internal server error');
} else {
res.end();
}
});
return;
}

res.writeHead(404);
res.end('Not found');
});

let transport: StreamableHTTPServerTransport | null = null;

httpServer.listen(port, '0.0.0.0', async () => {
process.stderr.write(`Indigo MCP HTTP server listening on 0.0.0.0:${port}\n`);

// Initialize MCP server after port is open
const server = createServer();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
process.stderr.write(`Indigo MCP server ready\n`);
httpServer.listen(port, host, () => {
process.stderr.write(`Indigo MCP HTTP server listening on ${host}:${port}/mcp\n`);
});
}

Expand All @@ -76,8 +154,9 @@ async function main(): Promise<void> {
const mode = process.env.MCP_TRANSPORT ?? 'stdio';

if (mode === 'http') {
const port = parseInt(process.env.PORT ?? '3000', 10);
await startHttpServer(port);
const port = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? '3000', 10);
const host = process.env.HOST ?? '0.0.0.0';
await startHttpServer(port, host);
} else {
await startStdioServer();
}
Expand Down
Loading
Loading