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