@shardflux/mcp 0.3.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/CHANGELOG.md +56 -0
- package/LICENSE +202 -0
- package/README.md +289 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +6 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +75 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +72 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +21 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +60 -0
- package/dist/server.d.ts +204 -0
- package/dist/server.js +687 -0
- package/package.json +62 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local stdio MCP server for Shardflux workspaces. It translates MCP tool
|
|
3
|
+
* calls into @shardflux/sdk calls made with the supplied scoped API key:
|
|
4
|
+
*
|
|
5
|
+
* - workspace management: workspace_open / _list / _status / _suspend /
|
|
6
|
+
* _resume / _fork, operation_wait, usage_summary;
|
|
7
|
+
* - templates (0.3.0): template_get (versions, settings, a version's recipe), template_languages and
|
|
8
|
+
* template_build (a recipe v2 object or a template.yaml path inside the
|
|
9
|
+
* server's working directory; local `from` paths are uploaded by the SDK);
|
|
10
|
+
* - the SDK's workspace tools (`workspaceTools()`: exec, files, processes,
|
|
11
|
+
* terminal, git, browser), published with the SDK's own JSON Schemas plus
|
|
12
|
+
* `workspace_key` (and `timeout_ms` where the SDK schema has none).
|
|
13
|
+
*
|
|
14
|
+
* There is no agent loop and no local workspace directory: every call is one
|
|
15
|
+
* SDK request/wait against the remote workspace. Workspace tools wake a
|
|
16
|
+
* suspended workspace on use (SHARDFLUX_WAKE, bounded by
|
|
17
|
+
* SHARDFLUX_WAKE_TIMEOUT_MS and the call's deadline). Each call has a deadline
|
|
18
|
+
* (`timeout_ms`, clamped to SHARDFLUX_MCP_TOOL_TIMEOUT_MS) and MCP cancellation
|
|
19
|
+
* (notifications/cancelled -> `extra.signal`) aborts the underlying SDK
|
|
20
|
+
* request or wait. Failures come back as `isError` tool results carrying the
|
|
21
|
+
* API error code. stdout is the protocol; logs go to stderr. Opens, waits and
|
|
22
|
+
* wakes carry the SDK's lifecycle timing, compacted (`compactTiming`).
|
|
23
|
+
*/
|
|
24
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
import { resolve as resolvePath } from 'node:path';
|
|
26
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
27
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
28
|
+
import { SDK_VERSION, Shardflux, ShardfluxApiError, TemplateBuildTimeoutError, TemplateFileError, validateArgs, workspaceTools } from '@shardflux/sdk';
|
|
29
|
+
import { parse as parseYaml } from 'yaml';
|
|
30
|
+
import { DEFAULT_WAKE_TIMEOUT_MS } from "./config.js";
|
|
31
|
+
import { ToolError, describeToolError, redact } from "./errors.js";
|
|
32
|
+
import { makeFetch } from "./http.js";
|
|
33
|
+
export const MCP_SERVER_VERSION = '0.3.0';
|
|
34
|
+
export const ALL_TOOL_PERMISSIONS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
|
|
35
|
+
const OBSERVED_STATES = ['creating', 'starting', 'running', 'suspending', 'suspended', 'resuming', 'forking', 'stopping', 'failed', 'deleting', 'deleted'];
|
|
36
|
+
/**
|
|
37
|
+
* The SDK's workspace tool definitions without a workspace. `workspaceTools()`
|
|
38
|
+
* only touches the workspace inside `execute`, which is never called on these:
|
|
39
|
+
* the proxy throws on any access, so a change in the SDK that did would fail loudly.
|
|
40
|
+
*/
|
|
41
|
+
export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS) {
|
|
42
|
+
const noWorkspace = new Proxy({}, {
|
|
43
|
+
get(_t, prop) {
|
|
44
|
+
throw new Error(`workspace tool definitions must not access workspace.${String(prop)}`);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return workspaceTools(noWorkspace, { tools: [...tools] });
|
|
48
|
+
}
|
|
49
|
+
function timeoutProp(ceiling) {
|
|
50
|
+
return { type: 'integer', minimum: 1, maximum: 3_600_000, description: `Deadline for this call in milliseconds (default and maximum ${ceiling}; larger values are clamped).` };
|
|
51
|
+
}
|
|
52
|
+
function workspaceKeyProp(pinned) {
|
|
53
|
+
return pinned
|
|
54
|
+
? { type: 'string', minLength: 1, maxLength: 200, description: `Workspace key. Optional: this server is pinned to "${pinned}" and only accepts that key.` }
|
|
55
|
+
: { type: 'string', minLength: 1, maxLength: 200, description: 'Workspace key, e.g. "customer-42/repo-7" (the stable name the workspace was opened with).' };
|
|
56
|
+
}
|
|
57
|
+
const capsProps = {
|
|
58
|
+
cpu_millis: { type: 'integer', minimum: 1, maximum: 1_000_000, description: 'Optional CPU cap in millicores (the ceiling is min(template, cap, plan)).' },
|
|
59
|
+
memory_mib: { type: 'integer', minimum: 1, maximum: 16_777_216, description: 'Optional memory cap in MiB.' },
|
|
60
|
+
disk_gib: { type: 'integer', minimum: 1, maximum: 1_000_000, description: 'Optional disk cap in GiB.' },
|
|
61
|
+
};
|
|
62
|
+
function capsFrom(args) {
|
|
63
|
+
const c = {};
|
|
64
|
+
if (typeof args.cpu_millis === 'number')
|
|
65
|
+
c.cpu_millis = args.cpu_millis;
|
|
66
|
+
if (typeof args.memory_mib === 'number')
|
|
67
|
+
c.memory_mib = args.memory_mib;
|
|
68
|
+
if (typeof args.disk_gib === 'number')
|
|
69
|
+
c.disk_gib = args.disk_gib;
|
|
70
|
+
return Object.keys(c).length ? c : undefined;
|
|
71
|
+
}
|
|
72
|
+
/** What tools return for a workspace (no tokens or other credentials). */
|
|
73
|
+
export function summarizeWorkspace(v) {
|
|
74
|
+
return {
|
|
75
|
+
id: v.id,
|
|
76
|
+
key: v.workspace_key,
|
|
77
|
+
ready: v.observed_state === 'running' && v.desired_state === 'running' && v.deleted_at === null,
|
|
78
|
+
observed_state: v.observed_state,
|
|
79
|
+
desired_state: v.desired_state,
|
|
80
|
+
template: { slug: v.template.slug, version: v.template.version },
|
|
81
|
+
active_operation: v.active_operation ? summarizeOperation(v.active_operation) : null,
|
|
82
|
+
pending_reason: v.pending_reason,
|
|
83
|
+
cell_endpoint: v.cell_endpoint,
|
|
84
|
+
grants: v.grants,
|
|
85
|
+
ceilings: v.ceilings ? { cpu_millis: v.ceilings.cpu_millis, memory_mib: v.ceilings.memory_mib, disk_gib: v.ceilings.disk_gib } : null,
|
|
86
|
+
forked_from_workspace_id: v.forked_from_workspace_id,
|
|
87
|
+
lifetime: v.lifetime,
|
|
88
|
+
purpose: v.purpose,
|
|
89
|
+
disk_layout: v.disk_layout,
|
|
90
|
+
idle_timeout_seconds: v.idle_timeout_seconds,
|
|
91
|
+
ended_reason: v.ended_reason,
|
|
92
|
+
/** Start commands and services of the template version; null when it has none. */
|
|
93
|
+
startup: v.startup ?? null,
|
|
94
|
+
created_at: v.created_at,
|
|
95
|
+
deleted_at: v.deleted_at,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A template build without the stored recipe (the compiled steps are long): state, version and what the builder refused. */
|
|
99
|
+
export function summarizeBuild(b) {
|
|
100
|
+
return {
|
|
101
|
+
id: b.id,
|
|
102
|
+
template: { slug: b.template.slug, name: b.template.name },
|
|
103
|
+
target_version: b.target_version,
|
|
104
|
+
state: b.state,
|
|
105
|
+
registration: b.registration,
|
|
106
|
+
template_version: b.template_version,
|
|
107
|
+
auto_publish: b.auto_publish,
|
|
108
|
+
failure: b.failure,
|
|
109
|
+
denied_hosts: b.denied_hosts ?? [],
|
|
110
|
+
network: b.network,
|
|
111
|
+
recipe_sha256: b.provenance.recipe_sha256,
|
|
112
|
+
builder_availability: b.builder_availability.state,
|
|
113
|
+
created_at: b.created_at,
|
|
114
|
+
completed_at: b.completed_at,
|
|
115
|
+
log_tail: b.log.tail ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** A template for model context: identity, what open resolves to, every version with its settings. */
|
|
119
|
+
export function summarizeTemplate(t) {
|
|
120
|
+
return {
|
|
121
|
+
slug: t.slug,
|
|
122
|
+
name: t.name,
|
|
123
|
+
owner: t.owner,
|
|
124
|
+
category: t.category ?? null,
|
|
125
|
+
open_resolves_to: t.open_resolves_to ? { version: t.open_resolves_to.version, owner: t.open_resolves_to.owner } : null,
|
|
126
|
+
draft: t.draft ? { workspace_key: t.draft.workspace_key, observed_state: t.draft.observed_state, base_version: t.draft.base_version.version } : null,
|
|
127
|
+
versions: t.versions.map((v) => ({
|
|
128
|
+
version: v.version,
|
|
129
|
+
state: v.state,
|
|
130
|
+
is_open_version: v.is_open_version,
|
|
131
|
+
description: v.description,
|
|
132
|
+
source: v.source?.kind ?? null,
|
|
133
|
+
disk_layouts: v.disk_layouts,
|
|
134
|
+
installed_tools: v.installed_tools,
|
|
135
|
+
settings: v.settings ?? null,
|
|
136
|
+
created_at: v.created_at,
|
|
137
|
+
})),
|
|
138
|
+
versions_truncated: t.versions_truncated,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export function summarizeOperation(o) {
|
|
142
|
+
return {
|
|
143
|
+
id: o.id,
|
|
144
|
+
kind: o.kind,
|
|
145
|
+
state: o.state,
|
|
146
|
+
state_reason: o.state_reason ?? null,
|
|
147
|
+
workspace_id: o.workspace_id,
|
|
148
|
+
created_at: o.created_at,
|
|
149
|
+
completed_at: o.completed_at ?? null,
|
|
150
|
+
...(o.error ? { error: o.error } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function isObject(v) {
|
|
154
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The SDK's LifecycleTiming, compact enough for model context: where an open, a wait or a wake spent its time. Phases
|
|
158
|
+
* are `phase(reason) ms` in order (`request(held)`: the server held the open; `capacity_pending(no_ready_host)`:
|
|
159
|
+
* waiting for a host); `server` is the operation's own queued/run/total time; `outside_server_ms` is the rest
|
|
160
|
+
* (network, polling, view and token).
|
|
161
|
+
*/
|
|
162
|
+
export function compactTiming(t) {
|
|
163
|
+
const ms = (v) => (v === null ? null : Math.round(v));
|
|
164
|
+
const s = t.server;
|
|
165
|
+
return {
|
|
166
|
+
action: t.action,
|
|
167
|
+
outcome: t.outcome,
|
|
168
|
+
...(t.operationId ? { operation_id: t.operationId } : {}),
|
|
169
|
+
total_ms: Math.round(t.totalMs),
|
|
170
|
+
phases: t.phases.map((p) => `${p.phase}${p.reason ? `(${p.reason})` : ''} ${Math.round(p.durationMs)} ms`),
|
|
171
|
+
server: s
|
|
172
|
+
? {
|
|
173
|
+
queued_ms: ms(s.queuedMs),
|
|
174
|
+
run_ms: ms(s.runMs),
|
|
175
|
+
total_ms: ms(s.totalMs),
|
|
176
|
+
...(s.startPath ? { start_path: s.startPath } : {}),
|
|
177
|
+
...(s.warmFallback ? { warm_fallback: s.warmFallback } : {}),
|
|
178
|
+
...(s.resumePath ? { resume_path: s.resumePath } : {}),
|
|
179
|
+
}
|
|
180
|
+
: null,
|
|
181
|
+
outside_server_ms: ms(t.outsideServerMs),
|
|
182
|
+
retries: t.retries.length,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export function okResult(value) {
|
|
186
|
+
if (isObject(value) && value.mime_type === 'image/png' && typeof value.data_base64 === 'string') {
|
|
187
|
+
const { data_base64: data, ...meta } = value;
|
|
188
|
+
return { content: [{ type: 'image', data, mimeType: 'image/png' }, { type: 'text', text: JSON.stringify(meta) }], structuredContent: meta };
|
|
189
|
+
}
|
|
190
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: isObject(value) ? value : { result: value } };
|
|
191
|
+
}
|
|
192
|
+
/** `timing` (optional): where the failed open/wait/wake spent its time, next to `error`. */
|
|
193
|
+
export function errorResult(info, timing) {
|
|
194
|
+
const body = timing ? { error: info, timing } : { error: info };
|
|
195
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body, isError: true };
|
|
196
|
+
}
|
|
197
|
+
const INSTRUCTIONS = [
|
|
198
|
+
'Shardflux persistent remote workspaces (Linux computers that keep files, packages and processes between sessions).',
|
|
199
|
+
'Call workspace_open first (it creates the workspace on first use and reconnects afterwards, never resetting it), then use exec, read_file, write_file and the other workspace tools with the same workspace_key.',
|
|
200
|
+
'Lifecycle calls return operations; operation_wait keeps waiting. Workspace tools resume a suspended workspace on use; if that takes too long the error (code timeout) names the operation to pass to operation_wait. Errors are tool results with error.code from the Shardflux API.',
|
|
201
|
+
'A start (open, resume, fork) that no host can admit waits for capacity for at most 15 minutes, then fails with code operation_failed, details.error_code capacity_unavailable and retryable true: nothing was started; retry later if you still need it. retryable false means retrying will not help.',
|
|
202
|
+
'Opens, waits and wakes add timing (phases, server queued/run time) saying where the time went.',
|
|
203
|
+
'Templates: template_get shows a template’s versions and settings (the inputs workspace_open takes); template_languages lists what a base offers build.languages; template_build builds a new version from a recipe v2 (unpublished unless publish is true).',
|
|
204
|
+
].join(' ');
|
|
205
|
+
/** POST <cell>/v1/workspaces/{id}/exec/{session}/cancel: the cleanup request the SDK sends when a call is aborted. */
|
|
206
|
+
function isExecCancel(input, init) {
|
|
207
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
208
|
+
if (method !== 'POST')
|
|
209
|
+
return false;
|
|
210
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
211
|
+
try {
|
|
212
|
+
return /^\/v1\/workspaces\/[^/]+\/exec\/[^/]+\/cancel$/.test(new URL(url).pathname);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** `{NAME: "value"}` from a tool argument (undefined when absent); every value must be a string. */
|
|
219
|
+
function stringMap(v, what) {
|
|
220
|
+
if (v === undefined)
|
|
221
|
+
return undefined;
|
|
222
|
+
if (!isObject(v))
|
|
223
|
+
throw new ToolError('invalid_arguments', `${what} must be an object of NAME: "value"`);
|
|
224
|
+
for (const [k, x] of Object.entries(v))
|
|
225
|
+
if (typeof x !== 'string')
|
|
226
|
+
throw new ToolError('invalid_arguments', `${what}.${k} must be a string`);
|
|
227
|
+
return v;
|
|
228
|
+
}
|
|
229
|
+
export function createShardfluxMcpServer(config, opts = {}) {
|
|
230
|
+
const log = opts.log ?? (() => undefined);
|
|
231
|
+
const cwd = resolvePath(opts.cwd ?? process.cwd());
|
|
232
|
+
// Each tool call runs with its abort signal (client cancel or deadline) in async context; every
|
|
233
|
+
// HTTP request the SDK makes for the call (API and cell gateway) and every backoff sleep observe
|
|
234
|
+
// it, so a canceled or timed-out call leaves no request or poll loop running behind it. The
|
|
235
|
+
// deadline (epoch ms) bounds a wake inside the call.
|
|
236
|
+
const ambient = new AsyncLocalStorage();
|
|
237
|
+
const baseFetch = opts.fetch ?? makeFetch(opts.env ?? {});
|
|
238
|
+
const callFetch = (input, init) => {
|
|
239
|
+
const s = ambient.getStore()?.signal;
|
|
240
|
+
// The SDK's cleanup after an abort (exec.run -> POST .../exec/{session}/cancel) must outlive the canceled
|
|
241
|
+
// call, or the command keeps running in the workspace; it is bounded by the SDK (5 s) and the HTTP timeout.
|
|
242
|
+
if (!s || isExecCancel(input, init))
|
|
243
|
+
return baseFetch(input, init);
|
|
244
|
+
return baseFetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, s]) : s });
|
|
245
|
+
};
|
|
246
|
+
const callSleep = (ms) => new Promise((resolve) => {
|
|
247
|
+
const s = ambient.getStore()?.signal;
|
|
248
|
+
if (s?.aborted)
|
|
249
|
+
return resolve();
|
|
250
|
+
const done = () => {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
s?.removeEventListener('abort', done);
|
|
253
|
+
resolve();
|
|
254
|
+
};
|
|
255
|
+
const timer = setTimeout(done, ms);
|
|
256
|
+
s?.addEventListener('abort', done, { once: true });
|
|
257
|
+
});
|
|
258
|
+
// Every traced SDK call (open, waited lifecycle calls, waits, wakes; tool-token fetches are skipped) reports to the tool
|
|
259
|
+
// call it runs in: its operation (for deadline errors) and its timing.
|
|
260
|
+
const onProgress = (e) => {
|
|
261
|
+
const call = ambient.getStore()?.call;
|
|
262
|
+
if (!call || e.action === 'token' || e.action === 'tool')
|
|
263
|
+
return;
|
|
264
|
+
if (e.operationId)
|
|
265
|
+
call.operationId = e.operationId;
|
|
266
|
+
if (e.type === 'done')
|
|
267
|
+
call.timing = e.timing;
|
|
268
|
+
};
|
|
269
|
+
const cloud = new Shardflux({
|
|
270
|
+
apiKey: config.apiKey,
|
|
271
|
+
baseUrl: config.apiUrl,
|
|
272
|
+
userAgent: `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
|
|
273
|
+
fetch: callFetch,
|
|
274
|
+
sleep: callSleep,
|
|
275
|
+
onProgress,
|
|
276
|
+
});
|
|
277
|
+
const pinned = config.workspaceKey;
|
|
278
|
+
const ceiling = config.toolTimeoutMs;
|
|
279
|
+
const wakeTimeoutMs = Math.min(config.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS, ceiling);
|
|
280
|
+
/**
|
|
281
|
+
* Wake on use for a workspace's tools: `workspace.wake()` within the SDK's transition budget (SHARDFLUX_WAKE_TIMEOUT_MS)
|
|
282
|
+
* and 250 ms short of the call's deadline, so a wake that runs out reports the operation (OperationTimeoutError) rather
|
|
283
|
+
* than a bare deadline. null when SHARDFLUX_WAKE=off: the refusal (workspace_not_running) comes back instead.
|
|
284
|
+
*/
|
|
285
|
+
const wakeFor = (ws) => config.wake === false
|
|
286
|
+
? null
|
|
287
|
+
: (timeoutMs, signal) => {
|
|
288
|
+
const deadline = ambient.getStore()?.deadline;
|
|
289
|
+
const left = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now() - 250);
|
|
290
|
+
return ws.wake({ timeoutMs: Math.max(1, left), ...(signal ? { signal } : {}) });
|
|
291
|
+
};
|
|
292
|
+
// ---- principal and workspace resolution ----------------------------------------------------
|
|
293
|
+
let meCache;
|
|
294
|
+
const principal = async () => {
|
|
295
|
+
if (meCache && Date.now() - meCache.at < 60_000)
|
|
296
|
+
return meCache.me;
|
|
297
|
+
const me = await cloud.me();
|
|
298
|
+
meCache = { me, at: Date.now() };
|
|
299
|
+
return me;
|
|
300
|
+
};
|
|
301
|
+
/** Workspace handles by key; keys are never reused, and a handle keeps its tool tokens between calls. */
|
|
302
|
+
const handles = new Map();
|
|
303
|
+
const keyOf = (args) => {
|
|
304
|
+
const given = typeof args.workspace_key === 'string' ? args.workspace_key : undefined;
|
|
305
|
+
if (pinned !== undefined) {
|
|
306
|
+
if (given !== undefined && given !== pinned)
|
|
307
|
+
throw new ToolError('workspace_pinned', `This server is pinned to workspace "${pinned}"; it cannot operate on "${given}".`, { pinned_workspace_key: pinned });
|
|
308
|
+
return pinned;
|
|
309
|
+
}
|
|
310
|
+
if (given === undefined)
|
|
311
|
+
throw new ToolError('invalid_arguments', 'workspace_key is required.');
|
|
312
|
+
return given;
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* The workspace a key names, across every lifetime and purpose (sessions, drafts and test instances are hidden from
|
|
316
|
+
* the default list), preferring the live workspace over tombstones: an ended session leaves a tombstone with the same
|
|
317
|
+
* key, and the key then opens a new workspace.
|
|
318
|
+
*/
|
|
319
|
+
const resolve = async (key, opts2) => {
|
|
320
|
+
const cached = handles.get(key);
|
|
321
|
+
if (cached)
|
|
322
|
+
return cached;
|
|
323
|
+
const w = await cloud.workspaces.findByKey(key, { includeDeleted: opts2.includeDeleted === true, signal: opts2.signal });
|
|
324
|
+
if (opts2.signal.aborted)
|
|
325
|
+
throw opts2.signal.reason;
|
|
326
|
+
if (w) {
|
|
327
|
+
if (w.data.deleted_at === null)
|
|
328
|
+
handles.set(key, w);
|
|
329
|
+
return w;
|
|
330
|
+
}
|
|
331
|
+
throw new ToolError('not_found', `No workspace with key "${key}" in this project. Create or reconnect it with workspace_open.`);
|
|
332
|
+
};
|
|
333
|
+
/** The SDK's wait options for a call: its own timeout fires just before the call deadline, so the result names the operation. */
|
|
334
|
+
const waitOpts = (call) => ({ timeoutMs: Math.max(1, call.timeoutMs - 250), signal: call.signal });
|
|
335
|
+
const wait = async (call, operationId) => {
|
|
336
|
+
call.operationId = operationId;
|
|
337
|
+
call.timed = true;
|
|
338
|
+
return cloud.workspaces.waitForOperation(operationId, waitOpts(call));
|
|
339
|
+
};
|
|
340
|
+
/** Lifecycle calls: `wait: true` is the SDK's own wait (one trace for the request and the wait). */
|
|
341
|
+
const lifecycleOpts = (args, call) => {
|
|
342
|
+
if (args.wait !== true)
|
|
343
|
+
return {};
|
|
344
|
+
call.timed = true;
|
|
345
|
+
return { wait: waitOpts(call) };
|
|
346
|
+
};
|
|
347
|
+
// ---- management tools ----------------------------------------------------------------------
|
|
348
|
+
const keyRequired = pinned === undefined ? ['workspace_key'] : [];
|
|
349
|
+
const obj = (properties, required = []) => ({
|
|
350
|
+
type: 'object',
|
|
351
|
+
properties: { ...properties, timeout_ms: timeoutProp(ceiling) },
|
|
352
|
+
required,
|
|
353
|
+
additionalProperties: false,
|
|
354
|
+
});
|
|
355
|
+
const management = [
|
|
356
|
+
{
|
|
357
|
+
name: 'workspace_open',
|
|
358
|
+
title: 'Open workspace',
|
|
359
|
+
description: 'Open a persistent workspace by key: creates it from the template on first use, reconnects (or resumes) it afterwards, never resets it. Waits until it is ready unless wait is false; on timeout the start continues server side (operation_wait), for at most 15 minutes while it waits for capacity. The result’s timing says where the time went.',
|
|
360
|
+
inputSchema: obj({
|
|
361
|
+
workspace_key: workspaceKeyProp(pinned),
|
|
362
|
+
template: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
minLength: 1,
|
|
365
|
+
maxLength: 100,
|
|
366
|
+
description: config.template ? `Template slug (default "${config.template}").` : 'Template slug for a new workspace, e.g. "python-node-browser".',
|
|
367
|
+
},
|
|
368
|
+
...capsProps,
|
|
369
|
+
lifetime: {
|
|
370
|
+
type: 'string',
|
|
371
|
+
enum: ['persistent', 'session'],
|
|
372
|
+
description: 'session: the workspace is discarded after it has been idle for its idle timeout (default 10 minutes); the key then opens a new workspace. Default: the template’s default, else persistent.',
|
|
373
|
+
},
|
|
374
|
+
inputs: {
|
|
375
|
+
type: 'object',
|
|
376
|
+
additionalProperties: true,
|
|
377
|
+
description: 'Text inputs the template declares, {"NAME": "value"} (template_get shows a version’s settings.inputs): put into the environment of every command, start command and service. Replaces the inputs of an existing workspace; omitted leaves them unchanged. Secret inputs are stored secrets, never passed here.',
|
|
378
|
+
},
|
|
379
|
+
wait: { type: 'boolean', description: 'Wait until the workspace is ready, its start commands and services included (default true).' },
|
|
380
|
+
}, [...keyRequired, ...(config.template ? [] : ['template'])]),
|
|
381
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
382
|
+
run: async (args, call) => {
|
|
383
|
+
const key = keyOf(args);
|
|
384
|
+
const template = typeof args.template === 'string' ? args.template : config.template;
|
|
385
|
+
if (!template)
|
|
386
|
+
throw new ToolError('invalid_arguments', 'template is required (no SHARDFLUX_TEMPLATE is configured).');
|
|
387
|
+
const caps = capsFrom(args);
|
|
388
|
+
const lifetime = args.lifetime === 'session' || args.lifetime === 'persistent' ? args.lifetime : undefined;
|
|
389
|
+
const inputs = stringMap(args.inputs, 'inputs');
|
|
390
|
+
call.timed = true;
|
|
391
|
+
// The SDK's waited open: held by the server until ready (Prefer: wait), then polled; the handle it returns
|
|
392
|
+
// carries the first tool token, so the next tool call starts at once.
|
|
393
|
+
const ws = await cloud.workspaces.open({ key, template, ...(caps ? { caps } : {}), ...(lifetime ? { lifetime } : {}), ...(inputs ? { inputs } : {}), agentLabel: config.agentLabel, wait: args.wait === false ? false : waitOpts(call) });
|
|
394
|
+
handles.set(key, ws);
|
|
395
|
+
return { workspace: summarizeWorkspace(ws.data), ready: ws.ready, ...(ws.ready ? {} : { next: ws.activeOperation ? `operation ${ws.activeOperation.id} is ${ws.activeOperation.state}; call operation_wait or workspace_open again` : 'the workspace is not running' }) };
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
name: 'workspace_list',
|
|
400
|
+
title: 'List workspaces',
|
|
401
|
+
description: 'List the workspaces of this API key’s project (key, state, template, active operation).',
|
|
402
|
+
inputSchema: obj({
|
|
403
|
+
prefix: { type: 'string', minLength: 1, maxLength: 200, description: 'Only keys starting with this prefix.' },
|
|
404
|
+
state: { type: 'string', enum: OBSERVED_STATES, description: 'Observed state filter.' },
|
|
405
|
+
include_deleted: { type: 'boolean', description: 'Include deleted workspaces (and ended sessions).' },
|
|
406
|
+
lifetime: { type: 'string', enum: ['persistent', 'session', 'any'], description: 'Lifetime filter (default persistent: session workspaces are hidden).' },
|
|
407
|
+
purpose: { type: 'string', enum: ['standard', 'template_draft', 'template_test', 'any'], description: 'Purpose filter (default standard: template drafts and test instances are hidden).' },
|
|
408
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Page size (default 50).' },
|
|
409
|
+
cursor: { type: 'string', minLength: 1, maxLength: 200, description: 'next_cursor from a previous call.' },
|
|
410
|
+
}),
|
|
411
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
412
|
+
run: async (args) => {
|
|
413
|
+
const page = await cloud.workspaces.list({
|
|
414
|
+
...(typeof args.prefix === 'string' ? { keyPrefix: args.prefix } : {}),
|
|
415
|
+
...(typeof args.state === 'string' ? { state: args.state } : {}),
|
|
416
|
+
...(args.include_deleted === true ? { includeDeleted: true } : {}),
|
|
417
|
+
...(typeof args.lifetime === 'string' ? { lifetime: args.lifetime } : {}),
|
|
418
|
+
...(typeof args.purpose === 'string' ? { purpose: args.purpose } : {}),
|
|
419
|
+
limit: typeof args.limit === 'number' ? args.limit : 50,
|
|
420
|
+
...(typeof args.cursor === 'string' ? { cursor: args.cursor } : {}),
|
|
421
|
+
});
|
|
422
|
+
return { workspaces: page.data.map((w) => summarizeWorkspace(w.data)), next_cursor: page.nextCursor };
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: 'workspace_status',
|
|
427
|
+
title: 'Workspace status',
|
|
428
|
+
description: 'Current state of one workspace (ready, observed/desired state, grants, pending reason) and its most recent operations.',
|
|
429
|
+
inputSchema: obj({ workspace_key: workspaceKeyProp(pinned) }, keyRequired),
|
|
430
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
431
|
+
run: async (args, call) => {
|
|
432
|
+
const ws = await resolve(keyOf(args), { includeDeleted: true, signal: call.signal });
|
|
433
|
+
await ws.refresh();
|
|
434
|
+
const ops = await cloud.workspaces.operations(ws.id, { limit: 5 });
|
|
435
|
+
return { workspace: summarizeWorkspace(ws.data), recent_operations: ops.data.map(summarizeOperation) };
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
lifecycleTool('workspace_suspend', 'Suspend workspace', 'Suspend a running workspace (durable full-state checkpoint; processes stop). Returns the suspend operation (with wait: once finished, and its timing).', (id, o) => cloud.workspaces.suspend(id, o)),
|
|
439
|
+
lifecycleTool('workspace_resume', 'Resume workspace', 'Resume a suspended workspace. Returns the resume operation (with wait: once finished, and its timing).', (id, o) => cloud.workspaces.resume(id, o)),
|
|
440
|
+
{
|
|
441
|
+
name: 'workspace_fork',
|
|
442
|
+
title: 'Fork workspace',
|
|
443
|
+
description: 'Fork a running or suspended workspace into a new key (an independent copy of its committed state). Returns the fork operation and the new workspace (with wait: once finished, and its timing).',
|
|
444
|
+
inputSchema: obj({
|
|
445
|
+
workspace_key: workspaceKeyProp(pinned),
|
|
446
|
+
new_key: { type: 'string', minLength: 1, maxLength: 200, description: 'Key of the new workspace.' },
|
|
447
|
+
...capsProps,
|
|
448
|
+
wait: { type: 'boolean', description: 'Wait until the fork finishes (default false).' },
|
|
449
|
+
}, [...keyRequired, 'new_key']),
|
|
450
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
451
|
+
run: async (args, call) => {
|
|
452
|
+
const source = await resolve(keyOf(args), { signal: call.signal });
|
|
453
|
+
const caps = capsFrom(args);
|
|
454
|
+
const res = await cloud.workspaces.fork(source.id, { key: String(args.new_key), ...(caps ? { caps } : {}) }, lifecycleOpts(args, call));
|
|
455
|
+
call.operationId = res.operation.id;
|
|
456
|
+
return { operation: summarizeOperation(res.operation), workspace: summarizeWorkspace(res.workspace.data) };
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: 'operation_wait',
|
|
461
|
+
title: 'Wait for operation',
|
|
462
|
+
description: 'Wait for a lifecycle operation (open, suspend, resume, fork, ...) to finish, up to timeout_ms. Returns the operation and the wait’s timing.',
|
|
463
|
+
inputSchema: obj({ operation_id: { type: 'string', minLength: 36, maxLength: 36, description: 'Operation id (UUID).' } }, ['operation_id']),
|
|
464
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
465
|
+
run: async (args, call) => ({ operation: summarizeOperation(await wait(call, String(args.operation_id))) }),
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
name: 'template_get',
|
|
469
|
+
title: 'Get template',
|
|
470
|
+
description: 'A template this project can open: its versions (state, source, installed tools) and each version’s settings (env, open-time inputs, start commands, services, defaults). With version, also that version’s recipe in request form (the recipe v2 document template_build takes; null for versions saved from a workspace and platform versions).',
|
|
471
|
+
inputSchema: obj({
|
|
472
|
+
slug: { type: 'string', minLength: 1, maxLength: 100, description: 'Template slug, e.g. "python-node-browser" or an organization template.' },
|
|
473
|
+
version: { type: 'integer', minimum: 1, maximum: 999_999_999, description: 'Also export this version’s recipe.' },
|
|
474
|
+
owner: { type: 'string', enum: ['platform', 'organization'], description: 'Pick the platform template even when an organization template shadows its slug.' },
|
|
475
|
+
}, ['slug']),
|
|
476
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
477
|
+
run: async (args) => {
|
|
478
|
+
const slug = String(args.slug);
|
|
479
|
+
const owner = args.owner === 'platform' || args.owner === 'organization' ? args.owner : undefined;
|
|
480
|
+
const detail = await cloud.templates.get(slug, owner ? { owner } : {});
|
|
481
|
+
const out = { template: summarizeTemplate(detail) };
|
|
482
|
+
if (typeof args.version === 'number')
|
|
483
|
+
out.recipe = await cloud.templates.versions.recipe(slug, args.version, owner ? { owner } : {});
|
|
484
|
+
return out;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'template_languages',
|
|
489
|
+
title: 'Template languages',
|
|
490
|
+
description: 'The languages (python, node, go, rust, java) and versions a base offers a recipe v2’s build.languages, e.g. base "ubuntu-24.04@1". included: the base already has that version (nothing is installed); default: what {id} without a version picks. A version the base has another version of is not offered.',
|
|
491
|
+
inputSchema: obj({ base: { type: 'string', minLength: 3, maxLength: 120, description: 'The base, <slug>@<version> (as recipe.base).' } }, ['base']),
|
|
492
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
493
|
+
run: async (args) => cloud.templates.languages(String(args.base)),
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: 'template_build',
|
|
497
|
+
title: 'Build template',
|
|
498
|
+
description: 'Build a version of an organization template from a recipe v2 (languages, apt/pip/npm packages, files, build steps, and settings: env, inputs, start commands, services). Give either recipe (the document) or file (a template.yaml or .json path inside this server’s working directory). A file entry may name a local `from` path (a file, or a folder copied as a tar) relative to the template file (to the working directory for recipe); it is uploaded first. The version stays unpublished unless publish is true. Returns the build; with wait, once it is registered or failed (denied_hosts lists hosts the build needed but its network refused).',
|
|
499
|
+
inputSchema: obj({
|
|
500
|
+
template_slug: { type: 'string', minLength: 1, maxLength: 100, description: 'Organization template to build into (created by the first build).' },
|
|
501
|
+
file: { type: 'string', minLength: 1, maxLength: 4096, description: 'template.yaml (or .json) path, relative to the server’s working directory.' },
|
|
502
|
+
recipe: { type: 'object', additionalProperties: true, description: 'The recipe v2 document: {schema: "shardflux.template-recipe.v2", base: "<slug>@<version>", build: {...}, settings: {...}}.' },
|
|
503
|
+
display_name: { type: 'string', minLength: 1, maxLength: 200, description: 'Template name when this build creates the template.' },
|
|
504
|
+
description: { type: 'string', minLength: 1, maxLength: 2000, description: 'Version description.' },
|
|
505
|
+
publish: { type: 'boolean', description: 'Publish the version once it is registered (new workspaces of the slug then use it). Default false.' },
|
|
506
|
+
wait: { type: 'boolean', description: 'Wait until the build is registered or failed, up to timeout_ms (default false: return the queued build).' },
|
|
507
|
+
}, ['template_slug']),
|
|
508
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
509
|
+
run: async (args, call) => {
|
|
510
|
+
const hasFile = typeof args.file === 'string';
|
|
511
|
+
const hasRecipe = isObject(args.recipe);
|
|
512
|
+
if (hasFile === hasRecipe)
|
|
513
|
+
throw new ToolError('invalid_arguments', 'Give exactly one of file (a template.yaml path) or recipe (the recipe v2 document).');
|
|
514
|
+
const common = {
|
|
515
|
+
templateSlug: String(args.template_slug),
|
|
516
|
+
...(typeof args.display_name === 'string' ? { displayName: args.display_name } : {}),
|
|
517
|
+
...(typeof args.description === 'string' ? { description: args.description } : {}),
|
|
518
|
+
autoPublish: args.publish === true,
|
|
519
|
+
root: cwd,
|
|
520
|
+
signal: call.signal,
|
|
521
|
+
...(args.wait === true ? { wait: { timeoutMs: Math.max(1, call.timeoutMs - 250), signal: call.signal } } : {}),
|
|
522
|
+
};
|
|
523
|
+
let res;
|
|
524
|
+
try {
|
|
525
|
+
res = hasFile
|
|
526
|
+
? await cloud.templates.buildFromFile(resolvePath(cwd, String(args.file)), { ...common, parseYaml: (text) => parseYaml(text) })
|
|
527
|
+
: await cloud.templates.buildFromRecipe(args.recipe, { ...common, baseDir: cwd });
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
if (err instanceof TemplateFileError)
|
|
531
|
+
throw new ToolError('invalid_arguments', err.message);
|
|
532
|
+
if (err instanceof TemplateBuildTimeoutError) {
|
|
533
|
+
throw new ToolError('timeout', `${err.message}; the build continues server side (template_get shows the version once it is registered).`, { build_id: err.build.id, state: err.build.state });
|
|
534
|
+
}
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
return { build: summarizeBuild(res.build), uploads: res.uploads.map((u) => ({ from: u.from, to: u.to, kind: u.kind, sha256: u.sha256, size: u.size, entries: u.entries, uploaded: u.uploaded })) };
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: 'usage_summary',
|
|
542
|
+
title: 'Usage summary',
|
|
543
|
+
description: 'Usage of the organization in the current billing period: each meter’s raw, billable and included quantity and cap state.',
|
|
544
|
+
inputSchema: obj({ organization_id: { type: 'string', minLength: 36, maxLength: 36, description: 'Organization id (default: the API key’s organization).' } }),
|
|
545
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
546
|
+
run: async (args, call) => {
|
|
547
|
+
const org = typeof args.organization_id === 'string' ? args.organization_id : (await principal()).api_key?.organization_id;
|
|
548
|
+
if (!org)
|
|
549
|
+
throw new ToolError('invalid_arguments', 'organization_id is required.');
|
|
550
|
+
return cloud.request('GET', `/v1/organizations/${encodeURIComponent(org)}/usage/summary`, { signal: call.signal });
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
];
|
|
554
|
+
function lifecycleTool(name, title, description, start) {
|
|
555
|
+
return {
|
|
556
|
+
name,
|
|
557
|
+
title,
|
|
558
|
+
description,
|
|
559
|
+
inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), wait: { type: 'boolean', description: 'Wait until the operation finishes (default false).' } }, keyRequired),
|
|
560
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
561
|
+
run: async (args, call) => {
|
|
562
|
+
const ws = await resolve(keyOf(args), { signal: call.signal });
|
|
563
|
+
const op = await start(ws.id, lifecycleOpts(args, call));
|
|
564
|
+
call.operationId = op.id;
|
|
565
|
+
return { operation: summarizeOperation(op) };
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
// ---- the SDK's workspace tools -------------------------------------------------------------
|
|
570
|
+
const readOnly = new Set(['read_file', 'list_files', 'list_processes', 'terminal_read', 'git_status', 'browser_screenshot', 'browser_content']);
|
|
571
|
+
const workspaceDefs = sdkToolDefinitions().map((sdk) => {
|
|
572
|
+
const own = sdk.parameters.properties ?? {};
|
|
573
|
+
const hasTimeout = 'timeout_ms' in own;
|
|
574
|
+
const inputSchema = {
|
|
575
|
+
...sdk.parameters,
|
|
576
|
+
properties: { ...own, workspace_key: workspaceKeyProp(pinned), ...(hasTimeout ? {} : { timeout_ms: timeoutProp(ceiling) }) },
|
|
577
|
+
required: [...(sdk.parameters.required ?? []), ...keyRequired],
|
|
578
|
+
};
|
|
579
|
+
return {
|
|
580
|
+
name: sdk.name,
|
|
581
|
+
title: sdk.name.replace(/_/g, ' '),
|
|
582
|
+
description: sdk.name === 'exec' ? `${sdk.description} Here timeout_ms defaults to, and is capped by, this server's per-call deadline (${ceiling} ms) minus 2 s.` : sdk.description,
|
|
583
|
+
inputSchema,
|
|
584
|
+
permission: sdk.permission,
|
|
585
|
+
annotations: {
|
|
586
|
+
readOnlyHint: readOnly.has(sdk.name),
|
|
587
|
+
destructiveHint: !readOnly.has(sdk.name),
|
|
588
|
+
openWorldHint: sdk.permission === 'browser' || sdk.name === 'git_clone' || sdk.name === 'exec',
|
|
589
|
+
},
|
|
590
|
+
run: async (args, call) => {
|
|
591
|
+
const key = keyOf(args);
|
|
592
|
+
const sdkArgs = { ...args };
|
|
593
|
+
delete sdkArgs.workspace_key;
|
|
594
|
+
if (!hasTimeout)
|
|
595
|
+
delete sdkArgs.timeout_ms;
|
|
596
|
+
else if (sdk.name === 'exec') {
|
|
597
|
+
// The command is killed before the call deadline so its output comes back.
|
|
598
|
+
const cmdTimeout = typeof sdkArgs.timeout_ms === 'number' ? Math.min(sdkArgs.timeout_ms, call.timeoutMs) : call.timeoutMs;
|
|
599
|
+
sdkArgs.timeout_ms = Math.max(1000, cmdTimeout - 2000);
|
|
600
|
+
}
|
|
601
|
+
const ws = await resolve(key, { signal: call.signal });
|
|
602
|
+
// A wake during the call (the workspace was suspended) is timed; a call without one has no timing.
|
|
603
|
+
call.timed = true;
|
|
604
|
+
const tool = workspaceTools(ws, { agentLabel: config.agentLabel, tools: [...ALL_TOOL_PERMISSIONS], wake: wakeFor(ws), transitionTimeoutMs: wakeTimeoutMs }).find((t) => t.name === sdk.name);
|
|
605
|
+
if (!tool)
|
|
606
|
+
throw new ToolError('internal_error', `SDK tool ${sdk.name} is missing`);
|
|
607
|
+
try {
|
|
608
|
+
return await tool.execute(sdkArgs, { signal: call.signal });
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
// A deleted or foreign workspace: forget the handle so the next call resolves the key again.
|
|
612
|
+
if (err instanceof ShardfluxApiError && err.source === 'api' && err.status === 404)
|
|
613
|
+
handles.delete(key);
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
});
|
|
619
|
+
const registry = new Map([...management, ...workspaceDefs].map((d) => [d.name, d]));
|
|
620
|
+
const toMcpTool = (d) => ({
|
|
621
|
+
name: d.name,
|
|
622
|
+
title: d.title,
|
|
623
|
+
description: d.description,
|
|
624
|
+
inputSchema: d.inputSchema,
|
|
625
|
+
annotations: { title: d.title, ...d.annotations },
|
|
626
|
+
});
|
|
627
|
+
// ---- protocol handlers ---------------------------------------------------------------------
|
|
628
|
+
const server = new Server({ name: 'shardflux', title: 'Shardflux workspaces', version: MCP_SERVER_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
629
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
630
|
+
let permitted = ALL_TOOL_PERMISSIONS;
|
|
631
|
+
try {
|
|
632
|
+
permitted = (await principal()).api_key?.tool_permissions ?? [];
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
log('warn', 'could not read the API key’s tool permissions; listing every workspace tool', { error: describeToolError(err) });
|
|
636
|
+
}
|
|
637
|
+
return { tools: [...management, ...workspaceDefs.filter((d) => d.permission !== undefined && permitted.includes(d.permission))].map(toMcpTool) };
|
|
638
|
+
});
|
|
639
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
640
|
+
const name = request.params.name;
|
|
641
|
+
const def = registry.get(name);
|
|
642
|
+
if (!def)
|
|
643
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
644
|
+
const args = request.params.arguments ?? {};
|
|
645
|
+
const started = Date.now();
|
|
646
|
+
const issues = validateArgs(def.inputSchema, args);
|
|
647
|
+
if (issues.length > 0) {
|
|
648
|
+
log('info', 'tool call rejected', { tool: name, outcome: 'invalid_arguments' });
|
|
649
|
+
return errorResult({ code: 'invalid_arguments', message: `Invalid arguments for ${name}: ${issues.join('; ')}`, issues });
|
|
650
|
+
}
|
|
651
|
+
const timeoutMs = Math.min(typeof args.timeout_ms === 'number' ? args.timeout_ms : ceiling, ceiling);
|
|
652
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
653
|
+
const signal = AbortSignal.any([extra.signal, deadline]);
|
|
654
|
+
const call = { signal, timeoutMs };
|
|
655
|
+
const aborted = new Promise((_, reject) => {
|
|
656
|
+
const onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new DOMException('aborted', extra.signal.aborted ? 'AbortError' : 'TimeoutError'));
|
|
657
|
+
if (signal.aborted)
|
|
658
|
+
onAbort();
|
|
659
|
+
else
|
|
660
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
661
|
+
});
|
|
662
|
+
const running = ambient.run({ signal, deadline: started + timeoutMs, call }, () => def.run(args, call));
|
|
663
|
+
running.catch(() => undefined); // settled after an abort: nobody awaits it any more
|
|
664
|
+
aborted.catch(() => undefined);
|
|
665
|
+
try {
|
|
666
|
+
const value = await Promise.race([running, aborted]);
|
|
667
|
+
log('info', 'tool call', { tool: name, outcome: 'ok', ms: Date.now() - started });
|
|
668
|
+
return okResult(call.timed && call.timing && isObject(value) ? { ...value, timing: compactTiming(call.timing) } : value);
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
if (extra.signal.aborted) {
|
|
672
|
+
// The client canceled: the SDK request/wait was aborted; no response is sent for a canceled request.
|
|
673
|
+
log('info', 'tool call canceled by the client', { tool: name, outcome: 'canceled', ms: Date.now() - started, ...(call.operationId ? { operation_id: call.operationId } : {}) });
|
|
674
|
+
throw new McpError(ErrorCode.RequestTimeout, 'canceled');
|
|
675
|
+
}
|
|
676
|
+
// A structured error (API refusal, the SDK's own wait timeout) wins; a bare abort after the deadline is a timeout.
|
|
677
|
+
const abortish = !(err instanceof Error) || err.name === 'TimeoutError' || err.name === 'AbortError';
|
|
678
|
+
const info = describeToolError(deadline.aborted && abortish ? new DOMException('deadline', 'TimeoutError') : err, { operationId: call.operationId, timeoutMs });
|
|
679
|
+
info.message = redact(info.message, config.apiKey);
|
|
680
|
+
if (info.code === 'internal_error')
|
|
681
|
+
log('error', 'tool call failed unexpectedly', { tool: name, error: redact(err instanceof Error ? (err.stack ?? err.message) : String(err), config.apiKey) });
|
|
682
|
+
log('info', 'tool call', { tool: name, outcome: info.code, ms: Date.now() - started, ...(info.request_id ? { request_id: info.request_id } : {}) });
|
|
683
|
+
return errorResult(info, call.timed && call.timing ? compactTiming(call.timing) : undefined);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
return server;
|
|
687
|
+
}
|