@chartobserver/mcp-server 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-06-12
4
+
5
+ Hardening & trust release. No breaking changes for users — configuration and
6
+ tool surface are unchanged.
7
+
8
+ ### Security
9
+
10
+ - **Webhook credential can no longer appear in tool output.** API errors now
11
+ carry a sanitized request label (e.g. `POST /transaction`) instead of the
12
+ raw URL path, and all error text returned to the agent passes through a
13
+ central secret-redaction backstop. Regression-tested.
14
+ - **Live trades now run the same validation as dry runs.** Previously,
15
+ `place_trade` with `dry_run: false` skipped the positive-count,
16
+ percentage-buy, sufficient-funds, and oversell checks. Execution now refuses
17
+ any trade the dry run would flag, without calling the API.
18
+ - **Config validation at startup**: numeric UID, minimum webhook-ID length,
19
+ `https:`-only API base. Clear, secret-free failure messages.
20
+ - An absent `dry_run` flag is treated as a dry run even if schema defaults are
21
+ bypassed (defense-in-depth).
22
+
23
+ ### Reliability
24
+
25
+ - Per-request timeout (default 15 s, override with `CHARTOBSERVER_TIMEOUT_MS`)
26
+ so a hung backend can no longer wedge the MCP client.
27
+ - Reads (GET) retry once on HTTP 429, honoring `Retry-After`. Trade execution
28
+ is **never** auto-retried.
29
+ - Trade execution sends a UUID `Idempotency-Key` header (forward-compat for
30
+ backend deduplication).
31
+
32
+ ### Trust & transparency
33
+
34
+ - All tools carry MCP annotations: read tools are `readOnlyHint`, and
35
+ `place_trade` is explicitly `destructiveHint` / non-idempotent.
36
+ - `SECURITY.md` with a full egress/data-flow disclosure and vulnerability
37
+ reporting process.
38
+ - Source published at https://github.com/bbusche/chartobserver-mcp;
39
+ `repository`/`homepage`/`bugs` metadata added; releases published from CI
40
+ with npm provenance.
41
+
42
+ ## 0.1.0 — 2026-06-09
43
+
44
+ Initial public release: read tools (profile, balance, positions, trades,
45
+ leaderboard, prices, portfolio summary) and `place_trade` with dry-run
46
+ default.
package/README.md CHANGED
@@ -4,6 +4,14 @@ An MCP (Model Context Protocol) server that lets an AI agent — Claude Desktop,
4
4
 
5
5
  ChartObserver is paper trading. This server cannot move real money. It can affect your public leaderboard standing and your visible portfolio.
6
6
 
7
+ Source code: https://github.com/bbusche/chartobserver-mcp — see [SECURITY.md](SECURITY.md) for the full egress/data-flow disclosure.
8
+
9
+ ## What this server sends and where
10
+
11
+ - Outbound HTTPS to **exactly one host**: the configured `CHARTOBSERVER_API_BASE` (default: the ChartObserver production API on AWS API Gateway, `https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/dev`).
12
+ - It transmits your UID, username, your webhook credential (as auth on trade execution), and the trade parameters the agent supplies. Nothing else.
13
+ - It reads **no** files, contacts **no** other host, collects **no** telemetry, runs **no** code fetched at runtime, and has **no** install scripts.
14
+
7
15
  ## Install
8
16
 
9
17
  You don't install this package directly. You add it to your MCP client's configuration and it runs on demand via `npx`.
@@ -48,7 +56,8 @@ Sign in at https://chart.observer and open **Settings → API & Integrations**.
48
56
  | `CHARTOBSERVER_WEBHOOK_ID` | yes | — | Your per-user webhook secret. Same value TradingView uses to fire trades into your account. Treat like a password. |
49
57
  | `CHARTOBSERVER_UID` | yes | — | Your numeric user ID. |
50
58
  | `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. |
59
+ | `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. Must be `https:`. |
60
+ | `CHARTOBSERVER_TIMEOUT_MS` | no | `15000` | Per-request timeout in milliseconds (max 120000). |
52
61
 
53
62
  ## Available tools
54
63
 
@@ -86,6 +95,8 @@ Sign in at https://chart.observer and open **Settings → API & Integrations**.
86
95
 
87
96
  - **Paper trading only.** Trades affect your simulated portfolio and your leaderboard standing. They do not move real money.
88
97
  - **`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.
98
+ - **Live trades are validated.** Execution runs the same checks as the dry run (sufficient funds, position size, well-formed quantities) and refuses trades that would fail, without calling the API.
99
+ - **Secret redaction.** Error text returned to the agent is sanitized; the webhook credential is redacted as defense-in-depth so it cannot leak into transcripts.
89
100
  - **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
101
  - **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
102
 
@@ -130,6 +141,7 @@ src/
130
141
  index.ts # MCP server entry, registers tools
131
142
  config.ts # Loads + validates env vars
132
143
  api-client.ts # HTTP client for ChartObserver API
144
+ redact.ts # Secret-redaction backstop for all outbound error text
133
145
  tools/
134
146
  account.ts
135
147
  trading.ts
@@ -139,4 +151,6 @@ src/
139
151
  __tests__/
140
152
  api-client.test.ts
141
153
  config.test.ts
154
+ trading.test.ts
155
+ redact.test.ts
142
156
  ```
package/SECURITY.md ADDED
@@ -0,0 +1,48 @@
1
+ # Security
2
+
3
+ ## What this server sends and where
4
+
5
+ This package makes outbound HTTPS requests to **exactly one host**: the
6
+ configured `CHARTOBSERVER_API_BASE` (default:
7
+ `https://g2uyqqluc4.execute-api.us-east-2.amazonaws.com/dev`, the ChartObserver
8
+ production API on AWS API Gateway). There is no other network egress of any
9
+ kind.
10
+
11
+ What it transmits to that host:
12
+
13
+ - your ChartObserver **UID** and **username** (to scope reads to your account)
14
+ - your **webhook credential** (as authentication on trade execution)
15
+ - the **trade parameters** the AI agent supplies (pair, action, count)
16
+ - a `User-Agent`/`X-Client` header identifying this package and version, and a
17
+ random `Idempotency-Key` UUID on trade execution
18
+
19
+ What it never does:
20
+
21
+ - **No telemetry, analytics, or usage beacons.** Nothing is collected.
22
+ - **No other hosts** are contacted, ever.
23
+ - **No filesystem reads or writes** beyond what the MCP SDK requires for
24
+ stdio transport.
25
+ - **No code fetched or evaluated at runtime**, no `postinstall`/`preinstall`
26
+ scripts, no obfuscated output — the published `dist/` is readable compiled
27
+ TypeScript, and the source is at
28
+ https://github.com/bbusche/chartobserver-mcp.
29
+
30
+ ## The webhook credential
31
+
32
+ `CHARTOBSERVER_WEBHOOK_ID` is a **trade-capable bearer secret** — anyone who
33
+ has it can place paper trades on your account (affecting your leaderboard
34
+ standing and visible portfolio, not real funds). Treat it like a password:
35
+
36
+ - Don't paste it into chats, screenshots, issues, or logs.
37
+ - This server sanitizes its error output and redacts the credential from any
38
+ text returned to the AI agent, as defense-in-depth.
39
+ - If you suspect it leaked, regenerate it from your ChartObserver account
40
+ settings (Settings → API & Integrations) — the old value stops working
41
+ immediately.
42
+
43
+ ## Reporting a vulnerability
44
+
45
+ Please report suspected vulnerabilities privately via GitHub's
46
+ [private vulnerability reporting](https://github.com/bbusche/chartobserver-mcp/security/advisories/new)
47
+ on this repository. Do not open a public issue for security reports. We aim to
48
+ acknowledge reports within 72 hours.
@@ -61,23 +61,32 @@ export interface LeaderboardResponse {
61
61
  }
62
62
  export declare class ChartObserverApiError extends Error {
63
63
  readonly status: number;
64
- readonly path: string;
64
+ readonly label: string;
65
65
  readonly bodyText: string;
66
- constructor(status: number, path: string, bodyText: string);
66
+ /**
67
+ * `label` is a sanitized request descriptor (e.g. "POST /transaction") —
68
+ * never the interpolated URL path, which can contain the webhook secret.
69
+ */
70
+ constructor(status: number, label: string, bodyText: string);
67
71
  }
68
72
  type FetchLike = (input: string, init?: {
69
73
  method?: string;
70
74
  headers?: Record<string, string>;
71
75
  body?: string;
76
+ signal?: AbortSignal;
72
77
  }) => Promise<{
73
78
  ok: boolean;
74
79
  status: number;
75
80
  text: () => Promise<string>;
81
+ headers?: {
82
+ get(name: string): string | null;
83
+ };
76
84
  }>;
77
85
  export declare class ChartObserverClient {
78
86
  readonly config: Config;
79
87
  private readonly fetchFn;
80
88
  constructor(config: Config, fetchFn?: FetchLike);
89
+ private fetchWithTimeout;
81
90
  private request;
82
91
  getBalance(): Promise<number>;
83
92
  getOpenPositions(): Promise<Transaction[]>;
@@ -1,15 +1,21 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  export class ChartObserverApiError extends Error {
2
3
  status;
3
- path;
4
+ label;
4
5
  bodyText;
5
- constructor(status, path, bodyText) {
6
- super(`ChartObserver API ${status} on ${path}: ${bodyText.slice(0, 500)}`);
6
+ /**
7
+ * `label` is a sanitized request descriptor (e.g. "POST /transaction")
8
+ * never the interpolated URL path, which can contain the webhook secret.
9
+ */
10
+ constructor(status, label, bodyText) {
11
+ super(`ChartObserver API ${status} on ${label}: ${bodyText.slice(0, 500)}`);
7
12
  this.status = status;
8
- this.path = path;
13
+ this.label = label;
9
14
  this.bodyText = bodyText;
10
15
  this.name = "ChartObserverApiError";
11
16
  }
12
17
  }
18
+ const MAX_RETRY_AFTER_S = 10;
13
19
  export class ChartObserverClient {
14
20
  config;
15
21
  fetchFn;
@@ -17,46 +23,91 @@ export class ChartObserverClient {
17
23
  this.config = config;
18
24
  this.fetchFn = fetchFn;
19
25
  }
26
+ async fetchWithTimeout(url, init, label) {
27
+ const timeoutMs = this.config.timeoutMs;
28
+ const signal = AbortSignal.timeout(timeoutMs);
29
+ const timeoutError = new Error(`Request to ChartObserver API timed out after ${timeoutMs}ms (${label})`);
30
+ let onAbort;
31
+ // Race an explicit rejection alongside the signal: injected fetch
32
+ // implementations may ignore `signal`, but the tool must still unblock.
33
+ const abortPromise = new Promise((_, reject) => {
34
+ onAbort = () => reject(timeoutError);
35
+ signal.addEventListener("abort", onAbort, { once: true });
36
+ });
37
+ try {
38
+ return await Promise.race([
39
+ this.fetchFn(url, { ...init, signal }),
40
+ abortPromise,
41
+ ]);
42
+ }
43
+ catch (e) {
44
+ const name = e?.name;
45
+ if (name === "AbortError" || name === "TimeoutError")
46
+ throw timeoutError;
47
+ throw e;
48
+ }
49
+ finally {
50
+ if (onAbort)
51
+ signal.removeEventListener("abort", onAbort);
52
+ }
53
+ }
20
54
  async request(path, opts = {}) {
55
+ const method = opts.method ?? "GET";
56
+ const label = opts.label ?? `${method} request`;
21
57
  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
- },
58
+ const headers = {
59
+ "Content-Type": "application/json",
60
+ "User-Agent": this.config.userAgent,
61
+ "X-Client": this.config.userAgent,
62
+ };
63
+ if (opts.idempotencyKey)
64
+ headers["Idempotency-Key"] = opts.idempotencyKey;
65
+ const init = {
66
+ method,
67
+ headers,
29
68
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
30
- });
69
+ };
70
+ let res = await this.fetchWithTimeout(url, init, label);
71
+ if (res.status === 429 && method === "GET") {
72
+ // Honor Retry-After once for idempotent reads. Never retry POSTs.
73
+ const retryAfterS = Number(res.headers?.get("retry-after") ?? "") || 1;
74
+ const waitMs = Math.min(retryAfterS, MAX_RETRY_AFTER_S) * 1000;
75
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
76
+ res = await this.fetchWithTimeout(url, init, label);
77
+ }
31
78
  const text = await res.text();
32
79
  if (!res.ok) {
33
- throw new ChartObserverApiError(res.status, path, text);
80
+ throw new ChartObserverApiError(res.status, label, text);
34
81
  }
35
82
  if (!text)
36
83
  return {};
37
84
  return JSON.parse(text);
38
85
  }
39
86
  async getBalance() {
40
- const result = await this.request(`/users/balance/${encodeURIComponent(this.config.uid)}`);
87
+ const result = await this.request(`/users/balance/${encodeURIComponent(this.config.uid)}`, { label: "GET /users/balance" });
41
88
  if (!Array.isArray(result) || result.length === 0) {
42
89
  throw new Error("Balance response was empty");
43
90
  }
44
91
  return Number(result[0].usdBalance);
45
92
  }
46
93
  async getOpenPositions() {
47
- return this.request(`/positions/open/${encodeURIComponent(this.config.uid)}`);
94
+ return this.request(`/positions/open/${encodeURIComponent(this.config.uid)}`, { label: "GET /positions/open" });
48
95
  }
49
96
  async getClosedPositions() {
50
- return this.request(`/positions/closed/${encodeURIComponent(this.config.uid)}`);
97
+ return this.request(`/positions/closed/${encodeURIComponent(this.config.uid)}`, { label: "GET /positions/closed" });
51
98
  }
52
99
  async getRecentTransactions() {
53
- return this.request(`/transactions/${encodeURIComponent(this.config.uid)}`);
100
+ return this.request(`/transactions/${encodeURIComponent(this.config.uid)}`, { label: "GET /transactions" });
54
101
  }
55
102
  async getLeaderboard() {
56
- return this.request("/leaderboard");
103
+ return this.request("/leaderboard", {
104
+ label: "GET /leaderboard",
105
+ });
57
106
  }
58
107
  async getPrice(tokenPair) {
59
- const result = await this.request(`/token/price/${encodeURIComponent(tokenPair)}`);
108
+ const result = await this.request(`/token/price/${encodeURIComponent(tokenPair)}`, {
109
+ label: "GET /token/price",
110
+ });
60
111
  const raw = result?.data?.amount ?? result?.amount ?? result?.price ?? NaN;
61
112
  const n = Number(raw);
62
113
  if (!Number.isFinite(n)) {
@@ -65,12 +116,16 @@ export class ChartObserverClient {
65
116
  return n;
66
117
  }
67
118
  async getPublicProfile(username) {
68
- const result = await this.request(`/user/profile/${encodeURIComponent(username)}`);
119
+ const result = await this.request(`/user/profile/${encodeURIComponent(username)}`, { label: "GET /user/profile" });
69
120
  return result?.data ?? {};
70
121
  }
71
122
  async placeTrade(args) {
72
123
  return this.request(`/transaction/${encodeURIComponent(this.config.webhookId)}`, {
73
124
  method: "POST",
125
+ label: "POST /transaction",
126
+ // Sent for forward-compat: the backend will dedupe on this key once
127
+ // idempotency support lands. The client never auto-retries this POST.
128
+ idempotencyKey: randomUUID(),
74
129
  body: {
75
130
  tokenpair: args.tokenPair,
76
131
  action: args.action,
package/dist/config.d.ts CHANGED
@@ -4,7 +4,9 @@ export interface Config {
4
4
  uid: string;
5
5
  username: string;
6
6
  userAgent: string;
7
+ timeoutMs: number;
7
8
  }
8
9
  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 const DEFAULT_TIMEOUT_MS = 15000;
11
+ export declare const PACKAGE_VERSION = "0.2.0";
10
12
  export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js CHANGED
@@ -1,5 +1,28 @@
1
+ import { z } from "zod";
1
2
  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 const DEFAULT_TIMEOUT_MS = 15_000;
4
+ export const PACKAGE_VERSION = "0.2.0";
5
+ // Validation messages must never echo the webhook value — they can end up in
6
+ // MCP client logs.
7
+ const configSchema = z.object({
8
+ webhookId: z
9
+ .string()
10
+ .min(8, "CHARTOBSERVER_WEBHOOK_ID looks too short to be a valid webhook ID."),
11
+ uid: z
12
+ .string()
13
+ .regex(/^\d+$/, "CHARTOBSERVER_UID must be your numeric user ID."),
14
+ username: z.string().min(1, "CHARTOBSERVER_USERNAME must not be empty."),
15
+ apiBase: z
16
+ .string()
17
+ .url("CHARTOBSERVER_API_BASE must be a valid URL.")
18
+ .refine((u) => new URL(u).protocol === "https:", "CHARTOBSERVER_API_BASE must use https: (credentials travel on this connection)."),
19
+ timeoutMs: z
20
+ .number()
21
+ .int()
22
+ .positive()
23
+ .max(120_000)
24
+ .describe("CHARTOBSERVER_TIMEOUT_MS"),
25
+ });
3
26
  export function loadConfig(env = process.env) {
4
27
  const webhookId = env.CHARTOBSERVER_WEBHOOK_ID?.trim();
5
28
  const uid = env.CHARTOBSERVER_UID?.trim();
@@ -14,11 +37,29 @@ export function loadConfig(env = process.env) {
14
37
  if (missing.length > 0) {
15
38
  throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. Configure them in your MCP client's mcpServers entry. See README.`);
16
39
  }
17
- return {
40
+ const rawTimeout = env.CHARTOBSERVER_TIMEOUT_MS?.trim();
41
+ const timeoutMs = rawTimeout ? Number(rawTimeout) : DEFAULT_TIMEOUT_MS;
42
+ if (rawTimeout && !Number.isFinite(timeoutMs)) {
43
+ throw new Error("CHARTOBSERVER_TIMEOUT_MS must be a number (milliseconds).");
44
+ }
45
+ const candidate = {
18
46
  apiBase: (env.CHARTOBSERVER_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/+$/, ""),
19
47
  webhookId: webhookId,
20
48
  uid: uid,
21
49
  username: username,
50
+ timeoutMs,
51
+ };
52
+ const parsed = configSchema.safeParse(candidate);
53
+ if (!parsed.success) {
54
+ const reasons = parsed.error.issues
55
+ .map((i) => i.path[0] === "timeoutMs"
56
+ ? "CHARTOBSERVER_TIMEOUT_MS must be a positive integer ≤ 120000."
57
+ : i.message)
58
+ .join(" ");
59
+ throw new Error(`Invalid configuration: ${reasons}`);
60
+ }
61
+ return {
62
+ ...parsed.data,
22
63
  userAgent: `chartobserver-mcp/${PACKAGE_VERSION}`,
23
64
  };
24
65
  }
package/dist/index.js CHANGED
@@ -3,12 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { loadConfig, PACKAGE_VERSION } from "./config.js";
5
5
  import { ChartObserverClient } from "./api-client.js";
6
+ import { registerSecret, redactSecrets } from "./redact.js";
6
7
  import { registerAccountTools } from "./tools/account.js";
7
8
  import { registerTradingTools } from "./tools/trading.js";
8
9
  import { registerMarketTools } from "./tools/market.js";
9
10
  import { registerPortfolioTools } from "./tools/portfolio.js";
10
11
  async function main() {
11
12
  const config = loadConfig();
13
+ registerSecret(config.webhookId);
12
14
  const client = new ChartObserverClient(config);
13
15
  const server = new McpServer({
14
16
  name: "chartobserver",
@@ -23,6 +25,6 @@ async function main() {
23
25
  }
24
26
  main().catch((err) => {
25
27
  // Stdio MCP uses stdout for protocol; errors must go to stderr.
26
- process.stderr.write(`chartobserver-mcp fatal: ${err?.stack ?? err}\n`);
28
+ process.stderr.write(redactSecrets(`chartobserver-mcp fatal: ${err?.stack ?? err}\n`));
27
29
  process.exit(1);
28
30
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Central secret-redaction registry. Secrets are registered once at startup
3
+ * (index.ts) and every string that can leave the process — tool error text,
4
+ * the fatal handler — is passed through redactSecrets() as a backstop, so a
5
+ * credential can never reach the model transcript even via an unexpected
6
+ * error path.
7
+ */
8
+ export declare function registerSecret(value: string): void;
9
+ /** Test helper — the registry is module-global. */
10
+ export declare function clearSecrets(): void;
11
+ export declare function redactSecrets(text: string): string;
package/dist/redact.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Central secret-redaction registry. Secrets are registered once at startup
3
+ * (index.ts) and every string that can leave the process — tool error text,
4
+ * the fatal handler — is passed through redactSecrets() as a backstop, so a
5
+ * credential can never reach the model transcript even via an unexpected
6
+ * error path.
7
+ */
8
+ const secrets = new Set();
9
+ export function registerSecret(value) {
10
+ if (value)
11
+ secrets.add(value);
12
+ }
13
+ /** Test helper — the registry is module-global. */
14
+ export function clearSecrets() {
15
+ secrets.clear();
16
+ }
17
+ export function redactSecrets(text) {
18
+ let out = text;
19
+ for (const secret of secrets) {
20
+ out = out.split(secret).join("***");
21
+ out = out.split(encodeURIComponent(secret)).join("***");
22
+ }
23
+ return out;
24
+ }
@@ -1,8 +1,9 @@
1
- import { ok, fail } from "./util.js";
1
+ import { ok, fail, READ_TOOL_ANNOTATIONS } from "./util.js";
2
2
  export function registerAccountTools(server, client) {
3
3
  server.registerTool("get_profile", {
4
4
  title: "Get profile",
5
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
+ annotations: { ...READ_TOOL_ANNOTATIONS },
6
7
  inputSchema: {},
7
8
  }, async () => {
8
9
  try {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ok, fail } from "./util.js";
2
+ import { ok, fail, READ_TOOL_ANNOTATIONS } from "./util.js";
3
3
  function findUserInLeaderboard(entries, username) {
4
4
  if (!entries)
5
5
  return null;
@@ -12,6 +12,7 @@ export function registerMarketTools(server, client) {
12
12
  server.registerTool("get_leaderboard", {
13
13
  title: "Get leaderboard",
14
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
+ annotations: { ...READ_TOOL_ANNOTATIONS },
15
16
  inputSchema: {
16
17
  limit: z
17
18
  .number()
@@ -39,6 +40,7 @@ export function registerMarketTools(server, client) {
39
40
  server.registerTool("get_my_ranking", {
40
41
  title: "Get my leaderboard rank",
41
42
  description: "Find the configured user's position on the 7-day leaderboard, if they appear. Returns null rank if not on the board.",
43
+ annotations: { ...READ_TOOL_ANNOTATIONS },
42
44
  inputSchema: {},
43
45
  }, async () => {
44
46
  try {
@@ -59,6 +61,7 @@ export function registerMarketTools(server, client) {
59
61
  server.registerTool("get_price", {
60
62
  title: "Get current price",
61
63
  description: "Fetch the latest USD price for a crypto pair from the ChartObserver price cache.",
64
+ annotations: { ...READ_TOOL_ANNOTATIONS },
62
65
  inputSchema: {
63
66
  tokenpair: z
64
67
  .string()
@@ -1,4 +1,4 @@
1
- import { ok, fail } from "./util.js";
1
+ import { ok, fail, READ_TOOL_ANNOTATIONS } from "./util.js";
2
2
  function groupOpenPositions(positions) {
3
3
  const grouped = {};
4
4
  for (const p of positions) {
@@ -23,6 +23,7 @@ export function registerPortfolioTools(server, client) {
23
23
  server.registerTool("get_portfolio_summary", {
24
24
  title: "Get portfolio summary",
25
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
+ annotations: { ...READ_TOOL_ANNOTATIONS },
26
27
  inputSchema: {},
27
28
  }, async () => {
28
29
  try {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ok, fail } from "./util.js";
2
+ import { ok, fail, READ_TOOL_ANNOTATIONS } from "./util.js";
3
3
  const TOKEN_PAIR_PATTERN = /^[A-Za-z0-9]+$/;
4
4
  function summarizeOpenPositionsByToken(positions) {
5
5
  const grouped = {};
@@ -39,6 +39,89 @@ function resolveSellQuantity(countInput, availableTokens) {
39
39
  }
40
40
  return n;
41
41
  }
42
+ /**
43
+ * Shared pre-flight for dry-run AND live execution, so the two paths cannot
44
+ * diverge: live trades get exactly the validation the dry run advertises.
45
+ */
46
+ async function evaluateTrade(client, args) {
47
+ const pair = args.tokenpair.toUpperCase();
48
+ const [price, balance, openPositions] = await Promise.all([
49
+ client.getPrice(pair),
50
+ client.getBalance(),
51
+ client.getOpenPositions(),
52
+ ]);
53
+ const grouped = summarizeOpenPositionsByToken(openPositions);
54
+ const held = grouped[pair]?.totalAmount ?? 0;
55
+ const heldCostBasis = grouped[pair]?.avgCostBasis ?? 0;
56
+ const base = { pair, price, balance, held, heldCostBasis };
57
+ if (args.action === "buy") {
58
+ if (String(args.count).includes("%")) {
59
+ return {
60
+ ...base,
61
+ normalizedCount: NaN,
62
+ inputError: "Buy orders may not use a percentage count.",
63
+ wouldSucceed: false,
64
+ reason: null,
65
+ };
66
+ }
67
+ const n = Number(args.count);
68
+ if (!Number.isFinite(n) || n <= 0) {
69
+ return {
70
+ ...base,
71
+ normalizedCount: NaN,
72
+ inputError: "Buy count must be a positive number.",
73
+ wouldSucceed: false,
74
+ reason: null,
75
+ };
76
+ }
77
+ const cost = n * price;
78
+ const sufficient = balance >= cost;
79
+ return {
80
+ ...base,
81
+ normalizedCount: n,
82
+ inputError: null,
83
+ wouldSucceed: sufficient,
84
+ reason: sufficient
85
+ ? null
86
+ : `Insufficient funds: need ${cost.toFixed(2)} USD, have ${balance.toFixed(2)} USD.`,
87
+ };
88
+ }
89
+ // sell
90
+ let sellQty;
91
+ try {
92
+ sellQty = resolveSellQuantity(String(args.count), held);
93
+ }
94
+ catch (e) {
95
+ return {
96
+ ...base,
97
+ normalizedCount: NaN,
98
+ inputError: e.message,
99
+ wouldSucceed: false,
100
+ reason: null,
101
+ };
102
+ }
103
+ if (sellQty <= 0) {
104
+ return {
105
+ ...base,
106
+ normalizedCount: sellQty,
107
+ inputError: "Sell count must be a positive quantity or percentage.",
108
+ wouldSucceed: false,
109
+ reason: null,
110
+ };
111
+ }
112
+ const wouldSucceed = held > 0 && sellQty <= held * 1.005;
113
+ return {
114
+ ...base,
115
+ normalizedCount: sellQty,
116
+ inputError: null,
117
+ wouldSucceed,
118
+ reason: held === 0
119
+ ? "No open position for this token — sell would be rejected."
120
+ : sellQty > held * 1.005
121
+ ? `Sell quantity (${sellQty}) exceeds held (${held}) — would be rejected.`
122
+ : null,
123
+ };
124
+ }
42
125
  export function registerTradingTools(server, client) {
43
126
  server.registerTool("place_trade", {
44
127
  title: "Place a paper trade",
@@ -51,7 +134,14 @@ export function registerTradingTools(server, client) {
51
134
  "- Crypto pairs only. Pair format is no-slash (e.g. BTCUSD, ETHUSDT).",
52
135
  "- Sell `count` may be a percentage string like '50%' or '100%'. Buy `count` must be a numeric quantity.",
53
136
  "- Buys require sufficient USD balance. Sells cannot exceed currently held tokens.",
137
+ "- Live execution runs the same validation as the dry run and refuses trades that would fail.",
54
138
  ].join("\n"),
139
+ annotations: {
140
+ readOnlyHint: false,
141
+ destructiveHint: true,
142
+ idempotentHint: false,
143
+ openWorldHint: true,
144
+ },
55
145
  inputSchema: {
56
146
  tokenpair: z
57
147
  .string()
@@ -69,74 +159,75 @@ export function registerTradingTools(server, client) {
69
159
  .default(true)
70
160
  .describe("When true (default), returns the projected impact without executing. Set to false ONLY after confirming with the user."),
71
161
  },
72
- }, async ({ tokenpair, action, count, dry_run }) => {
162
+ },
163
+ // `dry_run = true` in the destructure is defense-in-depth: if the zod
164
+ // default is ever bypassed, an absent flag must still mean dry run.
165
+ async ({ tokenpair, action, count, dry_run = true }) => {
73
166
  try {
167
+ const evaln = await evaluateTrade(client, {
168
+ tokenpair,
169
+ action,
170
+ count,
171
+ });
172
+ if (evaln.inputError) {
173
+ return fail("place_trade", new Error(evaln.inputError));
174
+ }
74
175
  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
176
  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;
177
+ const cost = evaln.normalizedCount * evaln.price;
92
178
  return ok({
93
179
  dry_run: true,
94
180
  action,
95
- tokenpair: tokenpair.toUpperCase(),
96
- currentPrice: price,
97
- count: n,
181
+ tokenpair: evaln.pair,
182
+ currentPrice: evaln.price,
183
+ count: evaln.normalizedCount,
98
184
  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.",
185
+ currentBalance: evaln.balance,
186
+ projectedBalance: evaln.balance - cost,
187
+ wouldSucceed: evaln.wouldSucceed,
188
+ note: evaln.reason ??
189
+ "Confirm with the user, then re-call with dry_run=false to execute.",
105
190
  });
106
191
  }
107
- // sell
108
- const sellQty = resolveSellQuantity(String(count), held);
109
- const proceeds = sellQty * price;
110
- const wouldSucceed = held > 0 && sellQty <= held * 1.005;
192
+ const proceeds = evaln.normalizedCount * evaln.price;
111
193
  return ok({
112
194
  dry_run: true,
113
195
  action,
114
- tokenpair: tokenpair.toUpperCase(),
115
- currentPrice: price,
116
- count: sellQty,
117
- currentHeld: held,
118
- costBasis: heldCostBasis,
196
+ tokenpair: evaln.pair,
197
+ currentPrice: evaln.price,
198
+ count: evaln.normalizedCount,
199
+ currentHeld: evaln.held,
200
+ costBasis: evaln.heldCostBasis,
119
201
  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.",
202
+ estimatedPnL: (evaln.price - evaln.heldCostBasis) * evaln.normalizedCount,
203
+ currentBalance: evaln.balance,
204
+ projectedBalance: evaln.balance + proceeds,
205
+ wouldSucceed: evaln.wouldSucceed,
206
+ note: evaln.reason ??
207
+ "Confirm with the user, then re-call with dry_run=false to execute.",
129
208
  });
130
209
  }
131
- // Real execution
210
+ // Live execution — refuse anything the dry run would flag.
211
+ if (!evaln.wouldSucceed) {
212
+ return fail("place_trade", new Error(`Trade refused: ${evaln.reason}`));
213
+ }
214
+ // Percentage sells stay in backend-native form ('100%') so the
215
+ // platform computes the exact close quantity at execution time;
216
+ // numeric counts are sent as the validated number.
217
+ const wireCount = action === "sell" && String(count).includes("%")
218
+ ? String(count)
219
+ : evaln.normalizedCount;
132
220
  const res = await client.placeTrade({
133
- tokenPair: tokenpair.toUpperCase(),
221
+ tokenPair: evaln.pair,
134
222
  action,
135
- count,
223
+ count: wireCount,
136
224
  });
137
225
  return ok({
138
226
  dry_run: false,
139
227
  executed: true,
228
+ tokenpair: evaln.pair,
229
+ action,
230
+ count: evaln.normalizedCount,
140
231
  response: res,
141
232
  });
142
233
  }
@@ -147,6 +238,7 @@ export function registerTradingTools(server, client) {
147
238
  server.registerTool("get_balance", {
148
239
  title: "Get USD balance",
149
240
  description: "Fetch the configured user's current USD paper-trading balance.",
241
+ annotations: { ...READ_TOOL_ANNOTATIONS },
150
242
  inputSchema: {},
151
243
  }, async () => {
152
244
  try {
@@ -160,6 +252,7 @@ export function registerTradingTools(server, client) {
160
252
  server.registerTool("get_open_positions", {
161
253
  title: "Get open positions",
162
254
  description: "List all currently open paper-trading positions (buy transactions that have not yet been closed by a sell).",
255
+ annotations: { ...READ_TOOL_ANNOTATIONS },
163
256
  inputSchema: {},
164
257
  }, async () => {
165
258
  try {
@@ -178,6 +271,7 @@ export function registerTradingTools(server, client) {
178
271
  server.registerTool("get_closed_trades", {
179
272
  title: "Get closed trades",
180
273
  description: "List closed paper trades (completed buy→sell roundtrips) for the configured user, most recent first.",
274
+ annotations: { ...READ_TOOL_ANNOTATIONS },
181
275
  inputSchema: {
182
276
  limit: z
183
277
  .number()
@@ -199,6 +293,7 @@ export function registerTradingTools(server, client) {
199
293
  server.registerTool("get_recent_transactions", {
200
294
  title: "Get recent transactions",
201
295
  description: "List the configured user's recent transactions (open + closed, all types). Most recent first.",
296
+ annotations: { ...READ_TOOL_ANNOTATIONS },
202
297
  inputSchema: {
203
298
  limit: z
204
299
  .number()
@@ -6,5 +6,10 @@ export interface ToolResult {
6
6
  }>;
7
7
  isError?: boolean;
8
8
  }
9
+ /** Every read tool hits the live ChartObserver API and mutates nothing. */
10
+ export declare const READ_TOOL_ANNOTATIONS: {
11
+ readonly readOnlyHint: true;
12
+ readonly openWorldHint: true;
13
+ };
9
14
  export declare function ok(payload: unknown): ToolResult;
10
15
  export declare function fail(toolName: string, error: unknown): ToolResult;
@@ -1,4 +1,10 @@
1
1
  import { ChartObserverApiError } from "../api-client.js";
2
+ import { redactSecrets } from "../redact.js";
3
+ /** Every read tool hits the live ChartObserver API and mutates nothing. */
4
+ export const READ_TOOL_ANNOTATIONS = {
5
+ readOnlyHint: true,
6
+ openWorldHint: true,
7
+ };
2
8
  export function ok(payload) {
3
9
  return {
4
10
  content: [
@@ -14,7 +20,7 @@ export function ok(payload) {
14
20
  export function fail(toolName, error) {
15
21
  let message;
16
22
  if (error instanceof ChartObserverApiError) {
17
- message = `${toolName} failed: HTTP ${error.status} from ${error.path}\n${error.bodyText.slice(0, 1000)}`;
23
+ message = `${toolName} failed: HTTP ${error.status} from ${error.label}\n${error.bodyText.slice(0, 1000)}`;
18
24
  }
19
25
  else if (error instanceof Error) {
20
26
  message = `${toolName} failed: ${error.message}`;
@@ -23,7 +29,7 @@ export function fail(toolName, error) {
23
29
  message = `${toolName} failed: ${String(error)}`;
24
30
  }
25
31
  return {
26
- content: [{ type: "text", text: message }],
32
+ content: [{ type: "text", text: redactSecrets(message) }],
27
33
  isError: true,
28
34
  };
29
35
  }
package/package.json CHANGED
@@ -1,8 +1,25 @@
1
1
  {
2
2
  "name": "@chartobserver/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
+ "author": "ChartObserver Corp",
7
+ "homepage": "https://chart.observer",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bbusche/chartobserver-mcp.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/bbusche/chartobserver-mcp/issues"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "chartobserver",
19
+ "paper-trading",
20
+ "crypto",
21
+ "claude"
22
+ ],
6
23
  "type": "module",
7
24
  "bin": {
8
25
  "chartobserver-mcp-server": "dist/index.js"
@@ -11,7 +28,9 @@
11
28
  "files": [
12
29
  "dist",
13
30
  "README.md",
14
- "LICENSE"
31
+ "LICENSE",
32
+ "SECURITY.md",
33
+ "CHANGELOG.md"
15
34
  ],
16
35
  "scripts": {
17
36
  "build": "rm -rf dist && tsc",