@shardflux/mcp 0.3.1 → 0.4.1

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/server.js CHANGED
@@ -2,11 +2,14 @@
2
2
  * Local stdio MCP server for Shardflux workspaces. It translates MCP tool
3
3
  * calls into @shardflux/sdk calls made with the supplied scoped API key:
4
4
  *
5
- * - workspace management: workspace_open / _list / _status / _suspend /
6
- * _resume / _fork, operation_wait, usage_summary;
5
+ * - workspace management: workspace_open / _list / _status / _suspend
6
+ * (now, or with after_seconds once idle: suspend-when-idle) / _resume /
7
+ * _fork, operation_wait, usage_summary;
7
8
  * - templates (0.3.0): template_get (versions, settings, a version's recipe), template_languages and
8
9
  * template_build (a recipe v2 object or a template.yaml path inside the
9
10
  * server's working directory; local `from` paths are uploaded by the SDK);
11
+ * - send_feedback (0.4.0): feedback straight to the Shardflux founder; the instructions, its description and
12
+ * the `feedback` field of error results ask agents to use it while they work;
10
13
  * - the SDK's workspace tools (`workspaceTools()`: exec, files, processes,
11
14
  * terminal, git, browser), published with the SDK's own JSON Schemas plus
12
15
  * `workspace_key` (and `timeout_ms` where the SDK schema has none).
@@ -14,38 +17,72 @@
14
17
  * There is no agent loop and no local workspace directory: every call is one
15
18
  * SDK request/wait against the remote workspace. Workspace tools wake a
16
19
  * suspended workspace on use (SHARDFLUX_WAKE, bounded by
17
- * SHARDFLUX_WAKE_TIMEOUT_MS and the call's deadline). Each call has a deadline
20
+ * SHARDFLUX_WAKE_TIMEOUT_MS and the call's deadline), and each workspace tool
21
+ * call first sends the wake hint (the SDK tool runner's `workspace.hint()`,
22
+ * fire-and-forget) so a parked workspace restores while the call is prepared,
23
+ * except read_file, list_files and search_files, which a sleeping workspace
24
+ * answers from its disk without waking (contracts §26.4). Each call has a deadline
18
25
  * (`timeout_ms`, clamped to SHARDFLUX_MCP_TOOL_TIMEOUT_MS) and MCP cancellation
19
26
  * (notifications/cancelled -> `extra.signal`) aborts the underlying SDK
20
27
  * request or wait. Failures come back as `isError` tool results carrying the
21
28
  * API error code. stdout is the protocol; logs go to stderr. Opens, waits and
22
29
  * wakes carry the SDK's lifecycle timing, compacted (`compactTiming`).
30
+ *
31
+ * File-first workspaces (0.4.0; contracts §29: `workspace_open` `mode:
32
+ * "file_first"`) have files and executions only. A pinned server lists the
33
+ * tools of its workspace's mode (the key's mode once it resolves, else
34
+ * SHARDFLUX_WORKSPACE_MODE, else processful) and sends
35
+ * `notifications/tools/list_changed` when the known mode changes what it
36
+ * listed; an unpinned server lists every tool, and a call of a tool the named
37
+ * workspace's mode lacks is the SDK's NotSupportedForModeError
38
+ * (`not_supported_for_mode`) before any request.
39
+ *
40
+ * At startup (0.4.0) the server asks GET /v1/client-versions whether
41
+ * @shardflux/mcp at MCP_SERVER_VERSION is current and logs one `warn` line
42
+ * when it is not (`checkServerVersion`); the SDK client it builds runs no
43
+ * check of its own (`versionCheck: false`). Account-level actions are not
44
+ * tools (the project API key cannot do them): the server instructions point
45
+ * the agent at the `shard` CLI.
23
46
  */
24
47
  import { AsyncLocalStorage } from 'node:async_hooks';
25
48
  import { resolve as resolvePath } from 'node:path';
26
49
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
27
50
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
28
- import { SDK_VERSION, Shardflux, ShardfluxApiError, TemplateBuildTimeoutError, TemplateFileError, validateArgs, workspaceTools } from '@shardflux/sdk';
51
+ import { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, Shardflux, ShardfluxApiError, TemplateBuildTimeoutError, TemplateFileError, checkClientVersion, validateArgs, versionCheckDisabledByEnv, workspaceTools } from '@shardflux/sdk';
29
52
  import { parse as parseYaml } from 'yaml';
30
53
  import { DEFAULT_WAKE_TIMEOUT_MS } from "./config.js";
31
54
  import { ToolError, describeToolError, redact } from "./errors.js";
32
55
  import { makeFetch } from "./http.js";
33
- export const MCP_SERVER_VERSION = '0.3.1';
56
+ export const MCP_SERVER_VERSION = '0.4.1';
57
+ /** The package this server is distributed as: its entry in GET /v1/client-versions (contracts §30.4). */
58
+ export const MCP_PACKAGE = '@shardflux/mcp';
59
+ const USER_AGENT = `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`;
34
60
  export const ALL_TOOL_PERMISSIONS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
35
61
  const OBSERVED_STATES = ['creating', 'starting', 'running', 'suspending', 'suspended', 'resuming', 'forking', 'stopping', 'failed', 'deleting', 'deleted'];
36
62
  /**
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.
63
+ * The SDK's workspace tool definitions for a workspace mode, without a workspace. Given `tools` and `mode`,
64
+ * `workspaceTools()` only touches the workspace inside `execute`, which is never called on these: the proxy throws on
65
+ * any access, so a change in the SDK that did would fail loudly. `file_first` (0.4.0): the SDK offers only the exec
66
+ * and files tools, and exec is its file-first definition (executions: the result adds execution_id, state,
67
+ * tree_revision and changed).
40
68
  */
41
- export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS) {
69
+ export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS, mode = 'processful') {
42
70
  const noWorkspace = new Proxy({}, {
43
71
  get(_t, prop) {
44
72
  throw new Error(`workspace tool definitions must not access workspace.${String(prop)}`);
45
73
  },
46
74
  });
47
- return workspaceTools(noWorkspace, { tools: [...tools] });
75
+ return workspaceTools(noWorkspace, { tools: [...tools], mode });
48
76
  }
77
+ /** Management tools that need a workspace VM or an operation: not listed for a pinned file-first workspace. */
78
+ const VM_ONLY_MANAGEMENT = new Set(['workspace_suspend', 'workspace_resume', 'workspace_fork', 'operation_wait']);
79
+ /** What to use instead of a workspace tool a file-first workspace does not have, by the tool's permission. */
80
+ const FILE_FIRST_INSTEAD = {
81
+ process: 'Nothing runs between exec calls: run the program, and whatever needs it, within one exec command.',
82
+ pty: 'Use exec instead: each call runs one command to its end (pass input through its stdin argument).',
83
+ git: 'Run git inside exec instead, e.g. exec "git clone <url> /home/user/repo"; only files under /home/user persist.',
84
+ browser: 'Run a headless browser within one exec command instead, printing what you need or saving it under /home/user.',
85
+ };
49
86
  function timeoutProp(ceiling) {
50
87
  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
88
  }
@@ -69,12 +106,19 @@ function capsFrom(args) {
69
106
  c.disk_gib = args.disk_gib;
70
107
  return Object.keys(c).length ? c : undefined;
71
108
  }
72
- /** What tools return for a workspace (no tokens or other credentials). */
109
+ /**
110
+ * What tools return for a workspace (no tokens or other credentials). `mode` (0.4.0) is `processful` or `file_first`
111
+ * (a view without it, from an older API, is processful); `tree_revision`, the latest revision of the file tree, only
112
+ * for file-first workspaces (the API reports 0 for every processful one).
113
+ */
73
114
  export function summarizeWorkspace(v) {
115
+ const mode = v.mode ?? 'processful';
74
116
  return {
75
117
  id: v.id,
76
118
  key: v.workspace_key,
77
119
  ready: v.observed_state === 'running' && v.desired_state === 'running' && v.deleted_at === null,
120
+ mode,
121
+ ...(mode === 'file_first' ? { tree_revision: v.tree_revision } : {}),
78
122
  observed_state: v.observed_state,
79
123
  desired_state: v.desired_state,
80
124
  template: { slug: v.template.slug, version: v.template.version },
@@ -91,6 +135,8 @@ export function summarizeWorkspace(v) {
91
135
  ended_reason: v.ended_reason,
92
136
  /** Start commands and services of the template version; null when it has none. */
93
137
  startup: v.startup ?? null,
138
+ /** A pending suspend-when-idle request (workspace_suspend with after_seconds); null when none. */
139
+ suspend_request: v.idle?.suspend_request ?? null,
94
140
  created_at: v.created_at,
95
141
  deleted_at: v.deleted_at,
96
142
  };
@@ -189,19 +235,85 @@ export function okResult(value) {
189
235
  }
190
236
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: isObject(value) ? value : { result: value } };
191
237
  }
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 };
238
+ /**
239
+ * `timing` (optional): where the failed open/wait/wake spent its time, next to `error`. `extra` (0.4.0): more sibling
240
+ * fields, e.g. `feedback` (how to report the failure with send_feedback) or `hint`.
241
+ */
242
+ export function errorResult(info, timing, extra = {}) {
243
+ const body = { error: info, ...(timing ? { timing } : {}), ...extra };
195
244
  return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body, isError: true };
196
245
  }
246
+ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
247
+ /**
248
+ * When to use send_feedback, for the agent: the same words as `shard feedback --help`, in the server instructions and
249
+ * the tool description. It has to make an agent actually call it while it works.
250
+ */
251
+ const FEEDBACK_USE = 'Call it actively while you work, not once at the end. Send a short note the moment: something fails unexpectedly, an error or doc is unclear, an option is missing, or you needed a workaround; your user complains or is frustrated about Shardflux or the workflow around it (slowness, cost or limits, confusing setup, a missing feature, a workaround): paraphrase it, with what they were trying to do; your user asked for something Shardflux could not do, or made awkward. Short and frequent beats polished; include the request_id and error code (failed calls carry a ready-made "feedback" field). No need to ask first, but tell your user you sent it. Leave out private data about the user and secrets or code they did not mean to share; paraphrase, never paste transcripts.';
252
+ /**
253
+ * No send_feedback suggestion for the expected flow: argument, configuration or credential problems of the call itself,
254
+ * a wait that gave up while the work continues, and refusals whose error names the exact next step (the same list as
255
+ * the CLI's `feedback:` line).
256
+ */
257
+ const NO_FEEDBACK_CODES = new Set(['invalid_arguments', 'workspace_pinned', 'unauthenticated', 'validation_failed', 'timeout']);
258
+ const NO_FEEDBACK_REASONS = new Set([
259
+ 'revision_mismatch',
260
+ 'tree_revision_mismatch',
261
+ 'edit_not_found',
262
+ 'edit_ambiguous',
263
+ 'edit_not_text',
264
+ 'execution_id_reused',
265
+ 'execution_in_progress',
266
+ 'exec_failed_to_start',
267
+ 'mode_mismatch',
268
+ 'not_supported_for_mode',
269
+ 'outside_tree_root',
270
+ 'layout_unsupported',
271
+ 'host_feature_unavailable',
272
+ ]);
273
+ const suggestsFeedback = (info) => !NO_FEEDBACK_CODES.has(info.code) && !(info.reason !== undefined && NO_FEEDBACK_REASONS.has(info.reason));
274
+ /** The `feedback` field of a failed call (0.4.0+): how to report it, with the error's request id and code. */
275
+ export function feedbackSuggestion(info) {
276
+ const code = (info.code === 'operation_failed' || info.code === 'execution_failed') && typeof info.details?.error_code === 'string' ? info.details.error_code : info.code;
277
+ const args = [`category "bug"`, ...(info.request_id ? [`request_id "${info.request_id}"`] : []), `error_code "${code}"`];
278
+ return `Unexpected or unclear? Call send_feedback with ${args.join(', ')} and a short message saying what you expected.`;
279
+ }
197
280
  const INSTRUCTIONS = [
198
281
  'Shardflux persistent remote workspaces (Linux computers that keep files, packages and processes between sessions).',
199
282
  '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.',
283
+ 'Lifecycle calls return operations; operation_wait keeps waiting. Workspace tools resume a suspended workspace on use (read_file, list_files and search_files read its disk without resuming it when they can); 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.',
284
+ 'When you finish your work on a workspace, call workspace_suspend with after_seconds (e.g. 60): it is suspended once idle that long, so it stops using RAM; your next tool call on it cancels that.',
201
285
  '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
286
  'Opens, waits and wakes add timing (phases, server queued/run time) saying where the time went.',
203
287
  '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).',
288
+ 'search_files finds text in files under a directory; edit_file replaces exact text in a file (each old_text must occur once) and returns the new revision, which you can pass as expected_revision to the next edit_file of that file so a change made by someone else is detected.',
289
+ 'File-first workspaces (workspace_open mode "file_first"; results show mode and tree_revision) keep only files: there is no VM between calls, each exec runs in a fresh VM, and only files under /home/user persist between exec calls (install dependencies there, e.g. a virtualenv, and start servers within the command that needs them).',
290
+ 'An exec result on a file-first workspace carries the paths it changed (changed) and the new tree_revision; an execution cannot be canceled. Process, terminal, git and browser tools and workspace_suspend, workspace_resume and workspace_fork do not apply to them (error reason not_supported_for_mode).',
291
+ 'Account-level actions are not tools of this server: it works inside one project with a project API key, which the API refuses for them. Registering, signing in, organizations, projects, API keys, members, billing and plan upgrades, spend alerts and audit export are done with the shard CLI: `npx @shardflux/cli@latest --help` (for example `shard auth login`, `shard setup`, `shard billing upgrade <plan>`). A person still opens the verification email and pays on the Checkout page the CLI prints.',
292
+ `Feedback: send_feedback goes straight to the Shardflux founder, who reads every message. ${FEEDBACK_USE}`,
204
293
  ].join(' ');
294
+ /**
295
+ * The startup version check (0.4.0; contracts §30.4): one GET <api>/v1/client-versions through the server's fetch
296
+ * (the SDK's checkClientVersion: 3 s timeout, never throws) for @shardflux/mcp at MCP_SERVER_VERSION. Outdated or
297
+ * unsupported: one `warn` line whose message is the notice (`@shardflux/mcp 0.4.0 is outdated: 0.5.0 is available.
298
+ * Update: <upgrade command>`) plus the status fields. Anything else (current; unknown: not listed, `latest` null while
299
+ * the package is not distributed, or the request failed) is a `debug` line only. createShardfluxMcpServer runs it in
300
+ * the background and never awaits it: the handshake and every tool call proceed without it. Resolves to the status;
301
+ * never rejects (a logger that throws is ignored).
302
+ */
303
+ export async function checkServerVersion(apiUrl, f, log) {
304
+ const s = await checkClientVersion({ baseUrl: apiUrl, fetch: f, package: MCP_PACKAGE, version: MCP_SERVER_VERSION, ecosystem: 'npm', userAgent: USER_AGENT });
305
+ const fields = { status: s.status, package: s.package, current: s.current, latest: s.latest, minimum_supported: s.minimumSupported, upgrade_command: s.upgradeCommand };
306
+ try {
307
+ if ((s.status === 'outdated' || s.status === 'unsupported') && s.message)
308
+ log('warn', s.message, { ...fields, release_notes_url: s.releaseNotesUrl });
309
+ else
310
+ log('debug', 'version check', fields);
311
+ }
312
+ catch {
313
+ // The check never fails the server.
314
+ }
315
+ return s;
316
+ }
205
317
  /** POST <cell>/v1/workspaces/{id}/exec/{session}/cancel: the cleanup request the SDK sends when a call is aborted. */
206
318
  function isExecCancel(input, init) {
207
319
  const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
@@ -269,25 +381,34 @@ export function createShardfluxMcpServer(config, opts = {}) {
269
381
  const cloud = new Shardflux({
270
382
  apiKey: config.apiKey,
271
383
  baseUrl: config.apiUrl,
272
- userAgent: `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
384
+ userAgent: USER_AGENT,
273
385
  fetch: callFetch,
274
386
  sleep: callSleep,
275
387
  onProgress,
388
+ // The server checks its own package at startup (below). The SDK's check would name @shardflux/sdk, which a
389
+ // server user cannot update on its own (it ships inside the server), and process.emitWarning would print a
390
+ // non-JSON line on stderr.
391
+ versionCheck: false,
276
392
  });
393
+ // In the background: the handshake and tool calls never wait for it (an unhandled rejection would end the process).
394
+ if (opts.versionCheck !== false && !versionCheckDisabledByEnv(opts.env))
395
+ checkServerVersion(config.apiUrl, baseFetch, log).catch(() => undefined);
277
396
  const pinned = config.workspaceKey;
278
397
  const ceiling = config.toolTimeoutMs;
279
398
  const wakeTimeoutMs = Math.min(config.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS, ceiling);
280
399
  /**
281
400
  * Wake on use for a workspace's tools: `workspace.wake()` within the SDK's transition budget (SHARDFLUX_WAKE_TIMEOUT_MS)
282
401
  * 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.
402
+ * than a bare deadline. null when SHARDFLUX_WAKE=off: the refusal (workspace_not_running) comes back instead. The
403
+ * wake's held resume (contracts §22.6) returns the token of this server's tools (its agent label), so the woken call
404
+ * runs at once with it.
284
405
  */
285
406
  const wakeFor = (ws) => config.wake === false
286
407
  ? null
287
408
  : (timeoutMs, signal) => {
288
409
  const deadline = ambient.getStore()?.deadline;
289
410
  const left = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now() - 250);
290
- return ws.wake({ timeoutMs: Math.max(1, left), ...(signal ? { signal } : {}) });
411
+ return ws.wake({ timeoutMs: Math.max(1, left), ...(signal ? { signal } : {}), agentLabel: config.agentLabel });
291
412
  };
292
413
  // ---- principal and workspace resolution ----------------------------------------------------
293
414
  let meCache;
@@ -311,25 +432,71 @@ export function createShardfluxMcpServer(config, opts = {}) {
311
432
  throw new ToolError('invalid_arguments', 'workspace_key is required.');
312
433
  return given;
313
434
  };
435
+ // ---- the pinned workspace's mode (0.4.0) -----------------------------------------------------
436
+ /** The mode of the pinned key's live workspace, once a lookup, open or tool call has seen it. */
437
+ let pinnedMode;
438
+ /** The mode the last tools/list answer of a pinned server was built for (undefined before the first). */
439
+ let listedMode;
440
+ /**
441
+ * Records the mode of a live workspace. For the pinned key, a mode other than the one the client's tool list was
442
+ * built for sends notifications/tools/list_changed (once per change), so the client lists the tools again.
443
+ */
444
+ const noteMode = (key, ws) => {
445
+ if (pinned === undefined || key !== pinned || ws.data.deleted_at !== null)
446
+ return;
447
+ pinnedMode = ws.mode;
448
+ if (listedMode === undefined || listedMode === ws.mode)
449
+ return;
450
+ log('info', 'tool list changed', { pinned_workspace_key: pinned, listed_mode: listedMode, mode: ws.mode });
451
+ listedMode = ws.mode;
452
+ server.sendToolListChanged().catch((err) => log('warn', 'could not send notifications/tools/list_changed', { error: err instanceof Error ? err.message : String(err) }));
453
+ };
454
+ const forget = (key) => {
455
+ handles.delete(key);
456
+ if (key === pinned)
457
+ pinnedMode = undefined;
458
+ };
314
459
  /**
315
460
  * The workspace a key names, across every lifetime and purpose (sessions, drafts and test instances are hidden from
316
461
  * 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.
462
+ * key, and the key then opens a new workspace (contracts §19.11). Null when the key names none.
318
463
  */
319
- const resolve = async (key, opts2) => {
464
+ const lookup = async (key, opts2) => {
320
465
  const cached = handles.get(key);
321
466
  if (cached)
322
467
  return cached;
323
468
  const w = await cloud.workspaces.findByKey(key, { includeDeleted: opts2.includeDeleted === true, signal: opts2.signal });
324
469
  if (opts2.signal.aborted)
325
470
  throw opts2.signal.reason;
326
- if (w) {
327
- if (w.data.deleted_at === null)
328
- handles.set(key, w);
329
- return w;
471
+ if (w && w.data.deleted_at === null) {
472
+ handles.set(key, w);
473
+ noteMode(key, w);
330
474
  }
475
+ return w;
476
+ };
477
+ const resolve = async (key, opts2) => {
478
+ const w = await lookup(key, opts2);
479
+ if (w)
480
+ return w;
331
481
  throw new ToolError('not_found', `No workspace with key "${key}" in this project. Create or reconnect it with workspace_open.`);
332
482
  };
483
+ /**
484
+ * The mode a pinned server lists tools for: its workspace's (looked up when not known yet), else
485
+ * SHARDFLUX_WORKSPACE_MODE (what workspace_open will create), else processful (the API's default for a new key).
486
+ */
487
+ const pinnedListMode = async (key, signal) => {
488
+ if (pinnedMode === undefined) {
489
+ try {
490
+ await lookup(key, { signal });
491
+ }
492
+ catch (err) {
493
+ log('warn', 'could not look up the pinned workspace’s mode; listing the tools of the configured mode', { error: describeToolError(err), mode: config.workspaceMode ?? 'processful' });
494
+ }
495
+ }
496
+ return pinnedMode ?? config.workspaceMode ?? 'processful';
497
+ };
498
+ /** The SDK's own refusal of a call the workspace's mode does not have (no request is made). */
499
+ const notForMode = (ws, operation, source, message) => NotSupportedForModeError.local(ws.mode, operation, source, message);
333
500
  /** The SDK's wait options for a call: its own timeout fires just before the call deadline, so the result names the operation. */
334
501
  const waitOpts = (call) => ({ timeoutMs: Math.max(1, call.timeoutMs - 250), signal: call.signal });
335
502
  const wait = async (call, operationId) => {
@@ -356,7 +523,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
356
523
  {
357
524
  name: 'workspace_open',
358
525
  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.',
526
+ 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. mode "file_first" opens a file-first workspace (files only, ready at once; see mode).',
360
527
  inputSchema: obj({
361
528
  workspace_key: workspaceKeyProp(pinned),
362
529
  template: {
@@ -377,6 +544,11 @@ export function createShardfluxMcpServer(config, opts = {}) {
377
544
  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
545
  },
379
546
  wait: { type: 'boolean', description: 'Wait until the workspace is ready, its start commands and services included (default true).' },
547
+ mode: {
548
+ type: 'string',
549
+ enum: ['processful', 'file_first'],
550
+ description: `processful: one VM keeps files, installed packages and processes between calls (suspended when idle, resumable, forkable). file_first: the workspace is a versioned file tree with no VM between calls: each exec runs in a fresh VM and only files under /home/user persist; it is ready at once, never suspended, persistent, and needs a layered template version. A workspace's mode never changes (reopening a key with another mode is refused with mode_mismatch). ${config.workspaceMode ? `Default "${config.workspaceMode}" (this server's SHARDFLUX_WORKSPACE_MODE).` : 'Default: processful for a new key, the stored mode for an existing one.'}`,
551
+ },
380
552
  }, [...keyRequired, ...(config.template ? [] : ['template'])]),
381
553
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
382
554
  run: async (args, call) => {
@@ -387,11 +559,22 @@ export function createShardfluxMcpServer(config, opts = {}) {
387
559
  const caps = capsFrom(args);
388
560
  const lifetime = args.lifetime === 'session' || args.lifetime === 'persistent' ? args.lifetime : undefined;
389
561
  const inputs = stringMap(args.inputs, 'inputs');
562
+ const mode = args.mode === 'processful' || args.mode === 'file_first' ? args.mode : config.workspaceMode;
390
563
  call.timed = true;
391
564
  // 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) });
565
+ // carries the first tool token, so the next tool call starts at once. A file-first open is ready at once.
566
+ const ws = await cloud.workspaces.open({
567
+ key,
568
+ template,
569
+ ...(caps ? { caps } : {}),
570
+ ...(lifetime ? { lifetime } : {}),
571
+ ...(inputs ? { inputs } : {}),
572
+ ...(mode ? { mode } : {}),
573
+ agentLabel: config.agentLabel,
574
+ wait: args.wait === false ? false : waitOpts(call),
575
+ });
394
576
  handles.set(key, ws);
577
+ noteMode(key, ws);
395
578
  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
579
  },
397
580
  },
@@ -425,22 +608,71 @@ export function createShardfluxMcpServer(config, opts = {}) {
425
608
  {
426
609
  name: 'workspace_status',
427
610
  title: 'Workspace status',
428
- description: 'Current state of one workspace (ready, observed/desired state, grants, pending reason) and its most recent operations.',
611
+ description: 'Current state of one workspace (ready, mode, observed/desired state, grants, pending reason; tree_revision for a file-first workspace) and its most recent operations.',
429
612
  inputSchema: obj({ workspace_key: workspaceKeyProp(pinned) }, keyRequired),
430
613
  annotations: { readOnlyHint: true, openWorldHint: false },
431
614
  run: async (args, call) => {
432
- const ws = await resolve(keyOf(args), { includeDeleted: true, signal: call.signal });
615
+ const key = keyOf(args);
616
+ const ws = await resolve(key, { includeDeleted: true, signal: call.signal });
433
617
  await ws.refresh();
618
+ noteMode(key, ws);
434
619
  const ops = await cloud.workspaces.operations(ws.id, { limit: 5 });
435
620
  return { workspace: summarizeWorkspace(ws.data), recent_operations: ops.data.map(summarizeOperation) };
436
621
  },
437
622
  },
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)),
623
+ {
624
+ name: 'workspace_suspend',
625
+ title: 'Suspend workspace',
626
+ description: 'Suspend a running workspace (durable full-state checkpoint; processes stop, files and state are kept, and the next tool call resumes it). Returns the suspend operation (with wait: once finished, and its timing). ' +
627
+ 'With after_seconds the suspend is deferred instead: the workspace is suspended once it has been idle that long. Use it when you finish your work (the end of your turn), e.g. after_seconds 60, so the workspace stops using RAM soon after instead of waiting for its idle timeout. ' +
628
+ 'Your next tool call on the workspace cancels it; a command still running or a keepalive postpones it until after_seconds after it ends. Returns suspend_request (not_before: the earliest suspend). Not for file-first workspaces (never suspended).',
629
+ inputSchema: obj({
630
+ workspace_key: workspaceKeyProp(pinned),
631
+ after_seconds: {
632
+ type: 'integer',
633
+ minimum: 30,
634
+ maximum: 3600,
635
+ description: 'Suspend once the workspace has been idle this many seconds (30-3600) instead of now. Omit to suspend now.',
636
+ },
637
+ wait: { type: 'boolean', description: 'Wait until the suspend finishes (default false). Not with after_seconds.' },
638
+ }, keyRequired),
639
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
640
+ run: async (args, call) => {
641
+ const afterSeconds = typeof args.after_seconds === 'number' ? args.after_seconds : undefined;
642
+ if (afterSeconds !== undefined && args.wait === true) {
643
+ throw new ToolError('invalid_arguments', 'wait does not apply with after_seconds: the suspend happens later, once the workspace has been idle. Omit wait, or omit after_seconds to suspend now.');
644
+ }
645
+ const key = keyOf(args);
646
+ const ws = await resolve(key, { signal: call.signal });
647
+ if (ws.mode === 'file_first') {
648
+ throw notForMode(ws, 'suspend', 'api', `workspace_suspend is not available for the file-first workspace "${key}": nothing runs between its exec calls, so it is never suspended; its files persist as they are.`);
649
+ }
650
+ if (afterSeconds === undefined) {
651
+ const op = await cloud.workspaces.suspend(ws.id, lifecycleOpts(args, call));
652
+ call.operationId = op.id;
653
+ return { operation: summarizeOperation(op) };
654
+ }
655
+ const r = await ws.suspendWhenIdle({ afterSeconds });
656
+ if (r.operation)
657
+ call.operationId = r.operation.id;
658
+ return {
659
+ suspend_request: r.suspendRequest,
660
+ operation: r.operation ? summarizeOperation(r.operation) : null,
661
+ workspace: summarizeWorkspace(ws.data),
662
+ message: r.suspendRequest
663
+ ? `Suspend scheduled: the workspace is suspended once it has been idle for ${r.suspendRequest.after_seconds} s (not before ${r.suspendRequest.not_before}). Your next tool call on it cancels this; a running command or keepalive postpones it.`
664
+ : `A suspend is already in progress (operation ${r.operation.id}, ${r.operation.state}); nothing was scheduled.`,
665
+ };
666
+ },
667
+ },
668
+ lifecycleTool('workspace_resume', 'Resume workspace', 'Resume a suspended workspace. Returns the resume operation (with wait: once finished, and its timing). Not for file-first workspaces (never suspended).', 'resume',
669
+ // Through the server's handle: a waited resume is held until the workspace runs (contracts §22.6) and the handle
670
+ // keeps the view and this server's tool token, so the next workspace tool starts at once.
671
+ (ws, o) => ws.resume({ ...o, agentLabel: config.agentLabel })),
440
672
  {
441
673
  name: 'workspace_fork',
442
674
  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).',
675
+ 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). Not for file-first workspaces.',
444
676
  inputSchema: obj({
445
677
  workspace_key: workspaceKeyProp(pinned),
446
678
  new_key: { type: 'string', minLength: 1, maxLength: 200, description: 'Key of the new workspace.' },
@@ -449,7 +681,11 @@ export function createShardfluxMcpServer(config, opts = {}) {
449
681
  }, [...keyRequired, 'new_key']),
450
682
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
451
683
  run: async (args, call) => {
452
- const source = await resolve(keyOf(args), { signal: call.signal });
684
+ const key = keyOf(args);
685
+ const source = await resolve(key, { signal: call.signal });
686
+ if (source.mode === 'file_first') {
687
+ throw notForMode(source, 'fork', 'api', `workspace_fork is not available for the file-first workspace "${key}": file-first workspaces cannot be forked. To copy its files, open a new file-first workspace and write them there.`);
688
+ }
453
689
  const caps = capsFrom(args);
454
690
  const res = await cloud.workspaces.fork(source.id, { key: String(args.new_key), ...(caps ? { caps } : {}) }, lifecycleOpts(args, call));
455
691
  call.operationId = res.operation.id;
@@ -540,7 +776,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
540
776
  {
541
777
  name: 'usage_summary',
542
778
  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.',
779
+ description: 'Usage of the organization in the current billing period: each meter’s raw and billable quantity; each allowance’s included, used and remaining amount and cap_state (overage: past the allowance while opt-in overage is on, charged under the spend cap); allowance_exhausted with exhausted_reason; and spend_cap (opt-in overage: state, cap_minor, effective_cap_minor, charges_minor, remaining_minor, lines per allowance, projected_reached_at; amounts in cents). While an allowance is used up, opens and resumes fail with code allowance_exhausted and reason allowance_used (upgrade, or turn on overage), overage_paused (a plan payment is past due) or spend_cap_reached (raise the cap or upgrade): an owner or billing member acts in the console, and retrying does not help.',
544
780
  inputSchema: obj({ organization_id: { type: 'string', minLength: 36, maxLength: 36, description: 'Organization id (default: the API key’s organization).' } }),
545
781
  annotations: { readOnlyHint: true, openWorldHint: false },
546
782
  run: async (args, call) => {
@@ -551,7 +787,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
551
787
  },
552
788
  },
553
789
  ];
554
- function lifecycleTool(name, title, description, start) {
790
+ function lifecycleTool(name, title, description, operation, start) {
555
791
  return {
556
792
  name,
557
793
  title,
@@ -559,16 +795,80 @@ export function createShardfluxMcpServer(config, opts = {}) {
559
795
  inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), wait: { type: 'boolean', description: 'Wait until the operation finishes (default false).' } }, keyRequired),
560
796
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
561
797
  run: async (args, call) => {
562
- const ws = await resolve(keyOf(args), { signal: call.signal });
563
- const op = await start(ws.id, lifecycleOpts(args, call));
798
+ const key = keyOf(args);
799
+ const ws = await resolve(key, { signal: call.signal });
800
+ if (ws.mode === 'file_first') {
801
+ throw notForMode(ws, operation, 'api', `${name} is not available for the file-first workspace "${key}": nothing runs between its exec calls, so it is never suspended and never needs resuming; its files persist as they are.`);
802
+ }
803
+ const op = await start(ws, lifecycleOpts(args, call));
564
804
  call.operationId = op.id;
565
805
  return { operation: summarizeOperation(op) };
566
806
  },
567
807
  };
568
808
  }
809
+ // ---- feedback (0.4.0) --------------------------------------------------------------------------
810
+ /** Who is reporting: the MCP client's name/version from initialize, else a non-default agent label. */
811
+ const reportingAgent = () => {
812
+ const c = server.getClientVersion();
813
+ if (c?.name)
814
+ return (c.version ? `${c.name}/${c.version}` : c.name).slice(0, 100);
815
+ return config.agentLabel !== 'mcp' ? config.agentLabel : undefined;
816
+ };
817
+ management.push({
818
+ name: 'send_feedback',
819
+ title: 'Send feedback',
820
+ description: `Send feedback straight to the Shardflux founder, who reads every message. ${FEEDBACK_USE} Categories: bug (something failed or behaved wrongly), confusing (an error, doc, name or output was unclear or misleading), missing (a capability, option or template you needed does not exist), idea, praise (something worked well), other. Rate limited per API key (rate_limited with details.retry_after_seconds); the same message within 24 hours is recorded once (duplicate: true).`,
821
+ inputSchema: obj({
822
+ message: { type: 'string', minLength: 1, maxLength: FEEDBACK_MESSAGE_MAX_LENGTH, description: 'What you did, what happened and what you expected (1-8000 characters). Leave secrets out.' },
823
+ category: { type: 'string', enum: [...FEEDBACK_CATEGORIES], description: 'bug, confusing, missing, idea, praise or other.' },
824
+ workspace: { type: 'string', minLength: 1, maxLength: 200, description: pinned ? `Workspace key or id it is about (default "${pinned}").` : 'Workspace key or id it is about.' },
825
+ request_id: { type: 'string', minLength: 1, maxLength: 200, description: 'request_id from the error, so the founder can find the logs.' },
826
+ error_code: { type: 'string', minLength: 1, maxLength: 100, description: 'The error code seen (error.code, or details.error_code of a failed operation), e.g. capacity_unavailable.' },
827
+ command: { type: 'string', minLength: 1, maxLength: 2000, description: 'The tool call (name and arguments) or command that led to it.' },
828
+ }, ['message', 'category']),
829
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
830
+ run: async (args) => {
831
+ const message = String(args.message).trim();
832
+ if (message.length === 0)
833
+ throw new ToolError('invalid_arguments', 'message is empty: say what happened and what you expected.');
834
+ const context = { client: `shardflux-mcp/${MCP_SERVER_VERSION}` };
835
+ const agent = reportingAgent();
836
+ if (agent)
837
+ context.agent = agent;
838
+ const workspace = typeof args.workspace === 'string' ? args.workspace : pinned;
839
+ if (workspace !== undefined)
840
+ context.workspace = workspace;
841
+ if (typeof args.request_id === 'string')
842
+ context.requestId = args.request_id;
843
+ if (typeof args.error_code === 'string')
844
+ context.errorCode = args.error_code;
845
+ if (typeof args.command === 'string')
846
+ context.command = args.command;
847
+ const r = await cloud.sendFeedback({ message, category: args.category, context });
848
+ return {
849
+ id: r.id,
850
+ received_at: r.receivedAt,
851
+ duplicate: r.duplicate,
852
+ note: r.duplicate ? 'The same message was already received in the last 24 hours; it was not emailed again.' : 'Delivered to the Shardflux founder. Keep sending feedback as you work.',
853
+ };
854
+ },
855
+ });
569
856
  // ---- 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) => {
857
+ const readOnly = new Set(['read_file', 'list_files', 'search_files', 'list_processes', 'terminal_read', 'git_status', 'browser_screenshot', 'browser_content']);
858
+ const execDeadline = ` Here timeout_ms defaults to, and is capped by, this server's per-call deadline (${ceiling} ms) minus 2 s.`;
859
+ /**
860
+ * exec's description as listed: the SDK's for the workspace's mode (pinned servers), or the processful one plus
861
+ * what differs on a file-first workspace (unpinned servers: every call names its workspace).
862
+ */
863
+ const execDescription = (sdkDescription, listing) => {
864
+ if (listing === 'file_first')
865
+ return `${sdkDescription}${execDeadline} An execution cannot be canceled: if the deadline passes first it continues server side (the error names its execution_id) and the next exec waits for it.`;
866
+ if (listing === 'processful')
867
+ return `${sdkDescription}${execDeadline}`;
868
+ return `${sdkDescription} On a file-first workspace (workspace_open mode "file_first") each call instead runs in a fresh VM on the workspace’s files: only files under /home/user persist between calls, and the result adds execution_id, state, tree_revision and changed (the paths the command added, modified or deleted).${execDeadline}`;
869
+ };
870
+ /** A workspace tool: the SDK's definition, `workspace_key` (and `timeout_ms`), run through the SDK tool of the workspace's mode. */
871
+ const workspaceDef = (sdk, description) => {
572
872
  const own = sdk.parameters.properties ?? {};
573
873
  const hasTimeout = 'timeout_ms' in own;
574
874
  const inputSchema = {
@@ -579,7 +879,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
579
879
  return {
580
880
  name: sdk.name,
581
881
  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,
882
+ description,
583
883
  inputSchema,
584
884
  permission: sdk.permission,
585
885
  annotations: {
@@ -599,23 +899,42 @@ export function createShardfluxMcpServer(config, opts = {}) {
599
899
  sdkArgs.timeout_ms = Math.max(1000, cmdTimeout - 2000);
600
900
  }
601
901
  const ws = await resolve(key, { signal: call.signal });
902
+ // The SDK's tools for the workspace's mode: a file-first workspace has exec (as executions) and the file tools.
903
+ // A file-first execution cannot be canceled; its id is kept so a deadline error can name it.
904
+ const tool = workspaceTools(ws, {
905
+ agentLabel: config.agentLabel,
906
+ tools: [...ALL_TOOL_PERMISSIONS],
907
+ wake: wakeFor(ws),
908
+ transitionTimeoutMs: wakeTimeoutMs,
909
+ onExecution: (id) => {
910
+ call.executionId = id;
911
+ },
912
+ }).find((t) => t.name === sdk.name);
913
+ if (!tool) {
914
+ // A tool the workspace's mode does not have: refused like the SDK refuses such a call, before any request.
915
+ if (ws.mode === 'file_first') {
916
+ throw notForMode(ws, sdk.name, 'cell', `${sdk.name} is not available for the file-first workspace "${key}": it has no VM between exec calls. ${FILE_FIRST_INSTEAD[sdk.permission] ?? 'Use exec and the file tools.'}`);
917
+ }
918
+ throw new ToolError('internal_error', `SDK tool ${sdk.name} is missing`);
919
+ }
602
920
  // A wake during the call (the workspace was suspended) is timed; a call without one has no timing.
603
921
  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
922
  try {
608
923
  return await tool.execute(sdkArgs, { signal: call.signal });
609
924
  }
610
925
  catch (err) {
611
926
  // A deleted or foreign workspace: forget the handle so the next call resolves the key again.
612
927
  if (err instanceof ShardfluxApiError && err.source === 'api' && err.status === 404)
613
- handles.delete(key);
928
+ forget(key);
614
929
  throw err;
615
930
  }
616
931
  },
617
932
  };
618
- });
933
+ };
934
+ // Every call runs through these (the processful definitions: every tool; exec's parameters are the same in both
935
+ // modes); a pinned file-first server lists the file-first definitions instead.
936
+ const workspaceDefs = sdkToolDefinitions(ALL_TOOL_PERMISSIONS, 'processful').map((sdk) => workspaceDef(sdk, sdk.name === 'exec' ? execDescription(sdk.description, pinned === undefined ? 'any' : 'processful') : sdk.description));
937
+ const fileFirstDefs = sdkToolDefinitions(ALL_TOOL_PERMISSIONS, 'file_first').map((sdk) => workspaceDef(sdk, sdk.name === 'exec' ? execDescription(sdk.description, 'file_first') : sdk.description));
619
938
  const registry = new Map([...management, ...workspaceDefs].map((d) => [d.name, d]));
620
939
  const toMcpTool = (d) => ({
621
940
  name: d.name,
@@ -625,16 +944,26 @@ export function createShardfluxMcpServer(config, opts = {}) {
625
944
  annotations: { title: d.title, ...d.annotations },
626
945
  });
627
946
  // ---- 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;
947
+ // listChanged: a pinned server's list follows its workspace's mode (notifications/tools/list_changed).
948
+ const server = new Server({ name: 'shardflux', title: 'Shardflux workspaces', version: MCP_SERVER_VERSION }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
949
+ const permittedTools = async () => {
631
950
  try {
632
- permitted = (await principal()).api_key?.tool_permissions ?? [];
951
+ return (await principal()).api_key?.tool_permissions ?? [];
633
952
  }
634
953
  catch (err) {
635
954
  log('warn', 'could not read the API key’s tool permissions; listing every workspace tool', { error: describeToolError(err) });
955
+ return ALL_TOOL_PERMISSIONS;
636
956
  }
637
- return { tools: [...management, ...workspaceDefs.filter((d) => d.permission !== undefined && permitted.includes(d.permission))].map(toMcpTool) };
957
+ };
958
+ server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
959
+ // Unpinned: every tool (each call names its workspace; a tool its mode lacks is refused). Pinned: its mode's tools.
960
+ const [permitted, mode] = await Promise.all([permittedTools(), pinned === undefined ? undefined : pinnedListMode(pinned, AbortSignal.any([extra.signal, AbortSignal.timeout(ceiling)]))]);
961
+ if (mode !== undefined)
962
+ listedMode = mode;
963
+ const fileFirst = mode === 'file_first';
964
+ const mgmt = fileFirst ? management.filter((d) => !VM_ONLY_MANAGEMENT.has(d.name)) : management;
965
+ const tools = (fileFirst ? fileFirstDefs : workspaceDefs).filter((d) => d.permission !== undefined && permitted.includes(d.permission));
966
+ return { tools: [...mgmt, ...tools].map(toMcpTool) };
638
967
  });
639
968
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
640
969
  const name = request.params.name;
@@ -669,18 +998,41 @@ export function createShardfluxMcpServer(config, opts = {}) {
669
998
  }
670
999
  catch (err) {
671
1000
  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 } : {}) });
1001
+ // The client canceled: the SDK request/wait was aborted; no response is sent for a canceled request. A
1002
+ // file-first execution is not canceled with it (it cannot be): the log names it.
1003
+ log('info', 'tool call canceled by the client', {
1004
+ tool: name,
1005
+ outcome: 'canceled',
1006
+ ms: Date.now() - started,
1007
+ ...(call.operationId ? { operation_id: call.operationId } : {}),
1008
+ ...(call.executionId ? { execution_id: call.executionId } : {}),
1009
+ });
674
1010
  throw new McpError(ErrorCode.RequestTimeout, 'canceled');
675
1011
  }
676
1012
  // A structured error (API refusal, the SDK's own wait timeout) wins; a bare abort after the deadline is a timeout.
677
1013
  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 });
1014
+ const info = describeToolError(deadline.aborted && abortish ? new DOMException('deadline', 'TimeoutError') : err, { operationId: call.operationId, timeoutMs, executionId: call.executionId });
679
1015
  info.message = redact(info.message, config.apiKey);
680
1016
  if (info.code === 'internal_error')
681
1017
  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);
1018
+ log('info', 'tool call', {
1019
+ tool: name,
1020
+ outcome: info.code,
1021
+ ms: Date.now() - started,
1022
+ ...(info.reason ? { reason: info.reason } : {}),
1023
+ ...(info.request_id ? { request_id: info.request_id } : {}),
1024
+ ...(info.execution_id ? { execution_id: info.execution_id } : {}),
1025
+ });
1026
+ // A failure suggests send_feedback; a failed send_feedback says how to still reach the founder.
1027
+ const s = info.details?.retry_after_seconds;
1028
+ const siblings = name === 'send_feedback'
1029
+ ? info.code === 'invalid_arguments'
1030
+ ? {}
1031
+ : { hint: `The feedback was not sent; ${info.code === 'rate_limited' ? `retry ${typeof s === 'number' ? `in ${s} s` : 'later'}, or ` : ''}email it to ${FEEDBACK_EMAIL}.` }
1032
+ : suggestsFeedback(info)
1033
+ ? { feedback: feedbackSuggestion(info) }
1034
+ : {};
1035
+ return errorResult(info, call.timed && call.timing ? compactTiming(call.timing) : undefined, siblings);
684
1036
  }
685
1037
  });
686
1038
  return server;