@chartobserver/mcp-server 0.1.0 → 0.2.1
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 +60 -0
- package/README.md +15 -1
- package/SECURITY.md +48 -0
- package/dist/api-client.d.ts +11 -2
- package/dist/api-client.js +75 -20
- package/dist/config.d.ts +4 -1
- package/dist/config.js +48 -3
- package/dist/index.js +5 -2
- package/dist/instructions.d.ts +7 -0
- package/dist/instructions.js +19 -0
- package/dist/redact.d.ts +11 -0
- package/dist/redact.js +24 -0
- package/dist/tools/account.js +3 -2
- package/dist/tools/market.js +4 -1
- package/dist/tools/portfolio.js +2 -1
- package/dist/tools/trading.js +144 -48
- package/dist/tools/util.d.ts +5 -0
- package/dist/tools/util.js +8 -2
- package/package.json +21 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1 — 2026-06-12
|
|
4
|
+
|
|
5
|
+
Agent-facing signup guidance. No behavior changes to tools or transport.
|
|
6
|
+
|
|
7
|
+
- **MCP server `instructions`**: the server now tells the connected AI agent,
|
|
8
|
+
at initialization, that an existing chart.observer account is required,
|
|
9
|
+
that accounts cannot be created through this server, and to direct the
|
|
10
|
+
user to sign up in a browser at https://chart.observer. Previously this
|
|
11
|
+
fact lived only in the README, which agents never see.
|
|
12
|
+
- Missing/invalid configuration errors now include the same guidance (where
|
|
13
|
+
credentials come from, where to create an account).
|
|
14
|
+
- `get_profile` and `place_trade` descriptions carry a one-line fallback of
|
|
15
|
+
the guidance for MCP clients that don't surface server instructions.
|
|
16
|
+
|
|
17
|
+
## 0.2.0 — 2026-06-12
|
|
18
|
+
|
|
19
|
+
Hardening & trust release. No breaking changes for users — configuration and
|
|
20
|
+
tool surface are unchanged.
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- **Webhook credential can no longer appear in tool output.** API errors now
|
|
25
|
+
carry a sanitized request label (e.g. `POST /transaction`) instead of the
|
|
26
|
+
raw URL path, and all error text returned to the agent passes through a
|
|
27
|
+
central secret-redaction backstop. Regression-tested.
|
|
28
|
+
- **Live trades now run the same validation as dry runs.** Previously,
|
|
29
|
+
`place_trade` with `dry_run: false` skipped the positive-count,
|
|
30
|
+
percentage-buy, sufficient-funds, and oversell checks. Execution now refuses
|
|
31
|
+
any trade the dry run would flag, without calling the API.
|
|
32
|
+
- **Config validation at startup**: numeric UID, minimum webhook-ID length,
|
|
33
|
+
`https:`-only API base. Clear, secret-free failure messages.
|
|
34
|
+
- An absent `dry_run` flag is treated as a dry run even if schema defaults are
|
|
35
|
+
bypassed (defense-in-depth).
|
|
36
|
+
|
|
37
|
+
### Reliability
|
|
38
|
+
|
|
39
|
+
- Per-request timeout (default 15 s, override with `CHARTOBSERVER_TIMEOUT_MS`)
|
|
40
|
+
so a hung backend can no longer wedge the MCP client.
|
|
41
|
+
- Reads (GET) retry once on HTTP 429, honoring `Retry-After`. Trade execution
|
|
42
|
+
is **never** auto-retried.
|
|
43
|
+
- Trade execution sends a UUID `Idempotency-Key` header (forward-compat for
|
|
44
|
+
backend deduplication).
|
|
45
|
+
|
|
46
|
+
### Trust & transparency
|
|
47
|
+
|
|
48
|
+
- All tools carry MCP annotations: read tools are `readOnlyHint`, and
|
|
49
|
+
`place_trade` is explicitly `destructiveHint` / non-idempotent.
|
|
50
|
+
- `SECURITY.md` with a full egress/data-flow disclosure and vulnerability
|
|
51
|
+
reporting process.
|
|
52
|
+
- Source published at https://github.com/bbusche/chartobserver-mcp;
|
|
53
|
+
`repository`/`homepage`/`bugs` metadata added; releases published from CI
|
|
54
|
+
with npm provenance.
|
|
55
|
+
|
|
56
|
+
## 0.1.0 — 2026-06-09
|
|
57
|
+
|
|
58
|
+
Initial public release: read tools (profile, balance, positions, trades,
|
|
59
|
+
leaderboard, prices, portfolio summary) and `place_trade` with dry-run
|
|
60
|
+
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.
|
package/dist/api-client.d.ts
CHANGED
|
@@ -61,23 +61,32 @@ export interface LeaderboardResponse {
|
|
|
61
61
|
}
|
|
62
62
|
export declare class ChartObserverApiError extends Error {
|
|
63
63
|
readonly status: number;
|
|
64
|
-
readonly
|
|
64
|
+
readonly label: string;
|
|
65
65
|
readonly bodyText: string;
|
|
66
|
-
|
|
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[]>;
|
package/dist/api-client.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
export class ChartObserverApiError extends Error {
|
|
2
3
|
status;
|
|
3
|
-
|
|
4
|
+
label;
|
|
4
5
|
bodyText;
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
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,10 @@ 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
|
|
10
|
+
export declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
11
|
+
export declare const PACKAGE_VERSION = "0.2.1";
|
|
12
|
+
export declare const CREDENTIALS_HINT: string;
|
|
10
13
|
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
4
|
+
export const PACKAGE_VERSION = "0.2.1";
|
|
5
|
+
export const CREDENTIALS_HINT = "These values come from your chart.observer account: create one in a " +
|
|
6
|
+
"browser at https://chart.observer (accounts cannot be created via this " +
|
|
7
|
+
"server), then copy your webhook ID, UID, and username from Settings → " +
|
|
8
|
+
"API & Integrations.";
|
|
9
|
+
// Validation messages must never echo the webhook value — they can end up in
|
|
10
|
+
// MCP client logs.
|
|
11
|
+
const configSchema = z.object({
|
|
12
|
+
webhookId: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(8, "CHARTOBSERVER_WEBHOOK_ID looks too short to be a valid webhook ID."),
|
|
15
|
+
uid: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^\d+$/, "CHARTOBSERVER_UID must be your numeric user ID."),
|
|
18
|
+
username: z.string().min(1, "CHARTOBSERVER_USERNAME must not be empty."),
|
|
19
|
+
apiBase: z
|
|
20
|
+
.string()
|
|
21
|
+
.url("CHARTOBSERVER_API_BASE must be a valid URL.")
|
|
22
|
+
.refine((u) => new URL(u).protocol === "https:", "CHARTOBSERVER_API_BASE must use https: (credentials travel on this connection)."),
|
|
23
|
+
timeoutMs: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.positive()
|
|
27
|
+
.max(120_000)
|
|
28
|
+
.describe("CHARTOBSERVER_TIMEOUT_MS"),
|
|
29
|
+
});
|
|
3
30
|
export function loadConfig(env = process.env) {
|
|
4
31
|
const webhookId = env.CHARTOBSERVER_WEBHOOK_ID?.trim();
|
|
5
32
|
const uid = env.CHARTOBSERVER_UID?.trim();
|
|
@@ -12,13 +39,31 @@ export function loadConfig(env = process.env) {
|
|
|
12
39
|
if (!username)
|
|
13
40
|
missing.push("CHARTOBSERVER_USERNAME");
|
|
14
41
|
if (missing.length > 0) {
|
|
15
|
-
throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. Configure them in your MCP client's mcpServers entry.
|
|
42
|
+
throw new Error(`Missing required environment variable(s): ${missing.join(", ")}. Configure them in your MCP client's mcpServers entry. ${CREDENTIALS_HINT}`);
|
|
16
43
|
}
|
|
17
|
-
|
|
44
|
+
const rawTimeout = env.CHARTOBSERVER_TIMEOUT_MS?.trim();
|
|
45
|
+
const timeoutMs = rawTimeout ? Number(rawTimeout) : DEFAULT_TIMEOUT_MS;
|
|
46
|
+
if (rawTimeout && !Number.isFinite(timeoutMs)) {
|
|
47
|
+
throw new Error("CHARTOBSERVER_TIMEOUT_MS must be a number (milliseconds).");
|
|
48
|
+
}
|
|
49
|
+
const candidate = {
|
|
18
50
|
apiBase: (env.CHARTOBSERVER_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/+$/, ""),
|
|
19
51
|
webhookId: webhookId,
|
|
20
52
|
uid: uid,
|
|
21
53
|
username: username,
|
|
54
|
+
timeoutMs,
|
|
55
|
+
};
|
|
56
|
+
const parsed = configSchema.safeParse(candidate);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
const reasons = parsed.error.issues
|
|
59
|
+
.map((i) => i.path[0] === "timeoutMs"
|
|
60
|
+
? "CHARTOBSERVER_TIMEOUT_MS must be a positive integer ≤ 120000."
|
|
61
|
+
: i.message)
|
|
62
|
+
.join(" ");
|
|
63
|
+
throw new Error(`Invalid configuration: ${reasons} ${CREDENTIALS_HINT}`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...parsed.data,
|
|
22
67
|
userAgent: `chartobserver-mcp/${PACKAGE_VERSION}`,
|
|
23
68
|
};
|
|
24
69
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,17 +3,20 @@ 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 { SERVER_INSTRUCTIONS } from "./instructions.js";
|
|
7
|
+
import { registerSecret, redactSecrets } from "./redact.js";
|
|
6
8
|
import { registerAccountTools } from "./tools/account.js";
|
|
7
9
|
import { registerTradingTools } from "./tools/trading.js";
|
|
8
10
|
import { registerMarketTools } from "./tools/market.js";
|
|
9
11
|
import { registerPortfolioTools } from "./tools/portfolio.js";
|
|
10
12
|
async function main() {
|
|
11
13
|
const config = loadConfig();
|
|
14
|
+
registerSecret(config.webhookId);
|
|
12
15
|
const client = new ChartObserverClient(config);
|
|
13
16
|
const server = new McpServer({
|
|
14
17
|
name: "chartobserver",
|
|
15
18
|
version: PACKAGE_VERSION,
|
|
16
|
-
});
|
|
19
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
17
20
|
registerAccountTools(server, client);
|
|
18
21
|
registerTradingTools(server, client);
|
|
19
22
|
registerMarketTools(server, client);
|
|
@@ -23,6 +26,6 @@ async function main() {
|
|
|
23
26
|
}
|
|
24
27
|
main().catch((err) => {
|
|
25
28
|
// Stdio MCP uses stdout for protocol; errors must go to stderr.
|
|
26
|
-
process.stderr.write(`chartobserver-mcp fatal: ${err?.stack ?? err}\n`);
|
|
29
|
+
process.stderr.write(redactSecrets(`chartobserver-mcp fatal: ${err?.stack ?? err}\n`));
|
|
27
30
|
process.exit(1);
|
|
28
31
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-level instructions surfaced to the AI agent at MCP initialization.
|
|
3
|
+
* This is the agent-visible counterpart of the README: it must carry any fact
|
|
4
|
+
* the model needs to relay to the user, because the agent never sees npm or
|
|
5
|
+
* website documentation.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-level instructions surfaced to the AI agent at MCP initialization.
|
|
3
|
+
* This is the agent-visible counterpart of the README: it must carry any fact
|
|
4
|
+
* the model needs to relay to the user, because the agent never sees npm or
|
|
5
|
+
* website documentation.
|
|
6
|
+
*/
|
|
7
|
+
export const SERVER_INSTRUCTIONS = [
|
|
8
|
+
"ChartObserver is a crypto paper-trading platform (https://chart.observer).",
|
|
9
|
+
"This server requires an EXISTING chart.observer account — accounts cannot",
|
|
10
|
+
"be created through this server or by any agent (signup is CAPTCHA-protected",
|
|
11
|
+
"and must be done by the user in a browser at https://chart.observer).",
|
|
12
|
+
"If the configured credentials are missing or invalid, tell the user to:",
|
|
13
|
+
"(1) create an account at https://chart.observer in their browser, then",
|
|
14
|
+
"(2) copy their webhook ID, UID, and username from Settings → API &",
|
|
15
|
+
"Integrations into this server's environment variables (see the package",
|
|
16
|
+
"README). All trading is simulated paper trading — no real funds move — but",
|
|
17
|
+
"trades do affect the user's public leaderboard standing, so always confirm",
|
|
18
|
+
"with the user before executing a trade (place_trade defaults to dry_run).",
|
|
19
|
+
].join(" ");
|
package/dist/redact.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tools/account.js
CHANGED
|
@@ -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
|
-
description: "Fetch the currently configured user's public profile (description, social links, follower counts) along with their USD paper-trading balance. Read-only.",
|
|
5
|
+
description: "Fetch the currently configured user's public profile (description, social links, follower counts) along with their USD paper-trading balance. Read-only. Requires an existing chart.observer account — if credentials are missing or rejected, direct the user to sign up in a browser at https://chart.observer (accounts cannot be created via this server).",
|
|
6
|
+
annotations: { ...READ_TOOL_ANNOTATIONS },
|
|
6
7
|
inputSchema: {},
|
|
7
8
|
}, async () => {
|
|
8
9
|
try {
|
package/dist/tools/market.js
CHANGED
|
@@ -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()
|
package/dist/tools/portfolio.js
CHANGED
|
@@ -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 {
|
package/dist/tools/trading.js
CHANGED
|
@@ -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,15 @@ 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.",
|
|
138
|
+
"- Requires an existing chart.observer account. If credentials are rejected, direct the user to https://chart.observer to sign up in a browser (accounts cannot be created via this server).",
|
|
54
139
|
].join("\n"),
|
|
140
|
+
annotations: {
|
|
141
|
+
readOnlyHint: false,
|
|
142
|
+
destructiveHint: true,
|
|
143
|
+
idempotentHint: false,
|
|
144
|
+
openWorldHint: true,
|
|
145
|
+
},
|
|
55
146
|
inputSchema: {
|
|
56
147
|
tokenpair: z
|
|
57
148
|
.string()
|
|
@@ -69,74 +160,75 @@ export function registerTradingTools(server, client) {
|
|
|
69
160
|
.default(true)
|
|
70
161
|
.describe("When true (default), returns the projected impact without executing. Set to false ONLY after confirming with the user."),
|
|
71
162
|
},
|
|
72
|
-
},
|
|
163
|
+
},
|
|
164
|
+
// `dry_run = true` in the destructure is defense-in-depth: if the zod
|
|
165
|
+
// default is ever bypassed, an absent flag must still mean dry run.
|
|
166
|
+
async ({ tokenpair, action, count, dry_run = true }) => {
|
|
73
167
|
try {
|
|
168
|
+
const evaln = await evaluateTrade(client, {
|
|
169
|
+
tokenpair,
|
|
170
|
+
action,
|
|
171
|
+
count,
|
|
172
|
+
});
|
|
173
|
+
if (evaln.inputError) {
|
|
174
|
+
return fail("place_trade", new Error(evaln.inputError));
|
|
175
|
+
}
|
|
74
176
|
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
177
|
if (action === "buy") {
|
|
84
|
-
|
|
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;
|
|
178
|
+
const cost = evaln.normalizedCount * evaln.price;
|
|
92
179
|
return ok({
|
|
93
180
|
dry_run: true,
|
|
94
181
|
action,
|
|
95
|
-
tokenpair:
|
|
96
|
-
currentPrice: price,
|
|
97
|
-
count:
|
|
182
|
+
tokenpair: evaln.pair,
|
|
183
|
+
currentPrice: evaln.price,
|
|
184
|
+
count: evaln.normalizedCount,
|
|
98
185
|
estimatedCost: cost,
|
|
99
|
-
currentBalance: balance,
|
|
100
|
-
projectedBalance: balance - cost,
|
|
101
|
-
wouldSucceed:
|
|
102
|
-
note:
|
|
103
|
-
|
|
104
|
-
: "Confirm with the user, then re-call with dry_run=false to execute.",
|
|
186
|
+
currentBalance: evaln.balance,
|
|
187
|
+
projectedBalance: evaln.balance - cost,
|
|
188
|
+
wouldSucceed: evaln.wouldSucceed,
|
|
189
|
+
note: evaln.reason ??
|
|
190
|
+
"Confirm with the user, then re-call with dry_run=false to execute.",
|
|
105
191
|
});
|
|
106
192
|
}
|
|
107
|
-
|
|
108
|
-
const sellQty = resolveSellQuantity(String(count), held);
|
|
109
|
-
const proceeds = sellQty * price;
|
|
110
|
-
const wouldSucceed = held > 0 && sellQty <= held * 1.005;
|
|
193
|
+
const proceeds = evaln.normalizedCount * evaln.price;
|
|
111
194
|
return ok({
|
|
112
195
|
dry_run: true,
|
|
113
196
|
action,
|
|
114
|
-
tokenpair:
|
|
115
|
-
currentPrice: price,
|
|
116
|
-
count:
|
|
117
|
-
currentHeld: held,
|
|
118
|
-
costBasis: heldCostBasis,
|
|
197
|
+
tokenpair: evaln.pair,
|
|
198
|
+
currentPrice: evaln.price,
|
|
199
|
+
count: evaln.normalizedCount,
|
|
200
|
+
currentHeld: evaln.held,
|
|
201
|
+
costBasis: evaln.heldCostBasis,
|
|
119
202
|
estimatedProceeds: proceeds,
|
|
120
|
-
estimatedPnL: (price - heldCostBasis) *
|
|
121
|
-
currentBalance: balance,
|
|
122
|
-
projectedBalance: balance + proceeds,
|
|
123
|
-
wouldSucceed,
|
|
124
|
-
note:
|
|
125
|
-
|
|
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.",
|
|
203
|
+
estimatedPnL: (evaln.price - evaln.heldCostBasis) * evaln.normalizedCount,
|
|
204
|
+
currentBalance: evaln.balance,
|
|
205
|
+
projectedBalance: evaln.balance + proceeds,
|
|
206
|
+
wouldSucceed: evaln.wouldSucceed,
|
|
207
|
+
note: evaln.reason ??
|
|
208
|
+
"Confirm with the user, then re-call with dry_run=false to execute.",
|
|
129
209
|
});
|
|
130
210
|
}
|
|
131
|
-
//
|
|
211
|
+
// Live execution — refuse anything the dry run would flag.
|
|
212
|
+
if (!evaln.wouldSucceed) {
|
|
213
|
+
return fail("place_trade", new Error(`Trade refused: ${evaln.reason}`));
|
|
214
|
+
}
|
|
215
|
+
// Percentage sells stay in backend-native form ('100%') so the
|
|
216
|
+
// platform computes the exact close quantity at execution time;
|
|
217
|
+
// numeric counts are sent as the validated number.
|
|
218
|
+
const wireCount = action === "sell" && String(count).includes("%")
|
|
219
|
+
? String(count)
|
|
220
|
+
: evaln.normalizedCount;
|
|
132
221
|
const res = await client.placeTrade({
|
|
133
|
-
tokenPair:
|
|
222
|
+
tokenPair: evaln.pair,
|
|
134
223
|
action,
|
|
135
|
-
count,
|
|
224
|
+
count: wireCount,
|
|
136
225
|
});
|
|
137
226
|
return ok({
|
|
138
227
|
dry_run: false,
|
|
139
228
|
executed: true,
|
|
229
|
+
tokenpair: evaln.pair,
|
|
230
|
+
action,
|
|
231
|
+
count: evaln.normalizedCount,
|
|
140
232
|
response: res,
|
|
141
233
|
});
|
|
142
234
|
}
|
|
@@ -147,6 +239,7 @@ export function registerTradingTools(server, client) {
|
|
|
147
239
|
server.registerTool("get_balance", {
|
|
148
240
|
title: "Get USD balance",
|
|
149
241
|
description: "Fetch the configured user's current USD paper-trading balance.",
|
|
242
|
+
annotations: { ...READ_TOOL_ANNOTATIONS },
|
|
150
243
|
inputSchema: {},
|
|
151
244
|
}, async () => {
|
|
152
245
|
try {
|
|
@@ -160,6 +253,7 @@ export function registerTradingTools(server, client) {
|
|
|
160
253
|
server.registerTool("get_open_positions", {
|
|
161
254
|
title: "Get open positions",
|
|
162
255
|
description: "List all currently open paper-trading positions (buy transactions that have not yet been closed by a sell).",
|
|
256
|
+
annotations: { ...READ_TOOL_ANNOTATIONS },
|
|
163
257
|
inputSchema: {},
|
|
164
258
|
}, async () => {
|
|
165
259
|
try {
|
|
@@ -178,6 +272,7 @@ export function registerTradingTools(server, client) {
|
|
|
178
272
|
server.registerTool("get_closed_trades", {
|
|
179
273
|
title: "Get closed trades",
|
|
180
274
|
description: "List closed paper trades (completed buy→sell roundtrips) for the configured user, most recent first.",
|
|
275
|
+
annotations: { ...READ_TOOL_ANNOTATIONS },
|
|
181
276
|
inputSchema: {
|
|
182
277
|
limit: z
|
|
183
278
|
.number()
|
|
@@ -199,6 +294,7 @@ export function registerTradingTools(server, client) {
|
|
|
199
294
|
server.registerTool("get_recent_transactions", {
|
|
200
295
|
title: "Get recent transactions",
|
|
201
296
|
description: "List the configured user's recent transactions (open + closed, all types). Most recent first.",
|
|
297
|
+
annotations: { ...READ_TOOL_ANNOTATIONS },
|
|
202
298
|
inputSchema: {
|
|
203
299
|
limit: z
|
|
204
300
|
.number()
|
package/dist/tools/util.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/util.js
CHANGED
|
@@ -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.
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|