@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
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@carbon-copy/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An MCP server that gives AI agents programmatic access to Carbon Copy - a Polymarket copy-trading platform.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"carboncopy-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"test:coverage": "vitest run --coverage"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/CarbonCopyInc/carboncopy-mcp.git"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"carboncopy",
|
|
24
|
+
"polymarket",
|
|
25
|
+
"copy-trading"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/CarbonCopyInc/carboncopy-mcp/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/CarbonCopyInc/carboncopy-mcp#readme",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
35
|
+
"zod": "^3.25.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
40
|
+
"typescript": "^5.8.0",
|
|
41
|
+
"vitest": "^4.0.18"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { CarbonCopyClient } from "../client.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
type MockResponseOptions = {
|
|
9
|
+
ok?: boolean;
|
|
10
|
+
status?: number;
|
|
11
|
+
statusText?: string;
|
|
12
|
+
contentLength?: string;
|
|
13
|
+
body?: unknown;
|
|
14
|
+
bodyText?: string;
|
|
15
|
+
/** If true, cloned.json() rejects (tests the text() fallback path) */
|
|
16
|
+
cloneJsonThrows?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function createMockResponse(opts: MockResponseOptions = {}) {
|
|
20
|
+
const {
|
|
21
|
+
ok = true,
|
|
22
|
+
status = 200,
|
|
23
|
+
statusText = "OK",
|
|
24
|
+
contentLength,
|
|
25
|
+
body = null,
|
|
26
|
+
bodyText = "",
|
|
27
|
+
cloneJsonThrows = false,
|
|
28
|
+
} = opts;
|
|
29
|
+
|
|
30
|
+
const headers: Record<string, string> = {};
|
|
31
|
+
if (contentLength !== undefined) headers["content-length"] = contentLength;
|
|
32
|
+
|
|
33
|
+
const jsonFn = vi.fn().mockResolvedValue(body);
|
|
34
|
+
const textFn = vi.fn().mockResolvedValue(
|
|
35
|
+
bodyText || (body !== null ? JSON.stringify(body) : "")
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// The clone is only used for error-path json parsing
|
|
39
|
+
const cloneJsonFn = cloneJsonThrows
|
|
40
|
+
? vi.fn().mockRejectedValue(new Error("not json"))
|
|
41
|
+
: vi.fn().mockResolvedValue(body);
|
|
42
|
+
|
|
43
|
+
const clone = vi.fn().mockReturnValue({ json: cloneJsonFn });
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
ok,
|
|
47
|
+
status,
|
|
48
|
+
statusText,
|
|
49
|
+
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
|
|
50
|
+
json: jsonFn,
|
|
51
|
+
text: textFn,
|
|
52
|
+
clone,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const BASE = "https://carboncopy.news";
|
|
57
|
+
|
|
58
|
+
describe("CarbonCopyClient", () => {
|
|
59
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
60
|
+
let client: CarbonCopyClient;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
fetchMock = vi.fn();
|
|
64
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
65
|
+
client = new CarbonCopyClient("cc_testkey");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
// Core request behaviour
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("request() — GET", () => {
|
|
77
|
+
it("sends correct URL and Authorization header, returns parsed JSON", async () => {
|
|
78
|
+
const data = { total: 42 };
|
|
79
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: data }));
|
|
80
|
+
|
|
81
|
+
const result = await client.getPortfolio();
|
|
82
|
+
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
84
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
85
|
+
expect(url).toBe(`${BASE}/api/v1/portfolio`);
|
|
86
|
+
expect((init.headers as Record<string, string>)["Authorization"]).toBe(
|
|
87
|
+
"Bearer cc_testkey"
|
|
88
|
+
);
|
|
89
|
+
expect((init.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
90
|
+
"application/json"
|
|
91
|
+
);
|
|
92
|
+
expect(init.method).toBe("GET");
|
|
93
|
+
expect(init.body).toBeUndefined();
|
|
94
|
+
expect(result).toEqual(data);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("request() — POST", () => {
|
|
99
|
+
it("serialises body to JSON and sends correct method", async () => {
|
|
100
|
+
const responseData = { id: "trader-1" };
|
|
101
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: responseData }));
|
|
102
|
+
|
|
103
|
+
const payload = {
|
|
104
|
+
walletAddress: "0xabc",
|
|
105
|
+
copyPercentage: 50,
|
|
106
|
+
};
|
|
107
|
+
await client.followTrader(payload);
|
|
108
|
+
|
|
109
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
110
|
+
expect(init.method).toBe("POST");
|
|
111
|
+
expect(init.body).toBe(JSON.stringify(payload));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("request() — error handling", () => {
|
|
116
|
+
it("throws an error including status and response body when response is not ok", async () => {
|
|
117
|
+
const errBody = { message: "Unauthorized" };
|
|
118
|
+
fetchMock.mockResolvedValue(
|
|
119
|
+
createMockResponse({
|
|
120
|
+
ok: false,
|
|
121
|
+
status: 401,
|
|
122
|
+
statusText: "Unauthorized",
|
|
123
|
+
body: errBody,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await expect(client.getPortfolio()).rejects.toThrow(
|
|
128
|
+
/Carbon Copy API error 401 Unauthorized/
|
|
129
|
+
);
|
|
130
|
+
await expect(client.getPortfolio()).rejects.toThrow(
|
|
131
|
+
/Unauthorized/
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("falls back to res.text() when cloned.json() fails on error response", async () => {
|
|
136
|
+
fetchMock.mockResolvedValue(
|
|
137
|
+
createMockResponse({
|
|
138
|
+
ok: false,
|
|
139
|
+
status: 500,
|
|
140
|
+
statusText: "Internal Server Error",
|
|
141
|
+
bodyText: "raw error text",
|
|
142
|
+
cloneJsonThrows: true,
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await expect(client.getPortfolio()).rejects.toThrow(
|
|
147
|
+
/raw error text/
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("request() — empty body handling", () => {
|
|
153
|
+
it("returns undefined for 204 No Content without calling res.json()", async () => {
|
|
154
|
+
const mock = createMockResponse({ status: 204 });
|
|
155
|
+
// ok defaults true but 204 has no body
|
|
156
|
+
fetchMock.mockResolvedValue({ ...mock, ok: true, status: 204 });
|
|
157
|
+
|
|
158
|
+
const result = await client.unfollowTrader("0xabc");
|
|
159
|
+
expect(result).toBeUndefined();
|
|
160
|
+
expect(mock.json).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns undefined when Content-Length is 0", async () => {
|
|
164
|
+
const mock = createMockResponse({ contentLength: "0" });
|
|
165
|
+
fetchMock.mockResolvedValue(mock);
|
|
166
|
+
|
|
167
|
+
const result = await client.pauseTrader("0xabc");
|
|
168
|
+
expect(result).toBeUndefined();
|
|
169
|
+
expect(mock.json).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
// buildQuery (tested via endpoint methods)
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
describe("buildQuery", () => {
|
|
178
|
+
it("appends query string for defined values", async () => {
|
|
179
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
180
|
+
await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
|
|
181
|
+
|
|
182
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
183
|
+
expect(url).toContain("limit=10");
|
|
184
|
+
expect(url).toContain("cursor=abc");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("omits undefined values from query string", async () => {
|
|
188
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
189
|
+
await client.getPortfolioHistory({ limit: 5 });
|
|
190
|
+
|
|
191
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
192
|
+
expect(url).toContain("limit=5");
|
|
193
|
+
expect(url).not.toContain("cursor");
|
|
194
|
+
expect(url).not.toContain("since");
|
|
195
|
+
expect(url).not.toContain("until");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("omits query string entirely when params are all undefined", async () => {
|
|
199
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
200
|
+
await client.getPositions({});
|
|
201
|
+
|
|
202
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
203
|
+
expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
|
|
204
|
+
expect(url).not.toContain("?");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// Endpoint methods — method + path
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
describe("endpoint methods", () => {
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("getPortfolio() → GET /api/v1/portfolio", async () => {
|
|
218
|
+
await client.getPortfolio();
|
|
219
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
220
|
+
expect(url).toBe(`${BASE}/api/v1/portfolio`);
|
|
221
|
+
expect(init.method).toBe("GET");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("getPortfolioHistory({limit:10, cursor:'abc'}) → GET /api/v1/portfolio/history?limit=10&cursor=abc", async () => {
|
|
225
|
+
await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
|
|
226
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
227
|
+
expect(url).toBe(
|
|
228
|
+
`${BASE}/api/v1/portfolio/history?limit=10&cursor=abc`
|
|
229
|
+
);
|
|
230
|
+
expect(init.method).toBe("GET");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("getPositions() → GET /api/v1/portfolio/positions", async () => {
|
|
234
|
+
await client.getPositions();
|
|
235
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
236
|
+
expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
|
|
237
|
+
expect(init.method).toBe("GET");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("getTraders() → GET /api/v1/traders", async () => {
|
|
241
|
+
await client.getTraders();
|
|
242
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
243
|
+
expect(url).toBe(`${BASE}/api/v1/traders`);
|
|
244
|
+
expect(init.method).toBe("GET");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("followTrader({...}) → POST /api/v1/traders with body", async () => {
|
|
248
|
+
const body = {
|
|
249
|
+
walletAddress: "0xabc",
|
|
250
|
+
copyPercentage: 50,
|
|
251
|
+
maxCopyAmount: 100,
|
|
252
|
+
notificationsEnabled: true,
|
|
253
|
+
};
|
|
254
|
+
await client.followTrader(body);
|
|
255
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
256
|
+
expect(url).toBe(`${BASE}/api/v1/traders`);
|
|
257
|
+
expect(init.method).toBe("POST");
|
|
258
|
+
expect(JSON.parse(init.body as string)).toEqual(body);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("getTrader('0xabc') → GET /api/v1/traders/0xabc", async () => {
|
|
262
|
+
await client.getTrader("0xabc");
|
|
263
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
264
|
+
expect(url).toBe(`${BASE}/api/v1/traders/0xabc`);
|
|
265
|
+
expect(init.method).toBe("GET");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("updateTrader('0xabc', {...}) → PATCH /api/v1/traders/0xabc with body", async () => {
|
|
269
|
+
const body = { copyPercentage: 75, copyTradingEnabled: false };
|
|
270
|
+
await client.updateTrader("0xabc", body);
|
|
271
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
272
|
+
expect(url).toBe(`${BASE}/api/v1/traders/0xabc`);
|
|
273
|
+
expect(init.method).toBe("PATCH");
|
|
274
|
+
expect(JSON.parse(init.body as string)).toEqual(body);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("unfollowTrader('0xabc') → DELETE /api/v1/traders/0xabc", async () => {
|
|
278
|
+
fetchMock.mockResolvedValue(createMockResponse({ status: 204, ok: true }));
|
|
279
|
+
await client.unfollowTrader("0xabc");
|
|
280
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
281
|
+
expect(url).toBe(`${BASE}/api/v1/traders/0xabc`);
|
|
282
|
+
expect(init.method).toBe("DELETE");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("pauseTrader('0xabc') → POST /api/v1/traders/0xabc/pause", async () => {
|
|
286
|
+
await client.pauseTrader("0xabc");
|
|
287
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
288
|
+
expect(url).toBe(`${BASE}/api/v1/traders/0xabc/pause`);
|
|
289
|
+
expect(init.method).toBe("POST");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("resumeTrader('0xabc') → POST /api/v1/traders/0xabc/resume", async () => {
|
|
293
|
+
await client.resumeTrader("0xabc");
|
|
294
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
295
|
+
expect(url).toBe(`${BASE}/api/v1/traders/0xabc/resume`);
|
|
296
|
+
expect(init.method).toBe("POST");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("getOrders({status:'filled'}) → GET /api/v1/orders?status=filled", async () => {
|
|
300
|
+
await client.getOrders({ status: "filled" });
|
|
301
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
302
|
+
expect(url).toBe(`${BASE}/api/v1/orders?status=filled`);
|
|
303
|
+
expect(init.method).toBe("GET");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("getOrder('id123') → GET /api/v1/orders/id123", async () => {
|
|
307
|
+
await client.getOrder("id123");
|
|
308
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
309
|
+
expect(url).toBe(`${BASE}/api/v1/orders/id123`);
|
|
310
|
+
expect(init.method).toBe("GET");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("getAccount() → GET /api/v1/account", async () => {
|
|
314
|
+
await client.getAccount();
|
|
315
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
316
|
+
expect(url).toBe(`${BASE}/api/v1/account`);
|
|
317
|
+
expect(init.method).toBe("GET");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("health() → GET /api/v1/health", async () => {
|
|
321
|
+
await client.health();
|
|
322
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
323
|
+
expect(url).toBe(`${BASE}/api/v1/health`);
|
|
324
|
+
expect(init.method).toBe("GET");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
// URL encoding
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe("URL encoding", () => {
|
|
333
|
+
beforeEach(() => {
|
|
334
|
+
fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("encodes wallet addresses with special characters in path", async () => {
|
|
338
|
+
const wallet = "0xabc/def?foo=bar&baz=qux";
|
|
339
|
+
await client.getTrader(wallet);
|
|
340
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
341
|
+
expect(url).toContain(encodeURIComponent(wallet));
|
|
342
|
+
expect(url).not.toContain("?foo=bar");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("encodes order IDs with special characters", async () => {
|
|
346
|
+
const id = "order/id?test=1";
|
|
347
|
+
await client.getOrder(id);
|
|
348
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
349
|
+
expect(url).toContain(encodeURIComponent(id));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("encodes wallet addresses for updateTrader", async () => {
|
|
353
|
+
const wallet = "0xabc def";
|
|
354
|
+
await client.updateTrader(wallet, { copyPercentage: 10 });
|
|
355
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
356
|
+
expect(url).toContain(encodeURIComponent(wallet));
|
|
357
|
+
expect(url).toBe(`${BASE}/api/v1/traders/${encodeURIComponent(wallet)}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("encodes wallet addresses for unfollowTrader", async () => {
|
|
361
|
+
const wallet = "0xabc def";
|
|
362
|
+
fetchMock.mockResolvedValue(createMockResponse({ status: 204, ok: true }));
|
|
363
|
+
await client.unfollowTrader(wallet);
|
|
364
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
365
|
+
expect(url).toBe(`${BASE}/api/v1/traders/${encodeURIComponent(wallet)}`);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("encodes wallet addresses for pauseTrader", async () => {
|
|
369
|
+
const wallet = "0xabc def";
|
|
370
|
+
await client.pauseTrader(wallet);
|
|
371
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
372
|
+
expect(url).toBe(
|
|
373
|
+
`${BASE}/api/v1/traders/${encodeURIComponent(wallet)}/pause`
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("encodes wallet addresses for resumeTrader", async () => {
|
|
378
|
+
const wallet = "0xabc def";
|
|
379
|
+
await client.resumeTrader(wallet);
|
|
380
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
381
|
+
expect(url).toBe(
|
|
382
|
+
`${BASE}/api/v1/traders/${encodeURIComponent(wallet)}/resume`
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("encodes query string values correctly", async () => {
|
|
387
|
+
await client.getPortfolioHistory({ cursor: "abc=def&ghi" });
|
|
388
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
389
|
+
expect(url).toContain("cursor=abc%3Ddef%26ghi");
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
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 { registerPortfolioResources } from "../resources/portfolio.js";
|
|
5
|
+
import { registerTraderResources } from "../resources/traders.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
type RegisteredResource = {
|
|
12
|
+
name: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
metadata?: {
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
mimeType?: string;
|
|
18
|
+
};
|
|
19
|
+
readCallback: (uri: URL, extra: unknown) => Promise<unknown>;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ServerInternals = {
|
|
24
|
+
_registeredResources: Record<string, RegisteredResource>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function getResources(server: McpServer) {
|
|
28
|
+
return (server as unknown as ServerInternals)._registeredResources;
|
|
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
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("Resource Registration", () => {
|
|
48
|
+
let server: McpServer;
|
|
49
|
+
let client: CarbonCopyClient;
|
|
50
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
fetchMock = vi.fn();
|
|
54
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
55
|
+
|
|
56
|
+
server = new McpServer({ name: "test", version: "0.0.1" });
|
|
57
|
+
client = new CarbonCopyClient("cc_test");
|
|
58
|
+
|
|
59
|
+
registerPortfolioResources(server, client);
|
|
60
|
+
registerTraderResources(server, client);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.restoreAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// Registration checks
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
it("registers exactly 2 resources", () => {
|
|
72
|
+
const resources = getResources(server);
|
|
73
|
+
expect(Object.keys(resources)).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("registers the portfolio resource at carboncopy://portfolio", () => {
|
|
77
|
+
const resources = getResources(server);
|
|
78
|
+
expect(resources).toHaveProperty("carboncopy://portfolio");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("registers the traders resource at carboncopy://traders", () => {
|
|
82
|
+
const resources = getResources(server);
|
|
83
|
+
expect(resources).toHaveProperty("carboncopy://traders");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("portfolio resource has name 'portfolio'", () => {
|
|
87
|
+
const resources = getResources(server);
|
|
88
|
+
expect(resources["carboncopy://portfolio"].name).toBe("portfolio");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("traders resource has name 'traders'", () => {
|
|
92
|
+
const resources = getResources(server);
|
|
93
|
+
expect(resources["carboncopy://traders"].name).toBe("traders");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("portfolio resource has mimeType application/json", () => {
|
|
97
|
+
const resources = getResources(server);
|
|
98
|
+
const resource = resources["carboncopy://portfolio"];
|
|
99
|
+
expect(resource.metadata?.mimeType).toBe("application/json");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("traders resource has mimeType application/json", () => {
|
|
103
|
+
const resources = getResources(server);
|
|
104
|
+
const resource = resources["carboncopy://traders"];
|
|
105
|
+
expect(resource.metadata?.mimeType).toBe("application/json");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("all resources are enabled by default", () => {
|
|
109
|
+
const resources = getResources(server);
|
|
110
|
+
for (const resource of Object.values(resources)) {
|
|
111
|
+
expect(resource.enabled).toBe(true);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Portfolio resource — readCallback
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe("portfolio resource — read", () => {
|
|
120
|
+
it("returns a content block with URI carboncopy://portfolio", async () => {
|
|
121
|
+
const portfolioData = { totalValue: 1234.56, pnl: 123.45 };
|
|
122
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(portfolioData));
|
|
123
|
+
|
|
124
|
+
const resource = getResources(server)["carboncopy://portfolio"];
|
|
125
|
+
const uri = new URL("carboncopy://portfolio");
|
|
126
|
+
const result = (await resource.readCallback(uri, {})) as {
|
|
127
|
+
contents: { uri: string; mimeType: string; text: string }[];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
expect(result.contents).toHaveLength(1);
|
|
131
|
+
expect(result.contents[0].uri).toBe("carboncopy://portfolio");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns application/json mimeType in content", async () => {
|
|
135
|
+
fetchMock.mockResolvedValue(createMockFetchResponse({ v: 1 }));
|
|
136
|
+
|
|
137
|
+
const resource = getResources(server)["carboncopy://portfolio"];
|
|
138
|
+
const result = (await resource.readCallback(
|
|
139
|
+
new URL("carboncopy://portfolio"),
|
|
140
|
+
{}
|
|
141
|
+
)) as { contents: { uri: string; mimeType: string; text: string }[] };
|
|
142
|
+
|
|
143
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns JSON-stringified portfolio data as text", async () => {
|
|
147
|
+
const portfolioData = { totalValue: 500, positions: [] };
|
|
148
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(portfolioData));
|
|
149
|
+
|
|
150
|
+
const resource = getResources(server)["carboncopy://portfolio"];
|
|
151
|
+
const result = (await resource.readCallback(
|
|
152
|
+
new URL("carboncopy://portfolio"),
|
|
153
|
+
{}
|
|
154
|
+
)) as { contents: { uri: string; mimeType: string; text: string }[] };
|
|
155
|
+
|
|
156
|
+
expect(JSON.parse(result.contents[0].text)).toEqual(portfolioData);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("calls GET /api/v1/portfolio when portfolio resource is read", async () => {
|
|
160
|
+
fetchMock.mockResolvedValue(createMockFetchResponse({}));
|
|
161
|
+
|
|
162
|
+
const resource = getResources(server)["carboncopy://portfolio"];
|
|
163
|
+
await resource.readCallback(new URL("carboncopy://portfolio"), {});
|
|
164
|
+
|
|
165
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
166
|
+
expect(url).toContain("/api/v1/portfolio");
|
|
167
|
+
expect(init.method).toBe("GET");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("propagates API errors from the portfolio resource read", async () => {
|
|
171
|
+
fetchMock.mockResolvedValue({
|
|
172
|
+
ok: false,
|
|
173
|
+
status: 401,
|
|
174
|
+
statusText: "Unauthorized",
|
|
175
|
+
headers: { get: () => null },
|
|
176
|
+
clone: vi.fn().mockReturnValue({
|
|
177
|
+
json: vi.fn().mockResolvedValue({ message: "unauthorized" }),
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const resource = getResources(server)["carboncopy://portfolio"];
|
|
182
|
+
await expect(
|
|
183
|
+
resource.readCallback(new URL("carboncopy://portfolio"), {})
|
|
184
|
+
).rejects.toThrow(/Carbon Copy API error 401/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// Traders resource — readCallback
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe("traders resource — read", () => {
|
|
193
|
+
it("returns a content block with URI carboncopy://traders", async () => {
|
|
194
|
+
const tradersData = [{ wallet: "0xabc", copyPercentage: 50 }];
|
|
195
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(tradersData));
|
|
196
|
+
|
|
197
|
+
const resource = getResources(server)["carboncopy://traders"];
|
|
198
|
+
const result = (await resource.readCallback(
|
|
199
|
+
new URL("carboncopy://traders"),
|
|
200
|
+
{}
|
|
201
|
+
)) as { contents: { uri: string; mimeType: string; text: string }[] };
|
|
202
|
+
|
|
203
|
+
expect(result.contents).toHaveLength(1);
|
|
204
|
+
expect(result.contents[0].uri).toBe("carboncopy://traders");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns application/json mimeType in content", async () => {
|
|
208
|
+
fetchMock.mockResolvedValue(createMockFetchResponse([]));
|
|
209
|
+
|
|
210
|
+
const resource = getResources(server)["carboncopy://traders"];
|
|
211
|
+
const result = (await resource.readCallback(
|
|
212
|
+
new URL("carboncopy://traders"),
|
|
213
|
+
{}
|
|
214
|
+
)) as { contents: { uri: string; mimeType: string; text: string }[] };
|
|
215
|
+
|
|
216
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("returns JSON-stringified traders list as text", async () => {
|
|
220
|
+
const tradersData = [
|
|
221
|
+
{ wallet: "0xabc", copyPercentage: 50 },
|
|
222
|
+
{ wallet: "0xdef", copyPercentage: 25 },
|
|
223
|
+
];
|
|
224
|
+
fetchMock.mockResolvedValue(createMockFetchResponse(tradersData));
|
|
225
|
+
|
|
226
|
+
const resource = getResources(server)["carboncopy://traders"];
|
|
227
|
+
const result = (await resource.readCallback(
|
|
228
|
+
new URL("carboncopy://traders"),
|
|
229
|
+
{}
|
|
230
|
+
)) as { contents: { uri: string; mimeType: string; text: string }[] };
|
|
231
|
+
|
|
232
|
+
expect(JSON.parse(result.contents[0].text)).toEqual(tradersData);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("calls GET /api/v1/traders when traders resource is read", async () => {
|
|
236
|
+
fetchMock.mockResolvedValue(createMockFetchResponse([]));
|
|
237
|
+
|
|
238
|
+
const resource = getResources(server)["carboncopy://traders"];
|
|
239
|
+
await resource.readCallback(new URL("carboncopy://traders"), {});
|
|
240
|
+
|
|
241
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
242
|
+
expect(url).toContain("/api/v1/traders");
|
|
243
|
+
expect(init.method).toBe("GET");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("propagates API errors from the traders resource read", async () => {
|
|
247
|
+
fetchMock.mockResolvedValue({
|
|
248
|
+
ok: false,
|
|
249
|
+
status: 403,
|
|
250
|
+
statusText: "Forbidden",
|
|
251
|
+
headers: { get: () => null },
|
|
252
|
+
clone: vi.fn().mockReturnValue({
|
|
253
|
+
json: vi.fn().mockResolvedValue({ message: "forbidden" }),
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const resource = getResources(server)["carboncopy://traders"];
|
|
258
|
+
await expect(
|
|
259
|
+
resource.readCallback(new URL("carboncopy://traders"), {})
|
|
260
|
+
).rejects.toThrow(/Carbon Copy API error 403/);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|