Skip to content
Closed
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,14 @@ MCP_PAID_CALL_ALLOWED_HOSTS=
# Leave unset to serve the openapi.json at the package root; set it only when the
# document lives elsewhere, e.g. mounted separately in a container.
MCP_OPENAPI_PATH=
# MCP transport mode: stdio (default) or http
MCP_TRANSPORT=stdio
# Required bearer / x-api-key token when MCP_TRANSPORT=http
MCP_API_KEY=
# HTTP port for MCP streamable HTTP transport (default: 3001)
MCP_HTTP_PORT=3001
# HTTP host binding for MCP streamable HTTP transport (default: 0.0.0.0)
MCP_HTTP_HOST=0.0.0.0


# ─── Snapshot retention ────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ and `stellar:pubnet`.
**⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration,
spending warning, and worked example before running.

The server supports two transports selectable by configuration (`MCP_TRANSPORT`):
- `stdio` (default) for local subprocess agents.
- `http` for hosted/remote setups (Streamable HTTP, requires `MCP_API_KEY` authentication).

Full documentation: [examples/mcp-server/README.md](examples/mcp-server/README.md)

### WebSocket backpressure
Expand Down
33 changes: 32 additions & 1 deletion examples/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ paid call from the mainnet account.
| `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`) |
| `MCP_TRANSPORT` | No | Transport mode: `stdio` (default) or `http` |
| `MCP_API_KEY` | If HTTP | Shared secret for authenticating incoming HTTP requests. Refuses to start if missing when `MCP_TRANSPORT=http` |
| `MCP_HTTP_PORT` | No | Port for HTTP transport (default: `3001` or `PORT`) |
| `MCP_HTTP_HOST` | No | Host interface for HTTP transport (default: `0.0.0.0`) |

Both keys are required: `run.ts` exits with an error if either is unset.
When running with `MCP_TRANSPORT=http`, `MCP_API_KEY` is also strictly required.

The server talks to two Stellar networks:
- `stellar:testnet` — safe, friendbot-funded play money
Expand Down Expand Up @@ -64,13 +69,39 @@ export MCP_AGENT_SECRET_KEY_MAINNET=<your-empty-mainnet-secret-key>
npm run mcp:server
```

The server starts on stdio and logs:
The server starts on stdio (default) and logs:

```
[mcp-server] Starting Bazaar MCP server on stdio...
[mcp-server] Agent configured for stellar:testnet and stellar:pubnet
```

### Running on HTTP Transport (hosted / remote)

To run the MCP server over HTTP/SSE instead of stdio, set `MCP_TRANSPORT=http` and provide `MCP_API_KEY`:

```bash
export MCP_AGENT_SECRET_KEY_TESTNET=<your-testnet-secret-key>
export MCP_AGENT_SECRET_KEY_MAINNET=<your-empty-mainnet-secret-key>
export MCP_TRANSPORT=http
export MCP_API_KEY=<your-secret-api-key>
export MCP_HTTP_PORT=3001 # optional, defaults to 3001
npm run mcp:server
```

The server starts on HTTP and logs:

```
[mcp-server] Starting Bazaar MCP server on HTTP transport (port 3001)...
[mcp-server] Authentication required via Authorization: Bearer <MCP_API_KEY> or x-api-key header
[mcp-server] Agent configured for stellar:testnet and stellar:pubnet
```

**Transport Selection & Purpose:**
- **`stdio` (Default):** Best for local developer use and CLI agents spawned as child processes on the same machine.
- **`http`:** Streamable HTTP transport for remote or hosted Lens deployments where agents connect over the network. Every incoming HTTP request must include authentication (`Authorization: Bearer <MCP_API_KEY>` or `x-api-key: <MCP_API_KEY>`), refusing unauthenticated calls with HTTP 401. Both transports expose the exact same tools (`bazaar_search` and `paid_call`).


In another terminal, run the agent example, which connects an MCP client,
lists the tools, and calls both of them:

Expand Down
14 changes: 13 additions & 1 deletion examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,19 @@ async function main() {
paidCallAllowlist
});

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
const transport = process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio';
const port = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : (process.env.PORT ? parseInt(process.env.PORT, 10) : 3001);

if (transport === 'http') {
if (!process.env.MCP_API_KEY) {
console.error('[mcp-server] Error: MCP_API_KEY must be set when running with MCP_TRANSPORT=http');
process.exit(1);
}
console.error(`[mcp-server] Starting Bazaar MCP server on HTTP transport (port ${port})...`);
console.error('[mcp-server] Authentication required via Authorization: Bearer <MCP_API_KEY> or x-api-key header');
} else {
console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
}
console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet');
console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`);
// Limits and file paths only: the signing key is never logged.
Expand Down
119 changes: 118 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import http from 'node:http';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
Expand All @@ -8,6 +9,8 @@ import {
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { MCP_ERROR_CODES, McpErrorCode, classifyPaidCallError } from './codes.js';
import { SpendBudget } from './budget.js';
import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js';
Expand Down Expand Up @@ -72,6 +75,40 @@ function hostOf(url: string): string {
}
}

export interface McpRunOptions {
/**
* Transport to use: 'stdio' (default) connects via stdin/stdout;
* 'http' starts an HTTP streamable transport server.
* Can also be controlled via the MCP_TRANSPORT environment variable.
*/
transport?: 'stdio' | 'http';
/**
* Port for the HTTP transport server.
* Defaults to MCP_HTTP_PORT || PORT || 3001.
*/
port?: number;
/**
* Host to bind the HTTP transport server.
* Defaults to MCP_HTTP_HOST || '0.0.0.0'.
*/
host?: string;
/**
* API key required for HTTP authentication.
* When HTTP transport is selected, requests must provide this key
* in `Authorization: Bearer <key>` or `x-api-key: <key>`.
* Can also be provided via the MCP_API_KEY environment variable.
* If unset when HTTP transport is used, the server refuses to start.
*/
apiKey?: string;
}

export interface McpRunningInstance {
transport: 'stdio' | 'http';
httpServer?: http.Server;
port?: number;
close: () => Promise<void>;
}

export interface McpServerConfig {
fetchWithPayment: typeof fetch;
/**
Expand Down Expand Up @@ -581,8 +618,88 @@ export class BazaarMcpServer {
};
}

async run() {
/**
* Connects the server to the given transport. Prefer this over `run()` when
* you manage the transport yourself.
*/
async connect(transport: Transport): Promise<void> {
await this.server.connect(transport);
}

/**
* Starts the MCP server on the configured transport.
* - Defaults to `stdio` unless configured otherwise.
* - Can be switched to `http` via `options.transport = 'http'` or `MCP_TRANSPORT=http`.
* - HTTP exposure strictly requires authentication (via `apiKey` or `MCP_API_KEY`)
* and refuses to start without one.
*/
async run(options: McpRunOptions = {}): Promise<McpRunningInstance> {
const transportType = options.transport ?? (process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio');

if (transportType === 'http') {
const apiKey = options.apiKey ?? process.env.MCP_API_KEY;
if (!apiKey || apiKey.trim() === '') {
throw new Error('MCP HTTP transport requires authentication: set MCP_API_KEY or provide apiKey in options');
}

const port = options.port ?? (process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : (process.env.PORT ? parseInt(process.env.PORT, 10) : 3001));
const host = options.host ?? process.env.MCP_HTTP_HOST ?? '0.0.0.0';

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await this.server.connect(transport);

const httpServer = http.createServer(async (req, res) => {
// Enforce authentication on all incoming requests
const authHeader = req.headers['authorization'];
const apiKeyHeader = req.headers['x-api-key'];
const token = authHeader?.startsWith('Bearer ')
? authHeader.slice(7).trim()
: (typeof apiKeyHeader === 'string' ? apiKeyHeader.trim() : undefined);

if (!token || token !== apiKey) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized: valid API key required for MCP HTTP transport' }));
return;
}

try {
await transport.handleRequest(req, res);
} catch (err: any) {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err?.message || 'Internal error' }));
}
}
});

await new Promise<void>((resolve, reject) => {
httpServer.listen(port, host, () => resolve());
httpServer.on('error', reject);
});

const actualPort = (httpServer.address() as any)?.port ?? port;

return {
transport: 'http',
httpServer,
port: actualPort,
close: async () => {
await transport.close();
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
},
};
}

const transport = new StdioServerTransport();
await this.server.connect(transport);

return {
transport: 'stdio',
close: async () => {
await transport.close();
},
};
}
}
103 changes: 103 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ describe('BazaarMcpServer bazaar_search upstream failures (#349)', () => {
});
});


import { classifyPaidCallError } from '../src/mcp/codes';

function captureHandler(server: BazaarMcpServer): any {
Expand Down Expand Up @@ -854,3 +855,105 @@ describe('cumulative spend budget', () => {
expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/);
})
})

describe('BazaarMcpServer — Transports (#419)', () => {
it('defaults to stdio transport when no options or env var is specified', async () => {
const oldEnv = process.env.MCP_TRANSPORT;
delete process.env.MCP_TRANSPORT;
try {
const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() });
const running = await server.run();
expect(running.transport).toBe('stdio');
await running.close();
} finally {
if (oldEnv) process.env.MCP_TRANSPORT = oldEnv;
}
});

it('refuses to start HTTP transport when apiKey / MCP_API_KEY is missing', async () => {
const oldKey = process.env.MCP_API_KEY;
delete process.env.MCP_API_KEY;
try {
const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() });
await expect(server.run({ transport: 'http' })).rejects.toThrow(
/MCP HTTP transport requires authentication/i
);
} finally {
if (oldKey) process.env.MCP_API_KEY = oldKey;
}
});

it('starts HTTP transport with valid apiKey and enforces authentication on requests', async () => {
const apiKey = 'test-secret-key-12345';
const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() });
// Bind to port 0 (dynamic port) to avoid collisions
const running = await server.run({ transport: 'http', port: 0, host: '127.0.0.1', apiKey });
expect(running.transport).toBe('http');
expect(running.port).toBeGreaterThan(0);

const baseUrl = `http://127.0.0.1:${running.port}`;

try {
// 1. Request without auth should be rejected with 401
const unauthRes = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});
expect(unauthRes.status).toBe(401);
const unauthJson = await unauthRes.json();
expect(unauthJson.error).toMatch(/Unauthorized/i);

// 2. Request with invalid key should be rejected with 401
const badAuthRes = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer wrong-key',
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});
expect(badAuthRes.status).toBe(401);

// 3. Request with valid x-api-key header should be accepted (not 401)
const validApiKeyRes = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});
expect(validApiKeyRes.status).not.toBe(401);

// 4. Request with valid Bearer token should be accepted (not 401)
const validBearerRes = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});
expect(validBearerRes.status).not.toBe(401);
} finally {
await running.close();
}
});

it('exposes identical tools across both stdio and HTTP setups', async () => {
const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() });
let listToolsHandler: any = null;
(server as any).server.setRequestHandler = (schema: any, fn: any) => {
if (schema === ListToolsRequestSchema) {
listToolsHandler = fn;
}
};
(server as any).setupHandlers();

expect(listToolsHandler).toBeTruthy();
const res = await listToolsHandler({ method: 'tools/list', params: {} });
const toolNames = res.tools.map((t: any) => t.name).sort();
expect(toolNames).toEqual(['bazaar_search', 'paid_call']);
});
});
Loading