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