@llmindset/hf-mcp 0.3.18 → 0.3.20
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/dist/index.browser.d.ts +3 -0
- package/dist/index.browser.d.ts.map +1 -1
- package/dist/index.browser.js +3 -0
- package/dist/index.browser.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jobs/types.d.ts +6 -0
- package/dist/jobs/types.d.ts.map +1 -1
- package/dist/jobs/types.js.map +1 -1
- package/dist/sandbox-tool.d.ts +161 -0
- package/dist/sandbox-tool.d.ts.map +1 -0
- package/dist/sandbox-tool.js +615 -0
- package/dist/sandbox-tool.js.map +1 -0
- package/dist/tool-ids.d.ts +3 -0
- package/dist/tool-ids.d.ts.map +1 -1
- package/dist/tool-ids.js +4 -1
- package/dist/tool-ids.js.map +1 -1
- package/package.json +1 -1
- package/src/index.browser.ts +3 -0
- package/src/index.ts +1 -0
- package/src/jobs/types.ts +4 -0
- package/src/sandbox-tool.ts +727 -0
- package/src/tool-ids.ts +5 -0
- package/test/sandbox-tool.spec.ts +253 -0
package/src/tool-ids.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
DOC_FETCH_CONFIG,
|
|
25
25
|
USE_SPACE_TOOL_CONFIG,
|
|
26
26
|
HF_JOBS_TOOL_CONFIG,
|
|
27
|
+
HF_SANDBOX_EXEC_TOOL_CONFIG,
|
|
28
|
+
HF_SANDBOX_TOOL_CONFIG,
|
|
27
29
|
DYNAMIC_SPACE_TOOL_CONFIG,
|
|
28
30
|
} from './index.js';
|
|
29
31
|
|
|
@@ -48,6 +50,8 @@ export const PAPER_SUMMARY_PROMPT_ID = PAPER_SUMMARY_PROMPT_CONFIG.name;
|
|
|
48
50
|
export const MODEL_DETAIL_PROMPT_ID = MODEL_DETAIL_PROMPT_CONFIG.name;
|
|
49
51
|
export const DATASET_DETAIL_PROMPT_ID = DATASET_DETAIL_PROMPT_CONFIG.name;
|
|
50
52
|
export const HF_JOBS_TOOL_ID = HF_JOBS_TOOL_CONFIG.name;
|
|
53
|
+
export const HF_SANDBOX_TOOL_ID = HF_SANDBOX_TOOL_CONFIG.name;
|
|
54
|
+
export const HF_SANDBOX_EXEC_TOOL_ID = HF_SANDBOX_EXEC_TOOL_CONFIG.name;
|
|
51
55
|
export const DYNAMIC_SPACE_TOOL_ID = DYNAMIC_SPACE_TOOL_CONFIG.name;
|
|
52
56
|
|
|
53
57
|
// Complete list of all built-in tool IDs
|
|
@@ -93,6 +97,7 @@ export const TOOL_ID_GROUPS = {
|
|
|
93
97
|
] as const,
|
|
94
98
|
dynamic_space: [DYNAMIC_SPACE_TOOL_ID] as const,
|
|
95
99
|
all: [...ALL_BUILTIN_TOOL_IDS] as const,
|
|
100
|
+
sandbox: [HF_SANDBOX_TOOL_ID, HF_SANDBOX_EXEC_TOOL_ID] as const,
|
|
96
101
|
} as const;
|
|
97
102
|
|
|
98
103
|
// TypeScript type for built-in tool IDs
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
HF_SANDBOX_TOOL_CONFIG,
|
|
4
|
+
HF_SANDBOX_EXEC_TOOL_CONFIG,
|
|
5
|
+
HfSandboxExecTool,
|
|
6
|
+
HfSandboxTool,
|
|
7
|
+
formatSandboxHandle,
|
|
8
|
+
parseSandboxHandle,
|
|
9
|
+
type SandboxJobsClient,
|
|
10
|
+
type SandboxRpcClient,
|
|
11
|
+
} from '../src/sandbox-tool.js';
|
|
12
|
+
import type { JobInfo, JobSpec } from '../src/jobs/types.js';
|
|
13
|
+
|
|
14
|
+
const TOKEN = 'steady-bridge-abcdefghijklmnopqrstuvwxyz123456';
|
|
15
|
+
const HANDLE = `hfsb1:evalstate:6a2bfe87871c005b5352b2d1:${TOKEN}`;
|
|
16
|
+
const STORED_VOLUMES = [
|
|
17
|
+
{ type: 'dataset', source: 'org/ds', mountPath: '/data', readOnly: true },
|
|
18
|
+
{ type: 'bucket', source: 'org/b', mountPath: '/output' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function parseToolJson(text: string): unknown {
|
|
22
|
+
const match = text.match(/^```json\n([\s\S]*)\n```$/);
|
|
23
|
+
if (!match?.[1]) {
|
|
24
|
+
throw new Error(`Expected JSON code block, got: ${text}`);
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(match[1]) as unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createJobInfo(overrides: Partial<JobInfo> = {}): JobInfo {
|
|
30
|
+
return {
|
|
31
|
+
id: '6a2bfe87871c005b5352b2d1',
|
|
32
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
33
|
+
dockerImage: 'python:3.12',
|
|
34
|
+
command: ['python', '-u', '-c', 'server'],
|
|
35
|
+
environment: {},
|
|
36
|
+
flavor: 'cpu-basic',
|
|
37
|
+
status: { stage: 'RUNNING', expose_urls: ['https://custom--8000.hf.jobs'] },
|
|
38
|
+
owner: { id: 'user-id', name: 'evalstate', type: 'user' },
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createJobsClient(): SandboxJobsClient {
|
|
44
|
+
return {
|
|
45
|
+
getNamespace: vi.fn(async (namespace?: string) => namespace ?? 'evalstate'),
|
|
46
|
+
runJob: vi.fn(async () => createJobInfo()),
|
|
47
|
+
getJob: vi.fn(async () => createJobInfo()),
|
|
48
|
+
cancelJob: vi.fn(async () => undefined),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createRpcClient(): SandboxRpcClient {
|
|
53
|
+
return {
|
|
54
|
+
health: vi.fn(async () => ({ ok: true })),
|
|
55
|
+
exec: vi.fn(async () => ({ returncode: 0, stdout: '42\n', stderr: '' })),
|
|
56
|
+
write: vi.fn(async () => ({ path: '/sandbox/message.txt', bytes: 17 })),
|
|
57
|
+
read: vi.fn(async () => ({
|
|
58
|
+
path: '/sandbox/message.txt',
|
|
59
|
+
content: 'tada! sandbox tool',
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
bytes: 18,
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('HfSandboxTool', () => {
|
|
67
|
+
it('exposes the expected tool name', () => {
|
|
68
|
+
expect(HF_SANDBOX_TOOL_CONFIG.name).toBe('hf_sandbox');
|
|
69
|
+
expect(HF_SANDBOX_EXEC_TOOL_CONFIG.name).toBe('hf_sandbox_exec');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('parses and formats portable handles', () => {
|
|
73
|
+
const parsed = parseSandboxHandle(HANDLE);
|
|
74
|
+
expect(parsed).toEqual({
|
|
75
|
+
namespace: 'evalstate',
|
|
76
|
+
jobId: '6a2bfe87871c005b5352b2d1',
|
|
77
|
+
sandboxToken: TOKEN,
|
|
78
|
+
});
|
|
79
|
+
expect(formatSandboxHandle(parsed)).toBe(HANDLE);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects weak sandbox tokens in handles', () => {
|
|
83
|
+
expect(() => parseSandboxHandle('hfsb1:evalstate:job123:short')).toThrow(/at least 32/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('creates a Jobs-backed sandbox with labels, exposed port, and secret token', async () => {
|
|
87
|
+
const jobsClient = createJobsClient();
|
|
88
|
+
const rpcClient = createRpcClient();
|
|
89
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', jobsClient, rpcClient);
|
|
90
|
+
|
|
91
|
+
const result = await tool.execute({
|
|
92
|
+
operation: 'create',
|
|
93
|
+
args: {
|
|
94
|
+
name: 'steady-bridge',
|
|
95
|
+
sandbox_token: TOKEN,
|
|
96
|
+
forward_hf_token: true,
|
|
97
|
+
volumes: ['hf://datasets/org/ds:/data:ro', 'hf://buckets/org/b:/output'],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.isError).toBeUndefined();
|
|
102
|
+
const payload = parseToolJson(result.formatted) as {
|
|
103
|
+
handle: string;
|
|
104
|
+
url: string;
|
|
105
|
+
job_url: string;
|
|
106
|
+
volumes: unknown[];
|
|
107
|
+
};
|
|
108
|
+
expect(payload.handle).toBe(HANDLE);
|
|
109
|
+
expect(payload.url).toBe('https://custom--8000.hf.jobs');
|
|
110
|
+
expect(payload.job_url).toBe('https://huggingface.co/jobs/evalstate/6a2bfe87871c005b5352b2d1');
|
|
111
|
+
expect(payload.volumes).toEqual(STORED_VOLUMES);
|
|
112
|
+
|
|
113
|
+
expect(jobsClient.runJob).toHaveBeenCalledOnce();
|
|
114
|
+
const [jobSpec, namespace] = vi.mocked(jobsClient.runJob).mock.calls[0] as [JobSpec, string];
|
|
115
|
+
expect(namespace).toBe('evalstate');
|
|
116
|
+
expect(jobSpec.expose).toEqual({ ports: [8000] });
|
|
117
|
+
expect(jobSpec.labels).toEqual({ 'hf-sandbox': '', pet: 'steady-bridge' });
|
|
118
|
+
expect(jobSpec.environment).toMatchObject({
|
|
119
|
+
HF_SANDBOX_NAME: 'steady-bridge',
|
|
120
|
+
HF_SANDBOX_HANDLE_VERSION: '1',
|
|
121
|
+
HF_SANDBOX_PORT: '8000',
|
|
122
|
+
HF_SANDBOX_ROOT: '/sandbox',
|
|
123
|
+
HF_SANDBOX_VOLUMES: JSON.stringify(STORED_VOLUMES),
|
|
124
|
+
});
|
|
125
|
+
expect(jobSpec.secrets).toEqual({
|
|
126
|
+
HF_SANDBOX_TOKEN: TOKEN,
|
|
127
|
+
HF_TOKEN: 'hf-token',
|
|
128
|
+
});
|
|
129
|
+
expect(jobSpec.volumes).toEqual(STORED_VOLUMES);
|
|
130
|
+
expect(jobSpec.command[0]).toBe('python');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('supports bucket convenience args for read-write mounts', async () => {
|
|
134
|
+
const jobsClient = createJobsClient();
|
|
135
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', jobsClient, createRpcClient());
|
|
136
|
+
|
|
137
|
+
const result = await tool.execute({
|
|
138
|
+
operation: 'create',
|
|
139
|
+
args: {
|
|
140
|
+
name: 'steady-bridge',
|
|
141
|
+
sandbox_token: TOKEN,
|
|
142
|
+
bucket: 'evalstate/sandbox-testing',
|
|
143
|
+
bucket_mode: 'rw',
|
|
144
|
+
bucket_mount_path: '/data',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.isError).toBeUndefined();
|
|
149
|
+
const payload = parseToolJson(result.formatted) as { volumes: unknown[] };
|
|
150
|
+
expect(payload.volumes).toEqual([
|
|
151
|
+
{ type: 'bucket', source: 'evalstate/sandbox-testing', mountPath: '/data', readOnly: false },
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const [jobSpec] = vi.mocked(jobsClient.runJob).mock.calls[0] as [JobSpec, string];
|
|
155
|
+
expect(jobSpec.volumes).toEqual([
|
|
156
|
+
{ type: 'bucket', source: 'evalstate/sandbox-testing', mountPath: '/data', readOnly: false },
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rejects unknown create arguments instead of silently ignoring them', async () => {
|
|
161
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', createJobsClient(), createRpcClient());
|
|
162
|
+
|
|
163
|
+
const result = await tool.execute({
|
|
164
|
+
operation: 'create',
|
|
165
|
+
args: {
|
|
166
|
+
name: 'steady-bridge',
|
|
167
|
+
sandbox_token: TOKEN,
|
|
168
|
+
unused_bucket_arg: 'evalstate/sandbox-testing',
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.isError).toBe(true);
|
|
173
|
+
expect(result.formatted).toContain('Unrecognized key');
|
|
174
|
+
expect(result.formatted).toContain('unused_bucket_arg');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('delegates shell exec to the sandbox RPC client', async () => {
|
|
178
|
+
const jobsClient = createJobsClient();
|
|
179
|
+
const rpcClient = createRpcClient();
|
|
180
|
+
const tool = new HfSandboxExecTool('hf-token', true, rpcClient);
|
|
181
|
+
|
|
182
|
+
const execResult = await tool.execute({ handle: HANDLE, cmd: 'python -c "print(6 * 7)" | cat' });
|
|
183
|
+
expect(parseToolJson(execResult.formatted)).toEqual({ returncode: 0, stdout: '42\n', stderr: '' });
|
|
184
|
+
|
|
185
|
+
expect(rpcClient.exec).toHaveBeenCalledWith(
|
|
186
|
+
expect.objectContaining({ jobId: '6a2bfe87871c005b5352b2d1' }),
|
|
187
|
+
'hf-token',
|
|
188
|
+
expect.objectContaining({ command: ['/bin/sh', '-lc', 'python -c "print(6 * 7)" | cat'] })
|
|
189
|
+
);
|
|
190
|
+
expect(jobsClient.runJob).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('delegates write and read to the sandbox RPC client', async () => {
|
|
194
|
+
const jobsClient = createJobsClient();
|
|
195
|
+
const rpcClient = createRpcClient();
|
|
196
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', jobsClient, rpcClient);
|
|
197
|
+
|
|
198
|
+
await tool.execute({
|
|
199
|
+
operation: 'write',
|
|
200
|
+
args: { handle: HANDLE, path: 'message.txt', content: 'tada! sandbox tool' },
|
|
201
|
+
});
|
|
202
|
+
await tool.execute({
|
|
203
|
+
operation: 'read',
|
|
204
|
+
args: { handle: HANDLE, path: 'message.txt' },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(rpcClient.write).toHaveBeenCalledOnce();
|
|
208
|
+
expect(rpcClient.read).toHaveBeenCalledOnce();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('returns job status plus best-effort sandbox health', async () => {
|
|
212
|
+
const jobsClient = createJobsClient();
|
|
213
|
+
vi.mocked(jobsClient.getJob).mockResolvedValueOnce(
|
|
214
|
+
createJobInfo({
|
|
215
|
+
environment: {
|
|
216
|
+
HF_SANDBOX_VOLUMES: JSON.stringify(STORED_VOLUMES),
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
const rpcClient = createRpcClient();
|
|
221
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', jobsClient, rpcClient);
|
|
222
|
+
|
|
223
|
+
const result = await tool.execute({ operation: 'status', args: { handle: HANDLE } });
|
|
224
|
+
|
|
225
|
+
expect(parseToolJson(result.formatted)).toMatchObject({
|
|
226
|
+
namespace: 'evalstate',
|
|
227
|
+
job_id: '6a2bfe87871c005b5352b2d1',
|
|
228
|
+
status: { stage: 'RUNNING' },
|
|
229
|
+
health: { ok: true },
|
|
230
|
+
volumes: STORED_VOLUMES,
|
|
231
|
+
});
|
|
232
|
+
expect(jobsClient.getJob).toHaveBeenCalledWith('6a2bfe87871c005b5352b2d1', 'evalstate');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('terminates the backing job', async () => {
|
|
236
|
+
const jobsClient = createJobsClient();
|
|
237
|
+
const tool = new HfSandboxTool('hf-token', true, 'evalstate', jobsClient, createRpcClient());
|
|
238
|
+
|
|
239
|
+
const result = await tool.execute({ operation: 'terminate', args: { handle: HANDLE } });
|
|
240
|
+
|
|
241
|
+
expect(parseToolJson(result.formatted)).toMatchObject({ terminated: true });
|
|
242
|
+
expect(jobsClient.cancelJob).toHaveBeenCalledWith('6a2bfe87871c005b5352b2d1', 'evalstate');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('requires authentication', async () => {
|
|
246
|
+
const tool = new HfSandboxTool(undefined, false, 'evalstate', createJobsClient(), createRpcClient());
|
|
247
|
+
|
|
248
|
+
const result = await tool.execute({ operation: 'create', args: { name: 'steady-bridge' } });
|
|
249
|
+
|
|
250
|
+
expect(result.isError).toBe(true);
|
|
251
|
+
expect(result.formatted).toContain('require authentication');
|
|
252
|
+
});
|
|
253
|
+
});
|