@carbon-copy/mcp 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/.github/workflows/publish.yml +23 -0
- package/CLAUDE.md +69 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/__tests__/client.test.d.ts +2 -0
- package/dist/__tests__/client.test.d.ts.map +1 -0
- package/dist/__tests__/client.test.js +292 -0
- package/dist/__tests__/client.test.js.map +1 -0
- package/dist/__tests__/resources.test.d.ts +2 -0
- package/dist/__tests__/resources.test.d.ts.map +1 -0
- package/dist/__tests__/resources.test.js +176 -0
- package/dist/__tests__/resources.test.js.map +1 -0
- package/dist/__tests__/tools.test.d.ts +2 -0
- package/dist/__tests__/tools.test.d.ts.map +1 -0
- package/dist/__tests__/tools.test.js +317 -0
- package/dist/__tests__/tools.test.js.map +1 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +94 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/portfolio.d.ts +4 -0
- package/dist/resources/portfolio.d.ts.map +1 -0
- package/dist/resources/portfolio.js +19 -0
- package/dist/resources/portfolio.js.map +1 -0
- package/dist/resources/traders.d.ts +4 -0
- package/dist/resources/traders.d.ts.map +1 -0
- package/dist/resources/traders.js +19 -0
- package/dist/resources/traders.js.map +1 -0
- package/dist/tools/account.d.ts +4 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +32 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/orders.d.ts +4 -0
- package/dist/tools/orders.d.ts.map +1 -0
- package/dist/tools/orders.js +57 -0
- package/dist/tools/orders.js.map +1 -0
- package/dist/tools/portfolio.d.ts +4 -0
- package/dist/tools/portfolio.d.ts.map +1 -0
- package/dist/tools/portfolio.js +84 -0
- package/dist/tools/portfolio.js.map +1 -0
- package/dist/tools/traders.d.ts +4 -0
- package/dist/tools/traders.d.ts.map +1 -0
- package/dist/tools/traders.js +163 -0
- package/dist/tools/traders.js.map +1 -0
- package/package.json +43 -0
- package/src/__tests__/client.test.ts +392 -0
- package/src/__tests__/resources.test.ts +263 -0
- package/src/__tests__/tools.test.ts +440 -0
- package/src/client.ts +161 -0
- package/src/index.ts +41 -0
- package/src/resources/portfolio.ts +30 -0
- package/src/resources/traders.ts +29 -0
- package/src/tools/account.ts +48 -0
- package/src/tools/orders.ts +74 -0
- package/src/tools/portfolio.ts +106 -0
- package/src/tools/traders.ts +207 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { CarbonCopyClient } from "../client.js";
|
|
4
|
+
import { registerPortfolioTools } from "../tools/portfolio.js";
|
|
5
|
+
import { registerTraderTools } from "../tools/traders.js";
|
|
6
|
+
import { registerOrderTools } from "../tools/orders.js";
|
|
7
|
+
import { registerAccountTools } from "../tools/account.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Cast McpServer to access private internals for assertions. */
|
|
14
|
+
type ServerInternals = {
|
|
15
|
+
_registeredTools: Record<
|
|
16
|
+
string,
|
|
17
|
+
{
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
annotations?: Record<string, unknown>;
|
|
21
|
+
handler: (...args: unknown[]) => Promise<unknown>;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
}
|
|
24
|
+
>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function getTools(server: McpServer) {
|
|
28
|
+
return (server as unknown as ServerInternals)._registeredTools;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createMockFetchResponse(body: unknown = {}) {
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
status: 200,
|
|
35
|
+
statusText: "OK",
|
|
36
|
+
headers: { get: () => null },
|
|
37
|
+
json: vi.fn().mockResolvedValue(body),
|
|
38
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
39
|
+
clone: vi.fn().mockReturnValue({ json: vi.fn().mockResolvedValue(body) }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Setup
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const ALL_TOOL_NAMES = [
|
|
48
|
+
"get_portfolio",
|
|
49
|
+
"get_portfolio_history",
|
|
50
|
+
"get_positions",
|
|
51
|
+
"list_traders",
|
|
52
|
+
"follow_trader",
|
|
53
|
+
"get_trader",
|
|
54
|
+
"update_trader",
|
|
55
|
+
"unfollow_trader",
|
|
56
|
+
"pause_trader",
|
|
57
|
+
"resume_trader",
|
|
58
|
+
"list_orders",
|
|
59
|
+
"get_order",
|
|
60
|
+
"get_account",
|
|
61
|
+
"health",
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
describe("Tool Registration", () => {
|
|
65
|
+
let server: McpServer;
|
|
66
|
+
let client: CarbonCopyClient;
|
|
67
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
fetchMock = vi.fn();
|
|
71
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
72
|
+
|
|
73
|
+
server = new McpServer({ name: "test", version: "0.0.1" });
|
|
74
|
+
client = new CarbonCopyClient("cc_test");
|
|
75
|
+
|
|
76
|
+
registerPortfolioTools(server, client);
|
|
77
|
+
registerTraderTools(server, client);
|
|
78
|
+
registerOrderTools(server, client);
|
|
79
|
+
registerAccountTools(server, client);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// Count & names
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
it("registers exactly 14 tools", () => {
|
|
91
|
+
const tools = getTools(server);
|
|
92
|
+
expect(Object.keys(tools)).toHaveLength(14);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("registers all expected tool names", () => {
|
|
96
|
+
const tools = getTools(server);
|
|
97
|
+
for (const name of ALL_TOOL_NAMES) {
|
|
98
|
+
expect(tools).toHaveProperty(name);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("all registered tools are enabled by default", () => {
|
|
103
|
+
const tools = getTools(server);
|
|
104
|
+
for (const tool of Object.values(tools)) {
|
|
105
|
+
expect(tool.enabled).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// Annotations
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
describe("tool annotations", () => {
|
|
114
|
+
it("get_portfolio — readOnlyHint:true, no destructiveHint", () => {
|
|
115
|
+
const { annotations } = getTools(server)["get_portfolio"];
|
|
116
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
117
|
+
expect(annotations?.destructiveHint).toBeUndefined();
|
|
118
|
+
expect(annotations?.idempotentHint).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("get_portfolio_history — readOnlyHint:true", () => {
|
|
122
|
+
const { annotations } = getTools(server)["get_portfolio_history"];
|
|
123
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("get_positions — readOnlyHint:true", () => {
|
|
127
|
+
const { annotations } = getTools(server)["get_positions"];
|
|
128
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("list_traders — readOnlyHint:true", () => {
|
|
132
|
+
const { annotations } = getTools(server)["list_traders"];
|
|
133
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("follow_trader — readOnlyHint:false, no destructiveHint", () => {
|
|
137
|
+
const { annotations } = getTools(server)["follow_trader"];
|
|
138
|
+
expect(annotations?.readOnlyHint).toBe(false);
|
|
139
|
+
expect(annotations?.destructiveHint).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("get_trader — readOnlyHint:true", () => {
|
|
143
|
+
const { annotations } = getTools(server)["get_trader"];
|
|
144
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("update_trader — readOnlyHint:false, idempotentHint:true", () => {
|
|
148
|
+
const { annotations } = getTools(server)["update_trader"];
|
|
149
|
+
expect(annotations?.readOnlyHint).toBe(false);
|
|
150
|
+
expect(annotations?.idempotentHint).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("unfollow_trader — readOnlyHint:false, destructiveHint:true", () => {
|
|
154
|
+
const { annotations } = getTools(server)["unfollow_trader"];
|
|
155
|
+
expect(annotations?.readOnlyHint).toBe(false);
|
|
156
|
+
expect(annotations?.destructiveHint).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("pause_trader — readOnlyHint:false, idempotentHint:true", () => {
|
|
160
|
+
const { annotations } = getTools(server)["pause_trader"];
|
|
161
|
+
expect(annotations?.readOnlyHint).toBe(false);
|
|
162
|
+
expect(annotations?.idempotentHint).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("resume_trader — readOnlyHint:false, idempotentHint:true", () => {
|
|
166
|
+
const { annotations } = getTools(server)["resume_trader"];
|
|
167
|
+
expect(annotations?.readOnlyHint).toBe(false);
|
|
168
|
+
expect(annotations?.idempotentHint).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("list_orders — readOnlyHint:true", () => {
|
|
172
|
+
const { annotations } = getTools(server)["list_orders"];
|
|
173
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("get_order — readOnlyHint:true", () => {
|
|
177
|
+
const { annotations } = getTools(server)["get_order"];
|
|
178
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("get_account — readOnlyHint:true", () => {
|
|
182
|
+
const { annotations } = getTools(server)["get_account"];
|
|
183
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("health — readOnlyHint:true", () => {
|
|
187
|
+
const { annotations } = getTools(server)["health"];
|
|
188
|
+
expect(annotations?.readOnlyHint).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------------------
|
|
193
|
+
// Tool execution — verify handlers call client and return content
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe("tool execution", () => {
|
|
197
|
+
it("get_portfolio — calls getPortfolio() and returns JSON text", async () => {
|
|
198
|
+
const responseData = { totalValue: 1000, pnl: 50 };
|
|
199
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
200
|
+
|
|
201
|
+
const tool = getTools(server)["get_portfolio"];
|
|
202
|
+
const result = (await tool.handler({}, {})) as {
|
|
203
|
+
content: { type: string; text: string }[];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
expect(result.content).toHaveLength(1);
|
|
207
|
+
expect(result.content[0].type).toBe("text");
|
|
208
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("get_portfolio_history — passes params and returns JSON text", async () => {
|
|
212
|
+
const responseData = { items: [], cursor: null };
|
|
213
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
214
|
+
|
|
215
|
+
const tool = getTools(server)["get_portfolio_history"];
|
|
216
|
+
const result = (await tool.handler({ limit: 10, cursor: "abc" }, {})) as {
|
|
217
|
+
content: { type: string; text: string }[];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
expect(result.content[0].type).toBe("text");
|
|
221
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
222
|
+
|
|
223
|
+
// Verify the correct URL was fetched
|
|
224
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
225
|
+
expect(url).toContain("/api/v1/portfolio/history");
|
|
226
|
+
expect(url).toContain("limit=10");
|
|
227
|
+
expect(url).toContain("cursor=abc");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("get_positions — calls getPositions() and returns JSON text", async () => {
|
|
231
|
+
const responseData = { positions: [] };
|
|
232
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
233
|
+
|
|
234
|
+
const tool = getTools(server)["get_positions"];
|
|
235
|
+
const result = (await tool.handler({}, {})) as {
|
|
236
|
+
content: { type: string; text: string }[];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
240
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
241
|
+
expect(url).toContain("/api/v1/portfolio/positions");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("list_traders — calls getTraders() and returns JSON text", async () => {
|
|
245
|
+
const responseData = [{ wallet: "0xabc", copyPercentage: 50 }];
|
|
246
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
247
|
+
|
|
248
|
+
const tool = getTools(server)["list_traders"];
|
|
249
|
+
const result = (await tool.handler({}, {})) as {
|
|
250
|
+
content: { type: string; text: string }[];
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
254
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
255
|
+
expect(url).toContain("/api/v1/traders");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("follow_trader — posts follow request and returns JSON text", async () => {
|
|
259
|
+
const responseData = { id: "follow-1", wallet: "0xabc" };
|
|
260
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
261
|
+
|
|
262
|
+
const tool = getTools(server)["follow_trader"];
|
|
263
|
+
const args = { walletAddress: "0xabc", copyPercentage: 50 };
|
|
264
|
+
const result = (await tool.handler(args, {})) as {
|
|
265
|
+
content: { type: string; text: string }[];
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
269
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
270
|
+
expect(url).toContain("/api/v1/traders");
|
|
271
|
+
expect(init.method).toBe("POST");
|
|
272
|
+
expect(JSON.parse(init.body as string)).toMatchObject(args);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("get_trader — calls getTrader(wallet) and returns JSON text", async () => {
|
|
276
|
+
const responseData = { wallet: "0xabc", copyPercentage: 50 };
|
|
277
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
278
|
+
|
|
279
|
+
const tool = getTools(server)["get_trader"];
|
|
280
|
+
const result = (await tool.handler({ wallet: "0xabc" }, {})) as {
|
|
281
|
+
content: { type: string; text: string }[];
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
285
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
286
|
+
expect(url).toContain("/api/v1/traders/0xabc");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("update_trader — patches trader settings and returns JSON text", async () => {
|
|
290
|
+
const responseData = { wallet: "0xabc", copyPercentage: 75 };
|
|
291
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
292
|
+
|
|
293
|
+
const tool = getTools(server)["update_trader"];
|
|
294
|
+
const result = (await tool.handler(
|
|
295
|
+
{ wallet: "0xabc", copyPercentage: 75 },
|
|
296
|
+
{}
|
|
297
|
+
)) as { content: { type: string; text: string }[] };
|
|
298
|
+
|
|
299
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
300
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
301
|
+
expect(url).toContain("/api/v1/traders/0xabc");
|
|
302
|
+
expect(init.method).toBe("PATCH");
|
|
303
|
+
expect(JSON.parse(init.body as string)).toEqual({ copyPercentage: 75 });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("unfollow_trader — deletes trader and returns JSON text (null body)", async () => {
|
|
307
|
+
fetchMock.mockResolvedValue({
|
|
308
|
+
ok: true,
|
|
309
|
+
status: 204,
|
|
310
|
+
statusText: "No Content",
|
|
311
|
+
headers: { get: () => null },
|
|
312
|
+
json: vi.fn(),
|
|
313
|
+
text: vi.fn(),
|
|
314
|
+
clone: vi.fn(),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const tool = getTools(server)["unfollow_trader"];
|
|
318
|
+
const result = (await tool.handler({ wallet: "0xabc" }, {})) as {
|
|
319
|
+
content: { type: string; text: string }[];
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
expect(result.content[0].type).toBe("text");
|
|
323
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ success: true });
|
|
324
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
325
|
+
expect(url).toContain("/api/v1/traders/0xabc");
|
|
326
|
+
expect(init.method).toBe("DELETE");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("pause_trader — pauses trader and returns JSON text", async () => {
|
|
330
|
+
const responseData = { paused: true };
|
|
331
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
332
|
+
|
|
333
|
+
const tool = getTools(server)["pause_trader"];
|
|
334
|
+
const result = (await tool.handler({ wallet: "0xabc" }, {})) as {
|
|
335
|
+
content: { type: string; text: string }[];
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
339
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
340
|
+
expect(url).toContain("/api/v1/traders/0xabc/pause");
|
|
341
|
+
expect(init.method).toBe("POST");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("resume_trader — resumes trader and returns JSON text", async () => {
|
|
345
|
+
const responseData = { paused: false };
|
|
346
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
347
|
+
|
|
348
|
+
const tool = getTools(server)["resume_trader"];
|
|
349
|
+
const result = (await tool.handler({ wallet: "0xabc" }, {})) as {
|
|
350
|
+
content: { type: string; text: string }[];
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
354
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
355
|
+
expect(url).toContain("/api/v1/traders/0xabc/resume");
|
|
356
|
+
expect(init.method).toBe("POST");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("list_orders — calls getOrders() with params and returns JSON text", async () => {
|
|
360
|
+
const responseData = { orders: [], cursor: null };
|
|
361
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
362
|
+
|
|
363
|
+
const tool = getTools(server)["list_orders"];
|
|
364
|
+
const result = (await tool.handler({ status: "filled" }, {})) as {
|
|
365
|
+
content: { type: string; text: string }[];
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
369
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
370
|
+
expect(url).toContain("/api/v1/orders");
|
|
371
|
+
expect(url).toContain("status=filled");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("get_order — calls getOrder(id) and returns JSON text", async () => {
|
|
375
|
+
const responseData = { id: "id123", status: "filled" };
|
|
376
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
377
|
+
|
|
378
|
+
const tool = getTools(server)["get_order"];
|
|
379
|
+
const result = (await tool.handler({ id: "id123" }, {})) as {
|
|
380
|
+
content: { type: string; text: string }[];
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
384
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
385
|
+
expect(url).toContain("/api/v1/orders/id123");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("get_account — calls getAccount() and returns JSON text", async () => {
|
|
389
|
+
const responseData = { wallet: "0xuser", balance: 500 };
|
|
390
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
391
|
+
|
|
392
|
+
const tool = getTools(server)["get_account"];
|
|
393
|
+
const result = (await tool.handler({}, {})) as {
|
|
394
|
+
content: { type: string; text: string }[];
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
398
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
399
|
+
expect(url).toContain("/api/v1/account");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("health — calls health() and returns JSON text", async () => {
|
|
403
|
+
const responseData = { status: "ok" };
|
|
404
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(responseData));
|
|
405
|
+
|
|
406
|
+
const tool = getTools(server)["health"];
|
|
407
|
+
const result = (await tool.handler({}, {})) as {
|
|
408
|
+
content: { type: string; text: string }[];
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
expect(JSON.parse(result.content[0].text)).toEqual(responseData);
|
|
412
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
413
|
+
expect(url).toContain("/api/v1/health");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// -------------------------------------------------------------------------
|
|
418
|
+
// Error propagation through tools
|
|
419
|
+
// -------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe("tool error propagation", () => {
|
|
422
|
+
it("get_portfolio rejects when client throws an API error", async () => {
|
|
423
|
+
fetchMock.mockResolvedValue({
|
|
424
|
+
ok: false,
|
|
425
|
+
status: 403,
|
|
426
|
+
statusText: "Forbidden",
|
|
427
|
+
headers: { get: () => null },
|
|
428
|
+
json: vi.fn().mockResolvedValue({ message: "forbidden" }),
|
|
429
|
+
clone: vi.fn().mockReturnValue({
|
|
430
|
+
json: vi.fn().mockResolvedValue({ message: "forbidden" }),
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const tool = getTools(server)["get_portfolio"];
|
|
435
|
+
await expect(tool.handler({}, {})).rejects.toThrow(
|
|
436
|
+
/Carbon Copy API error 403/
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const BASE_URL = "https://carboncopy.news";
|
|
2
|
+
|
|
3
|
+
function buildQuery(params: Record<string, string | number | undefined>): string {
|
|
4
|
+
const qs = Object.entries(params)
|
|
5
|
+
.filter(([, v]) => v !== undefined)
|
|
6
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
7
|
+
.join("&");
|
|
8
|
+
return qs ? `?${qs}` : "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CarbonCopyClient {
|
|
12
|
+
private readonly apiKey: string;
|
|
13
|
+
|
|
14
|
+
constructor(apiKey: string) {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private async request<T>(
|
|
19
|
+
method: string,
|
|
20
|
+
path: string,
|
|
21
|
+
body?: unknown
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const url = `${BASE_URL}${path}`;
|
|
24
|
+
const headers: Record<string, string> = {
|
|
25
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method,
|
|
31
|
+
headers,
|
|
32
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const cloned = res.clone();
|
|
37
|
+
let errorBody: unknown;
|
|
38
|
+
try {
|
|
39
|
+
errorBody = await cloned.json();
|
|
40
|
+
} catch {
|
|
41
|
+
errorBody = await res.text();
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Carbon Copy API error ${res.status} ${res.statusText}: ${JSON.stringify(errorBody)}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
49
|
+
return undefined as unknown as T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
if (!text) return undefined as unknown as T;
|
|
54
|
+
return JSON.parse(text) as T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Portfolio
|
|
58
|
+
getPortfolio(): Promise<unknown> {
|
|
59
|
+
return this.request("GET", "/api/v1/portfolio");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getPortfolioHistory(params?: {
|
|
63
|
+
limit?: number;
|
|
64
|
+
cursor?: string;
|
|
65
|
+
since?: string;
|
|
66
|
+
until?: string;
|
|
67
|
+
}): Promise<unknown> {
|
|
68
|
+
const qs = buildQuery(params ?? {});
|
|
69
|
+
return this.request("GET", `/api/v1/portfolio/history${qs}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getPositions(params?: {
|
|
73
|
+
limit?: number;
|
|
74
|
+
cursor?: string;
|
|
75
|
+
since?: string;
|
|
76
|
+
until?: string;
|
|
77
|
+
}): Promise<unknown> {
|
|
78
|
+
const qs = buildQuery(params ?? {});
|
|
79
|
+
return this.request("GET", `/api/v1/portfolio/positions${qs}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Traders
|
|
83
|
+
getTraders(): Promise<unknown> {
|
|
84
|
+
return this.request("GET", "/api/v1/traders");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
followTrader(body: {
|
|
88
|
+
walletAddress: string;
|
|
89
|
+
copyPercentage: number;
|
|
90
|
+
maxCopyAmount?: number;
|
|
91
|
+
notificationsEnabled?: boolean;
|
|
92
|
+
}): Promise<unknown> {
|
|
93
|
+
return this.request("POST", "/api/v1/traders", body);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getTrader(wallet: string): Promise<unknown> {
|
|
97
|
+
return this.request("GET", `/api/v1/traders/${encodeURIComponent(wallet)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
updateTrader(
|
|
101
|
+
wallet: string,
|
|
102
|
+
body: {
|
|
103
|
+
copyPercentage?: number;
|
|
104
|
+
maxCopyAmount?: number;
|
|
105
|
+
notificationsEnabled?: boolean;
|
|
106
|
+
copyTradingEnabled?: boolean;
|
|
107
|
+
}
|
|
108
|
+
): Promise<unknown> {
|
|
109
|
+
return this.request(
|
|
110
|
+
"PATCH",
|
|
111
|
+
`/api/v1/traders/${encodeURIComponent(wallet)}`,
|
|
112
|
+
body
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
unfollowTrader(wallet: string): Promise<unknown> {
|
|
117
|
+
return this.request(
|
|
118
|
+
"DELETE",
|
|
119
|
+
`/api/v1/traders/${encodeURIComponent(wallet)}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pauseTrader(wallet: string): Promise<unknown> {
|
|
124
|
+
return this.request(
|
|
125
|
+
"POST",
|
|
126
|
+
`/api/v1/traders/${encodeURIComponent(wallet)}/pause`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
resumeTrader(wallet: string): Promise<unknown> {
|
|
131
|
+
return this.request(
|
|
132
|
+
"POST",
|
|
133
|
+
`/api/v1/traders/${encodeURIComponent(wallet)}/resume`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Orders
|
|
138
|
+
getOrders(params?: {
|
|
139
|
+
status?: string;
|
|
140
|
+
limit?: number;
|
|
141
|
+
cursor?: string;
|
|
142
|
+
since?: string;
|
|
143
|
+
until?: string;
|
|
144
|
+
}): Promise<unknown> {
|
|
145
|
+
const qs = buildQuery(params ?? {});
|
|
146
|
+
return this.request("GET", `/api/v1/orders${qs}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getOrder(id: string): Promise<unknown> {
|
|
150
|
+
return this.request("GET", `/api/v1/orders/${encodeURIComponent(id)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Account
|
|
154
|
+
getAccount(): Promise<unknown> {
|
|
155
|
+
return this.request("GET", "/api/v1/account");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
health(): Promise<unknown> {
|
|
159
|
+
return this.request("GET", "/api/v1/health");
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { CarbonCopyClient } from "./client.js";
|
|
6
|
+
import { registerPortfolioTools } from "./tools/portfolio.js";
|
|
7
|
+
import { registerTraderTools } from "./tools/traders.js";
|
|
8
|
+
import { registerOrderTools } from "./tools/orders.js";
|
|
9
|
+
import { registerAccountTools } from "./tools/account.js";
|
|
10
|
+
import { registerPortfolioResources } from "./resources/portfolio.js";
|
|
11
|
+
import { registerTraderResources } from "./resources/traders.js";
|
|
12
|
+
|
|
13
|
+
const apiKey = process.env.CARBONCOPY_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
console.error(
|
|
16
|
+
"Error: CARBONCOPY_API_KEY environment variable is required.\n" +
|
|
17
|
+
"Set it to your Carbon Copy API key (format: cc_<64 hex chars>)."
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const client = new CarbonCopyClient(apiKey);
|
|
23
|
+
|
|
24
|
+
const server = new McpServer({
|
|
25
|
+
name: "carboncopy",
|
|
26
|
+
version: "0.1.0",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Register tools
|
|
30
|
+
registerPortfolioTools(server, client);
|
|
31
|
+
registerTraderTools(server, client);
|
|
32
|
+
registerOrderTools(server, client);
|
|
33
|
+
registerAccountTools(server, client);
|
|
34
|
+
|
|
35
|
+
// Register resources
|
|
36
|
+
registerPortfolioResources(server, client);
|
|
37
|
+
registerTraderResources(server, client);
|
|
38
|
+
|
|
39
|
+
// Connect via stdio transport
|
|
40
|
+
const transport = new StdioServerTransport();
|
|
41
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { CarbonCopyClient } from "../client.js";
|
|
3
|
+
|
|
4
|
+
export function registerPortfolioResources(
|
|
5
|
+
server: McpServer,
|
|
6
|
+
client: CarbonCopyClient
|
|
7
|
+
): void {
|
|
8
|
+
server.registerResource(
|
|
9
|
+
"portfolio",
|
|
10
|
+
"carboncopy://portfolio",
|
|
11
|
+
{
|
|
12
|
+
title: "Carbon Copy Portfolio",
|
|
13
|
+
description:
|
|
14
|
+
"Current portfolio snapshot including total value, P&L, and allocation.",
|
|
15
|
+
mimeType: "application/json",
|
|
16
|
+
},
|
|
17
|
+
async () => {
|
|
18
|
+
const data = await client.getPortfolio();
|
|
19
|
+
return {
|
|
20
|
+
contents: [
|
|
21
|
+
{
|
|
22
|
+
uri: "carboncopy://portfolio",
|
|
23
|
+
mimeType: "application/json",
|
|
24
|
+
text: JSON.stringify(data ?? {}, null, 2),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { CarbonCopyClient } from "../client.js";
|
|
3
|
+
|
|
4
|
+
export function registerTraderResources(
|
|
5
|
+
server: McpServer,
|
|
6
|
+
client: CarbonCopyClient
|
|
7
|
+
): void {
|
|
8
|
+
server.registerResource(
|
|
9
|
+
"traders",
|
|
10
|
+
"carboncopy://traders",
|
|
11
|
+
{
|
|
12
|
+
title: "Carbon Copy Traders",
|
|
13
|
+
description: "List of all traders you are currently following.",
|
|
14
|
+
mimeType: "application/json",
|
|
15
|
+
},
|
|
16
|
+
async () => {
|
|
17
|
+
const data = await client.getTraders();
|
|
18
|
+
return {
|
|
19
|
+
contents: [
|
|
20
|
+
{
|
|
21
|
+
uri: "carboncopy://traders",
|
|
22
|
+
mimeType: "application/json",
|
|
23
|
+
text: JSON.stringify(data ?? [], null, 2),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|