@shardflux/mcp 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -23,6 +23,27 @@ change).
23
23
  `host_feature_unavailable`: `search_files is not available for this workspace. Search with exec instead, ...
24
24
  Retrying does not help.` (the same for `edit_file` and other tools). `mode_not_available` drops "yet".
25
25
 
26
+ ## 0.6.0 (release candidate)
27
+
28
+ Completed exec output streams are drained before releasing their HTTP connections, with a bounded cleanup if a peer does not close.
29
+
30
+ Uses SDK 0.12.0 and its pooled transport. Adds label creation/filtering, workspace_idle, workspace_keepalive, workspace_set_idle_policy, workspace_set_labels, workspace_exec_start and workspace_exec_input. Exec tools follow key permissions and are hidden for file-first workspaces. Protocol errors include source.
31
+
32
+ Requires the QM integration backend release for labels, failed recovery, key reuse and pipe stdin. No production deployment has occurred from this branch.
33
+
34
+ ### Instant suspend: durable storage
35
+
36
+ - Operations in tool results carry `durable`, `suspend_path`, `durability` (suspend, fork) and `lost_suspend` (resume)
37
+ when the result has them; `timing.server` adds `durable` and `durability_state`.
38
+ - `workspace_suspend` takes `durable: true`: returns once the copy is in durable storage (implies `wait`; not with
39
+ `after_seconds`). A copy that cannot be made is the tool error `durability_lost` (`operation_id`, `details.state`
40
+ `lost`, `details.reason`); a durable wait that runs out is `timeout` with `details` `{durable: false, state:
41
+ "pending"}` and says to call `operation_wait` with `durable: true`.
42
+ - `operation_wait` takes `durable: true` (a suspend or fork).
43
+ - A resume whose result carries `lost_suspend` adds a `notice` naming the checkpoint it resumed from.
44
+ - A suspend-when-idle canceled `workspace_active`: the `operation_failed` message says the workspace keeps running.
45
+ - `workspace_suspend` no longer describes the suspend as a "durable" checkpoint.
46
+
26
47
  ## 0.5.1
27
48
 
28
49
  Needs `@shardflux/sdk` 0.11.0 (the workspace version).
package/README.md CHANGED
@@ -174,7 +174,7 @@ environment `SHARDFLUX_API_KEY`.
174
174
  | `SHARDFLUX_WAKE_TIMEOUT_MS` | Longest wait per tool call for a workspace to wake or finish a transition, 1000-3600000 ms. Default 120000. It is clamped to `SHARDFLUX_MCP_TOOL_TIMEOUT_MS`, and each wake also ends 250 ms before the call's own deadline. |
175
175
  | `SHARDFLUX_AGENT_LABEL` | Attribution label of every tool token this server obtains. Default `mcp`. It appears as an agent session in `GET /v1/workspaces/{id}/agent-sessions`, the console and `shard ws sessions`. |
176
176
  | `SHARDFLUX_MCP_LOG_LEVEL` | `debug`, `info` (default), `warn` or `error`. Logs are JSON lines on **stderr**; stdout is the protocol. The key is never logged, and anything key-shaped is redacted. |
177
- | `SHARDFLUX_HTTP_KEEPALIVE=1` | Reuse HTTP connections. By default every request uses a fresh connection (`Connection: close`). |
177
+ | `SHARDFLUX_HTTP_KEEPALIVE` | 0.6.0+: default private HTTP/1.1 pooling on Node 26; `0` forces close for diagnosis, `1` uses native pooling. |
178
178
  | `SHARDFLUX_NO_UPDATE_CHECK` | (0.4.0) `1`, `true`, `yes` or `on` turns off the startup version check (see "Updates"). |
179
179
  | `NO_UPDATE_NOTIFIER` | (0.4.0) The npm convention: any non-empty value also turns the check off. |
180
180
 
@@ -237,8 +237,9 @@ The management tools:
237
237
  | `workspace_status` | One workspace plus its five most recent operations. |
238
238
  | `workspace_suspend`, `workspace_resume` | Lifecycle operation. Returns at once unless `wait: true` (then once it finished, through the SDK's own `wait`). |
239
239
  | `workspace_suspend` with `after_seconds` | (0.4.1) Suspend when idle: the workspace is suspended once it has been idle for `after_seconds` (0-3600; 0 = as soon as it is idle, 0.5.1+), instead of now. The description tells the agent to use it when it finishes its work, so the workspace stops using RAM soon after. Its next tool call on the workspace cancels it; a running command or a keepalive postpones it. Returns `suspend_request` (`not_before`: the earliest suspend) and a `message`; `operation` when a suspend was already in progress. Not with `wait`. `workspace_status` shows a pending request as `workspace.suspend_request`. Not for file-first workspaces. |
240
- | `workspace_fork` | Fork into `new_key`. |
241
- | `operation_wait` | Keep waiting for an operation. |
240
+ | `workspace_suspend` with `durable: true` | (0.6.0+) Returns once the suspend is in durable storage. A suspend returns as soon as the workspace is sealed on its host, typically in a few hundred ms; its operation's `durable` turns true when the copy lands in durable storage, typically within a second (`durability` shows its progress). |
241
+ | `workspace_fork` | Fork into `new_key`. A fork of a running workspace carries `durable` and `durability` the same way (0.6.0+). |
242
+ | `operation_wait` | Keep waiting for an operation. `durable: true` (0.6.0+): a suspend or fork, until its copy is in durable storage. |
242
243
  | `usage_summary` | The organization's usage summary for the current period: meters, allowances with their cap state, `allowance_exhausted` with `exhausted_reason`, and (0.4.1+, an API with opt-in overage) `spend_cap`: the overage state, cap, charges, lines per allowance and the date the cap is projected to be reached. |
243
244
  | `send_feedback` | (0.4.0) Feedback straight to the Shardflux team: `message`, `category`, and optional `workspace`, `request_id`, `error_code`, `command`. See [Feedback](#feedback-agents-are-asked-to-use-send_feedback-while-they-work). |
244
245
  | `template_get` | (0.3.0) A template's versions with their settings (env, inputs, start commands, services, defaults); with `version`, that version's recipe in request form. |
@@ -416,3 +417,10 @@ await server.connect(transport); // any @modelcontextprotocol/sdk transport
416
417
  ## License
417
418
 
418
419
  Apache-2.0
420
+
421
+
422
+ ## Integration controls (0.6.0+)
423
+
424
+ `workspace_open` accepts `labels` and `idle_policy`; `workspace_list` accepts exact label filters. `workspace_set_labels` replaces all labels. `workspace_idle` reads activity without waking, `workspace_keepalive` declares ongoing work, and `workspace_set_idle_policy` accepts adaptive, never, fixed:<seconds>, or default.
425
+ `workspace_exec_start` starts a tracked background command with optional `stdin_open`; `workspace_exec_input` writes data at an acknowledged byte offset and can send EOF with `close`. Both require exec permission and processful workspaces. Frames are bounded to 64 KiB; use the returned offset after partial acknowledgements. The host and guest must advertise pipe-input support.
426
+ `workspace_resume` recovers failed IDs without replacing their disks. A completed delete frees the key for a new workspace ID. Protocol errors include their source; a bare 404 is not a workspace tombstone.
package/dist/errors.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface ToolErrorInfo {
18
18
  execution_id?: string;
19
19
  /** tree_revision_mismatch: the revision the workspace's file tree is at (0.4.0). */
20
20
  current_tree_revision?: number;
21
- source?: 'api' | 'cell';
21
+ source?: 'api' | 'cell' | 'unknown';
22
22
  details?: Record<string, unknown>;
23
23
  issues?: string[];
24
24
  last_state?: string;
package/dist/errors.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * tool that is not available for the workspace (host_feature_unavailable), a
8
8
  * `hint` saying what to do instead.
9
9
  */
10
- import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateUploadError, ToolArgumentError, TreeRevisionMismatchError } from '@shardflux/sdk';
10
+ import { DurabilityLostError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateUploadError, ToolArgumentError, TreeRevisionMismatchError } from '@shardflux/sdk';
11
11
  /** A refusal decided by this server (argument/pinning problems, unknown workspace key). */
12
12
  export class ToolError extends Error {
13
13
  name = 'ToolError';
@@ -112,15 +112,26 @@ export function describeToolError(err, context = {}) {
112
112
  if (err instanceof ToolError)
113
113
  return { code: err.code, message: err.message, ...(err.details ? { details: err.details } : {}) };
114
114
  if (err instanceof OperationTimeoutError) {
115
+ if (err.durable) {
116
+ return { code: 'timeout', message: `${err.message} Call operation_wait with this operation_id and durable: true to keep waiting.`, operation_id: err.operationId, last_state: err.lastState, retryable: true, details: { durable: false, state: 'pending' } };
117
+ }
115
118
  return { code: 'timeout', message: `${err.message} Call operation_wait with this operation_id to keep waiting.`, operation_id: err.operationId, last_state: err.lastState, retryable: true };
116
119
  }
120
+ // 0.6.0: the suspend (or fork) succeeded; its durable copy could not be made (durability.state lost).
121
+ if (err instanceof DurabilityLostError) {
122
+ return { code: 'durability_lost', message: err.message, operation_id: err.operationId, retryable: false, details: { state: 'lost', ...(err.durability.reason ? { reason: err.durability.reason } : {}), ...(err.durability.checkpointId ? { checkpoint_id: err.durability.checkpointId } : {}) } };
123
+ }
117
124
  if (err instanceof OperationFailedError) {
118
125
  // retryable: the operation error's own flag. capacity_unavailable (the start passed its deadline) is retryable:
119
126
  // nothing was started, so the agent may send it again.
120
127
  const opDetails = err.operation.error?.details;
121
128
  const reason = typeof opDetails === 'object' && opDetails !== null ? opDetails.reason : undefined;
122
129
  const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}) };
123
- const hint = err.errorCode === 'capacity_unavailable' ? '. The start passed its deadline and nothing was started; send it again.' : '';
130
+ const hint = err.errorCode === 'capacity_unavailable'
131
+ ? '. The start passed its deadline and nothing was started; send it again.'
132
+ : err.workspaceActive
133
+ ? '. The workspace was in use, so the suspend-when-idle was canceled; nothing changed and it keeps running.'
134
+ : '';
124
135
  return { code: 'operation_failed', message: `${err.message}${hint}`, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}) };
125
136
  }
126
137
  if (named(err, 'TimeoutError')) {
@@ -141,7 +152,7 @@ export function describeToolError(err, context = {}) {
141
152
  if (err instanceof TemplateUploadError)
142
153
  return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
143
154
  if (err instanceof ShardfluxProtocolError)
144
- return { code: 'protocol_error', message: err.message, status: err.status, ...(context.executionId ? { execution_id: context.executionId } : {}) };
155
+ return { code: 'protocol_error', message: err.message, status: err.status, source: err.source, ...(context.executionId ? { execution_id: context.executionId } : {}) };
145
156
  if (err instanceof TypeError && err.message === 'fetch failed') {
146
157
  const cause = err.cause;
147
158
  return {
package/dist/http.d.ts CHANGED
@@ -1,6 +1,2 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch (API and cell gateway). By
3
- * default every request uses a fresh connection (`Connection: close`);
4
- * SHARDFLUX_HTTP_KEEPALIVE=1 reuses connections.
5
- */
1
+ /** Share the SDK's pooled transport. A caller-supplied fetch stays under the caller's control. */
6
2
  export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch): typeof fetch;
package/dist/http.js CHANGED
@@ -1,15 +1,5 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch (API and cell gateway). By
3
- * default every request uses a fresh connection (`Connection: close`);
4
- * SHARDFLUX_HTTP_KEEPALIVE=1 reuses connections.
5
- */
6
- export function makeFetch(env, base = fetch) {
7
- // Why a fresh connection by default (Node 26 keep-alive measurements): docs/progress/startup-latency.md.
8
- if (env.SHARDFLUX_HTTP_KEEPALIVE === '1')
9
- return base;
10
- return (input, init) => {
11
- const headers = new Headers(init?.headers);
12
- headers.set('connection', 'close');
13
- return base(input, { ...init, headers });
14
- };
1
+ import { defaultFetch } from '@shardflux/sdk';
2
+ /** Share the SDK's pooled transport. A caller-supplied fetch stays under the caller's control. */
3
+ export function makeFetch(env, base = defaultFetch(env)) {
4
+ return base;
15
5
  }
package/dist/server.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
3
  import type { ClientVersionStatus, JsonSchema, LifecycleTiming, Operation, TemplateBuild, TemplateDetail, TemplateOwner, ToolName, WorkspaceMode, WorkspaceTool, WorkspaceView } from '@shardflux/sdk';
4
4
  import type { McpConfig } from './config.js';
5
5
  import type { ToolErrorInfo } from './errors.js';
6
- export declare const MCP_SERVER_VERSION = "0.5.2";
6
+ export declare const MCP_SERVER_VERSION = "0.6.0";
7
7
  /** The package this server is distributed as: its entry in GET /v1/client-versions. */
8
8
  export declare const MCP_PACKAGE = "@shardflux/mcp";
9
9
  export declare const ALL_TOOL_PERMISSIONS: readonly ToolName[];
@@ -36,6 +36,33 @@ export declare function summarizeWorkspace(v: WorkspaceView): {
36
36
  version: number;
37
37
  };
38
38
  active_operation: {
39
+ lost_suspend?: ({
40
+ checkpoint_id: string;
41
+ generation_id?: string;
42
+ reason?: string;
43
+ suspended_at?: string;
44
+ restored_checkpoint_id?: string;
45
+ state_as_of?: string;
46
+ } & {
47
+ [key: string]: unknown;
48
+ }) | undefined;
49
+ durability?: ({
50
+ state: "pending" | "durable" | "lost";
51
+ checkpoint_id?: string;
52
+ generation_id?: string;
53
+ local_commit_at?: string;
54
+ durable_by?: string;
55
+ durable_at?: string;
56
+ local_commit_to_durable_ms?: number;
57
+ overdue_at?: string;
58
+ reason?: string;
59
+ code?: string;
60
+ recovery_point_checkpoint_id?: string | null;
61
+ } & {
62
+ [key: string]: unknown;
63
+ }) | undefined;
64
+ suspend_path?: string | undefined;
65
+ durable?: boolean | undefined;
39
66
  error?: {
40
67
  [key: string]: unknown;
41
68
  } | undefined;
@@ -62,6 +89,9 @@ export declare function summarizeWorkspace(v: WorkspaceView): {
62
89
  purpose: "standard" | "template_draft" | "template_test";
63
90
  disk_layout: "legacy" | "layered";
64
91
  idle_timeout_seconds: number | null;
92
+ labels: {
93
+ [key: string]: string;
94
+ };
65
95
  ended_reason: "closed" | "idle_timeout" | "draft_discarded" | null;
66
96
  /** Start commands and services of the template version; null when it has none. */
67
97
  startup: {
@@ -171,6 +201,33 @@ export declare function summarizeTemplate(t: TemplateDetail): {
171
201
  versions_truncated: boolean;
172
202
  };
173
203
  export declare function summarizeOperation(o: Operation): {
204
+ lost_suspend?: ({
205
+ checkpoint_id: string;
206
+ generation_id?: string;
207
+ reason?: string;
208
+ suspended_at?: string;
209
+ restored_checkpoint_id?: string;
210
+ state_as_of?: string;
211
+ } & {
212
+ [key: string]: unknown;
213
+ }) | undefined;
214
+ durability?: ({
215
+ state: "pending" | "durable" | "lost";
216
+ checkpoint_id?: string;
217
+ generation_id?: string;
218
+ local_commit_at?: string;
219
+ durable_by?: string;
220
+ durable_at?: string;
221
+ local_commit_to_durable_ms?: number;
222
+ overdue_at?: string;
223
+ reason?: string;
224
+ code?: string;
225
+ recovery_point_checkpoint_id?: string | null;
226
+ } & {
227
+ [key: string]: unknown;
228
+ }) | undefined;
229
+ suspend_path?: string | undefined;
230
+ durable?: boolean | undefined;
174
231
  error?: {
175
232
  [key: string]: unknown;
176
233
  } | undefined;
@@ -192,6 +249,8 @@ export declare function compactTiming(t: LifecycleTiming): {
192
249
  total_ms: number;
193
250
  phases: string[];
194
251
  server: {
252
+ durability_state?: "pending" | "durable" | "lost" | undefined;
253
+ durable?: boolean | undefined;
195
254
  cold_boot_reason?: string | undefined;
196
255
  memory_restored?: boolean | undefined;
197
256
  resume_path?: string | undefined;
@@ -213,6 +272,13 @@ export declare function compactTiming(t: LifecycleTiming): {
213
272
  * and background jobs it started are gone. Undefined for any other timing, including one whose memory_restored is unknown.
214
273
  */
215
274
  export declare function processRestartNotice(t: LifecycleTiming): string | undefined;
275
+ /**
276
+ * (0.6.0) The `notice` of a result whose resume restored an earlier checkpoint because the workspace's latest suspend
277
+ * could not be kept (`lost_suspend`): the files and processes are as of that checkpoint.
278
+ */
279
+ export declare function lostSuspendNotice(t: LifecycleTiming): string | undefined;
280
+ /** The notices of a timing (cold boot, lost suspend), joined; undefined when there are none. */
281
+ export declare function timingNotice(t: LifecycleTiming): string | undefined;
216
282
  export declare function okResult(value: unknown): CallToolResult;
217
283
  /**
218
284
  * `timing` (optional): where the failed open/wait/wake spent its time, next to `error`. `extra` (0.4.0): more sibling
package/dist/server.js CHANGED
@@ -53,7 +53,7 @@ import { parse as parseYaml } from 'yaml';
53
53
  import { DEFAULT_WAKE_TIMEOUT_MS } from "./config.js";
54
54
  import { ToolError, describeToolError, redact } from "./errors.js";
55
55
  import { makeFetch } from "./http.js";
56
- export const MCP_SERVER_VERSION = '0.5.2';
56
+ export const MCP_SERVER_VERSION = '0.6.0';
57
57
  /** The package this server is distributed as: its entry in GET /v1/client-versions. */
58
58
  export const MCP_PACKAGE = '@shardflux/mcp';
59
59
  const USER_AGENT = `shardflux-mcp/${MCP_SERVER_VERSION} shardflux-sdk-ts/${SDK_VERSION}`;
@@ -75,7 +75,7 @@ export function sdkToolDefinitions(tools = ALL_TOOL_PERMISSIONS, mode = 'process
75
75
  return workspaceTools(noWorkspace, { tools: [...tools], mode });
76
76
  }
77
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']);
78
+ const VM_ONLY_MANAGEMENT = new Set(['workspace_suspend', 'workspace_resume', 'workspace_fork', 'operation_wait', 'workspace_idle', 'workspace_keepalive', 'workspace_set_idle_policy', 'workspace_exec_start', 'workspace_exec_input']);
79
79
  /** What to use instead of a workspace tool a file-first workspace does not have, by the tool's permission. */
80
80
  const FILE_FIRST_INSTEAD = {
81
81
  process: 'Nothing runs between exec calls: run the program, and whatever needs it, within one exec command.',
@@ -132,6 +132,7 @@ export function summarizeWorkspace(v) {
132
132
  purpose: v.purpose,
133
133
  disk_layout: v.disk_layout,
134
134
  idle_timeout_seconds: v.idle_timeout_seconds,
135
+ labels: v.labels,
135
136
  ended_reason: v.ended_reason,
136
137
  /** Start commands and services of the template version; null when it has none. */
137
138
  startup: v.startup ?? null,
@@ -194,6 +195,11 @@ export function summarizeOperation(o) {
194
195
  created_at: o.created_at,
195
196
  completed_at: o.completed_at ?? null,
196
197
  ...(o.error ? { error: o.error } : {}),
198
+ // 0.6.0: suspend and fork say whether their copy is in durable storage; a resume names a suspend it could not restore.
199
+ ...(typeof o.result?.durable === 'boolean' ? { durable: o.result.durable } : {}),
200
+ ...(o.result?.suspend_path ? { suspend_path: o.result.suspend_path } : {}),
201
+ ...(o.result?.durability ? { durability: o.result.durability } : {}),
202
+ ...(o.result?.lost_suspend ? { lost_suspend: o.result.lost_suspend } : {}),
197
203
  };
198
204
  }
199
205
  function isObject(v) {
@@ -225,6 +231,8 @@ export function compactTiming(t) {
225
231
  // 0.5.0: false when the resume booted the saved disk (processes restarted); absent when the API did not say.
226
232
  ...(typeof s.memoryRestored === 'boolean' ? { memory_restored: s.memoryRestored } : {}),
227
233
  ...(s.coldBootReason ? { cold_boot_reason: s.coldBootReason } : {}),
234
+ ...(typeof s.durable === 'boolean' ? { durable: s.durable } : {}),
235
+ ...(s.durability ? { durability_state: s.durability.state } : {}),
228
236
  }
229
237
  : null,
230
238
  outside_server_ms: ms(t.outsideServerMs),
@@ -248,6 +256,23 @@ export function processRestartNotice(t) {
248
256
  return (`The workspace was resumed from its saved disk, not from memory${why}. Its files are as they were when it was suspended, ` +
249
257
  'but every process was restarted, as after a reboot: start again any dev server, database, watcher or background job you had running, and open new terminals.');
250
258
  }
259
+ /**
260
+ * (0.6.0) The `notice` of a result whose resume restored an earlier checkpoint because the workspace's latest suspend
261
+ * could not be kept (`lost_suspend`): the files and processes are as of that checkpoint.
262
+ */
263
+ export function lostSuspendNotice(t) {
264
+ const l = t.server?.lostSuspend;
265
+ if (!l)
266
+ return undefined;
267
+ return (`The workspace was resumed from its checkpoint${l.stateAsOf ? ` of ${l.stateAsOf}` : ''} (${l.restoredCheckpointId ?? 'the previous one'}); ` +
268
+ `its suspend${l.suspendedAt ? ` at ${l.suspendedAt}` : ''} was not kept${l.reason ? ` (${l.reason})` : ''}. ` +
269
+ 'Work done between those times is not in the workspace: check the files you changed and run again what you need.');
270
+ }
271
+ /** The notices of a timing (cold boot, lost suspend), joined; undefined when there are none. */
272
+ export function timingNotice(t) {
273
+ const parts = [processRestartNotice(t), lostSuspendNotice(t)].filter((x) => x !== undefined);
274
+ return parts.length ? parts.join(' ') : undefined;
275
+ }
251
276
  export function okResult(value) {
252
277
  if (isObject(value) && value.mime_type === 'image/png' && typeof value.data_base64 === 'string') {
253
278
  const { data_base64: data, ...meta } = value;
@@ -554,6 +579,8 @@ export function createShardfluxMcpServer(config, opts = {}) {
554
579
  description: config.template ? `Template slug (default "${config.template}").` : 'Template slug for a new workspace, e.g. "python-node-browser".',
555
580
  },
556
581
  ...capsProps,
582
+ labels: { type: 'object', additionalProperties: true, description: 'Searchable workspace labels; replaces existing labels when supplied.' },
583
+ idle_policy: { type: 'string', pattern: '^(adaptive|never|fixed:[0-9]+)$', description: 'Automatic suspend policy.' },
557
584
  lifetime: {
558
585
  type: 'string',
559
586
  enum: ['persistent', 'session'],
@@ -590,6 +617,8 @@ export function createShardfluxMcpServer(config, opts = {}) {
590
617
  ...(caps ? { caps } : {}),
591
618
  ...(lifetime ? { lifetime } : {}),
592
619
  ...(inputs ? { inputs } : {}),
620
+ ...(args.labels !== undefined ? { labels: stringMap(args.labels, 'labels') } : {}),
621
+ ...(typeof args.idle_policy === 'string' ? { idlePolicy: args.idle_policy } : {}),
593
622
  ...(mode ? { mode } : {}),
594
623
  agentLabel: config.agentLabel,
595
624
  wait: args.wait === false ? false : waitOpts(call),
@@ -605,6 +634,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
605
634
  description: 'List the workspaces of this API key’s project (key, state, template, active operation).',
606
635
  inputSchema: obj({
607
636
  prefix: { type: 'string', minLength: 1, maxLength: 200, description: 'Only keys starting with this prefix.' },
637
+ labels: { type: 'object', additionalProperties: true, description: 'Exact label matches; all supplied pairs must match.' },
608
638
  state: { type: 'string', enum: OBSERVED_STATES, description: 'Observed state filter.' },
609
639
  include_deleted: { type: 'boolean', description: 'Include deleted workspaces (and ended sessions).' },
610
640
  lifetime: { type: 'string', enum: ['persistent', 'session', 'any'], description: 'Lifetime filter (default persistent: session workspaces are hidden).' },
@@ -616,6 +646,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
616
646
  run: async (args) => {
617
647
  const page = await cloud.workspaces.list({
618
648
  ...(typeof args.prefix === 'string' ? { keyPrefix: args.prefix } : {}),
649
+ ...(args.labels !== undefined ? { labels: stringMap(args.labels, 'labels') } : {}),
619
650
  ...(typeof args.state === 'string' ? { state: args.state } : {}),
620
651
  ...(args.include_deleted === true ? { includeDeleted: true } : {}),
621
652
  ...(typeof args.lifetime === 'string' ? { lifetime: args.lifetime } : {}),
@@ -641,10 +672,49 @@ export function createShardfluxMcpServer(config, opts = {}) {
641
672
  return { workspace: summarizeWorkspace(ws.data), recent_operations: ops.data.map(summarizeOperation) };
642
673
  },
643
674
  },
675
+ {
676
+ name: 'workspace_idle', title: 'Inspect idle status', description: 'Read idle policy and activity without waking or recording activity.',
677
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned) }, keyRequired),
678
+ annotations: { readOnlyHint: true, openWorldHint: false },
679
+ run: async (args, call) => (await resolve(keyOf(args), { signal: call.signal })).idle(call.signal),
680
+ },
681
+ {
682
+ name: 'workspace_keepalive', title: 'Declare ongoing work', description: 'Prevent idle suspension for seconds; never shortens an existing keepalive.',
683
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), seconds: { type: 'integer', minimum: 1 } }, [...keyRequired, 'seconds']),
684
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
685
+ run: async (args, call) => (await resolve(keyOf(args), { signal: call.signal })).keepalive(args.seconds, call.signal),
686
+ },
687
+ {
688
+ name: 'workspace_set_idle_policy', title: 'Set idle policy', description: 'Set adaptive, never or fixed:<seconds> (60..604800); default restores the inherited policy.',
689
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), policy: { type: 'string', description: 'adaptive, never, fixed:<seconds>, or default' } }, [...keyRequired, 'policy']),
690
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
691
+ run: async (args, call) => summarizeWorkspace((await (await resolve(keyOf(args), { signal: call.signal })).setIdlePolicy(args.policy === 'default' ? null : args.policy)).data),
692
+ },
693
+ {
694
+ name: 'workspace_set_labels', title: 'Replace workspace labels', description: 'Replace all labels; an empty object clears them. Labels are searchable metadata, not secrets.',
695
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), labels: { type: 'object', additionalProperties: true } }, [...keyRequired, 'labels']),
696
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
697
+ run: async (args, call) => summarizeWorkspace((await (await resolve(keyOf(args), { signal: call.signal })).setLabels(stringMap(args.labels, 'labels'))).data),
698
+ },
699
+ {
700
+ name: 'workspace_exec_start', permission: 'exec', title: 'Start background command', description: 'Start a tracked exec session. stdin_open keeps a pipe open for workspace_exec_input. Processful workspaces only.',
701
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), argv: { type: 'array', items: { type: 'string' }, minItems: 1 }, session_id: { type: 'string' }, stdin_open: { type: 'boolean' }, cwd: { type: 'string' } }, [...keyRequired, 'argv']),
702
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
703
+ run: async (args, call) => (await resolve(keyOf(args), { signal: call.signal })).cell().exec.start({ argv: args.argv,
704
+ ...(typeof args.session_id === 'string' ? { session_id: args.session_id } : {}), stdin_open: args.stdin_open === true,
705
+ ...(typeof args.cwd === 'string' ? { cwd: args.cwd } : {}) }, call.signal),
706
+ },
707
+ {
708
+ name: 'workspace_exec_input', permission: 'exec', title: 'Write command stdin', description: 'Write up to 64 KiB at the acknowledged byte offset (0 initially). close sends EOF. A repeated identical last frame cannot duplicate bytes; continue from a partial acknowledgement.',
709
+ inputSchema: obj({ workspace_key: workspaceKeyProp(pinned), session_id: { type: 'string' }, data: { type: 'string', maxLength: 65536 }, offset: { type: 'integer', minimum: 0 }, close: { type: 'boolean' } }, [...keyRequired, 'session_id', 'offset']),
710
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
711
+ run: async (args, call) => (await resolve(keyOf(args), { signal: call.signal })).cell().exec.input(args.session_id, typeof args.data === 'string' ? args.data : '', { offset: args.offset, close: args.close === true, signal: call.signal }),
712
+ },
644
713
  {
645
714
  name: 'workspace_suspend',
646
715
  title: 'Suspend workspace',
647
- 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). ' +
716
+ description: 'Suspend a running workspace (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). ' +
717
+ 'A finished suspend returns as soon as the workspace is sealed on its host (typically a few hundred ms); its durable field turns true when the copy lands in durable storage, typically within a second. Pass durable: true to return only then. ' +
648
718
  '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. ' +
649
719
  '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).',
650
720
  inputSchema: obj({
@@ -656,10 +726,14 @@ export function createShardfluxMcpServer(config, opts = {}) {
656
726
  description: 'Suspend once the workspace has been idle this many seconds (0-3600; 0 = as soon as it is idle) instead of now. Omit to suspend now.',
657
727
  },
658
728
  wait: { type: 'boolean', description: 'Wait until the suspend finishes (default false). Not with after_seconds.' },
729
+ durable: { type: 'boolean', description: 'Wait until the suspend is in durable storage (result durable: true; implies wait). Not with after_seconds.' },
659
730
  }, keyRequired),
660
731
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
661
732
  run: async (args, call) => {
662
733
  const afterSeconds = typeof args.after_seconds === 'number' ? args.after_seconds : undefined;
734
+ if (afterSeconds !== undefined && args.durable === true) {
735
+ throw new ToolError('invalid_arguments', 'durable does not apply with after_seconds: the suspend happens later, once the workspace has been idle. Omit durable, or omit after_seconds to suspend now.');
736
+ }
663
737
  if (afterSeconds !== undefined && args.wait === true) {
664
738
  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.');
665
739
  }
@@ -669,7 +743,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
669
743
  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.`);
670
744
  }
671
745
  if (afterSeconds === undefined) {
672
- const op = await cloud.workspaces.suspend(ws.id, lifecycleOpts(args, call));
746
+ const op = await cloud.workspaces.suspend(ws.id, args.durable === true ? { ...lifecycleOpts({ ...args, wait: true }, call), durable: true } : lifecycleOpts(args, call));
673
747
  call.operationId = op.id;
674
748
  return { operation: summarizeOperation(op) };
675
749
  }
@@ -716,10 +790,19 @@ export function createShardfluxMcpServer(config, opts = {}) {
716
790
  {
717
791
  name: 'operation_wait',
718
792
  title: 'Wait for operation',
719
- description: 'Wait for a lifecycle operation (open, suspend, resume, fork, ...) to finish, up to timeout_ms. Returns the operation and the wait’s timing.',
720
- inputSchema: obj({ operation_id: { type: 'string', minLength: 36, maxLength: 36, description: 'Operation id (UUID).' } }, ['operation_id']),
793
+ description: 'Wait for a lifecycle operation (open, suspend, resume, fork, ...) to finish, up to timeout_ms. Returns the operation and the wait’s timing. With durable (a suspend or fork), also wait until its copy is in durable storage.',
794
+ inputSchema: obj({
795
+ operation_id: { type: 'string', minLength: 36, maxLength: 36, description: 'Operation id (UUID).' },
796
+ durable: { type: 'boolean', description: 'A suspend or fork: also wait until result durable is true.' },
797
+ }, ['operation_id']),
721
798
  annotations: { readOnlyHint: true, openWorldHint: false },
722
- run: async (args, call) => ({ operation: summarizeOperation(await wait(call, String(args.operation_id))) }),
799
+ run: async (args, call) => {
800
+ if (args.durable !== true)
801
+ return { operation: summarizeOperation(await wait(call, String(args.operation_id))) };
802
+ call.timed = true;
803
+ call.operationId = String(args.operation_id);
804
+ return { operation: summarizeOperation(await cloud.workspaces.waitForDurable(String(args.operation_id), waitOpts(call))) };
805
+ },
723
806
  },
724
807
  {
725
808
  name: 'template_get',
@@ -982,7 +1065,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
982
1065
  if (mode !== undefined)
983
1066
  listedMode = mode;
984
1067
  const fileFirst = mode === 'file_first';
985
- const mgmt = fileFirst ? management.filter((d) => !VM_ONLY_MANAGEMENT.has(d.name)) : management;
1068
+ const mgmt = management.filter((d) => (!fileFirst || !VM_ONLY_MANAGEMENT.has(d.name)) && (d.permission === undefined || permitted.includes(d.permission)));
986
1069
  const tools = (fileFirst ? fileFirstDefs : workspaceDefs).filter((d) => d.permission !== undefined && permitted.includes(d.permission));
987
1070
  return { tools: [...mgmt, ...tools].map(toMcpTool) };
988
1071
  });
@@ -1015,7 +1098,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
1015
1098
  try {
1016
1099
  const value = await Promise.race([running, aborted]);
1017
1100
  log('info', 'tool call', { tool: name, outcome: 'ok', ms: Date.now() - started });
1018
- const notice = call.timed && call.timing ? processRestartNotice(call.timing) : undefined;
1101
+ const notice = call.timed && call.timing ? timingNotice(call.timing) : undefined;
1019
1102
  return okResult(call.timed && call.timing && isObject(value) ? { ...value, timing: compactTiming(call.timing), ...(notice ? { notice } : {}) } : value);
1020
1103
  }
1021
1104
  catch (err) {
@@ -1055,7 +1138,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
1055
1138
  ? { feedback: feedbackSuggestion(info) }
1056
1139
  : {};
1057
1140
  // A wake that booted the saved disk before the failure still restarted every process: said next to the error.
1058
- const notice = call.timed && call.timing ? processRestartNotice(call.timing) : undefined;
1141
+ const notice = call.timed && call.timing ? timingNotice(call.timing) : undefined;
1059
1142
  return errorResult(info, call.timed && call.timing ? compactTiming(call.timing) : undefined, { ...siblings, ...(notice ? { notice } : {}) });
1060
1143
  }
1061
1144
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/mcp",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "mcpName": "dev.shardflux/mcp",
5
5
  "type": "module",
6
6
  "description": "Shardflux MCP server (stdio): give Claude, Cursor, Codex or any MCP client persistent cloud workspaces to run commands, edit files, use git and a browser in. Uses your scoped Shardflux API key.",
@@ -46,7 +46,7 @@
46
46
  "@modelcontextprotocol/sdk": "1.30.0",
47
47
  "yaml": "2.9.1",
48
48
  "zod": "4.6.5",
49
- "@shardflux/sdk": "^0.11.1"
49
+ "@shardflux/sdk": "^0.12.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@eslint/js": "10.0.1",