@blocks-network/mcp-server 0.1.55 → 0.1.64

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.
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { checkBalance, requestTopup } from '../src/tools.js';
3
+ import { getConsumerBalance, createConsumerTopUp } from '../src/billing.js';
4
+ import {
5
+ PROTOCOL_VERSION_HEADER,
6
+ CURRENT_PROTOCOL_VERSION,
7
+ } from '../src/protocol-headers.js';
8
+ import { makeFakeDeps } from './helpers.js';
9
+
10
+ function mockResponse(body: unknown, ok = true, status = 200): Response {
11
+ return {
12
+ ok,
13
+ status,
14
+ json: async () => body,
15
+ text: async () => JSON.stringify(body),
16
+ } as Response;
17
+ }
18
+
19
+ describe('check_balance (handler)', () => {
20
+ it('returns an error when BLOCKS_API_KEY is missing', async () => {
21
+ const { deps } = makeFakeDeps();
22
+
23
+ const res = await checkBalance({}, deps);
24
+
25
+ expect(res.isError).toBe(true);
26
+ expect(res.content[0].text).toMatch(/BLOCKS_API_KEY/);
27
+ });
28
+
29
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
30
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
31
+
32
+ const res = await checkBalance({}, deps);
33
+
34
+ expect(res.isError).toBe(true);
35
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
36
+ });
37
+
38
+ it('forwards baseUrl, orgId, apiKey and emits balance JSON', async () => {
39
+ const balance = {
40
+ balance: '100.00',
41
+ reservedBalance: '5.00',
42
+ availableBalance: '95.00',
43
+ updatedAt: '2026-05-28T00:00:00Z',
44
+ };
45
+ const { deps, mocks } = makeFakeDeps({
46
+ apiKey: 'bk_test',
47
+ orgId: 'org_42',
48
+ balance,
49
+ });
50
+
51
+ const res = await checkBalance({}, deps);
52
+
53
+ expect(mocks.getConsumerBalance).toHaveBeenCalledWith({
54
+ baseUrl: 'http://api.test',
55
+ apiKey: 'bk_test',
56
+ orgId: 'org_42',
57
+ });
58
+ expect(JSON.parse(res.content[0].text)).toEqual(balance);
59
+ });
60
+ });
61
+
62
+ describe('request_topup (handler)', () => {
63
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
64
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
65
+
66
+ const res = await requestTopup({ amountUsd: 25 }, deps);
67
+
68
+ expect(res.isError).toBe(true);
69
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
70
+ });
71
+
72
+ it('returns the Stripe checkout URL with a human-readable label', async () => {
73
+ const { deps, mocks } = makeFakeDeps({
74
+ apiKey: 'bk_test',
75
+ orgId: 'org_42',
76
+ topUpSession: {
77
+ checkoutUrl: 'https://checkout.stripe.test/session/xyz',
78
+ sessionId: 'cs_test_abc',
79
+ },
80
+ });
81
+
82
+ const res = await requestTopup({ amountUsd: 25 }, deps);
83
+
84
+ expect(mocks.createConsumerTopUp).toHaveBeenCalledWith({
85
+ baseUrl: 'http://api.test',
86
+ apiKey: 'bk_test',
87
+ orgId: 'org_42',
88
+ amountUsd: 25,
89
+ });
90
+ expect(res.content[0].text).toContain('https://checkout.stripe.test/session/xyz');
91
+ expect(res.content[0].text).toContain('$25.00');
92
+ });
93
+ });
94
+
95
+ describe('getConsumerBalance (HTTP helper)', () => {
96
+ it('GETs /api/v1/billing/:orgId/consumer/balance with Bearer auth', async () => {
97
+ const fetchImpl = vi
98
+ .fn()
99
+ .mockResolvedValue(
100
+ mockResponse({
101
+ balance: '0',
102
+ reservedBalance: '0',
103
+ availableBalance: '0',
104
+ updatedAt: '2026-05-28T00:00:00Z',
105
+ }),
106
+ );
107
+
108
+ await getConsumerBalance({
109
+ baseUrl: 'http://api.test',
110
+ orgId: 'org_42',
111
+ apiKey: 'bk_test',
112
+ fetchImpl: fetchImpl as unknown as typeof fetch,
113
+ });
114
+
115
+ const [url, init] = fetchImpl.mock.calls[0];
116
+ const parsed = new URL(String(url));
117
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/balance');
118
+ expect(init?.method).toBe('GET');
119
+ const headers = init?.headers as Record<string, string>;
120
+ expect(headers.Authorization).toBe('Bearer bk_test');
121
+ expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
122
+ });
123
+
124
+ it('throws on non-OK responses', async () => {
125
+ const fetchImpl = vi.fn().mockResolvedValue(mockResponse({}, false, 403));
126
+
127
+ await expect(
128
+ getConsumerBalance({
129
+ baseUrl: 'http://api.test',
130
+ orgId: 'org_42',
131
+ apiKey: 'bk_test',
132
+ fetchImpl: fetchImpl as unknown as typeof fetch,
133
+ }),
134
+ ).rejects.toThrow('HTTP 403');
135
+ });
136
+ });
137
+
138
+ describe('createConsumerTopUp (HTTP helper)', () => {
139
+ it('POSTs JSON body with whole-cent amount string', async () => {
140
+ const fetchImpl = vi
141
+ .fn()
142
+ .mockResolvedValue(
143
+ mockResponse({
144
+ checkoutUrl: 'https://checkout.stripe.test/x',
145
+ sessionId: 'cs_test_x',
146
+ }),
147
+ );
148
+
149
+ await createConsumerTopUp({
150
+ baseUrl: 'http://api.test',
151
+ orgId: 'org_42',
152
+ apiKey: 'bk_test',
153
+ amountUsd: 19.99,
154
+ fetchImpl: fetchImpl as unknown as typeof fetch,
155
+ });
156
+
157
+ const [url, init] = fetchImpl.mock.calls[0];
158
+ const parsed = new URL(String(url));
159
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/topup');
160
+ expect(init?.method).toBe('POST');
161
+ expect(JSON.parse(String(init?.body))).toEqual({ amount: '19.99' });
162
+ const headers = init?.headers as Record<string, string>;
163
+ expect(headers['Content-Type']).toBe('application/json');
164
+ });
165
+
166
+ it('rejects non-positive amounts', async () => {
167
+ await expect(
168
+ createConsumerTopUp({
169
+ baseUrl: 'http://api.test',
170
+ orgId: 'org_42',
171
+ apiKey: 'bk_test',
172
+ amountUsd: 0,
173
+ }),
174
+ ).rejects.toThrow(/positive/);
175
+ });
176
+
177
+ it('rejects amounts below the $5 platform minimum', async () => {
178
+ await expect(
179
+ createConsumerTopUp({
180
+ baseUrl: 'http://api.test',
181
+ orgId: 'org_42',
182
+ apiKey: 'bk_test',
183
+ amountUsd: 1,
184
+ }),
185
+ ).rejects.toThrow(/at least \$5/);
186
+ });
187
+
188
+ it('throws on non-OK responses with body detail', async () => {
189
+ const fetchImpl = vi
190
+ .fn()
191
+ .mockResolvedValue(mockResponse({ error: 'amount too small' }, false, 400));
192
+
193
+ await expect(
194
+ createConsumerTopUp({
195
+ baseUrl: 'http://api.test',
196
+ orgId: 'org_42',
197
+ apiKey: 'bk_test',
198
+ amountUsd: 25,
199
+ fetchImpl: fetchImpl as unknown as typeof fetch,
200
+ }),
201
+ ).rejects.toThrow(/HTTP 400/);
202
+ });
203
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { cancelTask } from '../src/tools.js';
3
+ import { makeFakeDeps, makeFakeClient } from './helpers.js';
4
+
5
+ describe('cancel_task', () => {
6
+ it('calls client.cancelTask with the supplied id and confirms', async () => {
7
+ const client = makeFakeClient();
8
+ const { deps } = makeFakeDeps({ client });
9
+
10
+ const res = await cancelTask({ taskId: 'task_xyz' }, deps);
11
+
12
+ expect(client.cancelTaskMock).toHaveBeenCalledWith('task_xyz');
13
+ expect(res.content[0].text).toBe('Task task_xyz cancelled.');
14
+ expect(res.isError).toBeUndefined();
15
+ });
16
+
17
+ it('propagates errors from cancelTask', async () => {
18
+ const client = makeFakeClient();
19
+ client.cancelTaskMock.mockRejectedValueOnce(new Error('not allowed'));
20
+ const { deps } = makeFakeDeps({ client });
21
+
22
+ await expect(cancelTask({ taskId: 'task_zzz' }, deps)).rejects.toThrow('not allowed');
23
+ });
24
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { connectTask } from '../src/tools.js';
3
+ import {
4
+ asyncIterFrom,
5
+ makeFakeClient,
6
+ makeFakeDeps,
7
+ makeFakeSession,
8
+ } from './helpers.js';
9
+ import type { StreamRef } from '../src/tools.js';
10
+
11
+ function makeStreamRef(opts: {
12
+ streamId: string;
13
+ declaredStream?: string;
14
+ format: 'events' | 'bytes';
15
+ localDirection: 'inbound' | 'outbound' | 'bidirectional';
16
+ events?: unknown[];
17
+ bytes?: Uint8Array[];
18
+ }): StreamRef {
19
+ return {
20
+ descriptor: {
21
+ streamId: opts.streamId,
22
+ declaredStream: opts.declaredStream,
23
+ format: opts.format,
24
+ localDirection: opts.localDirection,
25
+ },
26
+ open: () => ({
27
+ events: () => asyncIterFrom(opts.events ?? []),
28
+ bytes: () => asyncIterFrom(opts.bytes ?? []),
29
+ }),
30
+ };
31
+ }
32
+
33
+ describe('connect_task', () => {
34
+ it('drains inbound event streams and includes them in the output', async () => {
35
+ const stream = makeStreamRef({
36
+ streamId: 's1',
37
+ declaredStream: 'thoughts',
38
+ format: 'events',
39
+ localDirection: 'inbound',
40
+ events: [{ kind: 'tick', n: 1 }, { kind: 'tick', n: 2 }],
41
+ });
42
+ const session = makeFakeSession({
43
+ taskId: 't1',
44
+ streams: [stream],
45
+ terminal: { state: 'completed' },
46
+ });
47
+ const client = makeFakeClient({
48
+ task: { taskId: 't1', state: 'running' },
49
+ session,
50
+ });
51
+ const { deps } = makeFakeDeps({ client });
52
+
53
+ const res = await connectTask({ taskId: 't1' }, deps);
54
+
55
+ expect(res.content[0].text).toContain('Task t1 completed');
56
+ expect(res.content[0].text).toContain('[stream: thoughts]');
57
+ expect(res.content[0].text).toContain('{"kind":"tick","n":1}');
58
+ expect(res.content[0].text).toContain('{"kind":"tick","n":2}');
59
+ expect(session.asyncCloseMock).toHaveBeenCalledOnce();
60
+ });
61
+
62
+ it('drains inbound byte streams and decodes UTF-8', async () => {
63
+ const stream = makeStreamRef({
64
+ streamId: 's2',
65
+ format: 'bytes',
66
+ localDirection: 'inbound',
67
+ bytes: [
68
+ new TextEncoder().encode('hello '),
69
+ new TextEncoder().encode('world'),
70
+ ],
71
+ });
72
+ const session = makeFakeSession({
73
+ streams: [stream],
74
+ terminal: { state: 'completed' },
75
+ });
76
+ const client = makeFakeClient({
77
+ task: { taskId: 't2', state: 'running' },
78
+ session,
79
+ });
80
+ const { deps } = makeFakeDeps({ client });
81
+
82
+ const res = await connectTask({ taskId: 't2' }, deps);
83
+ expect(res.content[0].text).toContain('[stream: s2]\nhello world');
84
+ });
85
+
86
+ it('skips outbound-only streams', async () => {
87
+ const stream = makeStreamRef({
88
+ streamId: 's3',
89
+ format: 'events',
90
+ localDirection: 'outbound',
91
+ events: [{ ignored: true }],
92
+ });
93
+ const session = makeFakeSession({
94
+ streams: [stream],
95
+ terminal: { state: 'completed' },
96
+ });
97
+ const client = makeFakeClient({
98
+ task: { taskId: 't3', state: 'running' },
99
+ session,
100
+ });
101
+ const { deps } = makeFakeDeps({ client });
102
+
103
+ const res = await connectTask({ taskId: 't3' }, deps);
104
+ expect(res.content[0].text).not.toContain('[stream:');
105
+ });
106
+
107
+ it('drains streams that arrive after connect via onStream callback', async () => {
108
+ const session = makeFakeSession({
109
+ streams: [],
110
+ lateStreams: [
111
+ makeStreamRef({
112
+ streamId: 'late',
113
+ format: 'events',
114
+ localDirection: 'bidirectional',
115
+ events: [{ msg: 'late-arrival' }],
116
+ }),
117
+ ],
118
+ terminal: { state: 'completed' },
119
+ });
120
+ const client = makeFakeClient({
121
+ task: { taskId: 't4', state: 'running' },
122
+ session,
123
+ });
124
+ const { deps } = makeFakeDeps({ client });
125
+
126
+ const res = await connectTask({ taskId: 't4' }, deps);
127
+ expect(res.content[0].text).toContain('[stream: late]');
128
+ expect(res.content[0].text).toContain('"late-arrival"');
129
+ });
130
+
131
+ it('returns isError=true and partial stream output when waitForTerminal rejects', async () => {
132
+ const stream = makeStreamRef({
133
+ streamId: 's5',
134
+ format: 'events',
135
+ localDirection: 'inbound',
136
+ events: [{ tick: 1 }],
137
+ });
138
+ const session = makeFakeSession({
139
+ streams: [stream],
140
+ terminalRejects: new Error('timeout'),
141
+ });
142
+ const client = makeFakeClient({
143
+ task: { taskId: 't5', state: 'running' },
144
+ session,
145
+ });
146
+ const { deps } = makeFakeDeps({ client });
147
+
148
+ const res = await connectTask({ taskId: 't5' }, deps);
149
+
150
+ expect(res.isError).toBe(true);
151
+ expect(res.content[0].text).toContain('Task t5 timed out (timeout)');
152
+ expect(session.asyncCloseMock).toHaveBeenCalledOnce();
153
+ });
154
+
155
+ it('routes paid tasks through the paid TaskClient', async () => {
156
+ const client = makeFakeClient({
157
+ task: { taskId: 't6', state: 'running', billingMode: 'paid' },
158
+ });
159
+ const { deps, mocks } = makeFakeDeps({ client });
160
+
161
+ await connectTask({ taskId: 't6' }, deps);
162
+
163
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('free');
164
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('paid');
165
+ });
166
+
167
+ it('appends "Error:" line for failed terminals', async () => {
168
+ const session = makeFakeSession({
169
+ terminal: { state: 'failed', reason: 'boom' },
170
+ });
171
+ const client = makeFakeClient({
172
+ task: { taskId: 't7', state: 'running' },
173
+ session,
174
+ });
175
+ const { deps } = makeFakeDeps({ client });
176
+
177
+ const res = await connectTask({ taskId: 't7' }, deps);
178
+ expect(res.content[0].text).toContain('Error: boom');
179
+ });
180
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { downloadArtifact } from '../src/tools.js';
3
+ import { makeFakeDeps, makeFakeClient, makeFakeSession } from './helpers.js';
4
+
5
+ describe('download_artifact', () => {
6
+ it('returns inline text content for a text artifact when savePath is omitted', async () => {
7
+ const session = makeFakeSession({
8
+ artifacts: [
9
+ {
10
+ fileName: 'output.json',
11
+ mimeType: 'application/json',
12
+ data: new TextEncoder().encode('{"ok":true}'),
13
+ },
14
+ ],
15
+ });
16
+ const client = makeFakeClient({
17
+ task: { taskId: 't1', state: 'completed' },
18
+ session,
19
+ });
20
+ const { deps } = makeFakeDeps({ client });
21
+
22
+ const res = await downloadArtifact(
23
+ { taskId: 't1', fileName: 'output.json' },
24
+ deps,
25
+ );
26
+
27
+ expect(client.connectMock).toHaveBeenCalledWith({ taskId: 't1' });
28
+ expect(res.isError).toBeUndefined();
29
+ expect(res.content[0].text).toContain('[artifact: output.json]');
30
+ expect(res.content[0].text).toContain('{"ok":true}');
31
+ expect(session.closeMock).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it('returns base64 content for binary artifacts when savePath is omitted', async () => {
35
+ const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
36
+ const session = makeFakeSession({
37
+ artifacts: [{ fileName: 'image.png', mimeType: 'image/png', data: bytes }],
38
+ });
39
+ const client = makeFakeClient({
40
+ task: { taskId: 't2', state: 'completed' },
41
+ session,
42
+ });
43
+ const { deps } = makeFakeDeps({ client });
44
+
45
+ const res = await downloadArtifact(
46
+ { taskId: 't2', fileName: 'image.png' },
47
+ deps,
48
+ );
49
+
50
+ expect(res.isError).toBeUndefined();
51
+ expect(res.content[0].text).toContain('image/png');
52
+ expect(res.content[0].text).toContain('base64');
53
+ expect(res.content[0].text).toContain(Buffer.from(bytes).toString('base64'));
54
+ });
55
+
56
+ it('writes to disk when savePath is provided', async () => {
57
+ const bytes = new TextEncoder().encode('hello');
58
+ const session = makeFakeSession({
59
+ artifacts: [{ fileName: 'note.txt', mimeType: 'text/plain', data: bytes }],
60
+ });
61
+ const client = makeFakeClient({
62
+ task: { taskId: 't3', state: 'completed' },
63
+ session,
64
+ });
65
+ const { deps, mocks } = makeFakeDeps({ client });
66
+
67
+ const res = await downloadArtifact(
68
+ { taskId: 't3', fileName: 'note.txt', savePath: 'out/note.txt' },
69
+ deps,
70
+ );
71
+
72
+ expect(res.isError).toBeUndefined();
73
+ expect(mocks.resolveSavePath).toHaveBeenCalledWith('out/note.txt');
74
+ expect(mocks.writeFile).toHaveBeenCalledWith('out/note.txt', bytes);
75
+ expect(res.content[0].text).toContain('Saved 5 bytes to out/note.txt');
76
+ });
77
+
78
+ it('returns an error when the requested artifact is not found', async () => {
79
+ const session = makeFakeSession({
80
+ artifacts: [
81
+ {
82
+ fileName: 'other.txt',
83
+ mimeType: 'text/plain',
84
+ data: new TextEncoder().encode('x'),
85
+ },
86
+ ],
87
+ });
88
+ const client = makeFakeClient({
89
+ task: { taskId: 't4', state: 'completed' },
90
+ session,
91
+ });
92
+ const { deps } = makeFakeDeps({ client });
93
+
94
+ const res = await downloadArtifact(
95
+ { taskId: 't4', fileName: 'missing.txt' },
96
+ deps,
97
+ );
98
+
99
+ expect(res.isError).toBe(true);
100
+ expect(res.content[0].text).toContain('"missing.txt" not found');
101
+ expect(res.content[0].text).toContain('other.txt');
102
+ expect(session.closeMock).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it('uses paid TaskClient for paid tasks', async () => {
106
+ const session = makeFakeSession({
107
+ artifacts: [
108
+ {
109
+ fileName: 'a.txt',
110
+ mimeType: 'text/plain',
111
+ data: new TextEncoder().encode('a'),
112
+ },
113
+ ],
114
+ });
115
+ const client = makeFakeClient({
116
+ task: { taskId: 't5', state: 'completed', billingMode: 'paid' },
117
+ session,
118
+ });
119
+ const { deps, mocks } = makeFakeDeps({ client });
120
+
121
+ await downloadArtifact({ taskId: 't5', fileName: 'a.txt' }, deps);
122
+
123
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('free');
124
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('paid');
125
+ });
126
+
127
+ it('returns an error when connect fails', async () => {
128
+ const client = makeFakeClient({
129
+ task: { taskId: 't6', state: 'completed' },
130
+ });
131
+ client.connectMock.mockRejectedValueOnce(new Error('connect refused'));
132
+ const { deps } = makeFakeDeps({ client });
133
+
134
+ const res = await downloadArtifact(
135
+ { taskId: 't6', fileName: 'x.txt' },
136
+ deps,
137
+ );
138
+
139
+ expect(res.isError).toBe(true);
140
+ expect(res.content[0].text).toContain('connect refused');
141
+ });
142
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getAgentCard } from '../src/tools.js';
3
+ import { makeFakeDeps } from './helpers.js';
4
+
5
+ describe('get_agent_card', () => {
6
+ it('returns the agent card JSON when found', async () => {
7
+ const card = {
8
+ io: { inputs: [{ id: 'prompt', contentType: 'text/plain' }] },
9
+ };
10
+ const { deps } = makeFakeDeps({
11
+ agentEntry: { agentName: 'alice', card },
12
+ });
13
+
14
+ const res = await getAgentCard({ agentName: 'alice' }, deps);
15
+
16
+ expect(res.isError).toBeUndefined();
17
+ expect(JSON.parse(res.content[0].text)).toEqual(card);
18
+ });
19
+
20
+ it('returns isError=true with a friendly message when not found', async () => {
21
+ const { deps } = makeFakeDeps({ agentEntry: null });
22
+
23
+ const res = await getAgentCard({ agentName: 'ghost' }, deps);
24
+
25
+ expect(res.isError).toBe(true);
26
+ expect(res.content[0].text).toBe('Agent "ghost" not found.');
27
+ });
28
+
29
+ it('falls back to the bare entry JSON when the card field is missing', async () => {
30
+ const { deps } = makeFakeDeps({
31
+ agentEntry: { agentName: 'cardless', billingMode: 'free' },
32
+ });
33
+
34
+ const res = await getAgentCard({ agentName: 'cardless' }, deps);
35
+ const parsed = JSON.parse(res.content[0].text);
36
+ expect(parsed).toMatchObject({ agentName: 'cardless', billingMode: 'free' });
37
+ });
38
+
39
+ it('passes baseUrl + apiKey through to getAgentByName', async () => {
40
+ const { deps, mocks } = makeFakeDeps({
41
+ apiKey: 'sk-test',
42
+ baseUrl: 'http://api.test',
43
+ agentEntry: { agentName: 'alice' },
44
+ });
45
+
46
+ await getAgentCard({ agentName: 'alice' }, deps);
47
+
48
+ expect(mocks.getAgentByName).toHaveBeenCalledWith('alice', {
49
+ baseUrl: 'http://api.test',
50
+ apiKey: 'sk-test',
51
+ });
52
+ });
53
+ });