@blocks-network/mcp-server 0.1.62 → 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.
- package/LICENSE +26 -0
- package/README.md +32 -20
- package/dist/agent-status.d.ts +49 -0
- package/dist/agent-status.js +40 -0
- package/dist/billing.d.ts +50 -0
- package/dist/billing.js +72 -0
- package/dist/index.js +50 -4
- package/dist/protocol-headers.d.ts +11 -0
- package/dist/protocol-headers.js +11 -0
- package/dist/registry-list.d.ts +2 -4
- package/dist/registry-list.js +3 -4
- package/dist/tools.d.ts +47 -2
- package/dist/tools.js +169 -3
- package/package.json +1 -1
- package/src/agent-status.ts +89 -0
- package/src/billing.ts +122 -0
- package/src/index.ts +102 -3
- package/src/protocol-headers.ts +12 -0
- package/src/registry-list.ts +7 -5
- package/src/tools.ts +248 -5
- package/tests/billing.test.ts +203 -0
- package/tests/download-artifact.test.ts +142 -0
- package/tests/get-agent-status.test.ts +108 -0
- package/tests/helpers.ts +75 -13
- package/tests/list-agents.test.ts +6 -6
- package/tests/registry-list.test.ts +5 -5
- package/tests/task-lifecycle.test.ts +64 -0
package/src/tools.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from '@blocks-network/sdk';
|
|
16
16
|
|
|
17
17
|
import type { ListAgentsResult } from './registry-list.js';
|
|
18
|
+
import type { AgentStatusResponse } from './agent-status.js';
|
|
19
|
+
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
18
20
|
|
|
19
21
|
// ============================================================================
|
|
20
22
|
// Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
|
|
@@ -85,6 +87,9 @@ export interface TaskClientLike {
|
|
|
85
87
|
limit?: number;
|
|
86
88
|
}): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
|
|
87
89
|
cancelTask(taskId: string): Promise<unknown>;
|
|
90
|
+
pauseTask(taskId: string): Promise<unknown>;
|
|
91
|
+
resumeTask(taskId: string): Promise<unknown>;
|
|
92
|
+
retryTask(taskId: string): Promise<unknown>;
|
|
88
93
|
connect(params: { taskId: string }): Promise<TaskSessionLike>;
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -115,15 +120,34 @@ export interface ToolDeps {
|
|
|
115
120
|
listAgents(options: {
|
|
116
121
|
baseUrl: string;
|
|
117
122
|
apiKey?: string;
|
|
118
|
-
|
|
123
|
+
tag?: string;
|
|
119
124
|
listing?: 'public' | 'private';
|
|
120
125
|
limit?: number;
|
|
121
126
|
}): Promise<ListAgentsResult>;
|
|
122
127
|
validateFilePath(filePath: string): string;
|
|
128
|
+
resolveSavePath(filePath: string): string;
|
|
129
|
+
writeFile(filePath: string, data: Uint8Array): void;
|
|
123
130
|
fileSize(path: string): number;
|
|
124
131
|
maxUploadBytes: number;
|
|
125
132
|
filePartFromPath: typeof filePartFromPath;
|
|
126
133
|
textPart: typeof textPart;
|
|
134
|
+
getOrgId(): string | undefined;
|
|
135
|
+
fetchAgentStatus(options: {
|
|
136
|
+
baseUrl: string;
|
|
137
|
+
apiKey?: string;
|
|
138
|
+
agentNames: string[];
|
|
139
|
+
}): Promise<AgentStatusResponse>;
|
|
140
|
+
getConsumerBalance(options: {
|
|
141
|
+
baseUrl: string;
|
|
142
|
+
apiKey: string;
|
|
143
|
+
orgId: string;
|
|
144
|
+
}): Promise<ConsumerBalance>;
|
|
145
|
+
createConsumerTopUp(options: {
|
|
146
|
+
baseUrl: string;
|
|
147
|
+
apiKey: string;
|
|
148
|
+
orgId: string;
|
|
149
|
+
amountUsd: number;
|
|
150
|
+
}): Promise<TopUpSession>;
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
// ============================================================================
|
|
@@ -196,7 +220,7 @@ export interface CancelTaskParams {
|
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
export interface ListAgentsParams {
|
|
199
|
-
|
|
223
|
+
tag?: string;
|
|
200
224
|
listing?: 'public' | 'private';
|
|
201
225
|
limit?: number;
|
|
202
226
|
}
|
|
@@ -210,6 +234,24 @@ export interface ConnectTaskParams {
|
|
|
210
234
|
timeoutMs?: number;
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
export interface DownloadArtifactParams {
|
|
238
|
+
taskId: string;
|
|
239
|
+
fileName: string;
|
|
240
|
+
savePath?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface TaskIdParams {
|
|
244
|
+
taskId: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface GetAgentStatusParams {
|
|
248
|
+
agentNames: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface RequestTopUpParams {
|
|
252
|
+
amountUsd: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
213
255
|
// ============================================================================
|
|
214
256
|
// send_task
|
|
215
257
|
// ============================================================================
|
|
@@ -369,14 +411,14 @@ export async function listAgents(
|
|
|
369
411
|
const result = await deps.listAgents({
|
|
370
412
|
baseUrl,
|
|
371
413
|
apiKey,
|
|
372
|
-
|
|
414
|
+
tag: params.tag,
|
|
373
415
|
listing: params.listing,
|
|
374
416
|
limit: params.limit,
|
|
375
417
|
});
|
|
376
418
|
|
|
377
419
|
const lines = result.agents.map((a) => {
|
|
378
|
-
const
|
|
379
|
-
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${
|
|
420
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
421
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
380
422
|
});
|
|
381
423
|
const header = `Agents (${result.totalCount ?? result.agents.length}):`;
|
|
382
424
|
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
@@ -487,6 +529,207 @@ export async function connectTask(
|
|
|
487
529
|
}
|
|
488
530
|
}
|
|
489
531
|
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// download_artifact
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
537
|
+
let binary = '';
|
|
538
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
539
|
+
return Buffer.from(binary, 'binary').toString('base64');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function downloadArtifact(
|
|
543
|
+
params: DownloadArtifactParams,
|
|
544
|
+
deps: ToolDeps,
|
|
545
|
+
): Promise<ToolResult> {
|
|
546
|
+
const freeClient = await deps.getTaskClient('free');
|
|
547
|
+
const task = await freeClient.getTask(params.taskId);
|
|
548
|
+
const billingMode = resolveBillingMode(task);
|
|
549
|
+
const client = await deps.getTaskClient(billingMode);
|
|
550
|
+
|
|
551
|
+
let session: TaskSessionLike;
|
|
552
|
+
try {
|
|
553
|
+
session = await client.connect({ taskId: params.taskId });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
556
|
+
return {
|
|
557
|
+
content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
|
|
558
|
+
isError: true,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const refs = session.listArtifacts();
|
|
564
|
+
const ref = refs.find((r) => r.fileName === params.fileName);
|
|
565
|
+
if (!ref) {
|
|
566
|
+
const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
|
|
567
|
+
return {
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: 'text',
|
|
571
|
+
text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
isError: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
579
|
+
|
|
580
|
+
if (params.savePath) {
|
|
581
|
+
const safePath = deps.resolveSavePath(params.savePath);
|
|
582
|
+
deps.writeFile(safePath, downloaded.data);
|
|
583
|
+
return {
|
|
584
|
+
content: [
|
|
585
|
+
{
|
|
586
|
+
type: 'text',
|
|
587
|
+
text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
594
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
595
|
+
return {
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: 'text',
|
|
599
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: 'text',
|
|
609
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
} finally {
|
|
614
|
+
session.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// pause_task / resume_task / retry_task
|
|
620
|
+
// ============================================================================
|
|
621
|
+
|
|
622
|
+
export async function pauseTask(
|
|
623
|
+
params: TaskIdParams,
|
|
624
|
+
deps: ToolDeps,
|
|
625
|
+
): Promise<ToolResult> {
|
|
626
|
+
const client = await deps.getTaskClient();
|
|
627
|
+
await client.pauseTask(params.taskId);
|
|
628
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function resumeTask(
|
|
632
|
+
params: TaskIdParams,
|
|
633
|
+
deps: ToolDeps,
|
|
634
|
+
): Promise<ToolResult> {
|
|
635
|
+
const client = await deps.getTaskClient();
|
|
636
|
+
await client.resumeTask(params.taskId);
|
|
637
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function retryTask(
|
|
641
|
+
params: TaskIdParams,
|
|
642
|
+
deps: ToolDeps,
|
|
643
|
+
): Promise<ToolResult> {
|
|
644
|
+
const client = await deps.getTaskClient();
|
|
645
|
+
await client.retryTask(params.taskId);
|
|
646
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// get_agent_status
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
export async function getAgentStatus(
|
|
654
|
+
params: GetAgentStatusParams,
|
|
655
|
+
deps: ToolDeps,
|
|
656
|
+
): Promise<ToolResult> {
|
|
657
|
+
const baseUrl = await deps.getBaseUrl();
|
|
658
|
+
const apiKey = deps.getApiKey();
|
|
659
|
+
const result = await deps.fetchAgentStatus({
|
|
660
|
+
baseUrl,
|
|
661
|
+
apiKey,
|
|
662
|
+
agentNames: params.agentNames,
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ============================================================================
|
|
670
|
+
// check_balance / request_topup
|
|
671
|
+
// ============================================================================
|
|
672
|
+
|
|
673
|
+
function requireBillingContext(deps: ToolDeps): { apiKey: string; orgId: string } | ToolResult {
|
|
674
|
+
const apiKey = deps.getApiKey();
|
|
675
|
+
if (!apiKey) {
|
|
676
|
+
return {
|
|
677
|
+
content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
|
|
678
|
+
isError: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const orgId = deps.getOrgId();
|
|
682
|
+
if (!orgId) {
|
|
683
|
+
return {
|
|
684
|
+
content: [
|
|
685
|
+
{
|
|
686
|
+
type: 'text',
|
|
687
|
+
text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
isError: true,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return { apiKey, orgId };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function checkBalance(
|
|
697
|
+
_params: Record<string, never>,
|
|
698
|
+
deps: ToolDeps,
|
|
699
|
+
): Promise<ToolResult> {
|
|
700
|
+
const ctx = requireBillingContext(deps);
|
|
701
|
+
if ('content' in ctx) return ctx;
|
|
702
|
+
const baseUrl = await deps.getBaseUrl();
|
|
703
|
+
const balance = await deps.getConsumerBalance({
|
|
704
|
+
baseUrl,
|
|
705
|
+
apiKey: ctx.apiKey,
|
|
706
|
+
orgId: ctx.orgId,
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export async function requestTopup(
|
|
714
|
+
params: RequestTopUpParams,
|
|
715
|
+
deps: ToolDeps,
|
|
716
|
+
): Promise<ToolResult> {
|
|
717
|
+
const ctx = requireBillingContext(deps);
|
|
718
|
+
if ('content' in ctx) return ctx;
|
|
719
|
+
const baseUrl = await deps.getBaseUrl();
|
|
720
|
+
const session = await deps.createConsumerTopUp({
|
|
721
|
+
baseUrl,
|
|
722
|
+
apiKey: ctx.apiKey,
|
|
723
|
+
orgId: ctx.orgId,
|
|
724
|
+
amountUsd: params.amountUsd,
|
|
725
|
+
});
|
|
726
|
+
const url = session.checkoutUrl;
|
|
727
|
+
const body = url
|
|
728
|
+
? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
|
|
729
|
+
: JSON.stringify(session, null, 2);
|
|
730
|
+
return { content: [{ type: 'text', text: body }] };
|
|
731
|
+
}
|
|
732
|
+
|
|
490
733
|
// ============================================================================
|
|
491
734
|
// Default file helpers (re-exported so index.ts can build a default ToolDeps)
|
|
492
735
|
// ============================================================================
|
|
@@ -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,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
|
+
});
|