@blocks-network/mcp-server 0.1.55 → 0.1.62
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 +16 -14
- package/dist/index.js +35 -305
- package/dist/registry-list.d.ts +33 -0
- package/dist/registry-list.js +33 -0
- package/dist/tools.d.ts +152 -0
- package/dist/tools.js +275 -0
- package/package.json +18 -6
- package/src/index.ts +55 -341
- package/src/registry-list.ts +58 -0
- package/src/tools.ts +496 -0
- package/tests/cancel-task.test.ts +24 -0
- package/tests/connect-task.test.ts +180 -0
- package/tests/get-agent-card.test.ts +53 -0
- package/tests/get-task.test.ts +76 -0
- package/tests/helpers.ts +188 -0
- package/tests/list-agents.test.ts +74 -0
- package/tests/list-tasks.test.ts +58 -0
- package/tests/registry-list.test.ts +120 -0
- package/tests/send-task.test.ts +221 -0
- package/vitest.config.ts +7 -0
- package/LICENSE +0 -26
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getTask } from '../src/tools.js';
|
|
3
|
+
import { makeFakeDeps, makeFakeClient, makeFakeSession } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('get_task', () => {
|
|
6
|
+
it('returns the task JSON for a still-running task without connecting', async () => {
|
|
7
|
+
const client = makeFakeClient({
|
|
8
|
+
task: { taskId: 't1', state: 'running', agentName: 'alice' },
|
|
9
|
+
});
|
|
10
|
+
const { deps } = makeFakeDeps({ client });
|
|
11
|
+
|
|
12
|
+
const res = await getTask({ taskId: 't1' }, deps);
|
|
13
|
+
|
|
14
|
+
expect(client.connectMock).not.toHaveBeenCalled();
|
|
15
|
+
const parsed = JSON.parse(res.content[0].text);
|
|
16
|
+
expect(parsed).toMatchObject({ taskId: 't1', state: 'running' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('connects on terminal state and appends artifact content', async () => {
|
|
20
|
+
const session = makeFakeSession({
|
|
21
|
+
artifacts: [
|
|
22
|
+
{
|
|
23
|
+
fileName: 'output.json',
|
|
24
|
+
mimeType: 'application/json',
|
|
25
|
+
data: new TextEncoder().encode('{"ok":true}'),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
const client = makeFakeClient({
|
|
30
|
+
task: { taskId: 't2', state: 'completed' },
|
|
31
|
+
session,
|
|
32
|
+
});
|
|
33
|
+
const { deps } = makeFakeDeps({ client });
|
|
34
|
+
|
|
35
|
+
const res = await getTask({ taskId: 't2' }, deps);
|
|
36
|
+
|
|
37
|
+
expect(client.connectMock).toHaveBeenCalledWith({ taskId: 't2' });
|
|
38
|
+
expect(res.content[0].text).toContain('[artifact: output.json]');
|
|
39
|
+
expect(res.content[0].text).toContain('{"ok":true}');
|
|
40
|
+
expect(session.closeMock).toHaveBeenCalledOnce();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('uses paid TaskClient for paid tasks', async () => {
|
|
44
|
+
const client = makeFakeClient({
|
|
45
|
+
task: { taskId: 't3', state: 'completed', billingMode: 'paid' },
|
|
46
|
+
});
|
|
47
|
+
const { deps, mocks } = makeFakeDeps({ client });
|
|
48
|
+
|
|
49
|
+
await getTask({ taskId: 't3' }, deps);
|
|
50
|
+
|
|
51
|
+
expect(mocks.getTaskClient).toHaveBeenCalledWith('free');
|
|
52
|
+
expect(mocks.getTaskClient).toHaveBeenCalledWith('paid');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('falls back to task JSON only when connect fails', async () => {
|
|
56
|
+
const client = makeFakeClient({
|
|
57
|
+
task: { taskId: 't4', state: 'failed' },
|
|
58
|
+
});
|
|
59
|
+
client.connectMock.mockRejectedValueOnce(new Error('connect refused'));
|
|
60
|
+
const { deps } = makeFakeDeps({ client });
|
|
61
|
+
|
|
62
|
+
const res = await getTask({ taskId: 't4' }, deps);
|
|
63
|
+
expect(res.isError).toBeUndefined();
|
|
64
|
+
expect(res.content[0].text).toContain('"taskId": "t4"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('connects for canceled tasks (also a terminal state)', async () => {
|
|
68
|
+
const client = makeFakeClient({
|
|
69
|
+
task: { taskId: 't5', state: 'canceled' },
|
|
70
|
+
});
|
|
71
|
+
const { deps } = makeFakeDeps({ client });
|
|
72
|
+
|
|
73
|
+
await getTask({ taskId: 't5' }, deps);
|
|
74
|
+
expect(client.connectMock).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
AgentEntryLike,
|
|
4
|
+
ArtifactRef,
|
|
5
|
+
DownloadedArtifact,
|
|
6
|
+
ProgressEvent,
|
|
7
|
+
StreamRef,
|
|
8
|
+
TaskClientLike,
|
|
9
|
+
TaskSessionLike,
|
|
10
|
+
TerminalEvent,
|
|
11
|
+
ToolDeps,
|
|
12
|
+
} from '../src/tools.js';
|
|
13
|
+
import type { ListAgentsResult } from '../src/registry-list.js';
|
|
14
|
+
import type { TaskInfo } from '@blocks-network/sdk';
|
|
15
|
+
|
|
16
|
+
export interface FakeSessionConfig {
|
|
17
|
+
taskId?: string;
|
|
18
|
+
artifacts?: Array<{
|
|
19
|
+
fileName?: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
data: Uint8Array;
|
|
22
|
+
downloadFails?: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
streams?: StreamRef[];
|
|
25
|
+
/** Streams delivered via onStream() callback after registration. */
|
|
26
|
+
lateStreams?: StreamRef[];
|
|
27
|
+
terminal?: TerminalEvent;
|
|
28
|
+
terminalRejects?: Error;
|
|
29
|
+
progressEvents?: ProgressEvent[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeFakeSession(cfg: FakeSessionConfig = {}): TaskSessionLike & {
|
|
33
|
+
closeMock: ReturnType<typeof vi.fn>;
|
|
34
|
+
asyncCloseMock: ReturnType<typeof vi.fn>;
|
|
35
|
+
emitStream: (ref: StreamRef) => void;
|
|
36
|
+
} {
|
|
37
|
+
const taskId = cfg.taskId ?? 'task_123';
|
|
38
|
+
const refs: ArtifactRef[] = (cfg.artifacts ?? []).map((a) => ({ fileName: a.fileName }));
|
|
39
|
+
const streamCallbacks: Array<(ref: StreamRef) => void> = [];
|
|
40
|
+
const closeMock = vi.fn();
|
|
41
|
+
const asyncCloseMock = vi.fn().mockResolvedValue(undefined);
|
|
42
|
+
|
|
43
|
+
const session = {
|
|
44
|
+
taskId,
|
|
45
|
+
onProgress(cb: (e: ProgressEvent) => void) {
|
|
46
|
+
for (const e of cfg.progressEvents ?? []) cb(e);
|
|
47
|
+
return () => {};
|
|
48
|
+
},
|
|
49
|
+
onStream(cb: (ref: StreamRef) => void) {
|
|
50
|
+
streamCallbacks.push(cb);
|
|
51
|
+
for (const ref of cfg.lateStreams ?? []) cb(ref);
|
|
52
|
+
return () => {};
|
|
53
|
+
},
|
|
54
|
+
listArtifacts() {
|
|
55
|
+
return refs;
|
|
56
|
+
},
|
|
57
|
+
listStreams() {
|
|
58
|
+
return cfg.streams ?? [];
|
|
59
|
+
},
|
|
60
|
+
async downloadArtifact(ref: ArtifactRef): Promise<DownloadedArtifact> {
|
|
61
|
+
const idx = refs.indexOf(ref);
|
|
62
|
+
const a = (cfg.artifacts ?? [])[idx];
|
|
63
|
+
if (!a || a.downloadFails) throw new Error('download failed');
|
|
64
|
+
return { fileName: a.fileName, mimeType: a.mimeType, data: a.data };
|
|
65
|
+
},
|
|
66
|
+
async waitForTerminal(_timeoutMs?: number) {
|
|
67
|
+
if (cfg.terminalRejects) throw cfg.terminalRejects;
|
|
68
|
+
return cfg.terminal ?? { state: 'completed' };
|
|
69
|
+
},
|
|
70
|
+
close: closeMock,
|
|
71
|
+
asyncClose: asyncCloseMock,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return Object.assign(session, {
|
|
75
|
+
closeMock,
|
|
76
|
+
asyncCloseMock,
|
|
77
|
+
emitStream: (ref: StreamRef) => {
|
|
78
|
+
for (const cb of streamCallbacks) cb(ref);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FakeClientConfig {
|
|
84
|
+
session?: TaskSessionLike;
|
|
85
|
+
task?: TaskInfo;
|
|
86
|
+
listResult?: { tasks: TaskInfo[]; totalCount?: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function makeFakeClient(cfg: FakeClientConfig = {}): TaskClientLike & {
|
|
90
|
+
sendMessageMock: ReturnType<typeof vi.fn>;
|
|
91
|
+
cancelTaskMock: ReturnType<typeof vi.fn>;
|
|
92
|
+
connectMock: ReturnType<typeof vi.fn>;
|
|
93
|
+
listTasksMock: ReturnType<typeof vi.fn>;
|
|
94
|
+
getTaskMock: ReturnType<typeof vi.fn>;
|
|
95
|
+
} {
|
|
96
|
+
const session = cfg.session ?? makeFakeSession();
|
|
97
|
+
const sendMessageMock = vi.fn().mockResolvedValue(session);
|
|
98
|
+
const connectMock = vi.fn().mockResolvedValue(session);
|
|
99
|
+
const cancelTaskMock = vi.fn().mockResolvedValue(undefined);
|
|
100
|
+
const listTasksMock = vi.fn().mockResolvedValue(
|
|
101
|
+
cfg.listResult ?? { tasks: [], totalCount: 0 },
|
|
102
|
+
);
|
|
103
|
+
const getTaskMock = vi.fn().mockResolvedValue(cfg.task ?? { taskId: 't0', state: 'running' });
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
sendMessage: sendMessageMock,
|
|
107
|
+
cancelTask: cancelTaskMock,
|
|
108
|
+
connect: connectMock,
|
|
109
|
+
listTasks: listTasksMock,
|
|
110
|
+
getTask: getTaskMock,
|
|
111
|
+
sendMessageMock,
|
|
112
|
+
cancelTaskMock,
|
|
113
|
+
connectMock,
|
|
114
|
+
listTasksMock,
|
|
115
|
+
getTaskMock,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface FakeDepsOverrides {
|
|
120
|
+
baseUrl?: string;
|
|
121
|
+
apiKey?: string;
|
|
122
|
+
client?: TaskClientLike;
|
|
123
|
+
agentEntry?: AgentEntryLike | null;
|
|
124
|
+
listAgentsResult?: ListAgentsResult;
|
|
125
|
+
maxUploadBytes?: number;
|
|
126
|
+
fileSize?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function makeFakeDeps(overrides: FakeDepsOverrides = {}) {
|
|
130
|
+
const client = overrides.client ?? makeFakeClient();
|
|
131
|
+
const getBaseUrl = vi.fn().mockResolvedValue(overrides.baseUrl ?? 'http://api.test');
|
|
132
|
+
const getApiKey = vi.fn().mockReturnValue(overrides.apiKey);
|
|
133
|
+
const getTaskClient = vi.fn().mockResolvedValue(client);
|
|
134
|
+
const getAgentByName = vi.fn().mockResolvedValue(overrides.agentEntry ?? null);
|
|
135
|
+
const listAgents = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValue(overrides.listAgentsResult ?? { agents: [], totalCount: 0 });
|
|
138
|
+
const validateFilePath = vi.fn((p: string) => p);
|
|
139
|
+
const fileSize = vi.fn().mockReturnValue(overrides.fileSize ?? 100);
|
|
140
|
+
const filePartFromPath = vi.fn(async (path: string, opts: { partId?: string; contentType?: string }) => ({
|
|
141
|
+
type: 'file' as const,
|
|
142
|
+
partId: opts.partId ?? 'file',
|
|
143
|
+
file: { id: 'file-id', mimeType: opts.contentType ?? 'application/octet-stream' },
|
|
144
|
+
_path: path,
|
|
145
|
+
}));
|
|
146
|
+
const textPart = vi.fn((text: string, partId: string) => ({
|
|
147
|
+
type: 'text' as const,
|
|
148
|
+
partId,
|
|
149
|
+
text,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const deps: ToolDeps = {
|
|
153
|
+
getBaseUrl,
|
|
154
|
+
getApiKey,
|
|
155
|
+
getTaskClient,
|
|
156
|
+
getAgentByName,
|
|
157
|
+
listAgents,
|
|
158
|
+
validateFilePath,
|
|
159
|
+
fileSize,
|
|
160
|
+
maxUploadBytes: overrides.maxUploadBytes ?? 25 * 1024 * 1024,
|
|
161
|
+
filePartFromPath: filePartFromPath as unknown as ToolDeps['filePartFromPath'],
|
|
162
|
+
textPart: textPart as unknown as ToolDeps['textPart'],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
deps,
|
|
167
|
+
client,
|
|
168
|
+
mocks: {
|
|
169
|
+
getBaseUrl,
|
|
170
|
+
getApiKey,
|
|
171
|
+
getTaskClient,
|
|
172
|
+
getAgentByName,
|
|
173
|
+
listAgents,
|
|
174
|
+
validateFilePath,
|
|
175
|
+
fileSize,
|
|
176
|
+
filePartFromPath,
|
|
177
|
+
textPart,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function asyncIterFrom<T>(items: T[]): AsyncIterable<T> {
|
|
183
|
+
return {
|
|
184
|
+
async *[Symbol.asyncIterator]() {
|
|
185
|
+
for (const i of items) yield i;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { listAgents } from '../src/tools.js';
|
|
3
|
+
import { makeFakeDeps } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('list_agents', () => {
|
|
6
|
+
it('renders one row per agent with skill names joined by commas', async () => {
|
|
7
|
+
const { deps } = makeFakeDeps({
|
|
8
|
+
listAgentsResult: {
|
|
9
|
+
agents: [
|
|
10
|
+
{
|
|
11
|
+
agentName: 'alice',
|
|
12
|
+
name: 'Alice',
|
|
13
|
+
listing: 'public',
|
|
14
|
+
skills: [
|
|
15
|
+
{ id: 'translate', name: 'Translate' },
|
|
16
|
+
{ id: 'summarize', name: 'Summarize' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{ agentName: 'bob', listing: 'private' },
|
|
20
|
+
],
|
|
21
|
+
totalCount: 2,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const res = await listAgents({}, deps);
|
|
26
|
+
const lines = res.content[0].text.split('\n');
|
|
27
|
+
expect(lines).toEqual([
|
|
28
|
+
'Agents (2):',
|
|
29
|
+
'alice | Alice | public | Translate, Summarize',
|
|
30
|
+
'bob | bob | private | ',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('forwards baseUrl, apiKey, skill, listing, limit to the registry helper', async () => {
|
|
35
|
+
const { deps, mocks } = makeFakeDeps({
|
|
36
|
+
apiKey: 'sk-test',
|
|
37
|
+
baseUrl: 'http://api.test',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await listAgents(
|
|
41
|
+
{ skill: 'translate', listing: 'private', limit: 25 },
|
|
42
|
+
deps,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(mocks.listAgents).toHaveBeenCalledWith({
|
|
46
|
+
baseUrl: 'http://api.test',
|
|
47
|
+
apiKey: 'sk-test',
|
|
48
|
+
skill: 'translate',
|
|
49
|
+
listing: 'private',
|
|
50
|
+
limit: 25,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('defaults to "public" for missing listing and uses agentName when name is absent', async () => {
|
|
55
|
+
const { deps } = makeFakeDeps({
|
|
56
|
+
listAgentsResult: {
|
|
57
|
+
agents: [{ agentName: 'naked', skills: [] }],
|
|
58
|
+
totalCount: 1,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const res = await listAgents({}, deps);
|
|
63
|
+
expect(res.content[0].text).toContain('naked | naked | public |');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses agents.length as the count when totalCount is omitted', async () => {
|
|
67
|
+
const { deps } = makeFakeDeps({
|
|
68
|
+
listAgentsResult: { agents: [{ agentName: 'a' }, { agentName: 'b' }] },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const res = await listAgents({}, deps);
|
|
72
|
+
expect(res.content[0].text.split('\n')[0]).toBe('Agents (2):');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { listTasks } from '../src/tools.js';
|
|
3
|
+
import { makeFakeDeps, makeFakeClient } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('list_tasks', () => {
|
|
6
|
+
it('renders one row per task with header showing totalCount', async () => {
|
|
7
|
+
const client = makeFakeClient({
|
|
8
|
+
listResult: {
|
|
9
|
+
tasks: [
|
|
10
|
+
{ taskId: 'a', agentName: 'alice', state: 'running', createdTime: '2026-05-18T00:00:00Z' },
|
|
11
|
+
{ taskId: 'b', agentName: 'bob', state: 'completed', createdTime: '2026-05-18T00:01:00Z' },
|
|
12
|
+
],
|
|
13
|
+
totalCount: 17,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
const { deps } = makeFakeDeps({ client });
|
|
17
|
+
|
|
18
|
+
const res = await listTasks({}, deps);
|
|
19
|
+
expect(res.content[0].text.split('\n')).toEqual([
|
|
20
|
+
'Tasks (17 total):',
|
|
21
|
+
'a | alice | running | 2026-05-18T00:00:00Z',
|
|
22
|
+
'b | bob | completed | 2026-05-18T00:01:00Z',
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('forwards filters to the client', async () => {
|
|
27
|
+
const client = makeFakeClient();
|
|
28
|
+
const { deps } = makeFakeDeps({ client });
|
|
29
|
+
|
|
30
|
+
await listTasks({ agentName: 'alice', state: 'failed', limit: 5 }, deps);
|
|
31
|
+
|
|
32
|
+
expect(client.listTasksMock).toHaveBeenCalledWith({
|
|
33
|
+
agentName: 'alice',
|
|
34
|
+
state: 'failed',
|
|
35
|
+
limit: 5,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('substitutes "?" for missing agentName/state', async () => {
|
|
40
|
+
const client = makeFakeClient({
|
|
41
|
+
listResult: { tasks: [{ taskId: 'x' }] },
|
|
42
|
+
});
|
|
43
|
+
const { deps } = makeFakeDeps({ client });
|
|
44
|
+
|
|
45
|
+
const res = await listTasks({}, deps);
|
|
46
|
+
expect(res.content[0].text).toContain('x | ? | ? |');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('falls back to tasks.length when totalCount is omitted', async () => {
|
|
50
|
+
const client = makeFakeClient({
|
|
51
|
+
listResult: { tasks: [{ taskId: 'x' }, { taskId: 'y' }] },
|
|
52
|
+
});
|
|
53
|
+
const { deps } = makeFakeDeps({ client });
|
|
54
|
+
|
|
55
|
+
const res = await listTasks({}, deps);
|
|
56
|
+
expect(res.content[0].text).toMatch(/^Tasks \(2 total\):/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
listAgentsAuthenticated,
|
|
4
|
+
PROTOCOL_VERSION_HEADER,
|
|
5
|
+
CURRENT_PROTOCOL_VERSION,
|
|
6
|
+
} from '../src/registry-list.js';
|
|
7
|
+
|
|
8
|
+
function mockResponse(body: unknown, ok = true, status = 200): Response {
|
|
9
|
+
return {
|
|
10
|
+
ok,
|
|
11
|
+
status,
|
|
12
|
+
json: async () => body,
|
|
13
|
+
} as Response;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('listAgentsAuthenticated', () => {
|
|
17
|
+
it('sends Blocks-Protocol-Version header on every request', async () => {
|
|
18
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
19
|
+
mockResponse({ agents: [], totalCount: 0 }),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
await listAgentsAuthenticated({
|
|
23
|
+
baseUrl: 'http://api.test',
|
|
24
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
28
|
+
const [, init] = fetchImpl.mock.calls[0];
|
|
29
|
+
const headers = init?.headers as Record<string, string>;
|
|
30
|
+
expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
|
|
31
|
+
expect(CURRENT_PROTOCOL_VERSION).toBe('2026-05-01');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('protocol version header is present even when an API key is supplied', async () => {
|
|
35
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
36
|
+
mockResponse({ agents: [], totalCount: 0 }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await listAgentsAuthenticated({
|
|
40
|
+
baseUrl: 'http://api.test',
|
|
41
|
+
apiKey: 'secret-key',
|
|
42
|
+
listing: 'private',
|
|
43
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const [, init] = fetchImpl.mock.calls[0];
|
|
47
|
+
const headers = init?.headers as Record<string, string>;
|
|
48
|
+
expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
|
|
49
|
+
expect(headers['Authorization']).toBe('Bearer secret-key');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('builds the registry URL with include=full and listing/scope params', async () => {
|
|
53
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
54
|
+
mockResponse({ agents: [], totalCount: 0 }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await listAgentsAuthenticated({
|
|
58
|
+
baseUrl: 'http://api.test/',
|
|
59
|
+
listing: 'private',
|
|
60
|
+
skill: 'translate',
|
|
61
|
+
limit: 50,
|
|
62
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
66
|
+
const parsed = new URL(String(url));
|
|
67
|
+
expect(parsed.pathname).toBe('/api/v1/registry/agents');
|
|
68
|
+
expect(parsed.searchParams.get('include')).toBe('full');
|
|
69
|
+
expect(parsed.searchParams.get('listing')).toBe('private');
|
|
70
|
+
expect(parsed.searchParams.get('scope')).toBe('owned');
|
|
71
|
+
expect(parsed.searchParams.get('skill')).toBe('translate');
|
|
72
|
+
expect(parsed.searchParams.get('limit')).toBe('50');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not set scope=owned for public listing', async () => {
|
|
76
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
77
|
+
mockResponse({ agents: [], totalCount: 0 }),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await listAgentsAuthenticated({
|
|
81
|
+
baseUrl: 'http://api.test',
|
|
82
|
+
listing: 'public',
|
|
83
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
87
|
+
const parsed = new URL(String(url));
|
|
88
|
+
expect(parsed.searchParams.get('listing')).toBe('public');
|
|
89
|
+
expect(parsed.searchParams.get('scope')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('throws on non-OK responses (e.g. 426 Upgrade Required)', async () => {
|
|
93
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
94
|
+
mockResponse({}, false, 426),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
listAgentsAuthenticated({
|
|
99
|
+
baseUrl: 'http://api.test',
|
|
100
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toThrow('HTTP 426');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns parsed JSON body on success', async () => {
|
|
106
|
+
const body = {
|
|
107
|
+
agents: [{ agentName: 'alice', skills: [] }],
|
|
108
|
+
totalCount: 1,
|
|
109
|
+
};
|
|
110
|
+
const fetchImpl = vi
|
|
111
|
+
.fn()
|
|
112
|
+
.mockResolvedValue(mockResponse(body));
|
|
113
|
+
|
|
114
|
+
const result = await listAgentsAuthenticated({
|
|
115
|
+
baseUrl: 'http://api.test',
|
|
116
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
117
|
+
});
|
|
118
|
+
expect(result).toEqual(body);
|
|
119
|
+
});
|
|
120
|
+
});
|