@carbon-copy/mcp 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@carbon-copy/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "An MCP server that gives AI agents programmatic access to Carbon Copy — a Polymarket copy-trading platform.",
5
5
  "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
6
9
  "bin": {
7
10
  "carboncopy-mcp": "dist/index.js"
8
11
  },
@@ -1,25 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- permissions:
11
- contents: read
12
- id-token: write
13
- environment: npm
14
- steps:
15
- - uses: actions/checkout@v4
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: 22
19
- registry-url: https://registry.npmjs.org
20
- - run: npm ci
21
- - run: npm run build
22
- - run: npm test
23
- - run: npm publish --access public --provenance
24
- env:
25
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/.husky/pre-commit DELETED
@@ -1 +0,0 @@
1
- npm run build && npm test
package/CLAUDE.md DELETED
@@ -1,69 +0,0 @@
1
- # Carbon Copy MCP Server
2
-
3
- MCP (Model Context Protocol) server that wraps the Carbon Copy REST API, enabling AI agents to manage Polymarket copy-trading portfolios.
4
-
5
- ## Build & Run
6
-
7
- ```bash
8
- npm install
9
- npm run build # TypeScript → dist/
10
- npm run dev # Dev mode with watch
11
- npx @carbon-copy/mcp # Run via npx (after publish)
12
- ```
13
-
14
- ## Configuration
15
-
16
- Set `CARBONCOPY_API_KEY` env var (format: `cc_<64 hex chars>`).
17
-
18
- ```json
19
- {
20
- "mcpServers": {
21
- "carboncopy": {
22
- "command": "npx",
23
- "args": ["@carbon-copy/mcp"],
24
- "env": { "CARBONCOPY_API_KEY": "cc_..." }
25
- }
26
- }
27
- }
28
- ```
29
-
30
- ## Structure
31
-
32
- ```
33
- src/
34
- index.ts # McpServer setup + stdio transport
35
- client.ts # HTTP client for CC REST API
36
- tools/
37
- portfolio.ts # get_portfolio, get_history, get_positions
38
- traders.ts # list/follow/update/unfollow/pause/resume
39
- orders.ts # list_orders, get_order
40
- account.ts # get_account, health
41
- resources/
42
- portfolio.ts # carboncopy://portfolio
43
- traders.ts # carboncopy://traders
44
- ```
45
-
46
- ## Public API Ecosystem
47
-
48
- Carbon Copy has three repos that form the public API surface:
49
-
50
- | Repo | Purpose | Deploys to |
51
- |---|---|---|
52
- | `CarbonCopyInc/habakkuk` | App + Convex backend + REST API (source of truth) | Vercel + Convex |
53
- | `CarbonCopyInc/docs` | Mintlify API documentation | Mintlify |
54
- | `CarbonCopyInc/carboncopy-mcp` (this repo) | MCP server | npm (`@carbon-copy/mcp`) |
55
-
56
- ### Sync Rules
57
-
58
- - **`habakkuk` is the source of truth** for API behavior. This server wraps it.
59
- - This is a **thin fetch wrapper** — it does NOT parse or validate API response fields.
60
- - Only needs updating when endpoints are added, removed, or renamed.
61
- - Auth is handled via `CARBONCOPY_API_KEY` env var. No OAuth flows.
62
-
63
- ## Conventions
64
-
65
- - Use `@modelcontextprotocol/sdk` (spec version 2025-11-25)
66
- - All tools must include `annotations` (readOnlyHint, destructiveHint, etc.)
67
- - All tools must return `structuredContent` alongside text content
68
- - Use `zod` for input/output schemas
69
- - Tool names use snake_case (`get_portfolio`, not `getPortfolio`)
@@ -1,427 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } 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
35
- .fn()
36
- .mockResolvedValue(bodyText || (body !== null ? JSON.stringify(body) : ""));
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://www.carboncopy.inc";
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
- wallet: "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(
112
- JSON.stringify({
113
- walletAddress: payload.wallet,
114
- copyPercentage: payload.copyPercentage,
115
- }),
116
- );
117
- });
118
- });
119
-
120
- describe("request() — error handling", () => {
121
- it("throws an error including status and response body when response is not ok", async () => {
122
- const errBody = { message: "Unauthorized" };
123
- fetchMock.mockResolvedValue(
124
- createMockResponse({
125
- ok: false,
126
- status: 401,
127
- statusText: "Unauthorized",
128
- body: errBody,
129
- }),
130
- );
131
-
132
- await expect(client.getPortfolio()).rejects.toThrow(
133
- /Carbon Copy API error 401 Unauthorized/,
134
- );
135
- await expect(client.getPortfolio()).rejects.toThrow(/Unauthorized/);
136
- });
137
-
138
- it("falls back to res.text() when cloned.json() fails on error response", async () => {
139
- fetchMock.mockResolvedValue(
140
- createMockResponse({
141
- ok: false,
142
- status: 500,
143
- statusText: "Internal Server Error",
144
- bodyText: "raw error text",
145
- cloneJsonThrows: true,
146
- }),
147
- );
148
-
149
- await expect(client.getPortfolio()).rejects.toThrow(/raw error text/);
150
- });
151
- });
152
-
153
- describe("request() — empty body handling", () => {
154
- it("returns undefined for 204 No Content without calling res.json()", async () => {
155
- const mock = createMockResponse({ status: 204 });
156
- // ok defaults true but 204 has no body
157
- fetchMock.mockResolvedValue({ ...mock, ok: true, status: 204 });
158
-
159
- const result = await client.unfollowTrader("0xabc");
160
- expect(result).toBeUndefined();
161
- expect(mock.json).not.toHaveBeenCalled();
162
- });
163
-
164
- it("returns undefined when Content-Length is 0", async () => {
165
- const mock = createMockResponse({ contentLength: "0" });
166
- fetchMock.mockResolvedValue(mock);
167
-
168
- const result = await client.pauseTrader("0xabc");
169
- expect(result).toBeUndefined();
170
- expect(mock.json).not.toHaveBeenCalled();
171
- });
172
- });
173
-
174
- // -------------------------------------------------------------------------
175
- // buildQuery (tested via endpoint methods)
176
- // -------------------------------------------------------------------------
177
-
178
- describe("buildQuery", () => {
179
- it("appends query string for defined values", async () => {
180
- fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
181
- await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
182
-
183
- const [url] = fetchMock.mock.calls[0] as [string];
184
- expect(url).toContain("limit=10");
185
- expect(url).toContain("cursor=abc");
186
- });
187
-
188
- it("omits undefined values from query string", async () => {
189
- fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
190
- await client.getPortfolioHistory({ limit: 5 });
191
-
192
- const [url] = fetchMock.mock.calls[0] as [string];
193
- expect(url).toContain("limit=5");
194
- expect(url).not.toContain("cursor");
195
- expect(url).not.toContain("since");
196
- expect(url).not.toContain("until");
197
- });
198
-
199
- it("omits query string entirely when params are all undefined", async () => {
200
- fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
201
- await client.getPositions({});
202
-
203
- const [url] = fetchMock.mock.calls[0] as [string];
204
- expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
205
- expect(url).not.toContain("?");
206
- });
207
- });
208
-
209
- // -------------------------------------------------------------------------
210
- // Endpoint methods — method + path
211
- // -------------------------------------------------------------------------
212
-
213
- describe("endpoint methods", () => {
214
- beforeEach(() => {
215
- fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
216
- });
217
-
218
- it("getPortfolio() → GET /api/v1/portfolio", async () => {
219
- await client.getPortfolio();
220
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
221
- expect(url).toBe(`${BASE}/api/v1/portfolio`);
222
- expect(init.method).toBe("GET");
223
- });
224
-
225
- it("getPortfolioHistory({limit:10, cursor:'abc'}) → GET /api/v1/portfolio/history?limit=10&cursor=abc", async () => {
226
- await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
227
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
228
- expect(url).toBe(`${BASE}/api/v1/portfolio/history?limit=10&cursor=abc`);
229
- expect(init.method).toBe("GET");
230
- });
231
-
232
- it("getPositions() → GET /api/v1/portfolio/positions", async () => {
233
- await client.getPositions();
234
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
235
- expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
236
- expect(init.method).toBe("GET");
237
- });
238
-
239
- it("getTraders() → GET /api/v1/portfolio/traders", async () => {
240
- await client.getTraders();
241
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
242
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders`);
243
- expect(init.method).toBe("GET");
244
- });
245
-
246
- it("discoverTraders({sortBy:'roi'}) → GET /api/v1/traders?sortBy=roi", async () => {
247
- await client.discoverTraders({ sortBy: "roi" });
248
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
249
- expect(url).toBe(`${BASE}/api/v1/traders?sortBy=roi`);
250
- expect(init.method).toBe("GET");
251
- });
252
-
253
- it("followTrader({...}) → POST /api/v1/portfolio/traders with body", async () => {
254
- const body = {
255
- wallet: "0xabc",
256
- copyPercentage: 50,
257
- maxCopyAmount: 100,
258
- notificationsEnabled: true,
259
- };
260
- await client.followTrader(body);
261
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
262
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders`);
263
- expect(init.method).toBe("POST");
264
- expect(JSON.parse(init.body as string)).toEqual({
265
- walletAddress: body.wallet,
266
- copyPercentage: body.copyPercentage,
267
- maxCopyAmount: body.maxCopyAmount,
268
- notificationsEnabled: body.notificationsEnabled,
269
- });
270
- });
271
-
272
- it("getTrader('0xabc') → GET /api/v1/portfolio/traders/0xabc", async () => {
273
- await client.getTrader("0xabc");
274
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
275
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
276
- expect(init.method).toBe("GET");
277
- });
278
-
279
- it("updateTrader('0xabc', {...}) → PATCH /api/v1/portfolio/traders/0xabc with body", async () => {
280
- const body = { copyPercentage: 75, copyTradingEnabled: false };
281
- await client.updateTrader("0xabc", body);
282
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
283
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
284
- expect(init.method).toBe("PATCH");
285
- expect(JSON.parse(init.body as string)).toEqual(body);
286
- });
287
-
288
- it("unfollowTrader('0xabc') → DELETE /api/v1/portfolio/traders/0xabc", async () => {
289
- fetchMock.mockResolvedValue(
290
- createMockResponse({ status: 204, ok: true }),
291
- );
292
- await client.unfollowTrader("0xabc");
293
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
294
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
295
- expect(init.method).toBe("DELETE");
296
- });
297
-
298
- it("pauseTrader('0xabc') → POST /api/v1/portfolio/traders/0xabc/pause", async () => {
299
- await client.pauseTrader("0xabc");
300
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
301
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc/pause`);
302
- expect(init.method).toBe("POST");
303
- });
304
-
305
- it("resumeTrader('0xabc') → POST /api/v1/portfolio/traders/0xabc/resume", async () => {
306
- await client.resumeTrader("0xabc");
307
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
308
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc/resume`);
309
- expect(init.method).toBe("POST");
310
- });
311
-
312
- it("getTraderPerformance('0xabc') → GET /api/v1/traders/0xabc/performance", async () => {
313
- await client.getTraderPerformance("0xabc");
314
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
315
- expect(url).toBe(`${BASE}/api/v1/traders/0xabc/performance`);
316
- expect(init.method).toBe("GET");
317
- });
318
-
319
- it("batchUpdateTraders([...]) → PATCH /api/v1/portfolio/traders/batch", async () => {
320
- const updates = [{ walletAddress: "0xabc", copyPercentage: 70 }];
321
- await client.batchUpdateTraders(updates);
322
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
323
- expect(url).toBe(`${BASE}/api/v1/portfolio/traders/batch`);
324
- expect(init.method).toBe("PATCH");
325
- expect(JSON.parse(init.body as string)).toEqual({ updates });
326
- });
327
-
328
- it("getOrders({status:'filled'}) → GET /api/v1/orders?status=filled", async () => {
329
- await client.getOrders({ status: "filled" });
330
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
331
- expect(url).toBe(`${BASE}/api/v1/orders?status=filled`);
332
- expect(init.method).toBe("GET");
333
- });
334
-
335
- it("getOrder('id123') → GET /api/v1/orders/id123", async () => {
336
- await client.getOrder("id123");
337
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
338
- expect(url).toBe(`${BASE}/api/v1/orders/id123`);
339
- expect(init.method).toBe("GET");
340
- });
341
-
342
- it("getAccount() → GET /api/v1/account", async () => {
343
- await client.getAccount();
344
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
345
- expect(url).toBe(`${BASE}/api/v1/account`);
346
- expect(init.method).toBe("GET");
347
- });
348
-
349
- it("health() → GET /api/v1/health", async () => {
350
- await client.health();
351
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
352
- expect(url).toBe(`${BASE}/api/v1/health`);
353
- expect(init.method).toBe("GET");
354
- });
355
- });
356
-
357
- // -------------------------------------------------------------------------
358
- // URL encoding
359
- // -------------------------------------------------------------------------
360
-
361
- describe("URL encoding", () => {
362
- beforeEach(() => {
363
- fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
364
- });
365
-
366
- it("encodes wallet addresses with special characters in path", async () => {
367
- const wallet = "0xabc/def?foo=bar&baz=qux";
368
- await client.getTrader(wallet);
369
- const [url] = fetchMock.mock.calls[0] as [string];
370
- expect(url).toContain(encodeURIComponent(wallet));
371
- expect(url).not.toContain("?foo=bar");
372
- });
373
-
374
- it("encodes order IDs with special characters", async () => {
375
- const id = "order/id?test=1";
376
- await client.getOrder(id);
377
- const [url] = fetchMock.mock.calls[0] as [string];
378
- expect(url).toContain(encodeURIComponent(id));
379
- });
380
-
381
- it("encodes wallet addresses for updateTrader", async () => {
382
- const wallet = "0xabc def";
383
- await client.updateTrader(wallet, { copyPercentage: 10 });
384
- const [url] = fetchMock.mock.calls[0] as [string];
385
- expect(url).toContain(encodeURIComponent(wallet));
386
- expect(url).toBe(
387
- `${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}`,
388
- );
389
- });
390
-
391
- it("encodes wallet addresses for unfollowTrader", async () => {
392
- const wallet = "0xabc def";
393
- fetchMock.mockResolvedValue(
394
- createMockResponse({ status: 204, ok: true }),
395
- );
396
- await client.unfollowTrader(wallet);
397
- const [url] = fetchMock.mock.calls[0] as [string];
398
- expect(url).toBe(
399
- `${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}`,
400
- );
401
- });
402
-
403
- it("encodes wallet addresses for pauseTrader", async () => {
404
- const wallet = "0xabc def";
405
- await client.pauseTrader(wallet);
406
- const [url] = fetchMock.mock.calls[0] as [string];
407
- expect(url).toBe(
408
- `${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}/pause`,
409
- );
410
- });
411
-
412
- it("encodes wallet addresses for resumeTrader", async () => {
413
- const wallet = "0xabc def";
414
- await client.resumeTrader(wallet);
415
- const [url] = fetchMock.mock.calls[0] as [string];
416
- expect(url).toBe(
417
- `${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}/resume`,
418
- );
419
- });
420
-
421
- it("encodes query string values correctly", async () => {
422
- await client.getPortfolioHistory({ cursor: "abc=def&ghi" });
423
- const [url] = fetchMock.mock.calls[0] as [string];
424
- expect(url).toContain("cursor=abc%3Ddef%26ghi");
425
- });
426
- });
427
- });