@chartobserver/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChartObserver Corp.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # `@chartobserver/mcp-server`
2
+
3
+ An MCP (Model Context Protocol) server that lets an AI agent — Claude Desktop, etc. — read your portfolio, place paper trades, and check the leaderboard on your [ChartObserver](https://chart.observer) account.
4
+
5
+ ChartObserver is paper trading. This server cannot move real money. It can affect your public leaderboard standing and your visible portfolio.
6
+
7
+ ## Install
8
+
9
+ You don't install this package directly. You add it to your MCP client's configuration and it runs on demand via `npx`.
10
+
11
+ ### Claude Desktop
12
+
13
+ Open your Claude Desktop config file:
14
+
15
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
16
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
17
+
18
+ Add a `chartobserver` entry under `mcpServers`:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "chartobserver": {
24
+ "command": "npx",
25
+ "args": ["-y", "@chartobserver/mcp-server"],
26
+ "env": {
27
+ "CHARTOBSERVER_WEBHOOK_ID": "your-webhook-id-here",
28
+ "CHARTOBSERVER_UID": "your-uid-here",
29
+ "CHARTOBSERVER_USERNAME": "your-username-here"
30
+ }
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Restart Claude Desktop. The tools become available in any conversation.
37
+
38
+ ### Where to find your credentials
39
+
40
+ Sign in at https://chart.observer and open **Settings → API & Integrations**. The page shows your webhook ID, UID, and username with copy buttons and a pre-filled config snippet.
41
+
42
+ (Until that settings page ships, ask Brian for your three values.)
43
+
44
+ ## Environment variables
45
+
46
+ | Variable | Required | Default | Description |
47
+ |---|---|---|---|
48
+ | `CHARTOBSERVER_WEBHOOK_ID` | yes | — | Your per-user webhook secret. Same value TradingView uses to fire trades into your account. Treat like a password. |
49
+ | `CHARTOBSERVER_UID` | yes | — | Your numeric user ID. |
50
+ | `CHARTOBSERVER_USERNAME` | yes | — | Your public username. |
51
+ | `CHARTOBSERVER_API_BASE` | no | `https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/dev` | API Gateway base URL. Override to point at staging during testing. |
52
+
53
+ ## Available tools
54
+
55
+ ### Account
56
+
57
+ | Tool | What it does |
58
+ |---|---|
59
+ | `get_profile` | Read your public profile and current USD balance. |
60
+
61
+ ### Trading
62
+
63
+ | Tool | What it does |
64
+ |---|---|
65
+ | `place_trade` | Place a buy or sell. **Defaults to `dry_run: true`** — returns the projected impact without executing. Set `dry_run: false` only after confirming with the user. |
66
+ | `get_balance` | Current USD balance. |
67
+ | `get_open_positions` | Open positions, grouped by token pair with average cost basis. |
68
+ | `get_closed_trades` | Recent closed trades (completed buy→sell roundtrips). |
69
+ | `get_recent_transactions` | Recent raw transactions (open + closed). |
70
+
71
+ ### Market
72
+
73
+ | Tool | What it does |
74
+ |---|---|
75
+ | `get_leaderboard` | 7-day rolling leaderboard: top traders by average % profit. |
76
+ | `get_my_ranking` | Your position on the leaderboard (or `null` if not ranked). |
77
+ | `get_price` | Current price for a crypto pair (e.g. `BTCUSD`). |
78
+
79
+ ### Portfolio
80
+
81
+ | Tool | What it does |
82
+ |---|---|
83
+ | `get_portfolio_summary` | One-shot snapshot: balance + open positions + recent closed trades + your leaderboard rank. Designed for periodic polling — compare snapshots to detect changes. |
84
+
85
+ ## Safety model
86
+
87
+ - **Paper trading only.** Trades affect your simulated portfolio and your leaderboard standing. They do not move real money.
88
+ - **`place_trade` defaults to dry-run.** The AI agent must explicitly pass `dry_run: false` to execute. You should be asked for confirmation before that happens.
89
+ - **Bearer-secret auth.** The webhook ID acts as a bearer token. If it leaks, anyone can act on your account. Don't paste it into screenshots, logs, or chat messages. If you suspect compromise, regenerate it from Settings → API & Integrations.
90
+ - **No account creation.** Sign up at https://chart.observer in a browser. Web signup requires a CAPTCHA, which a headless MCP server can't solve.
91
+
92
+ ## What's not in v1
93
+
94
+ - Real-time push notifications (poll `get_portfolio_summary` instead).
95
+ - Per-token rotation, multiple tokens, scoped tokens, expiry — these will come in a later revision that hardens the webhook-ID lifecycle.
96
+ - Equities/options/forex — the platform is crypto-only paper trading today.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ npm install
102
+ npm run typecheck
103
+ npm test
104
+ npm run build
105
+ ```
106
+
107
+ To test locally against staging, build then point Claude Desktop at the local file:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "chartobserver-local": {
113
+ "command": "node",
114
+ "args": ["/absolute/path/to/chartobserver/mcp-server/dist/index.js"],
115
+ "env": {
116
+ "CHARTOBSERVER_API_BASE": "https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/staging",
117
+ "CHARTOBSERVER_WEBHOOK_ID": "...",
118
+ "CHARTOBSERVER_UID": "...",
119
+ "CHARTOBSERVER_USERNAME": "..."
120
+ }
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ ## Repo layout
127
+
128
+ ```
129
+ src/
130
+ index.ts # MCP server entry, registers tools
131
+ config.ts # Loads + validates env vars
132
+ api-client.ts # HTTP client for ChartObserver API
133
+ tools/
134
+ account.ts
135
+ trading.ts
136
+ market.ts
137
+ portfolio.ts
138
+ util.ts
139
+ __tests__/
140
+ api-client.test.ts
141
+ config.test.ts
142
+ ```
@@ -0,0 +1,97 @@
1
+ import type { Config } from "./config.js";
2
+ export interface Transaction {
3
+ userId: string;
4
+ txnId: string;
5
+ entityType: "buy" | "sell" | "reconcile";
6
+ amount: number;
7
+ exchange: number;
8
+ isOpen: boolean;
9
+ txnPrice: number;
10
+ tokenPair: string;
11
+ txnDate: string;
12
+ closeDate?: string;
13
+ closePrice?: number;
14
+ costBasis?: number;
15
+ txnSource: string;
16
+ strategyAuthor?: string;
17
+ strategyInterval?: string;
18
+ }
19
+ export interface ClosedTrade {
20
+ userId: string;
21
+ username: string;
22
+ txnId: string;
23
+ tokenPair: string;
24
+ amount: number;
25
+ avgPrice: number;
26
+ closePrice: number;
27
+ openDate: string;
28
+ closeDate: string;
29
+ ymd: string;
30
+ strategyAuthor?: string;
31
+ strategyInterval?: string;
32
+ }
33
+ export interface PublicProfile {
34
+ description?: string;
35
+ showPortfolio?: string;
36
+ copyTradable?: string;
37
+ instagramURL?: string;
38
+ tradingviewURL?: string;
39
+ twitterURL?: string;
40
+ youtubeURL?: string;
41
+ telegramURL?: string;
42
+ farcasterURL?: string;
43
+ subscription?: string;
44
+ followers?: number;
45
+ following?: number;
46
+ userId?: string;
47
+ }
48
+ export interface LeaderboardEntry {
49
+ username: string;
50
+ tradeCount: number;
51
+ avgProfit: number;
52
+ largestProfit: number;
53
+ strategy?: string;
54
+ interval?: string;
55
+ }
56
+ export interface LeaderboardResponse {
57
+ [windowDays: string]: {
58
+ topTraders?: LeaderboardEntry[];
59
+ leaderBoard?: unknown[];
60
+ };
61
+ }
62
+ export declare class ChartObserverApiError extends Error {
63
+ readonly status: number;
64
+ readonly path: string;
65
+ readonly bodyText: string;
66
+ constructor(status: number, path: string, bodyText: string);
67
+ }
68
+ type FetchLike = (input: string, init?: {
69
+ method?: string;
70
+ headers?: Record<string, string>;
71
+ body?: string;
72
+ }) => Promise<{
73
+ ok: boolean;
74
+ status: number;
75
+ text: () => Promise<string>;
76
+ }>;
77
+ export declare class ChartObserverClient {
78
+ readonly config: Config;
79
+ private readonly fetchFn;
80
+ constructor(config: Config, fetchFn?: FetchLike);
81
+ private request;
82
+ getBalance(): Promise<number>;
83
+ getOpenPositions(): Promise<Transaction[]>;
84
+ getClosedPositions(): Promise<Transaction[]>;
85
+ getRecentTransactions(): Promise<Transaction[]>;
86
+ getLeaderboard(): Promise<LeaderboardResponse>;
87
+ getPrice(tokenPair: string): Promise<number>;
88
+ getPublicProfile(username: string): Promise<PublicProfile>;
89
+ placeTrade(args: {
90
+ tokenPair: string;
91
+ action: "buy" | "sell";
92
+ count: string | number;
93
+ }): Promise<{
94
+ message: string;
95
+ }>;
96
+ }
97
+ export {};
@@ -0,0 +1,83 @@
1
+ export class ChartObserverApiError extends Error {
2
+ status;
3
+ path;
4
+ bodyText;
5
+ constructor(status, path, bodyText) {
6
+ super(`ChartObserver API ${status} on ${path}: ${bodyText.slice(0, 500)}`);
7
+ this.status = status;
8
+ this.path = path;
9
+ this.bodyText = bodyText;
10
+ this.name = "ChartObserverApiError";
11
+ }
12
+ }
13
+ export class ChartObserverClient {
14
+ config;
15
+ fetchFn;
16
+ constructor(config, fetchFn = globalThis.fetch) {
17
+ this.config = config;
18
+ this.fetchFn = fetchFn;
19
+ }
20
+ async request(path, opts = {}) {
21
+ const url = `${this.config.apiBase}${path}`;
22
+ const res = await this.fetchFn(url, {
23
+ method: opts.method ?? "GET",
24
+ headers: {
25
+ "Content-Type": "application/json",
26
+ "User-Agent": this.config.userAgent,
27
+ "X-Client": this.config.userAgent,
28
+ },
29
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
30
+ });
31
+ const text = await res.text();
32
+ if (!res.ok) {
33
+ throw new ChartObserverApiError(res.status, path, text);
34
+ }
35
+ if (!text)
36
+ return {};
37
+ return JSON.parse(text);
38
+ }
39
+ async getBalance() {
40
+ const result = await this.request(`/users/balance/${encodeURIComponent(this.config.uid)}`);
41
+ if (!Array.isArray(result) || result.length === 0) {
42
+ throw new Error("Balance response was empty");
43
+ }
44
+ return Number(result[0].usdBalance);
45
+ }
46
+ async getOpenPositions() {
47
+ return this.request(`/positions/open/${encodeURIComponent(this.config.uid)}`);
48
+ }
49
+ async getClosedPositions() {
50
+ return this.request(`/positions/closed/${encodeURIComponent(this.config.uid)}`);
51
+ }
52
+ async getRecentTransactions() {
53
+ return this.request(`/transactions/${encodeURIComponent(this.config.uid)}`);
54
+ }
55
+ async getLeaderboard() {
56
+ return this.request("/leaderboard");
57
+ }
58
+ async getPrice(tokenPair) {
59
+ const result = await this.request(`/token/price/${encodeURIComponent(tokenPair)}`);
60
+ const raw = result?.data?.amount ?? result?.amount ?? result?.price ?? NaN;
61
+ const n = Number(raw);
62
+ if (!Number.isFinite(n)) {
63
+ throw new Error(`Unrecognized price response shape for ${tokenPair}: ${JSON.stringify(result)}`);
64
+ }
65
+ return n;
66
+ }
67
+ async getPublicProfile(username) {
68
+ const result = await this.request(`/user/profile/${encodeURIComponent(username)}`);
69
+ return result?.data ?? {};
70
+ }
71
+ async placeTrade(args) {
72
+ return this.request(`/transaction/${encodeURIComponent(this.config.webhookId)}`, {
73
+ method: "POST",
74
+ body: {
75
+ tokenpair: args.tokenPair,
76
+ action: args.action,
77
+ count: String(args.count),
78
+ user: this.config.uid,
79
+ exchange: "coinbase",
80
+ },
81
+ });
82
+ }
83
+ }
@@ -0,0 +1,10 @@
1
+ export interface Config {
2
+ apiBase: string;
3
+ webhookId: string;
4
+ uid: string;
5
+ username: string;
6
+ userAgent: string;
7
+ }
8
+ export declare const DEFAULT_API_BASE = "https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/dev";
9
+ export declare const PACKAGE_VERSION = "0.1.0";
10
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js ADDED
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_API_BASE = "https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/dev";
2
+ export const PACKAGE_VERSION = "0.1.0";
3
+ export function loadConfig(env = process.env) {
4
+ const webhookId = env.CHARTOBSERVER_WEBHOOK_ID?.trim();
5
+ const uid = env.CHARTOBSERVER_UID?.trim();
6
+ const username = env.CHARTOBSERVER_USERNAME?.trim();
7
+ const missing = [];
8
+ if (!webhookId)
9
+ missing.push("CHARTOBSERVER_WEBHOOK_ID");
10
+ if (!uid)
11
+ missing.push("CHARTOBSERVER_UID");
12
+ if (!username)
13
+ missing.push("CHARTOBSERVER_USERNAME");
14
+ if (missing.length > 0) {
15
+ throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. Configure them in your MCP client's mcpServers entry. See README.`);
16
+ }
17
+ return {
18
+ apiBase: (env.CHARTOBSERVER_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/+$/, ""),
19
+ webhookId: webhookId,
20
+ uid: uid,
21
+ username: username,
22
+ userAgent: `chartobserver-mcp/${PACKAGE_VERSION}`,
23
+ };
24
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { loadConfig, PACKAGE_VERSION } from "./config.js";
5
+ import { ChartObserverClient } from "./api-client.js";
6
+ import { registerAccountTools } from "./tools/account.js";
7
+ import { registerTradingTools } from "./tools/trading.js";
8
+ import { registerMarketTools } from "./tools/market.js";
9
+ import { registerPortfolioTools } from "./tools/portfolio.js";
10
+ async function main() {
11
+ const config = loadConfig();
12
+ const client = new ChartObserverClient(config);
13
+ const server = new McpServer({
14
+ name: "chartobserver",
15
+ version: PACKAGE_VERSION,
16
+ });
17
+ registerAccountTools(server, client);
18
+ registerTradingTools(server, client);
19
+ registerMarketTools(server, client);
20
+ registerPortfolioTools(server, client);
21
+ const transport = new StdioServerTransport();
22
+ await server.connect(transport);
23
+ }
24
+ main().catch((err) => {
25
+ // Stdio MCP uses stdout for protocol; errors must go to stderr.
26
+ process.stderr.write(`chartobserver-mcp fatal: ${err?.stack ?? err}\n`);
27
+ process.exit(1);
28
+ });
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ChartObserverClient } from "../api-client.js";
3
+ export declare function registerAccountTools(server: McpServer, client: ChartObserverClient): void;
@@ -0,0 +1,24 @@
1
+ import { ok, fail } from "./util.js";
2
+ export function registerAccountTools(server, client) {
3
+ server.registerTool("get_profile", {
4
+ title: "Get profile",
5
+ description: "Fetch the currently configured user's public profile (description, social links, follower counts) along with their USD paper-trading balance. Read-only.",
6
+ inputSchema: {},
7
+ }, async () => {
8
+ try {
9
+ const [profile, balance] = await Promise.all([
10
+ client.getPublicProfile(client.config.username),
11
+ client.getBalance(),
12
+ ]);
13
+ return ok({
14
+ username: client.config.username,
15
+ uid: client.config.uid,
16
+ balance,
17
+ profile,
18
+ });
19
+ }
20
+ catch (e) {
21
+ return fail("get_profile", e);
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ChartObserverClient } from "../api-client.js";
3
+ export declare function registerMarketTools(server: McpServer, client: ChartObserverClient): void;
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { ok, fail } from "./util.js";
3
+ function findUserInLeaderboard(entries, username) {
4
+ if (!entries)
5
+ return null;
6
+ const idx = entries.findIndex((e) => e.username?.toLowerCase() === username.toLowerCase());
7
+ if (idx === -1)
8
+ return null;
9
+ return { rank: idx + 1, entry: entries[idx] };
10
+ }
11
+ export function registerMarketTools(server, client) {
12
+ server.registerTool("get_leaderboard", {
13
+ title: "Get leaderboard",
14
+ description: "Fetch the 7-day rolling ChartObserver leaderboard: top traders by average % profit per closed trade, plus the top individual closed trades. Public data.",
15
+ inputSchema: {
16
+ limit: z
17
+ .number()
18
+ .int()
19
+ .positive()
20
+ .max(100)
21
+ .default(25)
22
+ .describe("Maximum number of top-traders rows to return."),
23
+ },
24
+ }, async ({ limit }) => {
25
+ try {
26
+ const board = await client.getLeaderboard();
27
+ const week = board["7"] ?? Object.values(board)[0];
28
+ const topTraders = (week?.topTraders ?? []).slice(0, limit);
29
+ return ok({
30
+ windowDays: 7,
31
+ topTraders,
32
+ topTradeCount: week?.leaderBoard?.length ?? 0,
33
+ });
34
+ }
35
+ catch (e) {
36
+ return fail("get_leaderboard", e);
37
+ }
38
+ });
39
+ server.registerTool("get_my_ranking", {
40
+ title: "Get my leaderboard rank",
41
+ description: "Find the configured user's position on the 7-day leaderboard, if they appear. Returns null rank if not on the board.",
42
+ inputSchema: {},
43
+ }, async () => {
44
+ try {
45
+ const board = await client.getLeaderboard();
46
+ const week = board["7"] ?? Object.values(board)[0];
47
+ const found = findUserInLeaderboard(week?.topTraders, client.config.username);
48
+ return ok({
49
+ username: client.config.username,
50
+ rank: found?.rank ?? null,
51
+ entry: found?.entry ?? null,
52
+ totalRanked: week?.topTraders?.length ?? 0,
53
+ });
54
+ }
55
+ catch (e) {
56
+ return fail("get_my_ranking", e);
57
+ }
58
+ });
59
+ server.registerTool("get_price", {
60
+ title: "Get current price",
61
+ description: "Fetch the latest USD price for a crypto pair from the ChartObserver price cache.",
62
+ inputSchema: {
63
+ tokenpair: z
64
+ .string()
65
+ .max(20)
66
+ .describe("Pair like BTCUSD, ETHUSD, SOLUSDT (no slash)."),
67
+ },
68
+ }, async ({ tokenpair }) => {
69
+ try {
70
+ const price = await client.getPrice(tokenpair.toUpperCase());
71
+ return ok({ tokenpair: tokenpair.toUpperCase(), price });
72
+ }
73
+ catch (e) {
74
+ return fail("get_price", e);
75
+ }
76
+ });
77
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ChartObserverClient } from "../api-client.js";
3
+ export declare function registerPortfolioTools(server: McpServer, client: ChartObserverClient): void;
@@ -0,0 +1,59 @@
1
+ import { ok, fail } from "./util.js";
2
+ function groupOpenPositions(positions) {
3
+ const grouped = {};
4
+ for (const p of positions) {
5
+ if (!p.isOpen)
6
+ continue;
7
+ const amt = Number(p.amount);
8
+ const price = Number(p.txnPrice);
9
+ if (!Number.isFinite(amt) || !Number.isFinite(price))
10
+ continue;
11
+ if (!grouped[p.tokenPair])
12
+ grouped[p.tokenPair] = { amount: 0, cost: 0 };
13
+ grouped[p.tokenPair].amount += amt;
14
+ grouped[p.tokenPair].cost += amt * price;
15
+ }
16
+ return Object.entries(grouped).map(([tokenPair, v]) => ({
17
+ tokenPair,
18
+ totalAmount: v.amount,
19
+ avgCostBasis: v.amount === 0 ? 0 : v.cost / v.amount,
20
+ }));
21
+ }
22
+ export function registerPortfolioTools(server, client) {
23
+ server.registerTool("get_portfolio_summary", {
24
+ title: "Get portfolio summary",
25
+ description: "One-call snapshot of the configured user's portfolio: USD balance, open positions grouped by token (with average cost basis), the 5 most recent closed trades, and the user's current leaderboard rank if any. Designed for periodic polling — agents can compare consecutive snapshots to detect changes.",
26
+ inputSchema: {},
27
+ }, async () => {
28
+ try {
29
+ const [balance, openPositions, closedTrades, leaderboard] = await Promise.all([
30
+ client.getBalance(),
31
+ client.getOpenPositions(),
32
+ client.getClosedPositions(),
33
+ client.getLeaderboard().catch(() => null),
34
+ ]);
35
+ const week = leaderboard?.["7"] ??
36
+ (leaderboard ? Object.values(leaderboard)[0] : undefined);
37
+ const myEntryIdx = week?.topTraders?.findIndex((e) => e.username?.toLowerCase() ===
38
+ client.config.username.toLowerCase()) ?? -1;
39
+ const grouped = groupOpenPositions(openPositions);
40
+ const recentClosed = closedTrades.slice(0, 5);
41
+ return ok({
42
+ asOf: new Date().toISOString(),
43
+ username: client.config.username,
44
+ uid: client.config.uid,
45
+ usdBalance: balance,
46
+ openPositions: grouped,
47
+ openPositionCount: openPositions.length,
48
+ recentClosedTrades: recentClosed,
49
+ leaderboardRank7d: myEntryIdx === -1 ? null : myEntryIdx + 1,
50
+ leaderboardEntry: myEntryIdx === -1
51
+ ? null
52
+ : (week?.topTraders?.[myEntryIdx] ?? null),
53
+ });
54
+ }
55
+ catch (e) {
56
+ return fail("get_portfolio_summary", e);
57
+ }
58
+ });
59
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ChartObserverClient } from "../api-client.js";
3
+ export declare function registerTradingTools(server: McpServer, client: ChartObserverClient): void;
@@ -0,0 +1,220 @@
1
+ import { z } from "zod";
2
+ import { ok, fail } from "./util.js";
3
+ const TOKEN_PAIR_PATTERN = /^[A-Za-z0-9]+$/;
4
+ function summarizeOpenPositionsByToken(positions) {
5
+ const grouped = {};
6
+ for (const p of positions) {
7
+ if (!p.isOpen)
8
+ continue;
9
+ const key = p.tokenPair;
10
+ const amount = Number(p.amount);
11
+ const price = Number(p.txnPrice);
12
+ if (!Number.isFinite(amount) || !Number.isFinite(price))
13
+ continue;
14
+ if (!grouped[key])
15
+ grouped[key] = { totalAmount: 0, totalCost: 0 };
16
+ grouped[key].totalAmount += amount;
17
+ grouped[key].totalCost += amount * price;
18
+ }
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(grouped)) {
21
+ out[k] = {
22
+ totalAmount: v.totalAmount,
23
+ avgCostBasis: v.totalAmount === 0 ? 0 : v.totalCost / v.totalAmount,
24
+ };
25
+ }
26
+ return out;
27
+ }
28
+ function resolveSellQuantity(countInput, availableTokens) {
29
+ if (countInput.includes("%")) {
30
+ const pct = parseFloat(countInput.replace("%", ""));
31
+ if (!Number.isFinite(pct)) {
32
+ throw new Error(`Invalid percent value: ${countInput}`);
33
+ }
34
+ return (pct / 100) * availableTokens;
35
+ }
36
+ const n = parseFloat(countInput);
37
+ if (!Number.isFinite(n)) {
38
+ throw new Error(`Invalid numeric count: ${countInput}`);
39
+ }
40
+ return n;
41
+ }
42
+ export function registerTradingTools(server, client) {
43
+ server.registerTool("place_trade", {
44
+ title: "Place a paper trade",
45
+ description: [
46
+ "Place a paper-trading buy or sell on the ChartObserver platform for the configured user.",
47
+ "",
48
+ "IMPORTANT SAFETY NOTES:",
49
+ "- Defaults to dry_run=true. With dry_run=true, NO trade is executed; the tool returns the would-be impact (cost, resulting balance, resulting position). Always start with dry_run=true and present the result to the user for confirmation before calling again with dry_run=false.",
50
+ "- This is paper trading (simulated). It does NOT move real funds. It DOES affect the user's leaderboard standing and visible portfolio.",
51
+ "- Crypto pairs only. Pair format is no-slash (e.g. BTCUSD, ETHUSDT).",
52
+ "- Sell `count` may be a percentage string like '50%' or '100%'. Buy `count` must be a numeric quantity.",
53
+ "- Buys require sufficient USD balance. Sells cannot exceed currently held tokens.",
54
+ ].join("\n"),
55
+ inputSchema: {
56
+ tokenpair: z
57
+ .string()
58
+ .regex(TOKEN_PAIR_PATTERN)
59
+ .max(20)
60
+ .describe("Trading pair in no-slash format, e.g. BTCUSD, ETHUSD, SOLUSDT."),
61
+ action: z
62
+ .enum(["buy", "sell"])
63
+ .describe("Trade direction."),
64
+ count: z
65
+ .union([z.number().positive(), z.string()])
66
+ .describe("Quantity of base token to trade. For sells, may be a percentage string ('50%', '100%') of currently held tokens. For buys, must be a positive number."),
67
+ dry_run: z
68
+ .boolean()
69
+ .default(true)
70
+ .describe("When true (default), returns the projected impact without executing. Set to false ONLY after confirming with the user."),
71
+ },
72
+ }, async ({ tokenpair, action, count, dry_run }) => {
73
+ try {
74
+ if (dry_run) {
75
+ const [price, balance, openPositions] = await Promise.all([
76
+ client.getPrice(tokenpair.toUpperCase()),
77
+ client.getBalance(),
78
+ client.getOpenPositions(),
79
+ ]);
80
+ const grouped = summarizeOpenPositionsByToken(openPositions);
81
+ const held = grouped[tokenpair.toUpperCase()]?.totalAmount ?? 0;
82
+ const heldCostBasis = grouped[tokenpair.toUpperCase()]?.avgCostBasis ?? 0;
83
+ if (action === "buy") {
84
+ if (String(count).includes("%")) {
85
+ return fail("place_trade", new Error("Buy orders may not use a percentage count."));
86
+ }
87
+ const n = Number(count);
88
+ if (!Number.isFinite(n) || n <= 0) {
89
+ return fail("place_trade", new Error("Buy count must be a positive number."));
90
+ }
91
+ const cost = n * price;
92
+ return ok({
93
+ dry_run: true,
94
+ action,
95
+ tokenpair: tokenpair.toUpperCase(),
96
+ currentPrice: price,
97
+ count: n,
98
+ estimatedCost: cost,
99
+ currentBalance: balance,
100
+ projectedBalance: balance - cost,
101
+ wouldSucceed: balance >= cost,
102
+ note: balance < cost
103
+ ? `Insufficient funds: need ${cost.toFixed(2)} USD, have ${balance.toFixed(2)} USD.`
104
+ : "Confirm with the user, then re-call with dry_run=false to execute.",
105
+ });
106
+ }
107
+ // sell
108
+ const sellQty = resolveSellQuantity(String(count), held);
109
+ const proceeds = sellQty * price;
110
+ const wouldSucceed = held > 0 && sellQty <= held * 1.005;
111
+ return ok({
112
+ dry_run: true,
113
+ action,
114
+ tokenpair: tokenpair.toUpperCase(),
115
+ currentPrice: price,
116
+ count: sellQty,
117
+ currentHeld: held,
118
+ costBasis: heldCostBasis,
119
+ estimatedProceeds: proceeds,
120
+ estimatedPnL: (price - heldCostBasis) * sellQty,
121
+ currentBalance: balance,
122
+ projectedBalance: balance + proceeds,
123
+ wouldSucceed,
124
+ note: held === 0
125
+ ? "No open position for this token — sell would be rejected."
126
+ : sellQty > held * 1.005
127
+ ? `Sell quantity (${sellQty}) exceeds held (${held}) — would be rejected.`
128
+ : "Confirm with the user, then re-call with dry_run=false to execute.",
129
+ });
130
+ }
131
+ // Real execution
132
+ const res = await client.placeTrade({
133
+ tokenPair: tokenpair.toUpperCase(),
134
+ action,
135
+ count,
136
+ });
137
+ return ok({
138
+ dry_run: false,
139
+ executed: true,
140
+ response: res,
141
+ });
142
+ }
143
+ catch (e) {
144
+ return fail("place_trade", e);
145
+ }
146
+ });
147
+ server.registerTool("get_balance", {
148
+ title: "Get USD balance",
149
+ description: "Fetch the configured user's current USD paper-trading balance.",
150
+ inputSchema: {},
151
+ }, async () => {
152
+ try {
153
+ const balance = await client.getBalance();
154
+ return ok({ usdBalance: balance });
155
+ }
156
+ catch (e) {
157
+ return fail("get_balance", e);
158
+ }
159
+ });
160
+ server.registerTool("get_open_positions", {
161
+ title: "Get open positions",
162
+ description: "List all currently open paper-trading positions (buy transactions that have not yet been closed by a sell).",
163
+ inputSchema: {},
164
+ }, async () => {
165
+ try {
166
+ const positions = await client.getOpenPositions();
167
+ const groupedByToken = summarizeOpenPositionsByToken(positions);
168
+ return ok({
169
+ openTransactionCount: positions.length,
170
+ aggregateByToken: groupedByToken,
171
+ rawTransactions: positions,
172
+ });
173
+ }
174
+ catch (e) {
175
+ return fail("get_open_positions", e);
176
+ }
177
+ });
178
+ server.registerTool("get_closed_trades", {
179
+ title: "Get closed trades",
180
+ description: "List closed paper trades (completed buy→sell roundtrips) for the configured user, most recent first.",
181
+ inputSchema: {
182
+ limit: z
183
+ .number()
184
+ .int()
185
+ .positive()
186
+ .max(300)
187
+ .default(50)
188
+ .describe("Maximum number of closed trades to return (server may cap)."),
189
+ },
190
+ }, async ({ limit }) => {
191
+ try {
192
+ const trades = await client.getClosedPositions();
193
+ return ok(trades.slice(0, limit));
194
+ }
195
+ catch (e) {
196
+ return fail("get_closed_trades", e);
197
+ }
198
+ });
199
+ server.registerTool("get_recent_transactions", {
200
+ title: "Get recent transactions",
201
+ description: "List the configured user's recent transactions (open + closed, all types). Most recent first.",
202
+ inputSchema: {
203
+ limit: z
204
+ .number()
205
+ .int()
206
+ .positive()
207
+ .max(300)
208
+ .default(50)
209
+ .describe("Maximum number of transactions to return."),
210
+ },
211
+ }, async ({ limit }) => {
212
+ try {
213
+ const txns = await client.getRecentTransactions();
214
+ return ok(txns.slice(0, limit));
215
+ }
216
+ catch (e) {
217
+ return fail("get_recent_transactions", e);
218
+ }
219
+ });
220
+ }
@@ -0,0 +1,10 @@
1
+ export interface ToolResult {
2
+ [key: string]: unknown;
3
+ content: Array<{
4
+ type: "text";
5
+ text: string;
6
+ }>;
7
+ isError?: boolean;
8
+ }
9
+ export declare function ok(payload: unknown): ToolResult;
10
+ export declare function fail(toolName: string, error: unknown): ToolResult;
@@ -0,0 +1,29 @@
1
+ import { ChartObserverApiError } from "../api-client.js";
2
+ export function ok(payload) {
3
+ return {
4
+ content: [
5
+ {
6
+ type: "text",
7
+ text: typeof payload === "string"
8
+ ? payload
9
+ : JSON.stringify(payload, null, 2),
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ export function fail(toolName, error) {
15
+ let message;
16
+ if (error instanceof ChartObserverApiError) {
17
+ message = `${toolName} failed: HTTP ${error.status} from ${error.path}\n${error.bodyText.slice(0, 1000)}`;
18
+ }
19
+ else if (error instanceof Error) {
20
+ message = `${toolName} failed: ${error.message}`;
21
+ }
22
+ else {
23
+ message = `${toolName} failed: ${String(error)}`;
24
+ }
25
+ return {
26
+ content: [{ type: "text", text: message }],
27
+ isError: true,
28
+ };
29
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@chartobserver/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the ChartObserver paper-trading platform. Lets an AI agent (Claude Desktop, etc.) read portfolio state, place trades, and check the leaderboard on behalf of the configured user.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "chartobserver-mcp-server": "dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "rm -rf dist && tsc",
18
+ "prepublishOnly": "npm run build",
19
+ "start": "node dist/index.js",
20
+ "dev": "tsx src/index.ts",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.4",
27
+ "zod": "^3.23.8"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.14.0",
31
+ "tsx": "^4.16.0",
32
+ "typescript": "^5.5.0",
33
+ "vitest": "^1.6.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }