@solworks/poll-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/README.md +121 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +97 -0
- package/dist/prompts/index.d.ts +19 -0
- package/dist/prompts/index.js +100 -0
- package/dist/resources/index.d.ts +9 -0
- package/dist/resources/index.js +83 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +686 -0
- package/package.json +28 -0
- package/tests/integration/server.test.ts +159 -0
- package/tests/security/excluded-endpoints.test.ts +25 -0
- package/tests/security/scope-enforcement.test.ts +66 -0
- package/tests/security/token-handling.test.ts +30 -0
- package/tests/unit/resources.test.ts +78 -0
- package/tests/unit/tools/bets.test.ts +116 -0
- package/tests/unit/tools/leaderboard.test.ts +45 -0
- package/tests/unit/tools/social.test.ts +47 -0
- package/tests/unit/tools/user.test.ts +49 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solworks/poll-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Poll.fun betting platform",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"poll-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest",
|
|
14
|
+
"test:unit": "vitest run tests/unit",
|
|
15
|
+
"test:integration": "vitest run tests/integration",
|
|
16
|
+
"test:security": "vitest run tests/security"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"poll-api-client": "@solworks/poll-api-client",
|
|
21
|
+
"zod": "^3.24.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^25.5.0",
|
|
25
|
+
"typescript": "^5.4.0",
|
|
26
|
+
"vitest": "^3.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
4
|
+
import { createServer } from '../../src/index.js';
|
|
5
|
+
import type { PollClient } from 'poll-api-client';
|
|
6
|
+
|
|
7
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
8
|
+
return {
|
|
9
|
+
getAccount: vi.fn().mockResolvedValue({
|
|
10
|
+
id: 1,
|
|
11
|
+
uuid: 'test-uuid',
|
|
12
|
+
displayName: 'TestUser',
|
|
13
|
+
email: 'test@example.com',
|
|
14
|
+
}),
|
|
15
|
+
getProfile: vi.fn().mockResolvedValue({
|
|
16
|
+
uuid: 'test-uuid',
|
|
17
|
+
displayName: 'TestUser',
|
|
18
|
+
}),
|
|
19
|
+
getXpBalance: vi.fn().mockResolvedValue({ xp: 1000, error: null }),
|
|
20
|
+
getUsdcBalance: vi.fn().mockResolvedValue({ usdc: 50, error: null }),
|
|
21
|
+
getTrendingBets: vi.fn().mockResolvedValue([
|
|
22
|
+
{ question: 'Trending bet 1', totalVolume: 100 },
|
|
23
|
+
]),
|
|
24
|
+
getLeaderboard: vi.fn().mockResolvedValue([
|
|
25
|
+
{ userId: 1, uuid: 'u1', displayName: 'Top', rank: 1, points: 999 },
|
|
26
|
+
]),
|
|
27
|
+
getMyBets: vi.fn().mockResolvedValue([]),
|
|
28
|
+
getMyWagers: vi.fn().mockResolvedValue([]),
|
|
29
|
+
...overrides,
|
|
30
|
+
} as unknown as PollClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('MCP Server integration', () => {
|
|
34
|
+
let mcpClient: Client;
|
|
35
|
+
let mcpServer: ReturnType<typeof createServer>;
|
|
36
|
+
let client: PollClient;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
client = mockClient();
|
|
40
|
+
mcpServer = createServer(undefined, client);
|
|
41
|
+
|
|
42
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
43
|
+
await mcpServer.connect(serverTransport);
|
|
44
|
+
|
|
45
|
+
mcpClient = new Client({ name: 'test-client', version: '1.0.0' });
|
|
46
|
+
await mcpClient.connect(clientTransport);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await mcpClient.close();
|
|
51
|
+
await mcpServer.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('lists all expected tools', async () => {
|
|
55
|
+
const { tools } = await mcpClient.listTools();
|
|
56
|
+
const toolNames = tools.map((t) => t.name);
|
|
57
|
+
|
|
58
|
+
const expectedTools = [
|
|
59
|
+
'get_account',
|
|
60
|
+
'get_profile',
|
|
61
|
+
'get_balance',
|
|
62
|
+
'list_public_bets',
|
|
63
|
+
'get_trending_bets',
|
|
64
|
+
'get_bet',
|
|
65
|
+
'get_my_wagers',
|
|
66
|
+
'get_leaderboard',
|
|
67
|
+
'get_my_ranking',
|
|
68
|
+
'get_wallet_transactions',
|
|
69
|
+
'get_friends',
|
|
70
|
+
'get_notifications',
|
|
71
|
+
'get_streak',
|
|
72
|
+
'get_favourite_bets',
|
|
73
|
+
'get_probability_history',
|
|
74
|
+
'create_bet',
|
|
75
|
+
'join_bet',
|
|
76
|
+
'place_wager',
|
|
77
|
+
'cancel_wager',
|
|
78
|
+
'initiate_vote',
|
|
79
|
+
'vote',
|
|
80
|
+
'settle_bet',
|
|
81
|
+
'update_display_name',
|
|
82
|
+
'send_friend_request',
|
|
83
|
+
'respond_friend_request',
|
|
84
|
+
'favourite_bet',
|
|
85
|
+
'unfavourite_bet',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const name of expectedTools) {
|
|
89
|
+
expect(toolNames).toContain(name);
|
|
90
|
+
}
|
|
91
|
+
expect(tools.length).toBe(27);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does NOT expose excluded tools', async () => {
|
|
95
|
+
const { tools } = await mcpClient.listTools();
|
|
96
|
+
const toolNames = tools.map((t) => t.name);
|
|
97
|
+
|
|
98
|
+
const excluded = [
|
|
99
|
+
'private_key',
|
|
100
|
+
'delete_account',
|
|
101
|
+
'admin_ban',
|
|
102
|
+
'admin_resolve',
|
|
103
|
+
'onramp',
|
|
104
|
+
'merge_accounts',
|
|
105
|
+
'coinbase_webhook',
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
for (const name of excluded) {
|
|
109
|
+
expect(toolNames).not.toContain(name);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('can call get_account tool', async () => {
|
|
114
|
+
const result = await mcpClient.callTool({ name: 'get_account', arguments: {} });
|
|
115
|
+
expect(result.isError).toBeFalsy();
|
|
116
|
+
const textContent = result.content as Array<{ type: string; text: string }>;
|
|
117
|
+
expect(textContent[0].text).toContain('TestUser');
|
|
118
|
+
expect(textContent[0].text).toContain('test-uuid');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('lists expected resources', async () => {
|
|
122
|
+
const { resources } = await mcpClient.listResources();
|
|
123
|
+
const uris = resources.map((r) => r.uri);
|
|
124
|
+
expect(uris).toContain('poll://user/profile');
|
|
125
|
+
expect(uris).toContain('poll://user/balances');
|
|
126
|
+
expect(uris).toContain('poll://bets/active');
|
|
127
|
+
expect(uris).toContain('poll://bets/trending');
|
|
128
|
+
expect(uris).toContain('poll://leaderboard/top10');
|
|
129
|
+
expect(resources.length).toBe(5);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('lists expected prompts', async () => {
|
|
133
|
+
const { prompts } = await mcpClient.listPrompts();
|
|
134
|
+
const promptNames = prompts.map((p) => p.name);
|
|
135
|
+
expect(promptNames).toContain('analyze_bet');
|
|
136
|
+
expect(promptNames).toContain('suggest_bets');
|
|
137
|
+
expect(promptNames).toContain('portfolio_review');
|
|
138
|
+
expect(prompts.length).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles API errors gracefully', async () => {
|
|
142
|
+
const errorClient = mockClient({
|
|
143
|
+
getAccount: vi.fn().mockRejectedValue(new Error('API Error 401: Unauthorized')),
|
|
144
|
+
});
|
|
145
|
+
const errorServer = createServer(undefined, errorClient);
|
|
146
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
147
|
+
await errorServer.connect(st);
|
|
148
|
+
const errorMcpClient = new Client({ name: 'error-test', version: '1.0.0' });
|
|
149
|
+
await errorMcpClient.connect(ct);
|
|
150
|
+
|
|
151
|
+
const result = await errorMcpClient.callTool({ name: 'get_account', arguments: {} });
|
|
152
|
+
expect(result.isError).toBe(true);
|
|
153
|
+
const textContent = result.content as Array<{ type: string; text: string }>;
|
|
154
|
+
expect(textContent[0].text).toContain('Unauthorized');
|
|
155
|
+
|
|
156
|
+
await errorMcpClient.close();
|
|
157
|
+
await errorServer.close();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../src/tools/index.js';
|
|
3
|
+
|
|
4
|
+
describe('Excluded endpoints', () => {
|
|
5
|
+
const excludedPatterns = [
|
|
6
|
+
'private_key',
|
|
7
|
+
'delete_account',
|
|
8
|
+
'admin',
|
|
9
|
+
'onramp',
|
|
10
|
+
'merge',
|
|
11
|
+
'coinbase',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it('no tool name matches excluded patterns', () => {
|
|
15
|
+
const toolNames = Object.keys(TOOL_DEFINITIONS);
|
|
16
|
+
for (const pattern of excludedPatterns) {
|
|
17
|
+
for (const name of toolNames) {
|
|
18
|
+
expect(
|
|
19
|
+
name.toLowerCase().includes(pattern.toLowerCase()),
|
|
20
|
+
`Tool "${name}" matches excluded pattern "${pattern}"`
|
|
21
|
+
).toBe(false);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../src/tools/index.js';
|
|
3
|
+
|
|
4
|
+
describe('Scope enforcement', () => {
|
|
5
|
+
it('every tool has a requiredScope defined', () => {
|
|
6
|
+
for (const [name, tool] of Object.entries(TOOL_DEFINITIONS)) {
|
|
7
|
+
expect(tool.requiredScope, `Tool ${name} missing requiredScope`).toBeTruthy();
|
|
8
|
+
expect(typeof tool.requiredScope).toBe('string');
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('write tools require :write scopes', () => {
|
|
13
|
+
const writeTools = [
|
|
14
|
+
'create_bet',
|
|
15
|
+
'join_bet',
|
|
16
|
+
'place_wager',
|
|
17
|
+
'cancel_wager',
|
|
18
|
+
'initiate_vote',
|
|
19
|
+
'vote',
|
|
20
|
+
'settle_bet',
|
|
21
|
+
'update_display_name',
|
|
22
|
+
'send_friend_request',
|
|
23
|
+
'respond_friend_request',
|
|
24
|
+
'favourite_bet',
|
|
25
|
+
'unfavourite_bet',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
for (const name of writeTools) {
|
|
29
|
+
const tool = TOOL_DEFINITIONS[name];
|
|
30
|
+
expect(tool, `Tool ${name} not found`).toBeDefined();
|
|
31
|
+
expect(
|
|
32
|
+
tool.requiredScope.includes(':write'),
|
|
33
|
+
`Tool ${name} has scope "${tool.requiredScope}" but should include ":write"`
|
|
34
|
+
).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('read tools require read scope', () => {
|
|
39
|
+
const readTools = [
|
|
40
|
+
'get_account',
|
|
41
|
+
'get_profile',
|
|
42
|
+
'get_balance',
|
|
43
|
+
'list_public_bets',
|
|
44
|
+
'get_trending_bets',
|
|
45
|
+
'get_bet',
|
|
46
|
+
'get_my_wagers',
|
|
47
|
+
'get_leaderboard',
|
|
48
|
+
'get_my_ranking',
|
|
49
|
+
'get_wallet_transactions',
|
|
50
|
+
'get_friends',
|
|
51
|
+
'get_notifications',
|
|
52
|
+
'get_streak',
|
|
53
|
+
'get_favourite_bets',
|
|
54
|
+
'get_probability_history',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const name of readTools) {
|
|
58
|
+
const tool = TOOL_DEFINITIONS[name];
|
|
59
|
+
expect(tool, `Tool ${name} not found`).toBeDefined();
|
|
60
|
+
expect(
|
|
61
|
+
tool.requiredScope,
|
|
62
|
+
`Tool ${name} has scope "${tool.requiredScope}" but should be "read"`
|
|
63
|
+
).toBe('read');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../src/tools/index.js';
|
|
3
|
+
|
|
4
|
+
describe('Token handling security', () => {
|
|
5
|
+
const sensitivePatterns = ['polld_', 'Bearer', 'https://api.'];
|
|
6
|
+
|
|
7
|
+
it('no tool description contains sensitive tokens', () => {
|
|
8
|
+
for (const [name, tool] of Object.entries(TOOL_DEFINITIONS)) {
|
|
9
|
+
for (const pattern of sensitivePatterns) {
|
|
10
|
+
expect(
|
|
11
|
+
tool.description.includes(pattern),
|
|
12
|
+
`Tool "${name}" description contains "${pattern}"`
|
|
13
|
+
).toBe(false);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('no tool inputSchema references token/apiKey/secret/password', () => {
|
|
19
|
+
const forbiddenKeys = ['token', 'apiKey', 'secret', 'password', 'apikey', 'api_key'];
|
|
20
|
+
for (const [name, tool] of Object.entries(TOOL_DEFINITIONS)) {
|
|
21
|
+
const schemaStr = JSON.stringify(tool.inputSchema).toLowerCase();
|
|
22
|
+
for (const key of forbiddenKeys) {
|
|
23
|
+
expect(
|
|
24
|
+
schemaStr.includes(`"${key}"`),
|
|
25
|
+
`Tool "${name}" inputSchema contains forbidden key "${key}"`
|
|
26
|
+
).toBe(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RESOURCE_DEFINITIONS } from '../../src/resources/index.js';
|
|
3
|
+
import type { PollClient } from 'poll-api-client';
|
|
4
|
+
|
|
5
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
6
|
+
return overrides as PollClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Resource handlers', () => {
|
|
10
|
+
it('poll://user/profile returns profile data', async () => {
|
|
11
|
+
const client = mockClient({
|
|
12
|
+
getProfile: vi.fn().mockResolvedValue({
|
|
13
|
+
uuid: 'uuid-1',
|
|
14
|
+
displayName: 'Alice',
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
const resource = RESOURCE_DEFINITIONS.find((r) => r.uri === 'poll://user/profile')!;
|
|
18
|
+
const text = await resource.handler(client);
|
|
19
|
+
expect(text).toContain('Alice');
|
|
20
|
+
expect(text).toContain('uuid-1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('poll://user/balances returns balances', async () => {
|
|
24
|
+
const client = mockClient({
|
|
25
|
+
getXpBalance: vi.fn().mockResolvedValue({ xp: 500, error: null }),
|
|
26
|
+
getUsdcBalance: vi.fn().mockResolvedValue({ usdc: 25.0, error: null }),
|
|
27
|
+
});
|
|
28
|
+
const resource = RESOURCE_DEFINITIONS.find((r) => r.uri === 'poll://user/balances')!;
|
|
29
|
+
const text = await resource.handler(client);
|
|
30
|
+
expect(text).toContain('XP: 500');
|
|
31
|
+
expect(text).toContain('USDC: 25');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('poll://bets/active returns bets and wagers', async () => {
|
|
35
|
+
const client = mockClient({
|
|
36
|
+
getMyBets: vi.fn().mockResolvedValue([
|
|
37
|
+
{ question: 'My bet', status: 'OPEN' },
|
|
38
|
+
]),
|
|
39
|
+
getMyWagers: vi.fn().mockResolvedValue([
|
|
40
|
+
{ betAddress: 'addr1', amount: 10, status: 'PLACED' },
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
const resource = RESOURCE_DEFINITIONS.find((r) => r.uri === 'poll://bets/active')!;
|
|
44
|
+
const text = await resource.handler(client);
|
|
45
|
+
expect(text).toContain('My bet');
|
|
46
|
+
expect(text).toContain('addr1');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('poll://bets/trending returns trending bets', async () => {
|
|
50
|
+
const client = mockClient({
|
|
51
|
+
getTrendingBets: vi.fn().mockResolvedValue([
|
|
52
|
+
{ question: 'Hot bet', totalVolume: 9999 },
|
|
53
|
+
]),
|
|
54
|
+
});
|
|
55
|
+
const resource = RESOURCE_DEFINITIONS.find((r) => r.uri === 'poll://bets/trending')!;
|
|
56
|
+
const text = await resource.handler(client);
|
|
57
|
+
expect(text).toContain('Hot bet');
|
|
58
|
+
expect(text).toContain('9999');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('poll://leaderboard/top10 returns top 10 entries', async () => {
|
|
62
|
+
const entries = Array.from({ length: 15 }, (_, i) => ({
|
|
63
|
+
userId: i + 1,
|
|
64
|
+
uuid: `uuid-${i}`,
|
|
65
|
+
displayName: `Player ${i + 1}`,
|
|
66
|
+
rank: i + 1,
|
|
67
|
+
points: 500 - i * 10,
|
|
68
|
+
}));
|
|
69
|
+
const client = mockClient({
|
|
70
|
+
getLeaderboard: vi.fn().mockResolvedValue(entries),
|
|
71
|
+
});
|
|
72
|
+
const resource = RESOURCE_DEFINITIONS.find((r) => r.uri === 'poll://leaderboard/top10')!;
|
|
73
|
+
const text = await resource.handler(client);
|
|
74
|
+
expect(text).toContain('Player 1');
|
|
75
|
+
expect(text).toContain('Player 10');
|
|
76
|
+
expect(text).not.toContain('Player 11');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../../src/tools/index.js';
|
|
3
|
+
import type { PollClient } from 'poll-api-client';
|
|
4
|
+
|
|
5
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
6
|
+
return overrides as PollClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Bet tool handlers', () => {
|
|
10
|
+
it('get_trending_bets returns formatted list', async () => {
|
|
11
|
+
const client = mockClient({
|
|
12
|
+
getTrendingBets: vi.fn().mockResolvedValue([
|
|
13
|
+
{ question: 'Will BTC hit 100k?', totalVolume: 5000 },
|
|
14
|
+
{ question: 'ETH above 5k?', totalVolume: 3000 },
|
|
15
|
+
]),
|
|
16
|
+
});
|
|
17
|
+
const result = await TOOL_DEFINITIONS.get_trending_bets.handler({}, client);
|
|
18
|
+
expect(result.isError).toBeUndefined();
|
|
19
|
+
expect(result.content[0].text).toContain('Trending Bets:');
|
|
20
|
+
expect(result.content[0].text).toContain('Will BTC hit 100k?');
|
|
21
|
+
expect(result.content[0].text).toContain('ETH above 5k?');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('get_bet with valid ID returns formatted details', async () => {
|
|
25
|
+
const client = mockClient({
|
|
26
|
+
getBet: vi.fn().mockResolvedValue({
|
|
27
|
+
id: 'bet-123',
|
|
28
|
+
question: 'Will it rain tomorrow?',
|
|
29
|
+
status: 'OPEN',
|
|
30
|
+
options: ['Yes', 'No'],
|
|
31
|
+
totalVolume: 1000,
|
|
32
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
33
|
+
betAddress: 'addr123',
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
const result = await TOOL_DEFINITIONS.get_bet.handler({ id: 'bet-123' }, client);
|
|
37
|
+
expect(result.isError).toBeUndefined();
|
|
38
|
+
expect(result.content[0].text).toContain('Will it rain tomorrow?');
|
|
39
|
+
expect(result.content[0].text).toContain('OPEN');
|
|
40
|
+
expect(result.content[0].text).toContain('Yes, No');
|
|
41
|
+
expect(result.content[0].text).toContain('addr123');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('get_bet with missing ID returns error', async () => {
|
|
45
|
+
const client = mockClient();
|
|
46
|
+
const result = await TOOL_DEFINITIONS.get_bet.handler({}, client);
|
|
47
|
+
expect(result.isError).toBe(true);
|
|
48
|
+
expect(result.content[0].text).toContain('Missing required parameter: id');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('create_bet calls client correctly', async () => {
|
|
52
|
+
const createBetFn = vi.fn().mockResolvedValue({
|
|
53
|
+
id: 'new-bet-1',
|
|
54
|
+
question: 'Will SOL hit 200?',
|
|
55
|
+
options: ['Yes', 'No'],
|
|
56
|
+
status: 'DRAFT',
|
|
57
|
+
});
|
|
58
|
+
const client = mockClient({ createBet: createBetFn });
|
|
59
|
+
const result = await TOOL_DEFINITIONS.create_bet.handler(
|
|
60
|
+
{
|
|
61
|
+
question: 'Will SOL hit 200?',
|
|
62
|
+
options: ['Yes', 'No'],
|
|
63
|
+
amount: 10,
|
|
64
|
+
},
|
|
65
|
+
client
|
|
66
|
+
);
|
|
67
|
+
expect(result.isError).toBeUndefined();
|
|
68
|
+
expect(result.content[0].text).toContain('Bet created successfully');
|
|
69
|
+
expect(result.content[0].text).toContain('Will SOL hit 200?');
|
|
70
|
+
expect(createBetFn).toHaveBeenCalledWith({
|
|
71
|
+
question: 'Will SOL hit 200?',
|
|
72
|
+
options: ['Yes', 'No'],
|
|
73
|
+
type: 'USDC',
|
|
74
|
+
amount: 10,
|
|
75
|
+
expiresAt: undefined,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('place_wager with valid params', async () => {
|
|
80
|
+
const placeWagerFn = vi.fn().mockResolvedValue({});
|
|
81
|
+
const client = mockClient({ placeWager: placeWagerFn });
|
|
82
|
+
const result = await TOOL_DEFINITIONS.place_wager.handler(
|
|
83
|
+
{ betAddress: 'addr1', optionIndex: 0, amount: 50 },
|
|
84
|
+
client
|
|
85
|
+
);
|
|
86
|
+
expect(result.isError).toBeUndefined();
|
|
87
|
+
expect(result.content[0].text).toContain('Wager placed successfully');
|
|
88
|
+
expect(placeWagerFn).toHaveBeenCalledWith({
|
|
89
|
+
betAddress: 'addr1',
|
|
90
|
+
optionIndex: 0,
|
|
91
|
+
amount: 50,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('place_wager with missing betAddress returns error', async () => {
|
|
96
|
+
const client = mockClient();
|
|
97
|
+
const result = await TOOL_DEFINITIONS.place_wager.handler(
|
|
98
|
+
{ optionIndex: 0, amount: 50 },
|
|
99
|
+
client
|
|
100
|
+
);
|
|
101
|
+
expect(result.isError).toBe(true);
|
|
102
|
+
expect(result.content[0].text).toContain('Missing required parameter: betAddress');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('create_bet handles API error', async () => {
|
|
106
|
+
const client = mockClient({
|
|
107
|
+
createBet: vi.fn().mockRejectedValue(new Error('API Error 400: Invalid question')),
|
|
108
|
+
});
|
|
109
|
+
const result = await TOOL_DEFINITIONS.create_bet.handler(
|
|
110
|
+
{ question: 'Bad bet?', options: ['A', 'B'] },
|
|
111
|
+
client
|
|
112
|
+
);
|
|
113
|
+
expect(result.isError).toBe(true);
|
|
114
|
+
expect(result.content[0].text).toContain('API Error 400: Invalid question');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../../src/tools/index.js';
|
|
3
|
+
import type { PollClient } from 'poll-api-client';
|
|
4
|
+
|
|
5
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
6
|
+
return overrides as PollClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Leaderboard tool handlers', () => {
|
|
10
|
+
it('get_leaderboard caps at 25', async () => {
|
|
11
|
+
const entries = Array.from({ length: 30 }, (_, i) => ({
|
|
12
|
+
userId: i + 1,
|
|
13
|
+
uuid: `uuid-${i}`,
|
|
14
|
+
displayName: `User ${i + 1}`,
|
|
15
|
+
rank: i + 1,
|
|
16
|
+
points: 1000 - i * 10,
|
|
17
|
+
}));
|
|
18
|
+
const client = mockClient({
|
|
19
|
+
getLeaderboard: vi.fn().mockResolvedValue(entries),
|
|
20
|
+
});
|
|
21
|
+
const result = await TOOL_DEFINITIONS.get_leaderboard.handler({}, client);
|
|
22
|
+
expect(result.isError).toBeUndefined();
|
|
23
|
+
expect(result.content[0].text).toContain('Leaderboard:');
|
|
24
|
+
expect(result.content[0].text).toContain('#1 User 1');
|
|
25
|
+
expect(result.content[0].text).toContain('#25 User 25');
|
|
26
|
+
expect(result.content[0].text).not.toContain('#26');
|
|
27
|
+
expect(result.content[0].text).toContain('Showing 25 of 30');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('get_my_ranking returns formatted rank', async () => {
|
|
31
|
+
const client = mockClient({
|
|
32
|
+
getMyRanking: vi.fn().mockResolvedValue({
|
|
33
|
+
userId: 42,
|
|
34
|
+
uuid: 'uuid-me',
|
|
35
|
+
displayName: 'Me',
|
|
36
|
+
rank: 15,
|
|
37
|
+
points: 850,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
const result = await TOOL_DEFINITIONS.get_my_ranking.handler({}, client);
|
|
41
|
+
expect(result.isError).toBeUndefined();
|
|
42
|
+
expect(result.content[0].text).toContain('Rank: #15');
|
|
43
|
+
expect(result.content[0].text).toContain('Points: 850');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../../src/tools/index.js';
|
|
3
|
+
import type { PollClient } from 'poll-api-client';
|
|
4
|
+
|
|
5
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
6
|
+
return overrides as PollClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Social tool handlers', () => {
|
|
10
|
+
it('get_friends returns list', async () => {
|
|
11
|
+
const client = mockClient({
|
|
12
|
+
getFriends: vi.fn().mockResolvedValue([
|
|
13
|
+
{ id: 1, uuid: 'friend-1', displayName: 'Alice' },
|
|
14
|
+
{ id: 2, uuid: 'friend-2', displayName: 'Bob' },
|
|
15
|
+
]),
|
|
16
|
+
});
|
|
17
|
+
const result = await TOOL_DEFINITIONS.get_friends.handler({}, client);
|
|
18
|
+
expect(result.isError).toBeUndefined();
|
|
19
|
+
expect(result.content[0].text).toContain('Friends:');
|
|
20
|
+
expect(result.content[0].text).toContain('Alice');
|
|
21
|
+
expect(result.content[0].text).toContain('Bob');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('favourite_bet calls client', async () => {
|
|
25
|
+
const favouriteFn = vi.fn().mockResolvedValue({});
|
|
26
|
+
const client = mockClient({ favouriteBet: favouriteFn });
|
|
27
|
+
const result = await TOOL_DEFINITIONS.favourite_bet.handler(
|
|
28
|
+
{ betAddress: 'addr-fav' },
|
|
29
|
+
client
|
|
30
|
+
);
|
|
31
|
+
expect(result.isError).toBeUndefined();
|
|
32
|
+
expect(result.content[0].text).toContain('Bet added to favourites: addr-fav');
|
|
33
|
+
expect(favouriteFn).toHaveBeenCalledWith('addr-fav');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('send_friend_request calls client', async () => {
|
|
37
|
+
const sendFn = vi.fn().mockResolvedValue({});
|
|
38
|
+
const client = mockClient({ sendFriendRequest: sendFn });
|
|
39
|
+
const result = await TOOL_DEFINITIONS.send_friend_request.handler(
|
|
40
|
+
{ uuid: 'target-uuid' },
|
|
41
|
+
client
|
|
42
|
+
);
|
|
43
|
+
expect(result.isError).toBeUndefined();
|
|
44
|
+
expect(result.content[0].text).toContain('Friend request sent to: target-uuid');
|
|
45
|
+
expect(sendFn).toHaveBeenCalledWith('target-uuid');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TOOL_DEFINITIONS } from '../../../src/tools/index.js';
|
|
3
|
+
import type { PollClient } from 'poll-api-client';
|
|
4
|
+
|
|
5
|
+
function mockClient(overrides: Partial<PollClient> = {}): PollClient {
|
|
6
|
+
return overrides as PollClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('User tool handlers', () => {
|
|
10
|
+
it('get_account returns formatted profile', async () => {
|
|
11
|
+
const client = mockClient({
|
|
12
|
+
getAccount: vi.fn().mockResolvedValue({
|
|
13
|
+
id: 42,
|
|
14
|
+
uuid: 'uuid-abc',
|
|
15
|
+
displayName: 'Alice',
|
|
16
|
+
email: 'alice@example.com',
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
const result = await TOOL_DEFINITIONS.get_account.handler({}, client);
|
|
20
|
+
expect(result.isError).toBeUndefined();
|
|
21
|
+
expect(result.content[0].text).toContain('Account Information:');
|
|
22
|
+
expect(result.content[0].text).toContain('Alice');
|
|
23
|
+
expect(result.content[0].text).toContain('uuid-abc');
|
|
24
|
+
expect(result.content[0].text).toContain('alice@example.com');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('get_balance combines XP and USDC', async () => {
|
|
28
|
+
const client = mockClient({
|
|
29
|
+
getXpBalance: vi.fn().mockResolvedValue({ xp: 1500, error: null }),
|
|
30
|
+
getUsdcBalance: vi.fn().mockResolvedValue({ usdc: 75.5, error: null }),
|
|
31
|
+
});
|
|
32
|
+
const result = await TOOL_DEFINITIONS.get_balance.handler({}, client);
|
|
33
|
+
expect(result.isError).toBeUndefined();
|
|
34
|
+
expect(result.content[0].text).toContain('XP: 1500');
|
|
35
|
+
expect(result.content[0].text).toContain('USDC: 75.5');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('update_display_name calls client', async () => {
|
|
39
|
+
const updateFn = vi.fn().mockResolvedValue(undefined);
|
|
40
|
+
const client = mockClient({ updateDisplayName: updateFn });
|
|
41
|
+
const result = await TOOL_DEFINITIONS.update_display_name.handler(
|
|
42
|
+
{ name: 'NewName' },
|
|
43
|
+
client
|
|
44
|
+
);
|
|
45
|
+
expect(result.isError).toBeUndefined();
|
|
46
|
+
expect(result.content[0].text).toContain('Display name updated to: NewName');
|
|
47
|
+
expect(updateFn).toHaveBeenCalledWith('NewName');
|
|
48
|
+
});
|
|
49
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
16
|
+
}
|