@shardflux/mcp 0.3.0 → 0.4.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/dist/server.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * - templates (0.3.0): template_get (versions, settings, a version's recipe), template_languages and
8
8
  * template_build (a recipe v2 object or a template.yaml path inside the
9
9
  * server's working directory; local `from` paths are uploaded by the SDK);
10
+ * - send_feedback (0.4.0): feedback straight to the Shardflux founder; the instructions, its description and
11
+ * the `feedback` field of error results ask agents to use it while they work;
10
12
  * - the SDK's workspace tools (`workspaceTools()`: exec, files, processes,
11
13
  * terminal, git, browser), published with the SDK's own JSON Schemas plus
12
14
  * `workspace_key` (and `timeout_ms` where the SDK schema has none).
@@ -14,38 +16,72 @@
14
16
  * There is no agent loop and no local workspace directory: every call is one
15
17
  * SDK request/wait against the remote workspace. Workspace tools wake a
16
18
  * suspended workspace on use (SHARDFLUX_WAKE, bounded by
17
- * SHARDFLUX_WAKE_TIMEOUT_MS and the call's deadline). Each call has a deadline
19
+ * SHARDFLUX_WAKE_TIMEOUT_MS and the call's deadline), and each workspace tool
20
+ * call first sends the wake hint (the SDK tool runner's `workspace.hint()`,
21
+ * fire-and-forget) so a parked workspace restores while the call is prepared,
22
+ * except read_file, list_files and search_files, which a sleeping workspace
23
+ * answers from its disk without waking (contracts §26.4). Each call has a deadline
18
24
  * (`timeout_ms`, clamped to SHARDFLUX_MCP_TOOL_TIMEOUT_MS) and MCP cancellation
19
25
  * (notifications/cancelled -> `extra.signal`) aborts the underlying SDK
20
26
  * request or wait. Failures come back as `isError` tool results carrying the
21
27
  * API error code. stdout is the protocol; logs go to stderr. Opens, waits and
22
28
  * wakes carry the SDK's lifecycle timing, compacted (`compactTiming`).
29
+ *
30
+ * File-first workspaces (0.4.0; contracts §29: `workspace_open` `mode:
31
+ * "file_first"`) have files and executions only. A pinned server lists the
32
+ * tools of its workspace's mode (the key's mode once it resolves, else
33
+ * SHARDFLUX_WORKSPACE_MODE, else processful) and sends
34
+ * `notifications/tools/list_changed` when the known mode changes what it
35
+ * listed; an unpinned server lists every tool, and a call of a tool the named
36
+ * workspace's mode lacks is the SDK's NotSupportedForModeError
37
+ * (`not_supported_for_mode`) before any request.
38
+ *
39
+ * At startup (0.4.0) the server asks GET /v1/client-versions whether
40
+ * @shardflux/mcp at MCP_SERVER_VERSION is current and logs one `warn` line
41
+ * when it is not (`checkServerVersion`); the SDK client it builds runs no
42
+ * check of its own (`versionCheck: false`). Account-level actions are not
43
+ * tools (the project API key cannot do them): the server instructions point
44
+ * the agent at the `shard` CLI.
23
45
  */
24
46
  import { AsyncLocalStorage } from 'node:async_hooks';
25
47
  import { resolve as resolvePath } from 'node:path';
26
48
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
27
49
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
28
- import { SDK_VERSION, Shardflux, ShardfluxApiError, TemplateBuildTimeoutError, TemplateFileError, validateArgs, workspaceTools } from '@shardflux/sdk';
50
+ import { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, Shardflux, ShardfluxApiError, TemplateBuildTimeoutError, TemplateFileError, checkClientVersion, validateArgs, versionCheckDisabledByEnv, workspaceTools } from '@shardflux/sdk';
29
51
  import { parse as parseYaml } from 'yaml';
30
52
  import { DEFAULT_WAKE_TIMEOUT_MS } from "./config.js";
31
53
  import { ToolError, describeToolError, redact } from "./errors.js";
32
54
  import { makeFetch } from "./http.js";
33
- export const MCP_SERVER_VERSION = '0.3.0';
55
+ export const MCP_SERVER_VERSION = '0.4.0';
56
+ /** The package this server is distributed as: its entry in GET /v1/client-versions (contracts §30.4). */
57
+ export const MCP_PACKAGE = '@shardflux/mcp';
58
+ const USER_AGENT = `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`;
34
59
  export const ALL_TOOL_PERMISSIONS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
35
60
  const OBSERVED_STATES = ['creating', 'starting', 'running', 'suspending', 'suspended', 'resuming', 'forking', 'stopping', 'failed', 'deleting', 'deleted'];
36
61
  /**
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.
62
+ * The SDK's workspace tool definitions for a workspace mode, without a workspace. Given `tools` and `mode`,
63
+ * `workspaceTools()` only touches the workspace inside `execute`, which is never called on these: the proxy throws on
64
+ * any access, so a change in the SDK that did would fail loudly. `file_first` (0.4.0): the SDK offers only the exec
65
+ * and files tools, and exec is its file-first definition (executions: the result adds execution_id, state,
66
+ * tree_revision and changed).
40
67
  */
41
- export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS) {
68
+ export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS, mode = 'processful') {
42
69
  const noWorkspace = new Proxy({}, {
43
70
  get(_t, prop) {
44
71
  throw new Error(`workspace tool definitions must not access workspace.${String(prop)}`);
45
72
  },
46
73
  });
47
- return workspaceTools(noWorkspace, { tools: [...tools] });
74
+ return workspaceTools(noWorkspace, { tools: [...tools], mode });
48
75
  }
76
+ /** Management tools that need a workspace VM or an operation: not listed for a pinned file-first workspace. */
77
+ const VM_ONLY_MANAGEMENT = new Set(['workspace_suspend', 'workspace_resume', 'workspace_fork', 'operation_wait']);
78
+ /** What to use instead of a workspace tool a file-first workspace does not have, by the tool's permission. */
79
+ const FILE_FIRST_INSTEAD = {
80
+ process: 'Nothing runs between exec calls: run the program, and whatever needs it, within one exec command.',
81
+ pty: 'Use exec instead: each call runs one command to its end (pass input through its stdin argument).',
82
+ git: 'Run git inside exec instead, e.g. exec "git clone <url> /home/user/repo"; only files under /home/user persist.',
83
+ browser: 'Run a headless browser within one exec command instead, printing what you need or saving it under /home/user.',
84
+ };
49
85
  function timeoutProp(ceiling) {
50
86
  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
87
  }
@@ -69,12 +105,19 @@ function capsFrom(args) {
69
105
  c.disk_gib = args.disk_gib;
70
106
  return Object.keys(c).length ? c : undefined;
71
107
  }
72
- /** What tools return for a workspace (no tokens or other credentials). */
108
+ /**
109
+ * What tools return for a workspace (no tokens or other credentials). `mode` (0.4.0) is `processful` or `file_first`
110
+ * (a view without it, from an older API, is processful); `tree_revision`, the latest revision of the file tree, only
111
+ * for file-first workspaces (the API reports 0 for every processful one).
112
+ */
73
113
  export function summarizeWorkspace(v) {
114
+ const mode = v.mode ?? 'processful';
74
115
  return {
75
116
  id: v.id,
76
117
  key: v.workspace_key,
77
118
  ready: v.observed_state === 'running' && v.desired_state === 'running' && v.deleted_at === null,
119
+ mode,
120
+ ...(mode === 'file_first' ? { tree_revision: v.tree_revision } : {}),
78
121
  observed_state: v.observed_state,
79
122
  desired_state: v.desired_state,
80
123
  template: { slug: v.template.slug, version: v.template.version },
@@ -189,19 +232,84 @@ export function okResult(value) {
189
232
  }
190
233
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: isObject(value) ? value : { result: value } };
191
234
  }
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 };
235
+ /**
236
+ * `timing` (optional): where the failed open/wait/wake spent its time, next to `error`. `extra` (0.4.0): more sibling
237
+ * fields, e.g. `feedback` (how to report the failure with send_feedback) or `hint`.
238
+ */
239
+ export function errorResult(info, timing, extra = {}) {
240
+ const body = { error: info, ...(timing ? { timing } : {}), ...extra };
195
241
  return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body, isError: true };
196
242
  }
243
+ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
244
+ /**
245
+ * When to use send_feedback, for the agent: the same words as `shard feedback --help`, in the server instructions and
246
+ * the tool description. It has to make an agent actually call it while it works.
247
+ */
248
+ 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.';
249
+ /**
250
+ * No send_feedback suggestion for the expected flow: argument, configuration or credential problems of the call itself,
251
+ * a wait that gave up while the work continues, and refusals whose error names the exact next step (the same list as
252
+ * the CLI's `feedback:` line).
253
+ */
254
+ const NO_FEEDBACK_CODES = new Set(['invalid_arguments', 'workspace_pinned', 'unauthenticated', 'validation_failed', 'timeout']);
255
+ const NO_FEEDBACK_REASONS = new Set([
256
+ 'revision_mismatch',
257
+ 'tree_revision_mismatch',
258
+ 'edit_not_found',
259
+ 'edit_ambiguous',
260
+ 'edit_not_text',
261
+ 'execution_id_reused',
262
+ 'execution_in_progress',
263
+ 'exec_failed_to_start',
264
+ 'mode_mismatch',
265
+ 'not_supported_for_mode',
266
+ 'outside_tree_root',
267
+ 'layout_unsupported',
268
+ 'host_feature_unavailable',
269
+ ]);
270
+ const suggestsFeedback = (info) => !NO_FEEDBACK_CODES.has(info.code) && !(info.reason !== undefined && NO_FEEDBACK_REASONS.has(info.reason));
271
+ /** The `feedback` field of a failed call (0.4.0+): how to report it, with the error's request id and code. */
272
+ export function feedbackSuggestion(info) {
273
+ const code = (info.code === 'operation_failed' || info.code === 'execution_failed') && typeof info.details?.error_code === 'string' ? info.details.error_code : info.code;
274
+ const args = [`category "bug"`, ...(info.request_id ? [`request_id "${info.request_id}"`] : []), `error_code "${code}"`];
275
+ return `Unexpected or unclear? Call send_feedback with ${args.join(', ')} and a short message saying what you expected.`;
276
+ }
197
277
  const INSTRUCTIONS = [
198
278
  'Shardflux persistent remote workspaces (Linux computers that keep files, packages and processes between sessions).',
199
279
  '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.',
280
+ '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.',
201
281
  '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
282
  'Opens, waits and wakes add timing (phases, server queued/run time) saying where the time went.',
203
283
  '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).',
284
+ '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.',
285
+ '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).',
286
+ '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).',
287
+ '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.',
288
+ `Feedback: send_feedback goes straight to the Shardflux founder, who reads every message. ${FEEDBACK_USE}`,
204
289
  ].join(' ');
290
+ /**
291
+ * The startup version check (0.4.0; contracts §30.4): one GET <api>/v1/client-versions through the server's fetch
292
+ * (the SDK's checkClientVersion: 3 s timeout, never throws) for @shardflux/mcp at MCP_SERVER_VERSION. Outdated or
293
+ * unsupported: one `warn` line whose message is the notice (`@shardflux/mcp 0.4.0 is outdated: 0.5.0 is available.
294
+ * Update: <upgrade command>`) plus the status fields. Anything else (current; unknown: not listed, `latest` null while
295
+ * the package is not distributed, or the request failed) is a `debug` line only. createShardfluxMcpServer runs it in
296
+ * the background and never awaits it: the handshake and every tool call proceed without it. Resolves to the status;
297
+ * never rejects (a logger that throws is ignored).
298
+ */
299
+ export async function checkServerVersion(apiUrl, f, log) {
300
+ const s = await checkClientVersion({ baseUrl: apiUrl, fetch: f, package: MCP_PACKAGE, version: MCP_SERVER_VERSION, ecosystem: 'npm', userAgent: USER_AGENT });
301
+ const fields = { status: s.status, package: s.package, current: s.current, latest: s.latest, minimum_supported: s.minimumSupported, upgrade_command: s.upgradeCommand };
302
+ try {
303
+ if ((s.status === 'outdated' || s.status === 'unsupported') && s.message)
304
+ log('warn', s.message, { ...fields, release_notes_url: s.releaseNotesUrl });
305
+ else
306
+ log('debug', 'version check', fields);
307
+ }
308
+ catch {
309
+ // The check never fails the server.
310
+ }
311
+ return s;
312
+ }
205
313
  /** POST <cell>/v1/workspaces/{id}/exec/{session}/cancel: the cleanup request the SDK sends when a call is aborted. */
206
314
  function isExecCancel(input, init) {
207
315
  const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
@@ -269,25 +377,34 @@ export function createShardfluxMcpServer(config, opts = {}) {
269
377
  const cloud = new Shardflux({
270
378
  apiKey: config.apiKey,
271
379
  baseUrl: config.apiUrl,
272
- userAgent: `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
380
+ userAgent: USER_AGENT,
273
381
  fetch: callFetch,
274
382
  sleep: callSleep,
275
383
  onProgress,
384
+ // The server checks its own package at startup (below). The SDK's check would name @shardflux/sdk, which a
385
+ // server user cannot update on its own (it ships inside the server), and process.emitWarning would print a
386
+ // non-JSON line on stderr.
387
+ versionCheck: false,
276
388
  });
389
+ // In the background: the handshake and tool calls never wait for it (an unhandled rejection would end the process).
390
+ if (opts.versionCheck !== false && !versionCheckDisabledByEnv(opts.env))
391
+ checkServerVersion(config.apiUrl, baseFetch, log).catch(() => undefined);
277
392
  const pinned = config.workspaceKey;
278
393
  const ceiling = config.toolTimeoutMs;
279
394
  const wakeTimeoutMs = Math.min(config.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS, ceiling);
280
395
  /**
281
396
  * Wake on use for a workspace's tools: `workspace.wake()` within the SDK's transition budget (SHARDFLUX_WAKE_TIMEOUT_MS)
282
397
  * 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.
398
+ * than a bare deadline. null when SHARDFLUX_WAKE=off: the refusal (workspace_not_running) comes back instead. The
399
+ * wake's held resume (contracts §22.6) returns the token of this server's tools (its agent label), so the woken call
400
+ * runs at once with it.
284
401
  */
285
402
  const wakeFor = (ws) => config.wake === false
286
403
  ? null
287
404
  : (timeoutMs, signal) => {
288
405
  const deadline = ambient.getStore()?.deadline;
289
406
  const left = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now() - 250);
290
- return ws.wake({ timeoutMs: Math.max(1, left), ...(signal ? { signal } : {}) });
407
+ return ws.wake({ timeoutMs: Math.max(1, left), ...(signal ? { signal } : {}), agentLabel: config.agentLabel });
291
408
  };
292
409
  // ---- principal and workspace resolution ----------------------------------------------------
293
410
  let meCache;
@@ -311,25 +428,71 @@ export function createShardfluxMcpServer(config, opts = {}) {
311
428
  throw new ToolError('invalid_arguments', 'workspace_key is required.');
312
429
  return given;
313
430
  };
431
+ // ---- the pinned workspace's mode (0.4.0) -----------------------------------------------------
432
+ /** The mode of the pinned key's live workspace, once a lookup, open or tool call has seen it. */
433
+ let pinnedMode;
434
+ /** The mode the last tools/list answer of a pinned server was built for (undefined before the first). */
435
+ let listedMode;
436
+ /**
437
+ * Records the mode of a live workspace. For the pinned key, a mode other than the one the client's tool list was
438
+ * built for sends notifications/tools/list_changed (once per change), so the client lists the tools again.
439
+ */
440
+ const noteMode = (key, ws) => {
441
+ if (pinned === undefined || key !== pinned || ws.data.deleted_at !== null)
442
+ return;
443
+ pinnedMode = ws.mode;
444
+ if (listedMode === undefined || listedMode === ws.mode)
445
+ return;
446
+ log('info', 'tool list changed', { pinned_workspace_key: pinned, listed_mode: listedMode, mode: ws.mode });
447
+ listedMode = ws.mode;
448
+ server.sendToolListChanged().catch((err) => log('warn', 'could not send notifications/tools/list_changed', { error: err instanceof Error ? err.message : String(err) }));
449
+ };
450
+ const forget = (key) => {
451
+ handles.delete(key);
452
+ if (key === pinned)
453
+ pinnedMode = undefined;
454
+ };
314
455
  /**
315
456
  * The workspace a key names, across every lifetime and purpose (sessions, drafts and test instances are hidden from
316
457
  * 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.
458
+ * key, and the key then opens a new workspace (contracts §19.11). Null when the key names none.
318
459
  */
319
- const resolve = async (key, opts2) => {
460
+ const lookup = async (key, opts2) => {
320
461
  const cached = handles.get(key);
321
462
  if (cached)
322
463
  return cached;
323
464
  const w = await cloud.workspaces.findByKey(key, { includeDeleted: opts2.includeDeleted === true, signal: opts2.signal });
324
465
  if (opts2.signal.aborted)
325
466
  throw opts2.signal.reason;
326
- if (w) {
327
- if (w.data.deleted_at === null)
328
- handles.set(key, w);
329
- return w;
467
+ if (w && w.data.deleted_at === null) {
468
+ handles.set(key, w);
469
+ noteMode(key, w);
330
470
  }
471
+ return w;
472
+ };
473
+ const resolve = async (key, opts2) => {
474
+ const w = await lookup(key, opts2);
475
+ if (w)
476
+ return w;
331
477
  throw new ToolError('not_found', `No workspace with key "${key}" in this project. Create or reconnect it with workspace_open.`);
332
478
  };
479
+ /**
480
+ * The mode a pinned server lists tools for: its workspace's (looked up when not known yet), else
481
+ * SHARDFLUX_WORKSPACE_MODE (what workspace_open will create), else processful (the API's default for a new key).
482
+ */
483
+ const pinnedListMode = async (key, signal) => {
484
+ if (pinnedMode === undefined) {
485
+ try {
486
+ await lookup(key, { signal });
487
+ }
488
+ catch (err) {
489
+ 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' });
490
+ }
491
+ }
492
+ return pinnedMode ?? config.workspaceMode ?? 'processful';
493
+ };
494
+ /** The SDK's own refusal of a call the workspace's mode does not have (no request is made). */
495
+ const notForMode = (ws, operation, source, message) => NotSupportedForModeError.local(ws.mode, operation, source, message);
333
496
  /** The SDK's wait options for a call: its own timeout fires just before the call deadline, so the result names the operation. */
334
497
  const waitOpts = (call) => ({ timeoutMs: Math.max(1, call.timeoutMs - 250), signal: call.signal });
335
498
  const wait = async (call, operationId) => {
@@ -356,7 +519,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
356
519
  {
357
520
  name: 'workspace_open',
358
521
  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.',
522
+ 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
523
  inputSchema: obj({
361
524
  workspace_key: workspaceKeyProp(pinned),
362
525
  template: {
@@ -377,6 +540,11 @@ export function createShardfluxMcpServer(config, opts = {}) {
377
540
  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
541
  },
379
542
  wait: { type: 'boolean', description: 'Wait until the workspace is ready, its start commands and services included (default true).' },
543
+ mode: {
544
+ type: 'string',
545
+ enum: ['processful', 'file_first'],
546
+ 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.'}`,
547
+ },
380
548
  }, [...keyRequired, ...(config.template ? [] : ['template'])]),
381
549
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
382
550
  run: async (args, call) => {
@@ -387,11 +555,22 @@ export function createShardfluxMcpServer(config, opts = {}) {
387
555
  const caps = capsFrom(args);
388
556
  const lifetime = args.lifetime === 'session' || args.lifetime === 'persistent' ? args.lifetime : undefined;
389
557
  const inputs = stringMap(args.inputs, 'inputs');
558
+ const mode = args.mode === 'processful' || args.mode === 'file_first' ? args.mode : config.workspaceMode;
390
559
  call.timed = true;
391
560
  // 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) });
561
+ // carries the first tool token, so the next tool call starts at once. A file-first open is ready at once.
562
+ const ws = await cloud.workspaces.open({
563
+ key,
564
+ template,
565
+ ...(caps ? { caps } : {}),
566
+ ...(lifetime ? { lifetime } : {}),
567
+ ...(inputs ? { inputs } : {}),
568
+ ...(mode ? { mode } : {}),
569
+ agentLabel: config.agentLabel,
570
+ wait: args.wait === false ? false : waitOpts(call),
571
+ });
394
572
  handles.set(key, ws);
573
+ noteMode(key, ws);
395
574
  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
575
  },
397
576
  },
@@ -425,22 +604,27 @@ export function createShardfluxMcpServer(config, opts = {}) {
425
604
  {
426
605
  name: 'workspace_status',
427
606
  title: 'Workspace status',
428
- description: 'Current state of one workspace (ready, observed/desired state, grants, pending reason) and its most recent operations.',
607
+ 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
608
  inputSchema: obj({ workspace_key: workspaceKeyProp(pinned) }, keyRequired),
430
609
  annotations: { readOnlyHint: true, openWorldHint: false },
431
610
  run: async (args, call) => {
432
- const ws = await resolve(keyOf(args), { includeDeleted: true, signal: call.signal });
611
+ const key = keyOf(args);
612
+ const ws = await resolve(key, { includeDeleted: true, signal: call.signal });
433
613
  await ws.refresh();
614
+ noteMode(key, ws);
434
615
  const ops = await cloud.workspaces.operations(ws.id, { limit: 5 });
435
616
  return { workspace: summarizeWorkspace(ws.data), recent_operations: ops.data.map(summarizeOperation) };
436
617
  },
437
618
  },
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)),
619
+ 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). Not for file-first workspaces (never suspended).', 'suspend', (ws, o) => cloud.workspaces.suspend(ws.id, o)),
620
+ 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',
621
+ // Through the server's handle: a waited resume is held until the workspace runs (contracts §22.6) and the handle
622
+ // keeps the view and this server's tool token, so the next workspace tool starts at once.
623
+ (ws, o) => ws.resume({ ...o, agentLabel: config.agentLabel })),
440
624
  {
441
625
  name: 'workspace_fork',
442
626
  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).',
627
+ 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
628
  inputSchema: obj({
445
629
  workspace_key: workspaceKeyProp(pinned),
446
630
  new_key: { type: 'string', minLength: 1, maxLength: 200, description: 'Key of the new workspace.' },
@@ -449,7 +633,11 @@ export function createShardfluxMcpServer(config, opts = {}) {
449
633
  }, [...keyRequired, 'new_key']),
450
634
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
451
635
  run: async (args, call) => {
452
- const source = await resolve(keyOf(args), { signal: call.signal });
636
+ const key = keyOf(args);
637
+ const source = await resolve(key, { signal: call.signal });
638
+ if (source.mode === 'file_first') {
639
+ 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.`);
640
+ }
453
641
  const caps = capsFrom(args);
454
642
  const res = await cloud.workspaces.fork(source.id, { key: String(args.new_key), ...(caps ? { caps } : {}) }, lifecycleOpts(args, call));
455
643
  call.operationId = res.operation.id;
@@ -551,7 +739,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
551
739
  },
552
740
  },
553
741
  ];
554
- function lifecycleTool(name, title, description, start) {
742
+ function lifecycleTool(name, title, description, operation, start) {
555
743
  return {
556
744
  name,
557
745
  title,
@@ -559,16 +747,80 @@ export function createShardfluxMcpServer(config, opts = {}) {
559
747
  inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), wait: { type: 'boolean', description: 'Wait until the operation finishes (default false).' } }, keyRequired),
560
748
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
561
749
  run: async (args, call) => {
562
- const ws = await resolve(keyOf(args), { signal: call.signal });
563
- const op = await start(ws.id, lifecycleOpts(args, call));
750
+ const key = keyOf(args);
751
+ const ws = await resolve(key, { signal: call.signal });
752
+ if (ws.mode === 'file_first') {
753
+ 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.`);
754
+ }
755
+ const op = await start(ws, lifecycleOpts(args, call));
564
756
  call.operationId = op.id;
565
757
  return { operation: summarizeOperation(op) };
566
758
  },
567
759
  };
568
760
  }
761
+ // ---- feedback (0.4.0) --------------------------------------------------------------------------
762
+ /** Who is reporting: the MCP client's name/version from initialize, else a non-default agent label. */
763
+ const reportingAgent = () => {
764
+ const c = server.getClientVersion();
765
+ if (c?.name)
766
+ return (c.version ? `${c.name}/${c.version}` : c.name).slice(0, 100);
767
+ return config.agentLabel !== 'mcp' ? config.agentLabel : undefined;
768
+ };
769
+ management.push({
770
+ name: 'send_feedback',
771
+ title: 'Send feedback',
772
+ 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).`,
773
+ inputSchema: obj({
774
+ 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.' },
775
+ category: { type: 'string', enum: [...FEEDBACK_CATEGORIES], description: 'bug, confusing, missing, idea, praise or other.' },
776
+ 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.' },
777
+ request_id: { type: 'string', minLength: 1, maxLength: 200, description: 'request_id from the error, so the founder can find the logs.' },
778
+ 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.' },
779
+ command: { type: 'string', minLength: 1, maxLength: 2000, description: 'The tool call (name and arguments) or command that led to it.' },
780
+ }, ['message', 'category']),
781
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
782
+ run: async (args) => {
783
+ const message = String(args.message).trim();
784
+ if (message.length === 0)
785
+ throw new ToolError('invalid_arguments', 'message is empty: say what happened and what you expected.');
786
+ const context = { client: `shardflux-mcp/${MCP_SERVER_VERSION}` };
787
+ const agent = reportingAgent();
788
+ if (agent)
789
+ context.agent = agent;
790
+ const workspace = typeof args.workspace === 'string' ? args.workspace : pinned;
791
+ if (workspace !== undefined)
792
+ context.workspace = workspace;
793
+ if (typeof args.request_id === 'string')
794
+ context.requestId = args.request_id;
795
+ if (typeof args.error_code === 'string')
796
+ context.errorCode = args.error_code;
797
+ if (typeof args.command === 'string')
798
+ context.command = args.command;
799
+ const r = await cloud.sendFeedback({ message, category: args.category, context });
800
+ return {
801
+ id: r.id,
802
+ received_at: r.receivedAt,
803
+ duplicate: r.duplicate,
804
+ 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.',
805
+ };
806
+ },
807
+ });
569
808
  // ---- 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) => {
809
+ const readOnly = new Set(['read_file', 'list_files', 'search_files', 'list_processes', 'terminal_read', 'git_status', 'browser_screenshot', 'browser_content']);
810
+ const execDeadline = ` Here timeout_ms defaults to, and is capped by, this server's per-call deadline (${ceiling} ms) minus 2 s.`;
811
+ /**
812
+ * exec's description as listed: the SDK's for the workspace's mode (pinned servers), or the processful one plus
813
+ * what differs on a file-first workspace (unpinned servers: every call names its workspace).
814
+ */
815
+ const execDescription = (sdkDescription, listing) => {
816
+ if (listing === 'file_first')
817
+ 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.`;
818
+ if (listing === 'processful')
819
+ return `${sdkDescription}${execDeadline}`;
820
+ 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}`;
821
+ };
822
+ /** A workspace tool: the SDK's definition, `workspace_key` (and `timeout_ms`), run through the SDK tool of the workspace's mode. */
823
+ const workspaceDef = (sdk, description) => {
572
824
  const own = sdk.parameters.properties ?? {};
573
825
  const hasTimeout = 'timeout_ms' in own;
574
826
  const inputSchema = {
@@ -579,7 +831,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
579
831
  return {
580
832
  name: sdk.name,
581
833
  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,
834
+ description,
583
835
  inputSchema,
584
836
  permission: sdk.permission,
585
837
  annotations: {
@@ -599,23 +851,42 @@ export function createShardfluxMcpServer(config, opts = {}) {
599
851
  sdkArgs.timeout_ms = Math.max(1000, cmdTimeout - 2000);
600
852
  }
601
853
  const ws = await resolve(key, { signal: call.signal });
854
+ // The SDK's tools for the workspace's mode: a file-first workspace has exec (as executions) and the file tools.
855
+ // A file-first execution cannot be canceled; its id is kept so a deadline error can name it.
856
+ const tool = workspaceTools(ws, {
857
+ agentLabel: config.agentLabel,
858
+ tools: [...ALL_TOOL_PERMISSIONS],
859
+ wake: wakeFor(ws),
860
+ transitionTimeoutMs: wakeTimeoutMs,
861
+ onExecution: (id) => {
862
+ call.executionId = id;
863
+ },
864
+ }).find((t) => t.name === sdk.name);
865
+ if (!tool) {
866
+ // A tool the workspace's mode does not have: refused like the SDK refuses such a call, before any request.
867
+ if (ws.mode === 'file_first') {
868
+ 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.'}`);
869
+ }
870
+ throw new ToolError('internal_error', `SDK tool ${sdk.name} is missing`);
871
+ }
602
872
  // A wake during the call (the workspace was suspended) is timed; a call without one has no timing.
603
873
  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
874
  try {
608
875
  return await tool.execute(sdkArgs, { signal: call.signal });
609
876
  }
610
877
  catch (err) {
611
878
  // A deleted or foreign workspace: forget the handle so the next call resolves the key again.
612
879
  if (err instanceof ShardfluxApiError && err.source === 'api' && err.status === 404)
613
- handles.delete(key);
880
+ forget(key);
614
881
  throw err;
615
882
  }
616
883
  },
617
884
  };
618
- });
885
+ };
886
+ // Every call runs through these (the processful definitions: every tool; exec's parameters are the same in both
887
+ // modes); a pinned file-first server lists the file-first definitions instead.
888
+ const workspaceDefs = sdkToolDefinitions(ALL_TOOL_PERMISSIONS, 'processful').map((sdk) => workspaceDef(sdk, sdk.name === 'exec' ? execDescription(sdk.description, pinned === undefined ? 'any' : 'processful') : sdk.description));
889
+ const fileFirstDefs = sdkToolDefinitions(ALL_TOOL_PERMISSIONS, 'file_first').map((sdk) => workspaceDef(sdk, sdk.name === 'exec' ? execDescription(sdk.description, 'file_first') : sdk.description));
619
890
  const registry = new Map([...management, ...workspaceDefs].map((d) => [d.name, d]));
620
891
  const toMcpTool = (d) => ({
621
892
  name: d.name,
@@ -625,16 +896,26 @@ export function createShardfluxMcpServer(config, opts = {}) {
625
896
  annotations: { title: d.title, ...d.annotations },
626
897
  });
627
898
  // ---- 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;
899
+ // listChanged: a pinned server's list follows its workspace's mode (notifications/tools/list_changed).
900
+ const server = new Server({ name: 'shardflux', title: 'Shardflux workspaces', version: MCP_SERVER_VERSION }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
901
+ const permittedTools = async () => {
631
902
  try {
632
- permitted = (await principal()).api_key?.tool_permissions ?? [];
903
+ return (await principal()).api_key?.tool_permissions ?? [];
633
904
  }
634
905
  catch (err) {
635
906
  log('warn', 'could not read the API key’s tool permissions; listing every workspace tool', { error: describeToolError(err) });
907
+ return ALL_TOOL_PERMISSIONS;
636
908
  }
637
- return { tools: [...management, ...workspaceDefs.filter((d) => d.permission !== undefined && permitted.includes(d.permission))].map(toMcpTool) };
909
+ };
910
+ server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
911
+ // Unpinned: every tool (each call names its workspace; a tool its mode lacks is refused). Pinned: its mode's tools.
912
+ const [permitted, mode] = await Promise.all([permittedTools(), pinned === undefined ? undefined : pinnedListMode(pinned, AbortSignal.any([extra.signal, AbortSignal.timeout(ceiling)]))]);
913
+ if (mode !== undefined)
914
+ listedMode = mode;
915
+ const fileFirst = mode === 'file_first';
916
+ const mgmt = fileFirst ? management.filter((d) => !VM_ONLY_MANAGEMENT.has(d.name)) : management;
917
+ const tools = (fileFirst ? fileFirstDefs : workspaceDefs).filter((d) => d.permission !== undefined && permitted.includes(d.permission));
918
+ return { tools: [...mgmt, ...tools].map(toMcpTool) };
638
919
  });
639
920
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
640
921
  const name = request.params.name;
@@ -669,18 +950,41 @@ export function createShardfluxMcpServer(config, opts = {}) {
669
950
  }
670
951
  catch (err) {
671
952
  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 } : {}) });
953
+ // The client canceled: the SDK request/wait was aborted; no response is sent for a canceled request. A
954
+ // file-first execution is not canceled with it (it cannot be): the log names it.
955
+ log('info', 'tool call canceled by the client', {
956
+ tool: name,
957
+ outcome: 'canceled',
958
+ ms: Date.now() - started,
959
+ ...(call.operationId ? { operation_id: call.operationId } : {}),
960
+ ...(call.executionId ? { execution_id: call.executionId } : {}),
961
+ });
674
962
  throw new McpError(ErrorCode.RequestTimeout, 'canceled');
675
963
  }
676
964
  // A structured error (API refusal, the SDK's own wait timeout) wins; a bare abort after the deadline is a timeout.
677
965
  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 });
966
+ const info = describeToolError(deadline.aborted && abortish ? new DOMException('deadline', 'TimeoutError') : err, { operationId: call.operationId, timeoutMs, executionId: call.executionId });
679
967
  info.message = redact(info.message, config.apiKey);
680
968
  if (info.code === 'internal_error')
681
969
  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);
970
+ log('info', 'tool call', {
971
+ tool: name,
972
+ outcome: info.code,
973
+ ms: Date.now() - started,
974
+ ...(info.reason ? { reason: info.reason } : {}),
975
+ ...(info.request_id ? { request_id: info.request_id } : {}),
976
+ ...(info.execution_id ? { execution_id: info.execution_id } : {}),
977
+ });
978
+ // A failure suggests send_feedback; a failed send_feedback says how to still reach the founder.
979
+ const s = info.details?.retry_after_seconds;
980
+ const siblings = name === 'send_feedback'
981
+ ? info.code === 'invalid_arguments'
982
+ ? {}
983
+ : { hint: `The feedback was not sent; ${info.code === 'rate_limited' ? `retry ${typeof s === 'number' ? `in ${s} s` : 'later'}, or ` : ''}email it to ${FEEDBACK_EMAIL}.` }
984
+ : suggestsFeedback(info)
985
+ ? { feedback: feedbackSuggestion(info) }
986
+ : {};
987
+ return errorResult(info, call.timed && call.timing ? compactTiming(call.timing) : undefined, siblings);
684
988
  }
685
989
  });
686
990
  return server;