@shardflux/mcp 0.5.1 → 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
@@ -1,7 +1,48 @@
1
1
  # Changelog
2
2
 
3
- Every tool, field and variable the README shows is available from the version named here. Below 1.0, a minor release
4
- may break compatibility; breaking changes are marked **Breaking**. Versions before 0.3.0 were not published.
3
+ Every tool, field and variable the README shows is available from the version named here. Breaking changes ship in
4
+ minor releases and are marked **Breaking**. Versions before 0.3.0 were not published.
5
+
6
+ ## 0.5.2
7
+
8
+ Needs `@shardflux/sdk` 0.11.1 (the workspace version).
9
+
10
+ Wording: the server instructions, tool descriptions and error hints say what to do, without internals (no behaviour
11
+ change).
12
+
13
+ - `send_feedback` goes to the Shardflux team (`note`: `Delivered to the Shardflux team. Keep sending feedback as you
14
+ work.`); the instructions and description ask agents to also pass on what their user asks for (a capability, an
15
+ option or a smoother workflow) and their ideas. `error_code` shows `e.g. template_not_found`.
16
+ - The instructions: a queued start (open, resume, fork) has a deadline 15 minutes after it was created; past it, it
17
+ fails with code `operation_failed`, `details.error_code` `capacity_unavailable` and `retryable` true. Nothing was
18
+ started, so send it again. The `capacity_unavailable` message ends `The start passed its deadline and nothing was
19
+ started; send it again.`
20
+ - `exec` on a file-first workspace and the instructions say `an execution runs to completion` (before: "cannot be
21
+ canceled"); the timeout message says `(a file-first execution runs to completion)`.
22
+ - Hints: `no_execution_host`: `The execution cannot be placed right now and nothing ran; retry in a few seconds.`
23
+ `host_feature_unavailable`: `search_files is not available for this workspace. Search with exec instead, ...
24
+ Retrying does not help.` (the same for `edit_file` and other tools). `mode_not_available` drops "yet".
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.
5
46
 
6
47
  ## 0.5.1
7
48
 
@@ -16,8 +57,7 @@ it. The result's `message` then says `the workspace is suspended as soon as it i
16
57
 
17
58
  Needs `@shardflux/sdk` 0.11.0 (the workspace version).
18
59
 
19
- A resume that restarted processes says so (cold boot, contracts §12). When the platform's VM runtime changed after a
20
- suspend and no host can restore the memory snapshot, the cell resumes the workspace by booting its saved disk,
60
+ A resume that restarted processes says so (cold boot). After a platform runtime change, the cell resumes the workspace by booting its saved disk,
21
61
  automatically, also when a workspace tool wakes it. Files are as of the suspend; every process was restarted.
22
62
 
23
63
  - `timing.server` of results and error results adds `memory_restored` (when the API reports it: `false` for a cold
@@ -59,7 +99,7 @@ Opt-in overage with a spend cap (through `@shardflux/sdk` 0.10.0):
59
99
  - A start refused with 402 `allowance_exhausted` comes back with `reason` `allowance_used`, `overage_paused` or
60
100
  `spend_cap_reached` and `details.spend_cap` (errors already carried `details`).
61
101
 
62
- Suspend when idle (contracts §20.6):
102
+ Suspend when idle:
63
103
 
64
104
  - `workspace_suspend` takes an optional `after_seconds` (30-3600): suspend when idle instead of now. The workspace is
65
105
  suspended once it has been idle that long; the agent's next tool call on it cancels that, and a running command or a
@@ -69,11 +109,11 @@ Suspend when idle (contracts §20.6):
69
109
  - Workspace summaries (`workspace_status`, `workspace_list`, `workspace_open`) carry `suspend_request`: the pending
70
110
  request, or null.
71
111
 
72
- ## 0.4.0 (not yet published; npm `latest` is 0.3.1)
112
+ ## 0.4.0
73
113
 
74
114
  Needs `@shardflux/sdk` 0.9.0 (the workspace version).
75
115
 
76
- File-first workspaces (contracts §29):
116
+ File-first workspaces:
77
117
 
78
118
  - `workspace_open` takes `mode` (`processful` | `file_first`); `SHARDFLUX_WORKSPACE_MODE` sets the default. Workspace
79
119
  results (`workspace_open`, `workspace_status`, `workspace_list`) carry `mode` and, for file-first workspaces,
@@ -92,7 +132,7 @@ File-first workspaces (contracts §29):
92
132
  `current_tree_revision`), `outside_tree_root`, `execution_in_progress`, `execution_id_reused`, `no_execution_host`.
93
133
  - The server instructions describe file-first workspaces.
94
134
 
95
- Waking a suspended workspace is one request (contracts §22.6, through `@shardflux/sdk` 0.9.0):
135
+ Waking a suspended workspace is one request (through `@shardflux/sdk` 0.9.0):
96
136
 
97
137
  - A workspace tool on a suspended workspace wakes it with one held resume that returns the running workspace and this
98
138
  server's tool token (its agent label), then runs the tool; the wake's `timing` is one `request(held)` phase. An API
@@ -100,7 +140,7 @@ Waking a suspended workspace is one request (contracts §22.6, through `@shardfl
100
140
  - `workspace_resume` with `wait` goes through the server's workspace handle: one held request, after which the handle
101
141
  holds the view and the token, so the next workspace tool starts without a token request.
102
142
 
103
- File tools and the wake hint (contracts §26):
143
+ File tools and the wake hint:
104
144
 
105
145
  - `search_files` (read-only) and `edit_file` come from the SDK's `workspaceTools()` with its schemas, plus
106
146
  `workspace_key` and `timeout_ms`, filtered by the `files` permission. The server instructions describe them.
@@ -108,19 +148,19 @@ File tools and the wake hint (contracts §26):
108
148
  fire-and-forget; a suspended workspace starts resuming in the background, bounded like any wake by
109
149
  `SHARDFLUX_WAKE_TIMEOUT_MS` and the call's deadline), except `read_file`, `list_files` and `search_files`.
110
150
  - `read_file`, `list_files` and `search_files` of a suspended workspace whose disk a host still holds are answered
111
- from that disk without resuming it (contracts §26.4), also as the first call after the suspend: the API now issues
151
+ from that disk without resuming it, also as the first call after the suspend: the API now issues
112
152
  tool tokens for suspended workspaces. Every other tool still resumes it. With an older API they resume it as before.
113
- - A workspace on a host that predates search and patches (contracts §26.7, while the fleet is upgraded):
153
+ - A workspace without search and patches:
114
154
  `search_files` and `edit_file` return `conflict`, `reason: host_feature_unavailable` (`details.feature`
115
155
  `file_search` or `file_patch`), `retryable: false`, with a `hint` naming another way (`exec` with `grep`;
116
- `read_file` then `write_file`). They work once the workspace runs on an upgraded host. `hostFeatureHint()` is
156
+ `read_file` then `write_file`). `hostFeatureHint()` is
117
157
  exported next to `modeHint()`.
118
158
  - New refusals come back as error results with their `details.reason`: `revision_mismatch` (with
119
159
  `current_revision`), `edit_not_found` / `edit_ambiguous` (with `index`), `host_capacity` (retryable). A read of a
120
160
  sleeping workspace its disk cannot answer (`offline_unavailable`, `offline_budget`) wakes it like any
121
161
  `workspace_not_running`; `offline_changed` (503) is retried.
122
162
 
123
- Version check (contracts §30.4; needs an API that serves `GET /v1/client-versions`):
163
+ Version check (needs an API that serves `GET /v1/client-versions`):
124
164
 
125
165
  - Version check at startup: after the configuration is validated, the server asks `GET /v1/client-versions` (through
126
166
  its own fetch, the SDK's `checkClientVersion`: one request, 3 s timeout, never awaited by the protocol) whether
@@ -141,7 +181,7 @@ Version check (contracts §30.4; needs an API that serves `GET /v1/client-versio
141
181
  authenticates with a project API key, which the API refuses for account actions (403 `forbidden` on the account
142
182
  routes, 401 on the `/v1/auth` session routes); they need a person's session (`sfu_...`), which the CLI signs in.
143
183
 
144
- ### send_feedback: feedback straight to the founder (needs an API with POST /v1/feedback)
184
+ ### send_feedback: feedback straight to the Shardflux team (needs an API with POST /v1/feedback)
145
185
 
146
186
  - New management tool `send_feedback` (`message`, `category`: bug, confusing, missing, idea, praise or other; optional
147
187
  `workspace`, `request_id`, `error_code`, `command`). It calls the SDK's `sendFeedback()` with `client:
@@ -149,8 +189,8 @@ Version check (contracts §30.4; needs an API that serves `GET /v1/client-versio
149
189
  `SHARDFLUX_AGENT_LABEL`); a pinned server fills `workspace` with its key. Result: `{id, received_at, duplicate,
150
190
  note}`. Always listed (any API key may send feedback).
151
191
  - The server instructions and the tool description ask the agent to call it actively while it works: the moment a
152
- call fails unexpectedly, an error or doc is confusing, something is missing or slow, or it needed a workaround, and
153
- when its user is frustrated about Shardflux or asked for something it could not do (paraphrased, without private
192
+ call fails unexpectedly, an error or doc is confusing, something is missing, or it needed a workaround, and
193
+ when its user asks for something new (paraphrased, without private
154
194
  data; the agent tells its user it sent feedback).
155
195
  - Failed calls carry a `feedback` field next to `error`, except the expected flow: `invalid_arguments`,
156
196
  `workspace_pinned`, `unauthenticated`, `validation_failed`, `timeout`, and refusals whose error names the next step
@@ -198,8 +238,7 @@ Needs `@shardflux/sdk` 0.7.0 or later. New dependency: `yaml` (template.yaml).
198
238
 
199
239
  Needs `@shardflux/sdk` 0.6.2 (the workspace version).
200
240
 
201
- The API no longer lets a start (open, resume, fork) wait in `capacity_pending` forever: one that no host could admit
202
- 15 minutes after it began fails with `capacity_unavailable` (retryable; nothing was started, a suspended workspace
241
+ The API no longer lets a start (open, resume, fork) wait in `capacity_pending` forever: one still queued 15 minutes after it began fails with `capacity_unavailable` (retryable; nothing was started, a suspended workspace
203
242
  stays suspended).
204
243
 
205
244
  - Failed-operation errors (`code: operation_failed`) carry `retryable`, the operation error's own flag, and
package/README.md CHANGED
@@ -14,8 +14,8 @@ with the scoped project API key you give it. Each tool call becomes one SDK requ
14
14
 
15
15
  Documentation: <https://docs.shardflux.dev/reference/mcp>.
16
16
 
17
- > **Early access.** Shardflux is in early access, and this server is below 1.0: tools and results
18
- > may still change in a minor release.
17
+ > **Compatibility.** The API is versioned (`/v1`). Breaking changes ship only in minor releases and are marked
18
+ > **Breaking** in the changelog.
19
19
 
20
20
  Requires Node.js 24 or later and a Shardflux project API key (`sfk_...`, from the Shardflux console).
21
21
 
@@ -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`), because on some Node.js releases a request sent on a reused keep-alive connection can stall until the request timeout. |
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
 
@@ -189,11 +189,9 @@ working directory: the directory your MCP client starts it in.
189
189
 
190
190
  ## Feedback: agents are asked to use `send_feedback` while they work
191
191
 
192
- **(0.4.0+)** The `send_feedback` tool sends a message straight to the Shardflux founder, who reads every one. The
192
+ **(0.4.0+)** The `send_feedback` tool sends a message straight to the Shardflux team, who read every one. The
193
193
  server instructions and the tool description ask the agent to call it actively during its work, not once at the end:
194
- the moment a call fails unexpectedly, an error or doc is confusing, something is missing or slow, or it needed a
195
- workaround; when its user complains or is frustrated about Shardflux or the workflow around it (paraphrased, with what
196
- they were trying to do); and when its user asked for something Shardflux could not do, or made awkward. Short and
194
+ the moment a call fails unexpectedly, an error or doc is confusing, something is missing, or it needed a workaround; when its user asks for a capability, an option or a smoother workflow (paraphrased, with what they were trying to do); and when its user has an idea for how Shardflux could fit their work better. Short and
197
195
  frequent beats polished, with the `request_id` and error code from the error. The agent tells its user it sent
198
196
  feedback, and leaves out private data about the user and secrets or code they did not mean to share (paraphrase, never
199
197
  transcripts). Failed calls carry a ready-made suggestion in a `feedback` field (see "Contract").
@@ -239,12 +237,11 @@ The management tools:
239
237
  | `workspace_status` | One workspace plus its five most recent operations. |
240
238
  | `workspace_suspend`, `workspace_resume` | Lifecycle operation. Returns at once unless `wait: true` (then once it finished, through the SDK's own `wait`). |
241
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. |
242
- | `workspace_fork` | Fork into `new_key`. |
243
- | `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. |
244
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. |
245
-
246
- | `usage_summary` | The organization's usage summary for the current period. |
247
- | `send_feedback` | (0.4.0) Feedback straight to the Shardflux founder: `message`, `category`, and optional `workspace`, `request_id`, `error_code`, `command`. See [Feedback](#feedback-agents-are-asked-to-use-send_feedback-while-they-work). |
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). |
248
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. |
249
246
  | `template_languages` | (0.3.0) The languages and versions a base (`<slug>@<version>`) offers `build.languages`. |
250
247
  | `template_build` | (0.3.0) Build a version of an organization template from a recipe v2: `recipe` (the document) or `file` (a template.yaml or .json path). File entries may name local `from` paths, uploaded first (folders as a tar). **Every local path must resolve inside the server's working directory**; others are refused before any request. Unpublished unless `publish: true`; `wait: true` follows the build within the call's deadline. |
@@ -259,24 +256,23 @@ The SDK's workspace tools come from `workspaceTools()`:
259
256
  - `browser_screenshot`, `browser_content`
260
257
 
261
258
  They are published with **the SDK's JSON Schemas verbatim**, plus `workspace_key` and, where the
262
- SDK schema has none, `timeout_ms`. They are filtered by the key's tool permissions (if the server
263
- cannot read them, for example because the API is unreachable, it lists every workspace tool and logs
264
- a warning). `exec`'s `cwd` is an absolute path (default `/home/user`): a relative one is an error result with
265
- `details.reason: invalid_cwd` whose message names the absolute path it likely means, and a command that could not
266
- start (a `cwd` that is not a directory, a program not on `PATH`) returns `exit_code: null` with `error: { code,
267
- message, reason: "exec_failed_to_start" }` naming the workspace's reason (0.4.1+). `browser_screenshot` returns an MCP image content block. `search_files` is annotated read-only; `edit_file` replaces
268
- exact text and, without an `expected_revision`, reads the file's revision first so a concurrent change fails the
259
+ SDK schema has none, `timeout_ms`. They are filtered by the key's tool permissions when the server
260
+ can read them; otherwise it lists every workspace tool. `exec`'s `cwd` is an absolute path (default
261
+ `/home/user`): a relative one is an error result with `details.reason: invalid_cwd` whose message names the absolute
262
+ path it likely means, and a command that could not start (a `cwd` that is not a directory, a program not on `PATH`)
263
+ returns `exit_code: null` with `error: { code, message, reason: "exec_failed_to_start" }` naming the workspace's
264
+ reason (0.4.1+). `browser_screenshot` returns an MCP image content block. `search_files` is annotated read-only;
265
+ `edit_file` replaces exact text and, without an `expected_revision`, reads the file's revision first so a concurrent change fails the
269
266
  edit (`conflict`, `details.reason: revision_mismatch`) instead of being overwritten.
270
267
 
271
- Each workspace tool call first sends the wake hint (`POST /wake-hint`, without waiting for it): a workspace its host
272
- has parked starts restoring while the call is prepared, and a suspended one starts resuming. `read_file`,
273
- `list_files` and `search_files` send none (0.4.0+): a suspended workspace whose disk its host still holds is read,
268
+ Each workspace tool call first sends the wake hint (`POST /wake-hint`, without waiting for it): a parked workspace
269
+ starts restoring while the call is prepared, and a suspended one starts resuming. `read_file`,
270
+ `list_files` and `search_files` send none (0.4.0+): a suspended workspace is read from its saved disk,
274
271
  listed and searched there without resuming it (the state at suspension).
275
272
 
276
- While the fleet is being upgraded a workspace may run on a host that predates `search_files` and `edit_file`: they
277
- fail with `conflict`, `reason: host_feature_unavailable` (`details.feature` `file_search` or `file_patch`),
278
- `retryable: false` and a `hint` naming another way (`exec` with `grep`; `read_file` then `write_file`). It lasts
279
- until the workspace runs on an upgraded host.
273
+ If search or patches are not available for a workspace, `search_files` and `edit_file` fail with `conflict`,
274
+ `reason: host_feature_unavailable` (`details.feature` `file_search` or `file_patch`), `retryable: false` and a `hint`
275
+ naming the fallback (`exec` with `grep`; `read_file` then `write_file`).
280
276
 
281
277
  Deleting a workspace is deliberately not exposed to agents; use the
282
278
  [CLI](https://www.npmjs.com/package/@shardflux/cli) or the console.
@@ -295,13 +291,13 @@ the state of the template's start commands and services (`failed` names the step
295
291
 
296
292
  ### File-first workspaces (0.4.0)
297
293
 
298
- `workspace_open` with `mode: "file_first"` (or `SHARDFLUX_WORKSPACE_MODE=file_first`) opens a file-first workspace
299
- (contracts §29): a versioned file tree under /home/user with no VM between calls. It is ready at once and never
294
+ `workspace_open` with `mode: "file_first"` (or `SHARDFLUX_WORKSPACE_MODE=file_first`) opens a file-first workspace:
295
+ a versioned file tree under /home/user with no VM between calls. It is ready at once and never
300
296
  suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_revision`.
301
297
 
302
298
  - `exec` runs each command as an execution: a fresh VM on the workspace's files. Only files under /home/user persist
303
299
  between calls. The result adds `execution_id`, `state`, `tree_revision` and `changed` (the paths the command added,
304
- modified or deleted, up to 200, with `changed_truncated`). An execution cannot be canceled: when the call's
300
+ modified or deleted, up to 200, with `changed_truncated`). An execution runs to completion: when the call's
305
301
  deadline passes first, the error carries its `execution_id` and the execution continues server side.
306
302
  - The files tools work as on a processful workspace. Each change publishes the next tree revision.
307
303
  - The process, terminal, git and browser tools and `workspace_suspend`, `workspace_resume`, `workspace_fork` and
@@ -311,17 +307,17 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
311
307
  `notifications/tools/list_changed`. An unpinned server lists every tool; calling one the named workspace's mode
312
308
  lacks returns an error with `reason: "not_supported_for_mode"` and a `hint`, before any request.
313
309
  - Errors of file-first workspaces carry `reason` and a `hint`: `not_supported_for_mode`, `mode_mismatch` (a key's
314
- mode never changes), `mode_not_available` (the deployment does not offer file-first workspaces), `layout_unsupported`
310
+ mode never changes), `mode_not_available` (the account does not have file-first workspaces), `layout_unsupported`
315
311
  (a legacy template), `tree_revision_mismatch` (with `current_tree_revision`), `outside_tree_root`,
316
- `execution_in_progress` (another execution holds the workspace), `execution_id_reused` and `no_execution_host` (no
317
- host had room, also after the SDK's retries with the same execution id; nothing ran).
312
+ `execution_in_progress` (another execution holds the workspace), `execution_id_reused` and `no_execution_host`
313
+ (the execution cannot be placed right now; retried with the same id; nothing ran).
318
314
 
319
315
  ```json
320
316
  {
321
317
  "mcpServers": {
322
318
  "shardflux": {
323
- "command": "node",
324
- "args": ["/path/to/packages/mcp/src/bin.ts"],
319
+ "command": "npx",
320
+ "args": ["-y", "@shardflux/mcp"],
325
321
  "env": { "SHARDFLUX_API_KEY": "sfk_...", "SHARDFLUX_WORKSPACE_KEY": "me/agent-files", "SHARDFLUX_TEMPLATE": "python-node-browser", "SHARDFLUX_WORKSPACE_MODE": "file_first" }
326
322
  }
327
323
  }
@@ -343,25 +339,23 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
343
339
  `allowance_used` (upgrade, or turn on overage), `overage_paused` (a plan payment is past due) or
344
340
  `spend_cap_reached` (raise the spend cap or upgrade), and `details.spend_cap`. An owner or billing member acts
345
341
  on it in the console; retrying does not help.
346
-
347
342
  - **(0.4.0+)** A failed call has a `feedback` field next to `error`: a one-sentence suggestion to call
348
343
  `send_feedback` with category `bug`, the `request_id` and the error code. Not for `invalid_arguments`,
349
344
  `workspace_pinned` or `unauthenticated`. A failed `send_feedback` carries a `hint` instead (when to retry, or the
350
345
  email address).
351
- - **Starts wait for capacity for at most 15 minutes (0.2.1+).** An open, resume or fork that no host can admit waits
352
- in `capacity_pending`; a wait that ends first returns `code: timeout`, and its message names when the start gives
353
- up. A start still pending then fails: `operation_failed`, `details.error_code: capacity_unavailable`,
354
- `retryable: true`, and a message telling the agent that nothing was started and it may retry later. The server
355
- never retries it itself. The server instructions say the same.
346
+ - **Start deadlines (0.2.1+).** A queued open, resume or fork (`capacity_pending`) has a deadline 15 minutes after
347
+ it was created; a wait that ends first returns `code: timeout`, and its message names the deadline. A start still
348
+ pending then fails: `operation_failed`, `details.error_code: capacity_unavailable`, `retryable: true`, and a
349
+ message telling the agent that nothing was started and it can retry. The server instructions say the same.
356
350
  - **Timing (0.2.0+).** A call that opens a workspace or waits says where the time went, from the SDK's lifecycle
357
351
  timing, compact for model context:
358
352
 
359
353
  ```json
360
354
  "timing": {
361
- "action": "open", "outcome": "succeeded", "operation_id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33", "total_ms": 34180,
362
- "phases": ["request(held) 20010 ms", "capacity_pending(no_ready_host) 13520 ms", "running 590 ms", "view 42 ms", "token 61 ms"],
363
- "server": { "queued_ms": 33400, "run_ms": 620, "total_ms": 34020, "start_path": "warm" },
364
- "outside_server_ms": 160, "retries": 0
355
+ "action": "resume", "outcome": "succeeded", "operation_id": "01a0ead6-bd61-737a-ae88-66e2d01a6e25", "total_ms": 413,
356
+ "phases": ["request 218 ms", "queued 195 ms"],
357
+ "server": { "queued_ms": 51, "run_ms": 290, "total_ms": 341, "resume_path": "local_cache" },
358
+ "outside_server_ms": 72, "retries": 0
365
359
  }
366
360
  ```
367
361
 
@@ -369,7 +363,7 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
369
363
  `workspace_fork` carry it with `wait: true`. Workspace tools carry it when they had to wake the workspace
370
364
  (`action: "wake"`). Other results do not.
371
365
  - Phases are in order, as `phase(reason) ms`. `request(held)` means the API held the open until the workspace was
372
- ready. `capacity_pending(no_ready_host)` is time spent waiting for a host.
366
+ ready. `capacity_pending(no_ready_host)` is time queued to start.
373
367
  - `server` is the operation's own queued, run and total time, plus the start or resume path the cell reported.
374
368
  A resume also has `memory_restored` (0.5.0+) when the API reports it, and `cold_boot_reason` when it is false.
375
369
  - `outside_server_ms` is everything else: network, polling, reading the workspace, the tool token.
@@ -378,7 +372,7 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
378
372
  with the first tool token, which the next tool call reuses.
379
373
  - **Deadlines.** Every call has one: `timeout_ms`, clamped to the ceiling.
380
374
  - Waits time out just before the deadline with `code: timeout` and the `operation_id`; the
381
- operation continues server side (a start waiting for capacity for at most 15 minutes).
375
+ operation continues server side (a queued start until its 15-minute deadline).
382
376
  - Any request still in flight at the deadline is aborted.
383
377
  - `exec`'s command timeout is capped at the deadline minus 2 s, so its output comes back.
384
378
  - **Cancellation.** MCP `notifications/cancelled` aborts the SDK work behind the call: waits,
@@ -386,7 +380,7 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
386
380
  the server stays up. The lifecycle operation itself is not canceled.
387
381
  - **Workspace tools wake suspended workspaces, but do not open new ones.** A workspace that was
388
382
  never opened is `not_found`: call `workspace_open` first. Reads (`read_file`, `list_files`,
389
- `search_files`) of a suspended workspace whose disk a host still holds are answered from that disk
383
+ `search_files`) of a suspended workspace are answered from its saved disk
390
384
  without waking it.
391
385
  - A suspended workspace is resumed (or the resume or open already running is joined), and the
392
386
  call runs once it is running. A call made during a suspend or resume waits for it to finish.
@@ -399,10 +393,10 @@ suspended. Workspace results carry `mode` and, for file-first workspaces, `tree_
399
393
  suspended).
400
394
  - With `SHARDFLUX_WAKE=off` the API's refusal comes back instead (`conflict`,
401
395
  `details.reason: workspace_not_running`, or `workspace_not_running` from the cell gateway).
402
- - **A resume can restart processes (0.5.0+).** A resume normally restores memory and running processes. When the
403
- platform's VM runtime changed after the suspend and no host can restore that memory snapshot, the resume boots
404
- the workspace's saved disk instead (a cold boot), also when a workspace tool wakes it. The call still runs, and
405
- files are as of the suspend, but every process was restarted. The result (or the error result) then carries
396
+ - **Detecting a cold resume (0.5.0+).** A resume restores memory and running processes. After a platform runtime
397
+ update, a resume can boot from the saved disk instead of restoring memory (a cold boot), also when a workspace
398
+ tool wakes it; check `memory_restored`. The call still runs with the files as of the suspend, and processes start
399
+ fresh. The result (or the error result) then carries
406
400
  `timing.server.resume_path: "cold_boot"`, `memory_restored: false`, `cold_boot_reason` (e.g. `runtime_changed`)
407
401
  and a `notice` the agent reads: to start its dev servers, databases, watchers and background jobs again. The
408
402
  server instructions and the `workspace_resume` description say so too.
@@ -423,3 +417,10 @@ await server.connect(transport); // any @modelcontextprotocol/sdk transport
423
417
  ## License
424
418
 
425
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,22 +18,22 @@ 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;
25
- /** What to do instead (0.4.0): refusals that concern a workspace's mode (contracts §29) or a host that predates a tool (§26.7). */
25
+ /** What to do instead (0.4.0): refusals that concern a workspace's mode or a tool that is not available for the workspace. */
26
26
  hint?: string;
27
27
  }
28
28
  export declare function redact(text: string, key?: string): string;
29
29
  /**
30
- * What the model can do about a refusal that concerns a workspace's mode (contracts §29.7, §29.8), from its
30
+ * What the model can do about a refusal that concerns a workspace's mode, from its
31
31
  * details.reason and details; undefined for every other refusal.
32
32
  */
33
33
  export declare function modeHint(reason: string | undefined, details?: Record<string, unknown>): string | undefined;
34
34
  /**
35
- * 409 conflict host_feature_unavailable (contracts §26.7; 0.4.0): the workspace runs on a host that predates the tool,
36
- * until it runs on an upgraded host (minutes to hours). Not retryable, so the hint names another way to do it now.
35
+ * 409 conflict host_feature_unavailable (0.4.0): the tool is not available for this workspace. Not retryable, so the
36
+ * hint names the fallback that works now.
37
37
  */
38
38
  export declare function hostFeatureHint(reason: string | undefined, details?: Record<string, unknown>): string | undefined;
39
39
  export interface DescribeContext {
package/dist/errors.js CHANGED
@@ -3,11 +3,11 @@
3
3
  * content is `{ "error": { code, message, ... } }` with the API's closed error
4
4
  * code, never as protocol failures, so the calling model can react to them.
5
5
  * API and cell refusals add `reason` (their details.reason) and, for refusals
6
- * that concern a workspace's mode (file-first workspaces, contracts §29) or a
7
- * host that predates a tool (host_feature_unavailable, contracts §26.7), a
6
+ * that concern a workspace's mode (file-first workspaces) or a
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';
@@ -31,7 +31,7 @@ const str = (v) => (typeof v === 'string' && v !== '' ? v : undefined);
31
31
  /** The tools a file-first workspace has, for hints. */
32
32
  const FILE_FIRST_TOOLS_TEXT = 'Run commands with exec and use read_file, write_file, list_files, search_files and edit_file for files; only files under /home/user persist between exec calls.';
33
33
  /**
34
- * What the model can do about a refusal that concerns a workspace's mode (contracts §29.7, §29.8), from its
34
+ * What the model can do about a refusal that concerns a workspace's mode, from its
35
35
  * details.reason and details; undefined for every other refusal.
36
36
  */
37
37
  export function modeHint(reason, details = {}) {
@@ -52,7 +52,7 @@ export function modeHint(reason, details = {}) {
52
52
  : "A workspace's mode never changes: open this key with the mode it was created with, or use another key.";
53
53
  }
54
54
  case 'mode_not_available':
55
- return 'This deployment does not offer file-first workspaces yet: open the workspace with mode "processful" (or without mode).';
55
+ return 'This deployment does not offer file-first workspaces: open the workspace with mode "processful" (or without mode).';
56
56
  case 'layout_unsupported':
57
57
  return mode === 'file_first'
58
58
  ? 'File-first workspaces run on layered template versions and this one is not: pick another template (template_get lists its versions and disk_layouts) or open with mode "processful".'
@@ -66,26 +66,26 @@ export function modeHint(reason, details = {}) {
66
66
  case 'execution_id_reused':
67
67
  return 'That execution id was already used for a different command; call exec again (each call uses a new execution id).';
68
68
  case 'no_execution_host':
69
- return 'No host has room to run the command now and nothing ran; retry in a few seconds.';
69
+ return 'The execution cannot be placed right now and nothing ran; retry in a few seconds.';
70
70
  default:
71
71
  return undefined;
72
72
  }
73
73
  }
74
74
  /**
75
- * 409 conflict host_feature_unavailable (contracts §26.7; 0.4.0): the workspace runs on a host that predates the tool,
76
- * until it runs on an upgraded host (minutes to hours). Not retryable, so the hint names another way to do it now.
75
+ * 409 conflict host_feature_unavailable (0.4.0): the tool is not available for this workspace. Not retryable, so the
76
+ * hint names the fallback that works now.
77
77
  */
78
78
  export function hostFeatureHint(reason, details = {}) {
79
79
  if (reason !== 'host_feature_unavailable')
80
80
  return undefined;
81
- const later = 'It will work once the workspace runs on an upgraded host; retrying now does not help.';
81
+ const retry = 'Retrying does not help.';
82
82
  switch (details.feature) {
83
83
  case 'file_search':
84
- return `This workspace's host does not support search_files yet. Search with exec instead, e.g. grep -rn -- PATTERN PATH (add -i to ignore case, -E for a regular expression). ${later}`;
84
+ return `search_files is not available for this workspace. Search with exec instead, e.g. grep -rn -- PATTERN PATH (add -i to ignore case, -E for a regular expression). ${retry}`;
85
85
  case 'file_patch':
86
- return `This workspace's host does not support edit_file yet. Use read_file, change the text, then write_file with the whole new content. ${later}`;
86
+ return `edit_file is not available for this workspace. Use read_file, change the text, then write_file with the whole new content. ${retry}`;
87
87
  default:
88
- return `This workspace's host does not support this tool yet. ${later}`;
88
+ return `This tool is not available for this workspace. ${retry}`;
89
89
  }
90
90
  }
91
91
  export function describeToolError(err, context = {}) {
@@ -101,7 +101,7 @@ export function describeToolError(err, context = {}) {
101
101
  retryable: err.retryable,
102
102
  source: err.source,
103
103
  ...(err.operationId ? { operation_id: err.operationId } : {}),
104
- // No execution_id: a refusal answers before anything ran, and the execution id stays unused (contracts §29.8).
104
+ // No execution_id: a refusal answers before anything ran, and the execution id stays unused.
105
105
  ...(err instanceof TreeRevisionMismatchError && err.currentTreeRevision !== null ? { current_tree_revision: err.currentTreeRevision } : {}),
106
106
  ...(err.details ? { details: err.details } : {}),
107
107
  ...(hint ? { hint } : {}),
@@ -112,21 +112,32 @@ 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
- // retryable: the operation error's own flag. capacity_unavailable (no host could admit the start before its
119
- // deadline) is retryable: nothing was started, so the agent may try again later.
125
+ // retryable: the operation error's own flag. capacity_unavailable (the start passed its deadline) is retryable:
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' ? '. No host could admit it before its deadline and nothing was started; you may retry later.' : '';
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')) {
127
- // A file-first execution cannot be canceled: it runs to its end server side and then publishes what it changed.
138
+ // A file-first execution runs to completion server side and then publishes what it changed.
128
139
  const continues = context.executionId
129
- ? `; execution ${context.executionId} continues server side (a file-first execution cannot be canceled): the files it changes are published when it ends, and the next exec or file change on this workspace waits for it`
140
+ ? `; execution ${context.executionId} continues server side (a file-first execution runs to completion): the files it changes are published when it ends, and the next exec or file change on this workspace waits for it`
130
141
  : context.operationId
131
142
  ? '; the operation continues server side (use operation_wait)'
132
143
  : '';
@@ -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,13 +1,2 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch with `Connection: close` on
3
- * every request (API and cell gateway).
4
- *
5
- * Why: Node 26.7's bundled undici (8.9.0) intermittently stalls a request
6
- * sent on a reused keep-alive connection until an unrelated timer fires (up to
7
- * ~30 s, the SDK's request timeout, after which the SDK retries). Reproduced
8
- * with bare fetch against both node:http and Fastify servers; Node 22 (undici
9
- * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
- * costs one TCP/TLS handshake, which is negligible for these call rates.
11
- * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
- */
1
+ /** Share the SDK's pooled transport. A caller-supplied fetch stays under the caller's control. */
13
2
  export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch): typeof fetch;
package/dist/http.js CHANGED
@@ -1,21 +1,5 @@
1
- /**
2
- * The fetch given to the SDK: the runtime's fetch with `Connection: close` on
3
- * every request (API and cell gateway).
4
- *
5
- * Why: Node 26.7's bundled undici (8.9.0) intermittently stalls a request
6
- * sent on a reused keep-alive connection until an unrelated timer fires (up to
7
- * ~30 s, the SDK's request timeout, after which the SDK retries). Reproduced
8
- * with bare fetch against both node:http and Fastify servers; Node 22 (undici
9
- * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
- * costs one TCP/TLS handshake, which is negligible for these call rates.
11
- * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
- */
13
- export function makeFetch(env, base = fetch) {
14
- if (env.SHARDFLUX_HTTP_KEEPALIVE === '1')
15
- return base;
16
- return (input, init) => {
17
- const headers = new Headers(init?.headers);
18
- headers.set('connection', 'close');
19
- return base(input, { ...init, headers });
20
- };
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;
21
5
  }
package/dist/server.d.ts CHANGED
@@ -3,8 +3,8 @@ 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.1";
7
- /** The package this server is distributed as: its entry in GET /v1/client-versions (contracts §30.4). */
6
+ export declare const MCP_SERVER_VERSION = "0.6.0";
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[];
10
10
  export type ObjectSchema = JsonSchema & {
@@ -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;
@@ -185,13 +242,15 @@ export declare function summarizeOperation(o: Operation): {
185
242
  /**
186
243
  * The SDK's LifecycleTiming, compact enough for model context: where an open, a wait or a wake spent its time. Phases
187
244
  * are `phase(reason) ms` in order (`request(held)`: the server held the open; `capacity_pending(no_ready_host)`:
188
- * waiting for a host); `server` is the operation's own queued/run/total time; `outside_server_ms` is the rest
245
+ * queued); `server` is the operation's own queued/run/total time; `outside_server_ms` is the rest
189
246
  * (network, polling, view and token).
190
247
  */
191
248
  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
@@ -239,7 +305,7 @@ export interface ServerOptions {
239
305
  versionCheck?: boolean;
240
306
  }
241
307
  /**
242
- * The startup version check (0.4.0; contracts §30.4): one GET <api>/v1/client-versions through the server's fetch
308
+ * The startup version check (0.4.0): one GET <api>/v1/client-versions through the server's fetch
243
309
  * (the SDK's checkClientVersion: 3 s timeout, never throws) for @shardflux/mcp at MCP_SERVER_VERSION. Outdated or
244
310
  * unsupported: one `warn` line whose message is the notice (`@shardflux/mcp 0.4.0 is outdated: 0.5.0 is available.
245
311
  * Update: <upgrade command>`) plus the status fields. Anything else (current; unknown: not listed, `latest` null while
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * - templates (0.3.0): template_get (versions, settings, a version's recipe), template_languages and
9
9
  * template_build (a recipe v2 object or a template.yaml path inside the
10
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
11
+ * - send_feedback (0.4.0): feedback straight to the Shardflux team; the instructions, its description and
12
12
  * the `feedback` field of error results ask agents to use it while they work;
13
13
  * - the SDK's workspace tools (`workspaceTools()`: exec, files, processes,
14
14
  * terminal, git, browser), published with the SDK's own JSON Schemas plus
@@ -21,14 +21,14 @@
21
21
  * call first sends the wake hint (the SDK tool runner's `workspace.hint()`,
22
22
  * fire-and-forget) so a parked workspace restores while the call is prepared,
23
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
24
+ * answers from its disk without waking. Each call has a deadline
25
25
  * (`timeout_ms`, clamped to SHARDFLUX_MCP_TOOL_TIMEOUT_MS) and MCP cancellation
26
26
  * (notifications/cancelled -> `extra.signal`) aborts the underlying SDK
27
27
  * request or wait. Failures come back as `isError` tool results carrying the
28
28
  * API error code. stdout is the protocol; logs go to stderr. Opens, waits and
29
29
  * wakes carry the SDK's lifecycle timing, compacted (`compactTiming`).
30
30
  *
31
- * File-first workspaces (0.4.0; contracts §29: `workspace_open` `mode:
31
+ * File-first workspaces (0.4.0: `workspace_open` `mode:
32
32
  * "file_first"`) have files and executions only. A pinned server lists the
33
33
  * tools of its workspace's mode (the key's mode once it resolves, else
34
34
  * SHARDFLUX_WORKSPACE_MODE, else processful) and sends
@@ -53,8 +53,8 @@ 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.1';
57
- /** The package this server is distributed as: its entry in GET /v1/client-versions (contracts §30.4). */
56
+ export const MCP_SERVER_VERSION = '0.6.0';
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}`;
60
60
  export const ALL_TOOL_PERMISSIONS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
@@ -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) {
@@ -202,7 +208,7 @@ function isObject(v) {
202
208
  /**
203
209
  * The SDK's LifecycleTiming, compact enough for model context: where an open, a wait or a wake spent its time. Phases
204
210
  * are `phase(reason) ms` in order (`request(held)`: the server held the open; `capacity_pending(no_ready_host)`:
205
- * waiting for a host); `server` is the operation's own queued/run/total time; `outside_server_ms` is the rest
211
+ * queued); `server` is the operation's own queued/run/total time; `outside_server_ms` is the rest
206
212
  * (network, polling, view and token).
207
213
  */
208
214
  export function compactTiming(t) {
@@ -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;
@@ -268,7 +293,7 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
268
293
  * When to use send_feedback, for the agent: the same words as `shard feedback --help`, in the server instructions and
269
294
  * the tool description. It has to make an agent actually call it while it works.
270
295
  */
271
- 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.';
296
+ 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 asks for a capability, an option or a smoother workflow: paraphrase it, with what they were trying to do; your user has an idea for how Shardflux could fit their work better. 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.';
272
297
  /**
273
298
  * No send_feedback suggestion for the expected flow: argument, configuration or credential problems of the call itself,
274
299
  * a wait that gave up while the work continues, and refusals whose error names the exact next step (the same list as
@@ -302,18 +327,18 @@ const INSTRUCTIONS = [
302
327
  '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.',
303
328
  '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.',
304
329
  '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.',
305
- '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.',
330
+ 'A queued start (open, resume, fork) has a deadline 15 minutes after it was created; past it, it fails with code operation_failed, details.error_code capacity_unavailable and retryable true. Nothing was started, so send it again. retryable false means retrying will not help.',
306
331
  'Opens, waits and wakes add timing (phases, server queued/run time) saying where the time went.',
307
332
  'A resume (also the automatic one when a tool call finds the workspace suspended) normally restores memory and running processes. If a result carries notice and timing.server.memory_restored false (resume_path cold_boot), the workspace booted from its saved disk instead: files are kept, but every process was restarted, so start your dev servers and background jobs again.',
308
333
  '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).',
309
334
  '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.',
310
335
  '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).',
311
- '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).',
336
+ 'An exec result on a file-first workspace carries the paths it changed (changed) and the new tree_revision; an execution runs to completion. 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).',
312
337
  '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.',
313
- `Feedback: send_feedback goes straight to the Shardflux founder, who reads every message. ${FEEDBACK_USE}`,
338
+ `Feedback: send_feedback goes straight to the Shardflux team, who read every message. ${FEEDBACK_USE}`,
314
339
  ].join(' ');
315
340
  /**
316
- * The startup version check (0.4.0; contracts §30.4): one GET <api>/v1/client-versions through the server's fetch
341
+ * The startup version check (0.4.0): one GET <api>/v1/client-versions through the server's fetch
317
342
  * (the SDK's checkClientVersion: 3 s timeout, never throws) for @shardflux/mcp at MCP_SERVER_VERSION. Outdated or
318
343
  * unsupported: one `warn` line whose message is the notice (`@shardflux/mcp 0.4.0 is outdated: 0.5.0 is available.
319
344
  * Update: <upgrade command>`) plus the status fields. Anything else (current; unknown: not listed, `latest` null while
@@ -421,7 +446,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
421
446
  * Wake on use for a workspace's tools: `workspace.wake()` within the SDK's transition budget (SHARDFLUX_WAKE_TIMEOUT_MS)
422
447
  * and 250 ms short of the call's deadline, so a wake that runs out reports the operation (OperationTimeoutError) rather
423
448
  * than a bare deadline. null when SHARDFLUX_WAKE=off: the refusal (workspace_not_running) comes back instead. The
424
- * wake's held resume (contracts §22.6) returns the token of this server's tools (its agent label), so the woken call
449
+ * wake's held resume returns the token of this server's tools (its agent label), so the woken call
425
450
  * runs at once with it.
426
451
  */
427
452
  const wakeFor = (ws) => config.wake === false
@@ -480,7 +505,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
480
505
  /**
481
506
  * The workspace a key names, across every lifetime and purpose (sessions, drafts and test instances are hidden from
482
507
  * the default list), preferring the live workspace over tombstones: an ended session leaves a tombstone with the same
483
- * key, and the key then opens a new workspace (contracts §19.11). Null when the key names none.
508
+ * key, and the key then opens a new workspace. Null when the key names none.
484
509
  */
485
510
  const lookup = async (key, opts2) => {
486
511
  const cached = handles.get(key);
@@ -544,7 +569,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
544
569
  {
545
570
  name: 'workspace_open',
546
571
  title: 'Open workspace',
547
- 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).',
572
+ 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); a queued start has a deadline 15 minutes after it was created. The result’s timing says where the time went. mode "file_first" opens a file-first workspace (files only, ready at once; see mode).',
548
573
  inputSchema: obj({
549
574
  workspace_key: workspaceKeyProp(pinned),
550
575
  template: {
@@ -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
  }
@@ -687,7 +761,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
687
761
  },
688
762
  },
689
763
  lifecycleTool('workspace_resume', 'Resume workspace', 'Resume a suspended workspace. Returns the resume operation (with wait: once finished, and its timing). A notice in the result (timing.server.memory_restored false) means it booted from its saved disk: files kept, processes restarted. Not for file-first workspaces (never suspended).', 'resume',
690
- // Through the server's handle: a waited resume is held until the workspace runs (contracts §22.6) and the handle
764
+ // Through the server's handle: a waited resume is held until the workspace runs and the handle
691
765
  // keeps the view and this server's tool token, so the next workspace tool starts at once.
692
766
  (ws, o) => ws.resume({ ...o, agentLabel: config.agentLabel })),
693
767
  {
@@ -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',
@@ -838,13 +921,13 @@ export function createShardfluxMcpServer(config, opts = {}) {
838
921
  management.push({
839
922
  name: 'send_feedback',
840
923
  title: 'Send feedback',
841
- 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).`,
924
+ description: `Send feedback straight to the Shardflux team, who read 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).`,
842
925
  inputSchema: obj({
843
926
  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.' },
844
927
  category: { type: 'string', enum: [...FEEDBACK_CATEGORIES], description: 'bug, confusing, missing, idea, praise or other.' },
845
928
  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.' },
846
- request_id: { type: 'string', minLength: 1, maxLength: 200, description: 'request_id from the error, so the founder can find the logs.' },
847
- 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.' },
929
+ request_id: { type: 'string', minLength: 1, maxLength: 200, description: 'request_id from the error, so the team can find the logs.' },
930
+ 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. template_not_found.' },
848
931
  command: { type: 'string', minLength: 1, maxLength: 2000, description: 'The tool call (name and arguments) or command that led to it.' },
849
932
  }, ['message', 'category']),
850
933
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -870,7 +953,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
870
953
  id: r.id,
871
954
  received_at: r.receivedAt,
872
955
  duplicate: r.duplicate,
873
- 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.',
956
+ note: r.duplicate ? 'The same message was already received in the last 24 hours; it was not emailed again.' : 'Delivered to the Shardflux team. Keep sending feedback as you work.',
874
957
  };
875
958
  },
876
959
  });
@@ -883,7 +966,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
883
966
  */
884
967
  const execDescription = (sdkDescription, listing) => {
885
968
  if (listing === 'file_first')
886
- 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.`;
969
+ return `${sdkDescription}${execDeadline} An execution runs to completion: if the deadline passes first it continues server side (the error names its execution_id) and the next exec waits for it.`;
887
970
  if (listing === 'processful')
888
971
  return `${sdkDescription}${execDeadline}`;
889
972
  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}`;
@@ -921,7 +1004,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
921
1004
  }
922
1005
  const ws = await resolve(key, { signal: call.signal });
923
1006
  // The SDK's tools for the workspace's mode: a file-first workspace has exec (as executions) and the file tools.
924
- // A file-first execution cannot be canceled; its id is kept so a deadline error can name it.
1007
+ // A file-first execution runs to completion; its id is kept so a deadline error can name it.
925
1008
  const tool = workspaceTools(ws, {
926
1009
  agentLabel: config.agentLabel,
927
1010
  tools: [...ALL_TOOL_PERMISSIONS],
@@ -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) {
@@ -1045,7 +1128,7 @@ export function createShardfluxMcpServer(config, opts = {}) {
1045
1128
  ...(info.request_id ? { request_id: info.request_id } : {}),
1046
1129
  ...(info.execution_id ? { execution_id: info.execution_id } : {}),
1047
1130
  });
1048
- // A failure suggests send_feedback; a failed send_feedback says how to still reach the founder.
1131
+ // A failure suggests send_feedback; a failed send_feedback says how to still reach the Shardflux team.
1049
1132
  const s = info.details?.retry_after_seconds;
1050
1133
  const siblings = name === 'send_feedback'
1051
1134
  ? info.code === 'invalid_arguments'
@@ -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.1",
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.0"
49
+ "@shardflux/sdk": "^0.12.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@eslint/js": "10.0.1",